move proxy related settings to new proxy tab for resource

This commit is contained in:
miloschwartz 2025-04-23 23:08:25 -04:00
parent f4fd33b47f
commit 91b4bb4683
No known key found for this signature in database
14 changed files with 324 additions and 277 deletions

View file

@ -99,6 +99,7 @@ const updateRawResourceBodySchema = z
.object({
name: z.string().min(1).max(255).optional(),
proxyPort: z.number().int().min(1).max(65535).optional(),
stickySession: z.boolean().optional(),
enabled: z.boolean().optional()
})
.strict()

View file

@ -41,7 +41,7 @@ export async function traefikConfigProvider(
orgId: orgs.orgId
},
enabled: resources.enabled,
stickySession: resources.stickySessionk,
stickySession: resources.stickySession,
tlsServerName: resources.tlsServerName,
setHostHeader: resources.setHostHeader
})
@ -288,7 +288,7 @@ export async function traefikConfigProvider(
? {
sticky: {
cookie: {
name: "pangolin_sticky",
name: "p_sticky", // TODO: make this configurable via config.yml like other cookies
secure: resource.ssl,
httpOnly: true
}

View file

@ -234,7 +234,7 @@ export default function GeneralPage() {
loading={loadingSave}
disabled={loadingSave}
>
Save Settings
Save General Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>

View file

@ -57,7 +57,8 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
const api = createApiClient(useEnvContext());
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [selectedResource, setSelectedResource] = useState<ResourceRow | null>();
const [selectedResource, setSelectedResource] =
useState<ResourceRow | null>();
const deleteResource = (resourceId: number) => {
api.delete(`/resource/${resourceId}`)
@ -238,7 +239,7 @@ export default function SitesTable({ resources, orgId }: ResourcesTableProps) {
<span>Not Protected</span>
</span>
) : (
<span>--</span>
<span>-</span>
)}
</div>
);

View file

@ -109,42 +109,8 @@ const TransferFormSchema = z.object({
siteId: z.number()
});
const AdvancedFormSchema = z
.object({
http: z.boolean(),
tlsServerName: z.string().optional(),
setHostHeader: z.string().optional()
})
.refine(
(data) => {
if (data.tlsServerName) {
return tlsNameSchema.safeParse(data.tlsServerName).success;
}
return true;
},
{
message:
"Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name.",
path: ["tlsServerName"]
}
)
.refine(
(data) => {
if (data.setHostHeader) {
return tlsNameSchema.safeParse(data.setHostHeader).success;
}
return true;
},
{
message:
"Invalid custom Host Header value. Use domain name format, or save empty to unset the custom Host Header",
path: ["tlsServerName"]
}
);
type GeneralFormValues = z.infer<typeof GeneralFormSchema>;
type TransferFormValues = z.infer<typeof TransferFormSchema>;
type AdvancedFormValues = z.infer<typeof AdvancedFormSchema>;
export default function GeneralForm() {
const [formKey, setFormKey] = useState(0);
@ -185,20 +151,6 @@ export default function GeneralForm() {
mode: "onChange"
});
const advancedForm = useForm<AdvancedFormValues>({
resolver: zodResolver(AdvancedFormSchema),
defaultValues: {
http: resource.http,
tlsServerName: resource.http
? resource.tlsServerName || ""
: undefined,
setHostHeader: resource.http
? resource.setHostHeader || ""
: undefined
},
mode: "onChange"
});
const transferForm = useForm<TransferFormValues>({
resolver: zodResolver(TransferFormSchema),
defaultValues: {
@ -327,46 +279,6 @@ export default function GeneralForm() {
setTransferLoading(false);
}
async function onSubmitAdvanced(data: AdvancedFormValues) {
setSaveLoading(true);
const res = await api
.post<AxiosResponse<UpdateResourceResponse>>(
`resource/${resource?.resourceId}`,
{
tlsServerName: data.http ? data.tlsServerName : undefined,
setHostHeader: data.http ? data.setHostHeader : undefined
}
)
.catch((e) => {
toast({
variant: "destructive",
title: "Failed to update resource",
description: formatAxiosError(
e,
"An error occurred while updating the resource"
)
});
});
if (res && res.status === 200) {
toast({
title: "Resource updated",
description: "The resource has been updated successfully"
});
const resource = res.data.data;
updateResource({
tlsServerName: data.tlsServerName,
setHostHeader: data.setHostHeader
});
router.refresh();
}
setSaveLoading(false);
}
async function toggleResourceEnabled(val: boolean) {
const res = await api
.post<AxiosResponse<UpdateResourceResponse>>(
@ -684,82 +596,11 @@ export default function GeneralForm() {
disabled={saveLoading}
form="general-settings-form"
>
Save Settings
Save General Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
{resource.http && (
<>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Advanced
</SettingsSectionTitle>
<SettingsSectionDescription>
Adjust advanced settings for the resource,
like customize the Host Header or set a TLS
Server Name for SNI based routing.
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...advancedForm}>
<form
onSubmit={advancedForm.handleSubmit(
onSubmitAdvanced
)}
id="advanced-settings-form"
>
<FormLabel>
TLS Server Name (optional)
</FormLabel>
<FormField
control={advancedForm.control}
name="tlsServerName"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormLabel>
Custom Host Header (optional)
</FormLabel>
<FormField
control={advancedForm.control}
name="setHostHeader"
render={({ field }) => (
<FormItem>
<FormControl>
<Input {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={saveLoading}
disabled={saveLoading}
form="advanced-settings-form"
>
Save Advanced Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
</>
)}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>

View file

@ -86,8 +86,8 @@ export default async function ResourceLayout(props: ResourceLayoutProps) {
href: `/{orgId}/settings/resources/{resourceId}/general`
},
{
title: "Connectivity",
href: `/{orgId}/settings/resources/{resourceId}/connectivity`
title: "Proxy",
href: `/{orgId}/settings/resources/{resourceId}/proxy`
}
];

View file

@ -5,6 +5,6 @@ export default async function ResourcePage(props: {
}) {
const params = await props.params;
redirect(
`/${params.orgId}/settings/resources/${params.resourceId}/connectivity`
`/${params.orgId}/settings/resources/${params.resourceId}/proxy`
);
}

View file

@ -60,17 +60,22 @@ import {
SettingsSectionTitle,
SettingsSectionDescription,
SettingsSectionBody,
SettingsSectionFooter
SettingsSectionFooter,
SettingsSectionForm
} from "@app/components/Settings";
import { SwitchInput } from "@app/components/SwitchInput";
import { useRouter } from "next/navigation";
import { isTargetValid } from "@server/lib/validators";
import { tlsNameSchema } from "@server/lib/schemas";
const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid),
method: z.string().nullable(),
port: z.coerce.number().int().positive()
// protocol: z.string(),
});
const targetsSettingsSchema = z.object({
stickySession: z.boolean()
});
type LocalTarget = Omit<
@ -81,6 +86,47 @@ type LocalTarget = Omit<
"protocol"
>;
const proxySettingsSchema = z.object({
setHostHeader: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message:
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
}
)
});
const tlsSettingsSchema = z.object({
ssl: z.boolean(),
tlsServerName: z
.string()
.optional()
.refine(
(data) => {
if (data) {
return tlsNameSchema.safeParse(data).success;
}
return true;
},
{
message:
"Invalid TLS Server Name. Use domain name format, or save empty to remove the TLS Server Name."
}
)
});
type ProxySettingsValues = z.infer<typeof proxySettingsSchema>;
type TlsSettingsValues = z.infer<typeof tlsSettingsSchema>;
type TargetsSettingsValues = z.infer<typeof targetsSettingsSchema>;
export default function ReverseProxyTargets(props: {
params: Promise<{ resourceId: number }>;
}) {
@ -93,10 +139,10 @@ export default function ReverseProxyTargets(props: {
const [targets, setTargets] = useState<LocalTarget[]>([]);
const [site, setSite] = useState<GetSiteResponse>();
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [sslEnabled, setSslEnabled] = useState(resource.ssl);
const [stickySession, setStickySession] = useState(resource.stickySession);
const [loading, setLoading] = useState(false);
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
const [targetsLoading, setTargetsLoading] = useState(false);
const [proxySettingsLoading, setProxySettingsLoading] = useState(false);
const [pageLoading, setPageLoading] = useState(true);
const router = useRouter();
@ -110,6 +156,28 @@ export default function ReverseProxyTargets(props: {
} as z.infer<typeof addTargetSchema>
});
const tlsSettingsForm = useForm<TlsSettingsValues>({
resolver: zodResolver(tlsSettingsSchema),
defaultValues: {
ssl: resource.ssl,
tlsServerName: resource.tlsServerName || ""
}
});
const proxySettingsForm = useForm<ProxySettingsValues>({
resolver: zodResolver(proxySettingsSchema),
defaultValues: {
setHostHeader: resource.setHostHeader || ""
}
});
const targetsSettingsForm = useForm<TargetsSettingsValues>({
resolver: zodResolver(targetsSettingsSchema),
defaultValues: {
stickySession: resource.stickySession
}
});
useEffect(() => {
const fetchTargets = async () => {
try {
@ -230,13 +298,12 @@ export default function ReverseProxyTargets(props: {
async function saveTargets() {
try {
setLoading(true);
setTargetsLoading(true);
for (let target of targets) {
const data = {
ip: target.ip,
port: target.port,
// protocol: target.protocol,
method: target.method,
enabled: target.enabled
};
@ -249,27 +316,22 @@ export default function ReverseProxyTargets(props: {
} else if (target.updated) {
await api.post(`/target/${target.targetId}`, data);
}
setTargets([
...targets.map((t) => {
let res = {
...t,
new: false,
updated: false
};
return res;
})
]);
}
for (const targetId of targetsToRemove) {
await api.delete(`/target/${targetId}`);
setTargets(targets.filter((t) => t.targetId !== targetId));
}
// Save sticky session setting
const stickySessionData = targetsSettingsForm.getValues();
await api.post(`/resource/${params.resourceId}`, {
stickySession: stickySessionData.stickySession
});
updateResource({ stickySession: stickySessionData.stickySession });
toast({
title: "Targets updated",
description: "Targets updated successfully"
description: "Targets and settings updated successfully"
});
setTargetsToRemove([]);
@ -278,72 +340,75 @@ export default function ReverseProxyTargets(props: {
console.error(err);
toast({
variant: "destructive",
title: "Operation failed",
title: "Failed to update targets",
description: formatAxiosError(
err,
"An error occurred during the save operation"
"An error occurred while updating targets"
)
});
} finally {
setTargetsLoading(false);
}
}
setLoading(false);
}
async function saveSsl(val: boolean) {
const res = await api
.post(`/resource/${params.resourceId}`, {
ssl: val
})
.catch((err) => {
async function saveTlsSettings(data: TlsSettingsValues) {
try {
setHttpsTlsLoading(true);
await api.post(`/resource/${params.resourceId}`, {
ssl: data.ssl,
tlsServerName: data.tlsServerName || undefined
});
updateResource({
...resource,
ssl: data.ssl,
tlsServerName: data.tlsServerName || undefined
});
toast({
title: "TLS settings updated",
description: "Your TLS settings have been updated successfully"
});
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update SSL configuration",
title: "Failed to update TLS settings",
description: formatAxiosError(
err,
"An error occurred while updating the SSL configuration"
"An error occurred while updating TLS settings"
)
});
} finally {
setHttpsTlsLoading(false);
}
}
async function saveProxySettings(data: ProxySettingsValues) {
try {
setProxySettingsLoading(true);
await api.post(`/resource/${params.resourceId}`, {
setHostHeader: data.setHostHeader || undefined
});
updateResource({
...resource,
setHostHeader: data.setHostHeader || undefined
});
if (res && res.status === 200) {
setSslEnabled(val);
updateResource({ ssl: val });
toast({
title: "SSL Configuration",
description: "SSL configuration updated successfully"
title: "Proxy settings updated",
description:
"Your proxy settings have been updated successfully"
});
router.refresh();
}
}
async function saveStickySession(val: boolean) {
const res = await api
.post(`/resource/${params.resourceId}`, {
stickySession: val
})
.catch((err) => {
} catch (err) {
console.error(err);
toast({
variant: "destructive",
title: "Failed to update sticky session configuration",
title: "Failed to update proxy settings",
description: formatAxiosError(
err,
"An error occurred while updating the sticky session configuration"
"An error occurred while updating proxy settings"
)
});
});
if (res && res.status === 200) {
setStickySession(val);
updateResource({ stickySession: val });
toast({
title: "Sticky Session Configuration",
description: "Sticky session configuration updated successfully"
});
router.refresh();
} finally {
setProxySettingsLoading(false);
}
}
@ -486,46 +551,128 @@ export default function ReverseProxyTargets(props: {
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Advanced Configuration
HTTPS & TLS Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure advanced settings for your resource
Configure TLS settings for your resource
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
{targets.length >= 2 && (
<SwitchInput
id="sticky-toggle"
label="Enable Sticky Sessions"
description="Keep users on the same backend target for their entire session. Useful for applications like VNC that require persistent connections."
defaultChecked={resource.stickySession}
onCheckedChange={async (val) => {
await saveStickySession(val);
}}
/>
<SettingsSectionForm>
<Form {...tlsSettingsForm}>
<form
onSubmit={tlsSettingsForm.handleSubmit(
saveTlsSettings
)}
className="space-y-4"
id="tls-settings-form"
>
<FormField
control={tlsSettingsForm.control}
name="ssl"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="ssl-toggle"
label="Enable SSL (https)"
defaultChecked={resource.ssl}
onCheckedChange={async (val) => {
await saveSsl(val);
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
<FormField
control={tlsSettingsForm.control}
name="tlsServerName"
render={({ field }) => (
<FormItem>
<FormLabel>
TLS Server Name (SNI)
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The TLS Server Name to use
for SNI. Leave empty to use
the default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={httpsTlsLoading}
form="tls-settings-form"
>
Save HTTPS & TLS Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
{/* Targets Section */}
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Target Configuration
Targets Configuration
</SettingsSectionTitle>
<SettingsSectionDescription>
Set up targets to route traffic to your services
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...targetsSettingsForm}>
<form
onSubmit={targetsSettingsForm.handleSubmit(
saveTargets
)}
className="space-y-4"
id="targets-settings-form"
>
{targets.length >= 2 && (
<FormField
control={targetsSettingsForm.control}
name="stickySession"
render={({ field }) => (
<FormItem>
<FormControl>
<SwitchInput
id="sticky-toggle"
label="Enable Sticky Sessions"
description="Keep connections on the same backend target for their entire session."
defaultChecked={
field.value
}
onCheckedChange={(
val
) => {
field.onChange(val);
}}
/>
</FormControl>
</FormItem>
)}
/>
)}
</form>
</Form>
</SettingsSectionForm>
<Form {...addTargetForm}>
<form
onSubmit={addTargetForm.handleSubmit(addTarget)}
@ -670,13 +817,70 @@ export default function ReverseProxyTargets(props: {
<SettingsSectionFooter>
<Button
onClick={saveTargets}
loading={loading}
disabled={loading}
loading={targetsLoading}
disabled={targetsLoading}
form="targets-settings-form"
>
Save Targets
</Button>
</SettingsSectionFooter>
</SettingsSection>
{resource.http && (
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>
Additional Proxy Settings
</SettingsSectionTitle>
<SettingsSectionDescription>
Configure how your resource handles proxy settings
</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm>
<Form {...proxySettingsForm}>
<form
onSubmit={proxySettingsForm.handleSubmit(
saveProxySettings
)}
className="space-y-4"
id="proxy-settings-form"
>
<FormField
control={proxySettingsForm.control}
name="setHostHeader"
render={({ field }) => (
<FormItem>
<FormLabel>
Custom Host Header
</FormLabel>
<FormControl>
<Input {...field} />
</FormControl>
<FormDescription>
The Host header to set when
proxying requests. Leave
empty to use the default.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button
type="submit"
loading={proxySettingsLoading}
form="proxy-settings-form"
>
Save Proxy Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>
)}
</SettingsContainer>
);
}

View file

@ -227,11 +227,11 @@ export default function CreateSiteForm({
mbIn:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
: "-",
mbOut:
data.type == "wireguard" || data.type == "newt"
? "0 MB"
: "--",
: "-",
orgId: orgId as string,
type: data.type as any,
online: false

View file

@ -163,7 +163,7 @@ export default function SitesTable({ sites, orgId }: SitesTableProps) {
);
}
} else {
return <span>--</span>;
return <span>-</span>;
}
}
},

View file

@ -134,7 +134,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
Save Settings
Save General Settings
</Button>
</SettingsSectionFooter>
</SettingsSection>

View file

@ -25,7 +25,7 @@ export default async function SitesPage(props: SitesPageProps) {
function formatSize(mb: number, type: string): string {
if (type === "local") {
return "--"; // because we are not able to track the data use in a local site right now
return "-"; // because we are not able to track the data use in a local site right now
}
if (mb >= 1024 * 1024) {
return `${(mb / (1024 * 1024)).toFixed(2)} TB`;

View file

@ -499,7 +499,7 @@ export default function GeneralPage() {
loading={loading}
disabled={loading}
>
Save Settings
Save General Settings
</Button>
</div>
</>

View file

@ -37,8 +37,8 @@ export function Breadcrumbs() {
// label = "Roles";
// } else if (segment === "invitations") {
// label = "Invitations";
// } else if (segment === "connectivity") {
// label = "Connectivity";
// } else if (segment === "proxy") {
// label = "proxy";
// } else if (segment === "authentication") {
// label = "Authentication";
// }