Headers input working on resource

This commit is contained in:
Owen
2025-09-11 13:58:12 -07:00
parent 1eacb8ff36
commit 2efd5c31ab
5 changed files with 326 additions and 100 deletions

View File

@@ -1505,5 +1505,8 @@
"internationaldomaindetected": "International Domain Detected", "internationaldomaindetected": "International Domain Detected",
"willbestoredas": "Will be stored as:", "willbestoredas": "Will be stored as:",
"idpGoogleDescription": "Google OAuth2/OIDC provider", "idpGoogleDescription": "Google OAuth2/OIDC provider",
"idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider" "idpAzureDescription": "Microsoft Azure OAuth2/OIDC provider",
"customHeaders": "Custom Headers",
"customHeadersDescription": "Headers new line separated: Header-Name: value",
"headersValidationError": "Headers must be in the format: Header-Name: value"
} }

View File

@@ -129,6 +129,40 @@ export function isValidDomain(domain: string): boolean {
return true; return true;
} }
export function validateHeaders(headers: string): boolean {
// Validate comma-separated headers in format "Header-Name: value"
const headerPairs = headers.split(",").map((pair) => pair.trim());
return headerPairs.every((pair) => {
// Check if the pair contains exactly one colon
const colonCount = (pair.match(/:/g) || []).length;
if (colonCount !== 1) {
return false;
}
const colonIndex = pair.indexOf(":");
if (colonIndex === 0 || colonIndex === pair.length - 1) {
return false;
}
const headerName = pair.substring(0, colonIndex).trim();
const headerValue = pair.substring(colonIndex + 1).trim();
// Header name should not be empty and should contain valid characters
// Header names are case-insensitive and can contain alphanumeric, hyphens
const headerNameRegex = /^[a-zA-Z0-9\-_]+$/;
if (!headerName || !headerNameRegex.test(headerName)) {
return false;
}
// Header value should not be empty and should not contain colons
if (!headerValue || headerValue.includes(":")) {
return false;
}
return true;
});
}
const validTlds = [ const validTlds = [
"AAA", "AAA",
"AARP", "AARP",

View File

@@ -21,6 +21,7 @@ import { subdomainSchema } from "@server/lib/schemas";
import { registry } from "@server/openApi"; import { registry } from "@server/openApi";
import { OpenAPITags } from "@server/openApi"; import { OpenAPITags } from "@server/openApi";
import { validateAndConstructDomain } from "@server/lib/domainUtils"; import { validateAndConstructDomain } from "@server/lib/domainUtils";
import { validateHeaders } from "@server/lib/validators";
const updateResourceParamsSchema = z const updateResourceParamsSchema = z
.object({ .object({
@@ -45,7 +46,8 @@ const updateHttpResourceBodySchema = z
stickySession: z.boolean().optional(), stickySession: z.boolean().optional(),
tlsServerName: z.string().nullable().optional(), tlsServerName: z.string().nullable().optional(),
setHostHeader: z.string().nullable().optional(), setHostHeader: z.string().nullable().optional(),
skipToIdpId: z.number().int().positive().nullable().optional() skipToIdpId: z.number().int().positive().nullable().optional(),
headers: z.string().nullable().optional()
}) })
.strict() .strict()
.refine((data) => Object.keys(data).length > 0, { .refine((data) => Object.keys(data).length > 0, {
@@ -83,6 +85,18 @@ const updateHttpResourceBodySchema = z
message: message:
"Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header." "Invalid custom Host Header value. Use domain name format, or save empty to unset custom Host Header."
} }
)
.refine(
(data) => {
if (data.headers) {
return validateHeaders(data.headers)
}
return true;
},
{
message:
"Invalid headers format. Use comma-separated format: 'Header-Name: value, Another-Header: another-value'. Header values cannot contain colons."
}
); );
export type UpdateResourceResponse = Resource; export type UpdateResourceResponse = Resource;

View File

@@ -95,6 +95,7 @@ import {
} from "@app/components/ui/command"; } from "@app/components/ui/command";
import { Badge } from "@app/components/ui/badge"; import { Badge } from "@app/components/ui/badge";
import { parseHostTarget } from "@app/lib/parseHostTarget"; import { parseHostTarget } from "@app/lib/parseHostTarget";
import { HeadersInput } from "@app/components/HeadersInput";
const addTargetSchema = z.object({ const addTargetSchema = z.object({
ip: z.string().refine(isTargetValid), ip: z.string().refine(isTargetValid),
@@ -129,7 +130,9 @@ export default function ReverseProxyTargets(props: {
const [targets, setTargets] = useState<LocalTarget[]>([]); const [targets, setTargets] = useState<LocalTarget[]>([]);
const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]); const [targetsToRemove, setTargetsToRemove] = useState<number[]>([]);
const [sites, setSites] = useState<ListSitesResponse["sites"]>([]); const [sites, setSites] = useState<ListSitesResponse["sites"]>([]);
const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(new Map()); const [dockerStates, setDockerStates] = useState<Map<number, DockerState>>(
new Map()
);
const initializeDockerForSite = async (siteId: number) => { const initializeDockerForSite = async (siteId: number) => {
if (dockerStates.has(siteId)) { if (dockerStates.has(siteId)) {
@@ -139,14 +142,14 @@ export default function ReverseProxyTargets(props: {
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const dockerState = await dockerManager.initializeDocker(); const dockerState = await dockerManager.initializeDocker();
setDockerStates(prev => new Map(prev.set(siteId, dockerState))); setDockerStates((prev) => new Map(prev.set(siteId, dockerState)));
}; };
const refreshContainersForSite = async (siteId: number) => { const refreshContainersForSite = async (siteId: number) => {
const dockerManager = new DockerManager(api, siteId); const dockerManager = new DockerManager(api, siteId);
const containers = await dockerManager.fetchContainers(); const containers = await dockerManager.fetchContainers();
setDockerStates(prev => { setDockerStates((prev) => {
const newMap = new Map(prev); const newMap = new Map(prev);
const existingState = newMap.get(siteId); const existingState = newMap.get(siteId);
if (existingState) { if (existingState) {
@@ -157,11 +160,13 @@ export default function ReverseProxyTargets(props: {
}; };
const getDockerStateForSite = (siteId: number): DockerState => { const getDockerStateForSite = (siteId: number): DockerState => {
return dockerStates.get(siteId) || { return (
isEnabled: false, dockerStates.get(siteId) || {
isAvailable: false, isEnabled: false,
containers: [] isAvailable: false,
}; containers: []
}
);
}; };
const [httpsTlsLoading, setHttpsTlsLoading] = useState(false); const [httpsTlsLoading, setHttpsTlsLoading] = useState(false);
@@ -185,7 +190,8 @@ export default function ReverseProxyTargets(props: {
{ {
message: t("proxyErrorInvalidHeader") message: t("proxyErrorInvalidHeader")
} }
) ),
headers: z.string().optional()
}); });
const tlsSettingsSchema = z.object({ const tlsSettingsSchema = z.object({
@@ -241,7 +247,8 @@ export default function ReverseProxyTargets(props: {
const proxySettingsForm = useForm<ProxySettingsValues>({ const proxySettingsForm = useForm<ProxySettingsValues>({
resolver: zodResolver(proxySettingsSchema), resolver: zodResolver(proxySettingsSchema),
defaultValues: { defaultValues: {
setHostHeader: resource.setHostHeader || "" setHostHeader: resource.setHostHeader || "",
headers: resource.headers || ""
} }
}); });
@@ -298,7 +305,9 @@ export default function ReverseProxyTargets(props: {
setSites(res.data.data.sites); setSites(res.data.data.sites);
// Initialize Docker for newt sites // Initialize Docker for newt sites
const newtSites = res.data.data.sites.filter(site => site.type === "newt"); const newtSites = res.data.data.sites.filter(
(site) => site.type === "newt"
);
for (const site of newtSites) { for (const site of newtSites) {
initializeDockerForSite(site.siteId); initializeDockerForSite(site.siteId);
} }
@@ -418,11 +427,11 @@ export default function ReverseProxyTargets(props: {
targets.map((target) => targets.map((target) =>
target.targetId === targetId target.targetId === targetId
? { ? {
...target, ...target,
...data, ...data,
updated: true, updated: true,
siteType: site?.type || null siteType: site?.type || null
} }
: target : target
) )
); );
@@ -471,7 +480,8 @@ export default function ReverseProxyTargets(props: {
stickySession: stickySessionData.stickySession, stickySession: stickySessionData.stickySession,
ssl: tlsData.ssl, ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null, tlsServerName: tlsData.tlsServerName || null,
setHostHeader: proxyData.setHostHeader || null setHostHeader: proxyData.setHostHeader || null,
headers: proxyData.headers || null
}; };
// Single API call to update all settings // Single API call to update all settings
@@ -483,7 +493,8 @@ export default function ReverseProxyTargets(props: {
stickySession: stickySessionData.stickySession, stickySession: stickySessionData.stickySession,
ssl: tlsData.ssl, ssl: tlsData.ssl,
tlsServerName: tlsData.tlsServerName || null, tlsServerName: tlsData.tlsServerName || null,
setHostHeader: proxyData.setHostHeader || null setHostHeader: proxyData.setHostHeader || null,
headers: proxyData.headers || null
}); });
} }
@@ -546,7 +557,7 @@ export default function ReverseProxyTargets(props: {
className={cn( className={cn(
"justify-between flex-1", "justify-between flex-1",
!row.original.siteId && !row.original.siteId &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{row.original.siteId {row.original.siteId
@@ -597,49 +608,59 @@ export default function ReverseProxyTargets(props: {
</Command> </Command>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
{selectedSite && selectedSite.type === "newt" && (() => { {selectedSite &&
const dockerState = getDockerStateForSite(selectedSite.siteId); selectedSite.type === "newt" &&
return ( (() => {
<ContainersSelector const dockerState = getDockerStateForSite(
site={selectedSite} selectedSite.siteId
containers={dockerState.containers} );
isAvailable={dockerState.isAvailable} return (
onContainerSelect={handleContainerSelectForTarget} <ContainersSelector
onRefresh={() => refreshContainersForSite(selectedSite.siteId)} site={selectedSite}
/> containers={dockerState.containers}
); isAvailable={dockerState.isAvailable}
})()} onContainerSelect={
handleContainerSelectForTarget
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()}
</div> </div>
); );
} }
}, },
...(resource.http ...(resource.http
? [ ? [
{ {
accessorKey: "method", accessorKey: "method",
header: t("method"), header: t("method"),
cell: ({ row }: { row: Row<LocalTarget> }) => ( cell: ({ row }: { row: Row<LocalTarget> }) => (
<Select <Select
defaultValue={row.original.method ?? ""} defaultValue={row.original.method ?? ""}
onValueChange={(value) => onValueChange={(value) =>
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original, ...row.original,
method: value method: value
}) })
} }
> >
<SelectTrigger> <SelectTrigger>
{row.original.method} {row.original.method}
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="http">http</SelectItem> <SelectItem value="http">http</SelectItem>
<SelectItem value="https">https</SelectItem> <SelectItem value="https">https</SelectItem>
<SelectItem value="h2c">h2c</SelectItem> <SelectItem value="h2c">h2c</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
) )
} }
] ]
: []), : []),
{ {
accessorKey: "ip", accessorKey: "ip",
@@ -658,9 +679,13 @@ export default function ReverseProxyTargets(props: {
if (parsed) { if (parsed) {
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
...row.original, ...row.original,
method: hasProtocol ? parsed.protocol : row.original.method, method: hasProtocol
? parsed.protocol
: row.original.method,
ip: parsed.host, ip: parsed.host,
port: hasPort ? parsed.port : row.original.port port: hasPort
? parsed.port
: row.original.port
}); });
} else { } else {
updateTarget(row.original.targetId, { updateTarget(row.original.targetId, {
@@ -807,21 +832,21 @@ export default function ReverseProxyTargets(props: {
className={cn( className={cn(
"justify-between flex-1", "justify-between flex-1",
!field.value && !field.value &&
"text-muted-foreground" "text-muted-foreground"
)} )}
> >
{field.value {field.value
? sites.find( ? sites.find(
( (
site site
) => ) =>
site.siteId === site.siteId ===
field.value field.value
) )
?.name ?.name
: t( : t(
"siteSelect" "siteSelect"
)} )}
<CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" /> <CaretSortIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button> </Button>
</FormControl> </FormControl>
@@ -887,18 +912,35 @@ export default function ReverseProxyTargets(props: {
); );
return selectedSite && return selectedSite &&
selectedSite.type === selectedSite.type ===
"newt" ? (() => { "newt"
const dockerState = getDockerStateForSite(selectedSite.siteId); ? (() => {
return ( const dockerState =
<ContainersSelector getDockerStateForSite(
site={selectedSite} selectedSite.siteId
containers={dockerState.containers} );
isAvailable={dockerState.isAvailable} return (
onContainerSelect={handleContainerSelect} <ContainersSelector
onRefresh={() => refreshContainersForSite(selectedSite.siteId)} site={
/> selectedSite
); }
})() : null; containers={
dockerState.containers
}
isAvailable={
dockerState.isAvailable
}
onContainerSelect={
handleContainerSelect
}
onRefresh={() =>
refreshContainersForSite(
selectedSite.siteId
)
}
/>
);
})()
: null;
})()} })()}
</div> </div>
<FormMessage /> <FormMessage />
@@ -964,25 +1006,59 @@ export default function ReverseProxyTargets(props: {
name="ip" name="ip"
render={({ field }) => ( render={({ field }) => (
<FormItem className="relative"> <FormItem className="relative">
<FormLabel>{t("targetAddr")}</FormLabel> <FormLabel>
{t("targetAddr")}
</FormLabel>
<FormControl> <FormControl>
<Input <Input
id="ip" id="ip"
{...field} {...field}
onBlur={(e) => { onBlur={(e) => {
const input = e.target.value.trim(); const input =
const hasProtocol = /^(https?|h2c):\/\//.test(input); e.target.value.trim();
const hasPort = /:\d+(?:\/|$)/.test(input); const hasProtocol =
/^(https?|h2c):\/\//.test(
input
);
const hasPort =
/:\d+(?:\/|$)/.test(
input
);
if (hasProtocol || hasPort) { if (
const parsed = parseHostTarget(input); hasProtocol ||
hasPort
) {
const parsed =
parseHostTarget(
input
);
if (parsed) { if (parsed) {
if (hasProtocol || !addTargetForm.getValues("method")) { if (
addTargetForm.setValue("method", parsed.protocol); hasProtocol ||
!addTargetForm.getValues(
"method"
)
) {
addTargetForm.setValue(
"method",
parsed.protocol
);
} }
addTargetForm.setValue("ip", parsed.host); addTargetForm.setValue(
if (hasPort || !addTargetForm.getValues("port")) { "ip",
addTargetForm.setValue("port", parsed.port); parsed.host
);
if (
hasPort ||
!addTargetForm.getValues(
"port"
)
) {
addTargetForm.setValue(
"port",
parsed.port
);
} }
} }
} else { } else {
@@ -1091,12 +1167,12 @@ export default function ReverseProxyTargets(props: {
{header.isPlaceholder {header.isPlaceholder
? null ? null
: flexRender( : flexRender(
header header
.column .column
.columnDef .columnDef
.header, .header,
header.getContext() header.getContext()
)} )}
</TableHead> </TableHead>
) )
)} )}
@@ -1256,6 +1332,36 @@ export default function ReverseProxyTargets(props: {
</FormItem> </FormItem>
)} )}
/> />
<FormField
control={proxySettingsForm.control}
name="headers"
render={({ field }) => (
<FormItem>
<FormLabel className="text-base font-semibold">
{t("customHeaders")}
</FormLabel>
<FormControl>
<HeadersInput
value={
field.value || ""
}
onChange={(value) => {
field.onChange(
value
);
}}
rows={4}
/>
</FormControl>
<FormDescription>
{t(
"customHeadersDescription"
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</form> </form>
</Form> </Form>
</SettingsSectionForm> </SettingsSectionForm>

View File

@@ -0,0 +1,69 @@
"use client";
import { useEffect, useState } from "react";
import { Textarea } from "@/components/ui/textarea";
interface HeadersInputProps {
value?: string;
onChange: (value: string) => void;
placeholder?: string;
rows?: number;
className?: string;
}
export function HeadersInput({
value = "",
onChange,
placeholder = `X-Example-Header: example-value
X-Another-Header: another-value`,
rows = 4,
className
}: HeadersInputProps) {
const [internalValue, setInternalValue] = useState("");
// Convert comma-separated to newline-separated for display
const convertToNewlineSeparated = (commaSeparated: string): string => {
if (!commaSeparated || commaSeparated.trim() === "") return "";
return commaSeparated
.split(',')
.map(header => header.trim())
.filter(header => header.length > 0)
.join('\n');
};
// Convert newline-separated to comma-separated for output
const convertToCommaSeparated = (newlineSeparated: string): string => {
if (!newlineSeparated || newlineSeparated.trim() === "") return "";
return newlineSeparated
.split('\n')
.map(header => header.trim())
.filter(header => header.length > 0)
.join(', ');
};
// Update internal value when external value changes
useEffect(() => {
setInternalValue(convertToNewlineSeparated(value));
}, [value]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setInternalValue(newValue);
// Convert back to comma-separated format for the parent
const commaSeparatedValue = convertToCommaSeparated(newValue);
onChange(commaSeparatedValue);
};
return (
<Textarea
value={internalValue}
onChange={handleChange}
placeholder={placeholder}
rows={rows}
className={className}
/>
);
}