Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0841caecbb | ||
|
|
c7846760d1 | ||
|
|
8c283b6ef9 | ||
|
|
34ae3b4da6 |
8
package-lock.json
generated
8
package-lock.json
generated
@@ -59,7 +59,7 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
@@ -6923,9 +6923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.562.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
||||
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
||||
"version": "0.566.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.566.0.tgz",
|
||||
"integrity": "sha512-b18qC/JAh1X9rVKlF5EtSIyumdIYuh78b0JShynZnHbcaWR4AW4oZyi8Ms/aQYVSnLPlAnMhug2hSr19BgVZAw==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
|
||||
@@ -67,7 +67,7 @@
|
||||
"ip-cidr": "^3.1.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.23",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^0.566.0",
|
||||
"next": "^16.1.7",
|
||||
"next-themes": "^0.2.1",
|
||||
"punycode": "^2.3.1",
|
||||
|
||||
@@ -74,7 +74,7 @@ export const buttonVariants = cva(
|
||||
"",
|
||||
],
|
||||
"danger-text": [
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50",
|
||||
"dark:bg-transparent dark:text-red-500 dark:hover:text-red-600 dark:border-transparent !px-0 !shadow-none !py-0 focus:ring-red-500/30 dark:ring-offset-neutral-950/50 rounded-sm",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
|
||||
@@ -98,7 +98,7 @@ const SelectItem = React.forwardRef<
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="flex-shrink-0">{icon}</span>
|
||||
<div className="flex flex-col">
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
|
||||
@@ -48,6 +48,9 @@ interface SelectDropdownProps {
|
||||
children?: React.ReactNode;
|
||||
maxHeight?: number;
|
||||
triggerClassName?: string;
|
||||
iconSize?: number;
|
||||
truncate?: boolean;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function SelectDropdown({
|
||||
@@ -68,6 +71,9 @@ export function SelectDropdown({
|
||||
children,
|
||||
maxHeight,
|
||||
triggerClassName,
|
||||
iconSize = 14,
|
||||
truncate = false,
|
||||
compact = false,
|
||||
}: Readonly<SelectDropdownProps>) {
|
||||
const [inputRef, { width }] = useElementSize<HTMLButtonElement>();
|
||||
|
||||
@@ -107,15 +113,18 @@ export function SelectDropdown({
|
||||
|
||||
const SelectedItem = () => {
|
||||
return (
|
||||
<div className={"flex items-center gap-2.5"}>
|
||||
{selected?.icon && <selected.icon size={14} width={14} />}
|
||||
<div className={cn("flex items-center gap-2.5", truncate && "min-w-0")}>
|
||||
{selected?.icon && <selected.icon size={iconSize} width={iconSize} />}
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col text-sm font-medium",
|
||||
size === "xs" && "text-xs",
|
||||
truncate && "min-w-0",
|
||||
)}
|
||||
>
|
||||
<span className={"text-nb-gray-200"}>{selected?.label}</span>
|
||||
<span className={cn("text-nb-gray-200", truncate && "truncate")}>
|
||||
{selected?.label}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -216,20 +225,22 @@ export function SelectDropdown({
|
||||
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"overflow-y-auto flex flex-col gap-1 pl-2 pr-3",
|
||||
!showSearch && "pt-2",
|
||||
"overflow-y-auto flex flex-col gap-1",
|
||||
compact ? "pl-1 pr-1" : "pl-2 pr-3",
|
||||
!showSearch && (compact ? "pt-1" : "pt-2"),
|
||||
)}
|
||||
style={{
|
||||
maxHeight: maxHeight ?? 380,
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
<div className={"grid grid-cols-1 gap-1 pb-2 w-full"}>
|
||||
<div className={cn("grid grid-cols-1 gap-1 w-full", compact ? "pb-1" : "pb-2")}>
|
||||
{filteredItems.map((option) => (
|
||||
<SelectDropdownItem
|
||||
option={option}
|
||||
toggle={toggle}
|
||||
key={option.value}
|
||||
iconSize={iconSize}
|
||||
showValue={showValues}
|
||||
size={size}
|
||||
/>
|
||||
@@ -249,11 +260,13 @@ const SelectDropdownItem = ({
|
||||
toggle,
|
||||
showValue = false,
|
||||
size = "sm",
|
||||
iconSize = 14,
|
||||
}: {
|
||||
option: SelectOption;
|
||||
toggle: (value: string) => void;
|
||||
showValue?: boolean;
|
||||
size: "xs" | "sm";
|
||||
iconSize?: number;
|
||||
}) => {
|
||||
const value = option.value || "" + option.label || "";
|
||||
const elementRef = useRef<HTMLDivElement>(null);
|
||||
@@ -285,7 +298,12 @@ const SelectDropdownItem = ({
|
||||
option?.disabled && "cursor-not-allowed",
|
||||
)}
|
||||
>
|
||||
{option.icon && <option.icon size={14} width={14} />}
|
||||
{option.icon && (
|
||||
<div className={"shrink-0"}>
|
||||
<option.icon size={iconSize} width={iconSize} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{option?.renderItem && option.renderItem()}
|
||||
{!option?.renderItem && (
|
||||
<div
|
||||
|
||||
@@ -53,6 +53,7 @@ declare module "@tanstack/table-core" {
|
||||
}
|
||||
interface SortingFns {
|
||||
checkbox: SortingFn<unknown>;
|
||||
datetime: SortingFn<unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +100,15 @@ const arrIncludesSomeExact: FilterFn<any> = (
|
||||
return value.some((val) => val === rowValue);
|
||||
};
|
||||
|
||||
const datetimeSort: SortingFn<any> = (rowA, rowB, columnId) => {
|
||||
const aConnected = rowA.original?.connected;
|
||||
const bConnected = rowB.original?.connected;
|
||||
if (aConnected !== bConnected) return aConnected ? 1 : -1;
|
||||
const a = dayjs(rowA.getValue(columnId)).valueOf();
|
||||
const b = dayjs(rowB.getValue(columnId)).valueOf();
|
||||
return a - b;
|
||||
};
|
||||
|
||||
const checkboxSort: SortingFn<any> = (rowA, rowB, columnId) => {
|
||||
const valueA =
|
||||
columnId === "select" ? rowA.getIsSelected() : rowA.getValue(columnId);
|
||||
@@ -324,6 +334,7 @@ export function DataTable<TData, TValue>({
|
||||
},
|
||||
sortingFns: {
|
||||
checkbox: checkboxSort,
|
||||
datetime: datetimeSort,
|
||||
},
|
||||
getRowId: useRowId ? (row) => row.id : undefined,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
|
||||
@@ -14,6 +14,7 @@ type Props = {
|
||||
center?: boolean;
|
||||
className?: string;
|
||||
sorting?: boolean;
|
||||
onSort?: () => void;
|
||||
name?: string;
|
||||
};
|
||||
export default function DataTableHeader({
|
||||
@@ -23,14 +24,20 @@ export default function DataTableHeader({
|
||||
center,
|
||||
className,
|
||||
sorting = true,
|
||||
onSort,
|
||||
name,
|
||||
}: Props) {
|
||||
const serverPagination = useOptionalServerPagination();
|
||||
|
||||
const handleSort = () => {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
column.toggleSorting(direction === "desc");
|
||||
if (onSort) {
|
||||
onSort();
|
||||
} else {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
column.toggleSorting(direction === "desc");
|
||||
}
|
||||
if (name && serverPagination?.setSort) {
|
||||
const direction = column.getIsSorted() === "asc" ? "desc" : "asc";
|
||||
serverPagination.setSort(name, direction);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -9,8 +9,11 @@ import { useCountries } from "@/contexts/CountryProvider";
|
||||
type Props = {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
iconSize?: number;
|
||||
popoverWidth?: "auto" | "content" | number;
|
||||
truncate?: boolean;
|
||||
};
|
||||
export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
export const CountrySelector = ({ value, onChange, iconSize = 20, popoverWidth, truncate }: Props) => {
|
||||
const { countries, isLoading } = useCountries();
|
||||
|
||||
const countryList = useMemo(() => {
|
||||
@@ -22,7 +25,7 @@ export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
}) =>
|
||||
createElement(RoundedFlag, {
|
||||
country: country.country_code,
|
||||
size: 20,
|
||||
size: iconSize,
|
||||
...props,
|
||||
});
|
||||
return {
|
||||
@@ -42,7 +45,10 @@ export const CountrySelector = ({ value, onChange }: Props) => {
|
||||
searchPlaceholder={"Search country..."}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
iconSize={iconSize}
|
||||
options={countryList || []}
|
||||
popoverWidth={popoverWidth}
|
||||
truncate={truncate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,11 @@ const CountryContext = React.createContext(
|
||||
countries: Country[] | undefined;
|
||||
isLoading: boolean;
|
||||
getRegionByPeer: (peer: Peer) => string;
|
||||
getRegionText: (country_code: string, city_name: string) => string;
|
||||
getRegionText: (
|
||||
country_code: string,
|
||||
city_name: string,
|
||||
subdivision_code?: string,
|
||||
) => string;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -21,7 +25,11 @@ export default function CountryProvider({ children }: Props) {
|
||||
const { isRestricted } = usePermissions();
|
||||
|
||||
const getRegionByPeer = (peer: Peer) => "Unknown";
|
||||
const getRegionText = (country_code: string, city_name: string) => "Unknown";
|
||||
const getRegionText = (
|
||||
country_code: string,
|
||||
city_name: string,
|
||||
subdivision_code?: string,
|
||||
) => "Unknown";
|
||||
|
||||
return isRestricted ? (
|
||||
<CountryContext.Provider
|
||||
@@ -47,12 +55,14 @@ function CountryProviderContent({ children }: Props) {
|
||||
);
|
||||
|
||||
const getRegionText = useCallback(
|
||||
(country_code: string, city_name: string) => {
|
||||
(country_code: string, city_name: string, subdivision_code?: string) => {
|
||||
if (!countries) return "Unknown";
|
||||
const country = countries.find((c) => c.country_code === country_code);
|
||||
if (!country) return "Unknown";
|
||||
if (!city_name) return country.country_name;
|
||||
return `${country.country_name}, ${city_name}`;
|
||||
const parts = [country.country_name];
|
||||
if (subdivision_code) parts.push(subdivision_code);
|
||||
if (city_name) parts.push(city_name);
|
||||
return parts.join(", ");
|
||||
},
|
||||
[countries],
|
||||
);
|
||||
|
||||
@@ -18,9 +18,17 @@ export interface ReverseProxy {
|
||||
pass_host_header?: boolean;
|
||||
rewrite_redirects?: boolean;
|
||||
auth?: ReverseProxyAuth;
|
||||
access_restrictions?: AccessRestrictions;
|
||||
meta?: ReverseProxyMeta;
|
||||
}
|
||||
|
||||
export interface AccessRestrictions {
|
||||
allowed_cidrs?: string[];
|
||||
blocked_cidrs?: string[];
|
||||
allowed_countries?: string[];
|
||||
blocked_countries?: string[];
|
||||
}
|
||||
|
||||
export interface ReverseProxyMeta {
|
||||
created_at: string;
|
||||
status: ReverseProxyStatus;
|
||||
@@ -77,6 +85,13 @@ export interface ReverseProxyAuth {
|
||||
link_auth?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
header_auths?: HeaderAuthConfig[];
|
||||
}
|
||||
|
||||
export interface HeaderAuthConfig {
|
||||
enabled: boolean;
|
||||
header: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface ReverseProxyDomain {
|
||||
@@ -86,6 +101,7 @@ export interface ReverseProxyDomain {
|
||||
type: ReverseProxyDomainType;
|
||||
target_cluster?: string;
|
||||
supports_custom_ports?: boolean;
|
||||
require_subdomain?: boolean;
|
||||
}
|
||||
|
||||
export enum ReverseProxyDomainType {
|
||||
@@ -129,6 +145,7 @@ export interface ReverseProxyEvent {
|
||||
auth_method_used?: string;
|
||||
country_code?: string;
|
||||
city_name?: string;
|
||||
subdivision_code?: string;
|
||||
bytes_upload: number;
|
||||
bytes_download: number;
|
||||
protocol?: EventProtocol;
|
||||
@@ -181,5 +198,8 @@ export const REVERSE_PROXY_DOMAIN_VERIFICATION_LINK =
|
||||
export const REVERSE_PROXY_EVENTS_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy/access-logs";
|
||||
|
||||
export const REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy";
|
||||
|
||||
export const REVERSE_PROXY_TROUBLESHOOTING_DOCS_LINK =
|
||||
"https://docs.netbird.io/manage/reverse-proxy#troubleshooting";
|
||||
|
||||
@@ -91,10 +91,11 @@ export function DNSZoneModalContent({
|
||||
if (domain == "") return "";
|
||||
const valid = validator.isValidDomain(domain, {
|
||||
allowWildcard: false,
|
||||
allowOnlyTld: false,
|
||||
allowOnlyTld: true,
|
||||
preventLeadingAndTrailingDots: true,
|
||||
});
|
||||
if (!valid) {
|
||||
return "Please enter a valid domain, e.g. company.internal or intra.example.com";
|
||||
return "Please enter a valid domain, e.g. internal, company.internal or intra.example.com";
|
||||
}
|
||||
}, [domain]);
|
||||
|
||||
|
||||
@@ -138,8 +138,18 @@ const PeersTableColumns: ColumnDef<Peer>[] = [
|
||||
},
|
||||
{
|
||||
accessorKey: "last_seen",
|
||||
header: ({ column }) => {
|
||||
return <DataTableHeader column={column}>Last seen</DataTableHeader>;
|
||||
header: ({ column, table }) => {
|
||||
return (
|
||||
<DataTableHeader
|
||||
column={column}
|
||||
onSort={() => {
|
||||
const desc = column.getIsSorted() === "desc";
|
||||
table.setSorting([{ id: "last_seen", desc: !desc }]);
|
||||
}}
|
||||
>
|
||||
Last seen
|
||||
</DataTableHeader>
|
||||
);
|
||||
},
|
||||
sortingFn: "datetime",
|
||||
cell: ({ row }) => <PeerLastSeenCell peer={row.original} />,
|
||||
@@ -226,17 +236,13 @@ export default function PeersTable({
|
||||
"netbird-table-sort" + path,
|
||||
[
|
||||
{
|
||||
id: "connected",
|
||||
id: "last_seen",
|
||||
desc: true,
|
||||
},
|
||||
{
|
||||
id: "name",
|
||||
desc: false,
|
||||
},
|
||||
{
|
||||
id: "last_seen",
|
||||
desc: true,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
315
src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Normal file
315
src/modules/reverse-proxy/ReverseProxyAccessControlRules.tsx
Normal file
@@ -0,0 +1,315 @@
|
||||
import { useEffect, useMemo, useReducer, useRef } from "react";
|
||||
import { Label } from "@components/Label";
|
||||
import HelpText from "@components/HelpText";
|
||||
import Button from "@components/Button";
|
||||
import { Input } from "@components/Input";
|
||||
import cidr from "ip-cidr";
|
||||
import {
|
||||
FlagIcon,
|
||||
MinusCircleIcon,
|
||||
NetworkIcon,
|
||||
PlusIcon,
|
||||
ShieldCheckIcon,
|
||||
ShieldXIcon,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import { CountrySelector } from "@/components/ui/CountrySelector";
|
||||
import { AccessRestrictions } from "@/interfaces/ReverseProxy";
|
||||
|
||||
type AccessAction = "allow" | "block";
|
||||
type AccessRuleType = "country" | "ip" | "cidr";
|
||||
|
||||
const ACTION_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "Allow Only",
|
||||
value: "allow",
|
||||
icon: (props) => <ShieldCheckIcon {...props} className="text-green-500" />,
|
||||
},
|
||||
{
|
||||
label: "Block Only",
|
||||
value: "block",
|
||||
icon: (props) => <ShieldXIcon {...props} className="text-red-500" />,
|
||||
},
|
||||
];
|
||||
|
||||
const TYPE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
label: "Country",
|
||||
value: "country",
|
||||
icon: (props) => <FlagIcon {...props} />,
|
||||
},
|
||||
{
|
||||
label: "IP Address",
|
||||
value: "ip",
|
||||
icon: (props) => <WorkflowIcon {...props} />,
|
||||
},
|
||||
{
|
||||
label: "CIDR Block",
|
||||
value: "cidr",
|
||||
icon: (props) => <NetworkIcon {...props} />,
|
||||
},
|
||||
];
|
||||
|
||||
type AccessRule = {
|
||||
id: string;
|
||||
action: AccessAction;
|
||||
type: AccessRuleType;
|
||||
value: string;
|
||||
};
|
||||
|
||||
type RulesAction =
|
||||
| { type: "add" }
|
||||
| { type: "remove"; id: string }
|
||||
| {
|
||||
type: "update";
|
||||
id: string;
|
||||
field: "action" | "type" | "value";
|
||||
value: string;
|
||||
};
|
||||
|
||||
const nextId = () => crypto.randomUUID();
|
||||
|
||||
function rulesReducer(state: AccessRule[], action: RulesAction): AccessRule[] {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [
|
||||
...state,
|
||||
{ id: nextId(), action: "allow", type: "country", value: "" },
|
||||
];
|
||||
case "remove":
|
||||
return state.filter((r) => r.id !== action.id);
|
||||
case "update":
|
||||
return state.map((r) => {
|
||||
if (r.id !== action.id) return r;
|
||||
if (action.field === "type") {
|
||||
return { ...r, type: action.value as AccessRuleType, value: "" };
|
||||
}
|
||||
return { ...r, [action.field]: action.value };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function restrictionsToRules(
|
||||
restrictions: AccessRestrictions | undefined,
|
||||
): AccessRule[] {
|
||||
if (!restrictions) return [];
|
||||
const rules: AccessRule[] = [];
|
||||
restrictions.allowed_countries?.forEach((v) =>
|
||||
rules.push({ id: nextId(), action: "allow", type: "country", value: v }),
|
||||
);
|
||||
restrictions.blocked_countries?.forEach((v) =>
|
||||
rules.push({ id: nextId(), action: "block", type: "country", value: v }),
|
||||
);
|
||||
restrictions.allowed_cidrs?.forEach((v) => {
|
||||
const isIp = v.endsWith("/32");
|
||||
rules.push({ id: nextId(), action: "allow", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
|
||||
});
|
||||
restrictions.blocked_cidrs?.forEach((v) => {
|
||||
const isIp = v.endsWith("/32");
|
||||
rules.push({ id: nextId(), action: "block", type: isIp ? "ip" : "cidr", value: isIp ? v.replace(/\/32$/, "") : v });
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
|
||||
function rulesToRestrictions(
|
||||
rules: AccessRule[],
|
||||
): AccessRestrictions | undefined {
|
||||
const allowed_countries: string[] = [];
|
||||
const blocked_countries: string[] = [];
|
||||
const allowed_cidrs: string[] = [];
|
||||
const blocked_cidrs: string[] = [];
|
||||
|
||||
for (const rule of rules) {
|
||||
if (!rule.value) continue;
|
||||
if (rule.type === "country") {
|
||||
if (rule.action === "allow") allowed_countries.push(rule.value);
|
||||
else blocked_countries.push(rule.value);
|
||||
} else {
|
||||
const value = rule.type === "ip" && !rule.value.includes("/") ? `${rule.value}/32` : rule.value;
|
||||
if (rule.action === "allow") allowed_cidrs.push(value);
|
||||
else blocked_cidrs.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
const hasAny =
|
||||
allowed_countries.length > 0 ||
|
||||
blocked_countries.length > 0 ||
|
||||
allowed_cidrs.length > 0 ||
|
||||
blocked_cidrs.length > 0;
|
||||
|
||||
if (!hasAny) return undefined;
|
||||
|
||||
return {
|
||||
...(allowed_countries.length > 0 && { allowed_countries }),
|
||||
...(blocked_countries.length > 0 && { blocked_countries }),
|
||||
...(allowed_cidrs.length > 0 && { allowed_cidrs }),
|
||||
...(blocked_cidrs.length > 0 && { blocked_cidrs }),
|
||||
};
|
||||
}
|
||||
|
||||
type Props = {
|
||||
value: AccessRestrictions | undefined;
|
||||
onChange: (value: AccessRestrictions | undefined) => void;
|
||||
onValidationChange?: (hasErrors: boolean) => void;
|
||||
};
|
||||
|
||||
function validateRule(rule: AccessRule): string {
|
||||
if (rule.type === "country" || !rule.value) return "";
|
||||
if (rule.type === "ip") {
|
||||
const val = rule.value.includes("/") ? rule.value : `${rule.value}/32`;
|
||||
if (!cidr.isValidAddress(val)) {
|
||||
return "Please enter a valid IP address, e.g., 85.203.15.42";
|
||||
}
|
||||
} else {
|
||||
if (!rule.value.includes("/") || !cidr.isValidAddress(rule.value)) {
|
||||
return "Please enter a valid CIDR block, e.g., 74.125.0.0/16";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
export const ReverseProxyAccessControlRules = ({ value, onChange, onValidationChange }: Props) => {
|
||||
const [rules, dispatch] = useReducer(
|
||||
rulesReducer,
|
||||
value,
|
||||
restrictionsToRules,
|
||||
);
|
||||
|
||||
const errors = useMemo(
|
||||
() => Object.fromEntries(rules.map((r) => [r.id, validateRule(r)])),
|
||||
[rules],
|
||||
);
|
||||
|
||||
const hasErrors = useMemo(
|
||||
() => Object.values(errors).some((e) => e !== ""),
|
||||
[errors],
|
||||
);
|
||||
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
const onValidationChangeRef = useRef(onValidationChange);
|
||||
onValidationChangeRef.current = onValidationChange;
|
||||
|
||||
useEffect(() => {
|
||||
onChangeRef.current(rulesToRestrictions(rules));
|
||||
}, [rules]);
|
||||
|
||||
useEffect(() => {
|
||||
onValidationChangeRef.current?.(hasErrors);
|
||||
}, [hasErrors]);
|
||||
|
||||
return (
|
||||
<div className={"flex-col flex"}>
|
||||
<div>
|
||||
<Label>Access Control Rules</Label>
|
||||
<HelpText>
|
||||
Define rules to allow or block traffic based on country, IP address,
|
||||
or CIDR block.
|
||||
<br />
|
||||
Block rules always take priority over allow rules.
|
||||
</HelpText>
|
||||
</div>
|
||||
{rules.length > 0 && (
|
||||
<div className="flex flex-col gap-3 mt-1 mb-4">
|
||||
{rules.map((rule) => (
|
||||
<div key={rule.id} className="flex items-center">
|
||||
<div className="w-[160px] shrink-0 [&_button]:rounded-r-none [&_button]:w-[160px]">
|
||||
<SelectDropdown
|
||||
value={rule.action}
|
||||
onChange={(v) =>
|
||||
dispatch({
|
||||
type: "update",
|
||||
id: rule.id,
|
||||
field: "action",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
options={ACTION_OPTIONS}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="w-[160px] shrink-0 -ml-px [&_button]:rounded-none [&_button]:w-[160px]">
|
||||
<SelectDropdown
|
||||
value={rule.type}
|
||||
onChange={(v) =>
|
||||
dispatch({
|
||||
type: "update",
|
||||
id: rule.id,
|
||||
field: "type",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
options={TYPE_OPTIONS}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0 -ml-px [&_button]:rounded-l-none [&_input]:rounded-l-none">
|
||||
{rule.type === "country" ? (
|
||||
<CountrySelector
|
||||
iconSize={16}
|
||||
popoverWidth={350}
|
||||
truncate
|
||||
value={rule.value}
|
||||
onChange={(v) =>
|
||||
dispatch({
|
||||
type: "update",
|
||||
id: rule.id,
|
||||
field: "value",
|
||||
value: v,
|
||||
})
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
placeholder={
|
||||
rule.type === "ip"
|
||||
? "e.g., 85.203.15.42"
|
||||
: "e.g., 74.125.0.0/16"
|
||||
}
|
||||
value={rule.value}
|
||||
onChange={(e) =>
|
||||
dispatch({
|
||||
type: "update",
|
||||
id: rule.id,
|
||||
field: "value",
|
||||
value: e.target.value,
|
||||
})
|
||||
}
|
||||
error={errors[rule.id]}
|
||||
errorTooltip={true}
|
||||
maxWidthClass="w-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="default-outline"
|
||||
className="h-[42px] w-[42px] !px-0 shrink-0 ml-2"
|
||||
onClick={() => dispatch({ type: "remove", id: rule.id })}
|
||||
aria-label="Remove rule"
|
||||
>
|
||||
<MinusCircleIcon size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="dotted"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "add" })}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Rule
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -21,12 +21,14 @@ import {
|
||||
Binary,
|
||||
ClockFadingIcon,
|
||||
ExternalLinkIcon,
|
||||
FileCode2Icon,
|
||||
GlobeIcon,
|
||||
LockKeyhole,
|
||||
MapPinned,
|
||||
PlusCircle,
|
||||
RectangleEllipsis,
|
||||
Settings,
|
||||
ShieldCheckIcon,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -37,7 +39,10 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { Network, NetworkResource } from "@/interfaces/Network";
|
||||
import { Peer } from "@/interfaces/Peer";
|
||||
import {
|
||||
AccessRestrictions,
|
||||
HeaderAuthConfig,
|
||||
isL4Mode as isL4ServiceMode,
|
||||
REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
|
||||
REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
|
||||
REVERSE_PROXY_SERVICES_DOCS_LINK,
|
||||
REVERSE_PROXY_SETTINGS_DOCS_LINK,
|
||||
@@ -53,6 +58,7 @@ import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import ReverseProxyDomainInput from "./domain/ReverseProxyDomainInput";
|
||||
import { useReverseProxyDomain } from "./domain/useReverseProxyDomain";
|
||||
import AuthPasswordModal from "@/modules/reverse-proxy/auth/AuthPasswordModal";
|
||||
import AuthHeaderModal from "@/modules/reverse-proxy/auth/AuthHeaderModal";
|
||||
import AuthPinModal from "@/modules/reverse-proxy/auth/AuthPinModal";
|
||||
import AuthSSOModal from "@/modules/reverse-proxy/auth/AuthSSOModal";
|
||||
import ReverseProxyHTTPTargets from "@/modules/reverse-proxy/ReverseProxyHTTPTargets";
|
||||
@@ -61,14 +67,15 @@ import ReverseProxyTargetModal from "@/modules/reverse-proxy/targets/ReverseProx
|
||||
import { type Target } from "@/modules/reverse-proxy/targets/ReverseProxyTargetSelector";
|
||||
import { useReverseProxyAddress } from "@/modules/reverse-proxy/targets/ReverseProxyAddressInput";
|
||||
import {
|
||||
validateTimeout,
|
||||
validateSessionIdleTimeout,
|
||||
validateTimeout,
|
||||
} from "@/modules/reverse-proxy/targets/useReverseProxyTargetOptions";
|
||||
import useGroupHelper from "@/modules/groups/useGroupHelper";
|
||||
import {
|
||||
ReverseProxyServiceModeSelector,
|
||||
SERVICE_MODES,
|
||||
} from "@/modules/reverse-proxy/ReverseProxyServiceModeSelector";
|
||||
import { ReverseProxyAccessControlRules } from "@/modules/reverse-proxy/ReverseProxyAccessControlRules";
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
@@ -236,10 +243,24 @@ export default function ReverseProxyModal({
|
||||
reverseProxy?.auth?.link_auth?.enabled ?? false,
|
||||
);
|
||||
|
||||
const [headerAuthsEnabled, setHeaderAuthsEnabled] = useState(
|
||||
(reverseProxy?.auth?.header_auths ?? []).some((h) => h.enabled),
|
||||
);
|
||||
const [headerAuths, setHeaderAuths] = useState<HeaderAuthConfig[]>(
|
||||
reverseProxy?.auth?.header_auths ?? [],
|
||||
);
|
||||
|
||||
const [accessRestrictions, setAccessRestrictions] = useState<
|
||||
AccessRestrictions | undefined
|
||||
>(reverseProxy?.access_restrictions);
|
||||
|
||||
const [accessControlHasErrors, setAccessControlHasErrors] = useState(false);
|
||||
|
||||
// Auth modal states
|
||||
const [passwordModalOpen, setPasswordModalOpen] = useState(false);
|
||||
const [ssoModalOpen, setSsoModalOpen] = useState(false);
|
||||
const [pinModalOpen, setPinModalOpen] = useState(false);
|
||||
const [headerModalOpen, setHeaderModalOpen] = useState(false);
|
||||
|
||||
// Target being added/edited
|
||||
const [targetModalOpen, setTargetModalOpen] = useState(false);
|
||||
@@ -248,8 +269,12 @@ export default function ReverseProxyModal({
|
||||
);
|
||||
|
||||
const canContinueToSettings = useMemo(() => {
|
||||
const subdomainRequired =
|
||||
selectedDomain?.require_subdomain === true;
|
||||
const isSubdomainValid =
|
||||
subdomain.length > 0 && baseDomain.length > 0 && !domainAlreadyExists;
|
||||
baseDomain.length > 0 &&
|
||||
!domainAlreadyExists &&
|
||||
(subdomain.length > 0 || !subdomainRequired);
|
||||
const isValidPort = (port: number) => port >= 1 && port <= 65535;
|
||||
const hasHttpEndpoint = !isL4Mode && targets.length > 0;
|
||||
const hasL4Endpoint =
|
||||
@@ -264,6 +289,7 @@ export default function ReverseProxyModal({
|
||||
subdomain,
|
||||
baseDomain,
|
||||
domainAlreadyExists,
|
||||
selectedDomain,
|
||||
serviceMode,
|
||||
targets.length,
|
||||
isL4Mode,
|
||||
@@ -305,16 +331,20 @@ export default function ReverseProxyModal({
|
||||
);
|
||||
};
|
||||
|
||||
const hasNoAuth =
|
||||
!passwordEnabled && !pinEnabled && !bearerEnabled && !linkAuthEnabled;
|
||||
const isUnprotected =
|
||||
!passwordEnabled &&
|
||||
!pinEnabled &&
|
||||
!bearerEnabled &&
|
||||
!linkAuthEnabled &&
|
||||
!headerAuthsEnabled &&
|
||||
!accessRestrictions;
|
||||
|
||||
const handleSubmit = async () => {
|
||||
// Show warning if no authentication is configured (HTTP only; TLS is pass-through)
|
||||
if (!isL4Mode && hasNoAuth) {
|
||||
if (isUnprotected) {
|
||||
const confirmed = await confirm({
|
||||
title: "No Authentication Configured",
|
||||
title: "No Protection Configured",
|
||||
description:
|
||||
"This service will be publicly accessible to everyone on the internet without any restrictions. Are you sure you want to continue?",
|
||||
"This service has no authentication or access control rules configured. It will be publicly accessible to everyone on the internet. Are you sure you want to continue?",
|
||||
type: "warning",
|
||||
confirmText: reverseProxy ? "Save Changes" : "Add Service",
|
||||
cancelText: "Cancel",
|
||||
@@ -341,6 +371,9 @@ export default function ReverseProxyModal({
|
||||
link_auth: {
|
||||
enabled: linkAuthEnabled,
|
||||
},
|
||||
header_auths: headerAuthsEnabled
|
||||
? headerAuths.map((h) => ({ ...h, enabled: true }))
|
||||
: [],
|
||||
};
|
||||
|
||||
const l4TargetPayload: ReverseProxyTarget | undefined = l4Target
|
||||
@@ -383,6 +416,7 @@ export default function ReverseProxyModal({
|
||||
pass_host_header: isL4Mode ? undefined : passHostHeader,
|
||||
rewrite_redirects: isL4Mode ? undefined : rewriteRedirects,
|
||||
auth: isL4Mode ? undefined : auth,
|
||||
access_restrictions: accessRestrictions,
|
||||
},
|
||||
proxyId: reverseProxy?.id,
|
||||
onSuccess: () => {
|
||||
@@ -426,10 +460,17 @@ export default function ReverseProxyModal({
|
||||
</TabsTrigger>
|
||||
{!isL4Mode && (
|
||||
<TabsTrigger value={"auth"} disabled={!canContinueToSettings}>
|
||||
<LockKeyhole size={16} />
|
||||
<LockKeyhole size={14} />
|
||||
Authentication
|
||||
</TabsTrigger>
|
||||
)}
|
||||
<TabsTrigger
|
||||
value={"access-control"}
|
||||
disabled={!canContinueToSettings}
|
||||
>
|
||||
<ShieldCheckIcon size={14} />
|
||||
Access Control
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value={"settings"} disabled={!canContinueToSettings}>
|
||||
<Settings size={14} />
|
||||
Advanced Settings
|
||||
@@ -444,6 +485,7 @@ export default function ReverseProxyModal({
|
||||
baseDomain={baseDomain}
|
||||
onBaseDomainChange={setBaseDomain}
|
||||
domainAlreadyExists={domainAlreadyExists}
|
||||
subdomainRequired={selectedDomain?.require_subdomain === true}
|
||||
clusterOffline={
|
||||
reverseProxy?.proxy_cluster && !isClusterConnected
|
||||
? { clusterName: reverseProxy.proxy_cluster }
|
||||
@@ -527,10 +569,31 @@ export default function ReverseProxyModal({
|
||||
enabled={pinEnabled}
|
||||
onClick={() => setPinModalOpen(true)}
|
||||
/>
|
||||
<SettingCard.Item
|
||||
label={
|
||||
<>
|
||||
<FileCode2Icon size={15} />
|
||||
HTTP Headers
|
||||
</>
|
||||
}
|
||||
description="Require specific HTTP headers to access this service."
|
||||
enabled={headerAuthsEnabled}
|
||||
onClick={() => setHeaderModalOpen(true)}
|
||||
/>
|
||||
</SettingCard>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"access-control"} className={"pb-8"}>
|
||||
<div className={"px-8 flex-col flex gap-4"}>
|
||||
<ReverseProxyAccessControlRules
|
||||
value={accessRestrictions}
|
||||
onChange={setAccessRestrictions}
|
||||
onValidationChange={setAccessControlHasErrors}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value={"settings"} className={"pb-8"}>
|
||||
<div className={"px-8 flex-col flex gap-6"}>
|
||||
{(serviceMode === ServiceMode.TCP ||
|
||||
@@ -627,6 +690,10 @@ export default function ReverseProxyModal({
|
||||
href: REVERSE_PROXY_AUTHENTICATION_DOCS_LINK,
|
||||
label: "Authentication",
|
||||
},
|
||||
"access-control": {
|
||||
href: REVERSE_PROXY_ACCESS_CONTROL_DOCS_LINK,
|
||||
label: "Access Control",
|
||||
},
|
||||
settings: {
|
||||
href: REVERSE_PROXY_SETTINGS_DOCS_LINK,
|
||||
label: "Settings",
|
||||
@@ -653,7 +720,9 @@ export default function ReverseProxyModal({
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab(isL4Mode ? "settings" : "auth")}
|
||||
onClick={() =>
|
||||
setTab(isL4Mode ? "access-control" : "auth")
|
||||
}
|
||||
disabled={!canContinueToSettings}
|
||||
>
|
||||
Continue
|
||||
@@ -669,9 +738,27 @@ export default function ReverseProxyModal({
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("access-control")}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "access-control" && (
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
onClick={() => setTab("settings")}
|
||||
disabled={accessControlHasErrors}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
@@ -682,7 +769,7 @@ export default function ReverseProxyModal({
|
||||
<>
|
||||
<Button
|
||||
variant={"secondary"}
|
||||
onClick={() => setTab(isL4Mode ? "targets" : "auth")}
|
||||
onClick={() => setTab("access-control")}
|
||||
>
|
||||
Back
|
||||
</Button>
|
||||
@@ -691,7 +778,8 @@ export default function ReverseProxyModal({
|
||||
disabled={
|
||||
!canContinueToSettings ||
|
||||
!permission?.services?.create ||
|
||||
!!timeoutError
|
||||
!!timeoutError ||
|
||||
accessControlHasErrors
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
@@ -711,7 +799,8 @@ export default function ReverseProxyModal({
|
||||
disabled={
|
||||
!canContinueToSettings ||
|
||||
!permission?.services?.update ||
|
||||
!!timeoutError
|
||||
!!timeoutError ||
|
||||
accessControlHasErrors
|
||||
}
|
||||
onClick={handleSubmit}
|
||||
>
|
||||
@@ -806,6 +895,25 @@ export default function ReverseProxyModal({
|
||||
}, 200);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AuthHeaderModal
|
||||
open={headerModalOpen}
|
||||
onOpenChange={setHeaderModalOpen}
|
||||
key={headerModalOpen ? "h1" : "h0"}
|
||||
currentHeaders={headerAuths}
|
||||
onSave={(headers) => {
|
||||
setTimeout(() => {
|
||||
setHeaderAuths(headers);
|
||||
setHeaderAuthsEnabled(true);
|
||||
}, 200);
|
||||
}}
|
||||
onRemove={() => {
|
||||
setTimeout(() => {
|
||||
setHeaderAuths([]);
|
||||
setHeaderAuthsEnabled(false);
|
||||
}, 200);
|
||||
}}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
454
src/modules/reverse-proxy/auth/AuthHeaderModal.tsx
Normal file
454
src/modules/reverse-proxy/auth/AuthHeaderModal.tsx
Normal file
@@ -0,0 +1,454 @@
|
||||
import Button from "@components/Button";
|
||||
import { Callout } from "@components/Callout";
|
||||
import { Input } from "@components/Input";
|
||||
import { Modal, ModalClose, ModalContent } from "@components/modal/Modal";
|
||||
import ModalHeader from "@components/modal/ModalHeader";
|
||||
import {
|
||||
SelectDropdown,
|
||||
SelectOption,
|
||||
} from "@components/select/SelectDropdown";
|
||||
import {
|
||||
BracesIcon,
|
||||
CircleUserIcon,
|
||||
FileCode2Icon,
|
||||
KeyRoundIcon,
|
||||
MinusCircleIcon,
|
||||
PlusIcon,
|
||||
UserIcon,
|
||||
} from "lucide-react";
|
||||
import React, { useMemo, useReducer, useRef } from "react";
|
||||
import { useHasChanges } from "@/hooks/useHasChanges";
|
||||
import type { HeaderAuthConfig } from "@/interfaces/ReverseProxy";
|
||||
|
||||
type HeaderType = "basic" | "bearer" | "custom";
|
||||
|
||||
interface HeaderAuthItem {
|
||||
id: string;
|
||||
type: HeaderType;
|
||||
header: string;
|
||||
value: string;
|
||||
username: string;
|
||||
password: string;
|
||||
existingSecret: boolean;
|
||||
}
|
||||
|
||||
const HEADER_TYPE_OPTIONS: SelectOption[] = [
|
||||
{
|
||||
value: "basic" satisfies HeaderType,
|
||||
label: "Basic Auth",
|
||||
icon: () => <CircleUserIcon size={14} />,
|
||||
},
|
||||
{
|
||||
value: "bearer" satisfies HeaderType,
|
||||
label: "Bearer Token",
|
||||
icon: () => <KeyRoundIcon size={14} />,
|
||||
},
|
||||
{
|
||||
value: "custom" satisfies HeaderType,
|
||||
label: "Custom Header",
|
||||
icon: () => <BracesIcon size={14} />,
|
||||
},
|
||||
];
|
||||
|
||||
const MASKED_VALUE = "••••••••";
|
||||
|
||||
const INPUT_PROPS = {
|
||||
autoComplete: "off",
|
||||
"data-1p-ignore": true,
|
||||
"data-lpignore": "true",
|
||||
"data-form-type": "other",
|
||||
} as const;
|
||||
|
||||
function createHeaderEntry(
|
||||
overrides?: Partial<HeaderAuthItem>,
|
||||
): HeaderAuthItem {
|
||||
return {
|
||||
id: crypto.randomUUID(),
|
||||
type: "basic",
|
||||
header: "Authorization",
|
||||
value: "",
|
||||
username: "",
|
||||
password: "",
|
||||
existingSecret: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function toBase64(str: string): string {
|
||||
return btoa(
|
||||
new TextEncoder()
|
||||
.encode(str)
|
||||
.reduce((acc, byte) => acc + String.fromCharCode(byte), ""),
|
||||
);
|
||||
}
|
||||
|
||||
function fromBase64(b64: string): string {
|
||||
return new TextDecoder().decode(
|
||||
Uint8Array.from(atob(b64), (c) => c.charCodeAt(0)),
|
||||
);
|
||||
}
|
||||
|
||||
function headerEntryToConfig(entry: HeaderAuthItem): HeaderAuthConfig {
|
||||
if (entry.existingSecret) {
|
||||
const value = entry.value === MASKED_VALUE ? "" : entry.value;
|
||||
return { enabled: true, header: entry.header, value };
|
||||
}
|
||||
switch (entry.type) {
|
||||
case "basic": {
|
||||
const encoded = toBase64(`${entry.username}:${entry.password}`);
|
||||
return {
|
||||
enabled: true,
|
||||
header: "Authorization",
|
||||
value: `Basic ${encoded}`,
|
||||
};
|
||||
}
|
||||
case "bearer":
|
||||
return {
|
||||
enabled: true,
|
||||
header: "Authorization",
|
||||
value: `Bearer ${entry.value}`,
|
||||
};
|
||||
case "custom":
|
||||
return { enabled: true, header: entry.header, value: entry.value };
|
||||
}
|
||||
}
|
||||
|
||||
function configToHeaderEntry(config: HeaderAuthConfig): HeaderAuthItem {
|
||||
const isExisting = !config.value;
|
||||
|
||||
if (config.header === "Authorization" && config.value?.startsWith("Basic ")) {
|
||||
try {
|
||||
const decoded = fromBase64(config.value.slice(6));
|
||||
const sep = decoded.indexOf(":");
|
||||
if (sep >= 0) {
|
||||
return createHeaderEntry({
|
||||
type: "basic",
|
||||
username: decoded.slice(0, sep),
|
||||
password: decoded.slice(sep + 1),
|
||||
});
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (
|
||||
config.header === "Authorization" &&
|
||||
config.value?.startsWith("Bearer ")
|
||||
) {
|
||||
return createHeaderEntry({ type: "bearer", value: config.value.slice(7) });
|
||||
}
|
||||
|
||||
return createHeaderEntry({
|
||||
type: isExisting && config.header === "Authorization" ? "basic" : "custom",
|
||||
header: config.header,
|
||||
value: isExisting ? MASKED_VALUE : config.value ?? "",
|
||||
existingSecret: isExisting,
|
||||
});
|
||||
}
|
||||
|
||||
function isHeaderValid(entry: HeaderAuthItem): boolean {
|
||||
if (entry.existingSecret) return true;
|
||||
switch (entry.type) {
|
||||
case "basic":
|
||||
return entry.username.trim().length > 0 && entry.password.length > 0;
|
||||
case "bearer":
|
||||
return entry.value.trim().length > 0;
|
||||
case "custom":
|
||||
return entry.header.trim().length > 0 && entry.value.trim().length > 0;
|
||||
}
|
||||
}
|
||||
|
||||
type HeaderAction =
|
||||
| { type: "add" }
|
||||
| { type: "remove"; index: number }
|
||||
| { type: "update"; index: number; updates: Partial<HeaderAuthItem> };
|
||||
|
||||
function headersReducer(
|
||||
state: HeaderAuthItem[],
|
||||
action: HeaderAction,
|
||||
): HeaderAuthItem[] {
|
||||
switch (action.type) {
|
||||
case "add":
|
||||
return [...state, createHeaderEntry()];
|
||||
case "remove":
|
||||
return state.length === 1
|
||||
? [createHeaderEntry()]
|
||||
: state.filter((_, i) => i !== action.index);
|
||||
case "update":
|
||||
return state.map((e, i) =>
|
||||
i === action.index ? { ...e, ...action.updates } : e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function initHeaders(headers: HeaderAuthConfig[]): HeaderAuthItem[] {
|
||||
return headers.length > 0
|
||||
? headers.map(configToHeaderEntry)
|
||||
: [createHeaderEntry()];
|
||||
}
|
||||
|
||||
type Props = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
currentHeaders: HeaderAuthConfig[];
|
||||
onSave: (headers: HeaderAuthConfig[]) => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
export default function AuthHeaderModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentHeaders,
|
||||
onSave,
|
||||
onRemove,
|
||||
}: Readonly<Props>) {
|
||||
const [items, dispatch] = useReducer(
|
||||
headersReducer,
|
||||
currentHeaders,
|
||||
initHeaders,
|
||||
);
|
||||
const isEditing = currentHeaders.length > 0;
|
||||
const canSave = useMemo(() => items.every(isHeaderValid), [items]);
|
||||
const { hasChanges } = useHasChanges(items);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!canSave) return;
|
||||
onOpenChange(false);
|
||||
onSave(items.map(headerEntryToConfig));
|
||||
};
|
||||
|
||||
const handleRemoveAll = () => {
|
||||
onOpenChange(false);
|
||||
onRemove();
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
maxWidthClass="max-w-xl"
|
||||
onOpenAutoFocus={(e) => {
|
||||
e.preventDefault();
|
||||
const container = e.currentTarget as HTMLElement | null;
|
||||
container
|
||||
?.querySelector<HTMLInputElement>("input:not([type=hidden])")
|
||||
?.focus();
|
||||
}}
|
||||
>
|
||||
<ModalHeader
|
||||
title="HTTP Headers"
|
||||
description="Require specific HTTP headers to access this service."
|
||||
/>
|
||||
|
||||
<div className="px-8">
|
||||
<div className="flex flex-col gap-3">
|
||||
{items.map((item, index) => (
|
||||
<HeaderItemRow
|
||||
key={item.id}
|
||||
item={item}
|
||||
index={index}
|
||||
onChange={(updates) =>
|
||||
dispatch({ type: "update", index, updates })
|
||||
}
|
||||
onRemove={() => dispatch({ type: "remove", index })}
|
||||
showRemove={items.length > 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="dotted"
|
||||
className="w-full mt-4"
|
||||
size="sm"
|
||||
onClick={() => dispatch({ type: "add" })}
|
||||
>
|
||||
<PlusIcon size={14} />
|
||||
Add Header
|
||||
</Button>
|
||||
|
||||
{items.length > 1 && (
|
||||
<Callout className="mt-4" variant="info">
|
||||
Any request matching one of these headers will grant access.
|
||||
<br />
|
||||
Matched headers are stripped before reaching your backend.
|
||||
</Callout>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 w-full justify-between mt-6">
|
||||
{isEditing ? (
|
||||
<>
|
||||
<Button variant="danger-text" onClick={handleRemoveAll}>
|
||||
Remove All
|
||||
</Button>
|
||||
<div className="flex gap-3">
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave || !hasChanges}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div />
|
||||
<div className="flex gap-3">
|
||||
<ModalClose asChild>
|
||||
<Button variant="secondary">Cancel</Button>
|
||||
</ModalClose>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
disabled={!canSave}
|
||||
>
|
||||
Add Headers
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type HeaderItemRowProps = {
|
||||
item: HeaderAuthItem;
|
||||
index: number;
|
||||
onChange: (updates: Partial<HeaderAuthItem>) => void;
|
||||
onRemove: () => void;
|
||||
showRemove: boolean;
|
||||
};
|
||||
|
||||
function HeaderItemRow({
|
||||
item,
|
||||
index,
|
||||
onChange,
|
||||
onRemove,
|
||||
showRemove,
|
||||
}: Readonly<HeaderItemRowProps>) {
|
||||
const isMaskedRef = useRef(item.existingSecret);
|
||||
|
||||
const handleHeaderTypeChange = (value: string) => {
|
||||
const type = value as HeaderType;
|
||||
onChange({
|
||||
type,
|
||||
header: type === "custom" ? "" : "Authorization",
|
||||
value: "",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-nb-gray-900 bg-nb-gray-920/30 overflow-hidden">
|
||||
<div className="flex flex-col gap-2 px-4 pt-2 pb-4 bg-nb-gray-920/30">
|
||||
<div className="flex items-center justify-between h-6 mt-0.5">
|
||||
<span className="text-xs font-normal text-nb-gray-200 flex items-center gap-2">
|
||||
<FileCode2Icon size={14} />
|
||||
{item.existingSecret
|
||||
? `Header ${index + 1} - ${item.header}`
|
||||
: `Header ${index + 1}`}
|
||||
</span>
|
||||
{showRemove && (
|
||||
<Button variant="danger-text" size="xs" onClick={onRemove}>
|
||||
<MinusCircleIcon size={12} />
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{item.existingSecret ? (
|
||||
<div>
|
||||
<Input
|
||||
customPrefix={<span className="min-w-[38px]">Value</span>}
|
||||
type="password"
|
||||
showPasswordToggle={!isMaskedRef.current}
|
||||
value={isMaskedRef.current ? MASKED_VALUE : item.value}
|
||||
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
|
||||
{...INPUT_PROPS}
|
||||
onChange={(e) => {
|
||||
if (isMaskedRef.current) {
|
||||
isMaskedRef.current = false;
|
||||
const nativeEvent = e.nativeEvent as InputEvent;
|
||||
onChange({ value: nativeEvent.data ?? "" });
|
||||
return;
|
||||
}
|
||||
onChange({ value: e.target.value });
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<SelectDropdown
|
||||
value={item.type}
|
||||
onChange={handleHeaderTypeChange}
|
||||
options={HEADER_TYPE_OPTIONS}
|
||||
/>
|
||||
|
||||
{item.type === "basic" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
customPrefix={<UserIcon size={16} />}
|
||||
placeholder="Username"
|
||||
maxWidthClass="w-full"
|
||||
value={item.username}
|
||||
onChange={(e) => onChange({ username: e.target.value })}
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
<Input
|
||||
customPrefix={<KeyRoundIcon size={16} />}
|
||||
placeholder="Password"
|
||||
maxWidthClass="w-full"
|
||||
value={item.password}
|
||||
onChange={(e) => onChange({ password: e.target.value })}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{item.type === "bearer" && (
|
||||
<Input
|
||||
customPrefix={"Bearer"}
|
||||
placeholder="e.g. eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
maxWidthClass="w-full"
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
)}
|
||||
|
||||
{item.type === "custom" && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Input
|
||||
customPrefix={<span className="min-w-[38px]">Name</span>}
|
||||
placeholder="e.g., X-API-Key"
|
||||
maxWidthClass="w-full"
|
||||
value={item.header}
|
||||
onChange={(e) => onChange({ header: e.target.value })}
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
<Input
|
||||
customPrefix={<span className="min-w-[38px]">Value</span>}
|
||||
placeholder="e.g., AIiaSyDaGmWKa4JsXZ-HjGw7ISLn_3namBGewQe"
|
||||
maxWidthClass="w-full"
|
||||
value={item.value}
|
||||
onChange={(e) => onChange({ value: e.target.value })}
|
||||
type="password"
|
||||
showPasswordToggle
|
||||
{...INPUT_PROPS}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ type Props = {
|
||||
baseDomain: string;
|
||||
onBaseDomainChange: (value: string) => void;
|
||||
domainAlreadyExists: boolean;
|
||||
subdomainRequired?: boolean;
|
||||
clusterOffline?: {
|
||||
clusterName: string;
|
||||
};
|
||||
@@ -24,13 +25,16 @@ export default function ReverseProxyDomainInput({
|
||||
baseDomain,
|
||||
onBaseDomainChange,
|
||||
domainAlreadyExists,
|
||||
subdomainRequired = false,
|
||||
clusterOffline,
|
||||
}: Readonly<Props>) {
|
||||
return (
|
||||
<div>
|
||||
<Label>Domain</Label>
|
||||
<HelpText>
|
||||
Enter a subdomain and select a domain for your service.
|
||||
{subdomainRequired
|
||||
? "Enter a subdomain and select a domain for your service."
|
||||
: "Optionally enter a subdomain, or use the domain directly."}
|
||||
</HelpText>
|
||||
<div className="flex items-start mt-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
@@ -47,7 +51,7 @@ export default function ReverseProxyDomainInput({
|
||||
? "This domain is already used by another service."
|
||||
: undefined
|
||||
}
|
||||
placeholder={"myapp"}
|
||||
placeholder={subdomainRequired ? "myapp" : "myapp (optional)"}
|
||||
className="!rounded-r-none !border-r-0"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -23,6 +23,13 @@ function parseDomain(
|
||||
.filter((d) => d.domain)
|
||||
.sort((a, b) => b.domain.length - a.domain.length);
|
||||
for (const d of sorted) {
|
||||
if (fullDomain === d.domain) {
|
||||
return {
|
||||
subdomain: "",
|
||||
baseDomain: d.domain,
|
||||
isCustom: d.type === ReverseProxyDomainType.CUSTOM,
|
||||
};
|
||||
}
|
||||
if (fullDomain.endsWith(`.${d.domain}`)) {
|
||||
return {
|
||||
subdomain: fullDomain.slice(0, -(d.domain.length + 1)),
|
||||
@@ -103,7 +110,11 @@ export function useReverseProxyDomain({
|
||||
return customDomain?.domain || freeDomain?.domain || "";
|
||||
});
|
||||
|
||||
const fullDomain = baseDomain ? `${subdomain}.${baseDomain}` : subdomain;
|
||||
const fullDomain = baseDomain
|
||||
? subdomain
|
||||
? `${subdomain}.${baseDomain}`
|
||||
: baseDomain
|
||||
: subdomain;
|
||||
|
||||
const domainAlreadyExists = useMemo(() => {
|
||||
if (!reverseProxies || !fullDomain) return false;
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
import Badge from "@components/Badge";
|
||||
import { Binary, Mail, RectangleEllipsis, Users } from "lucide-react";
|
||||
import {
|
||||
Binary,
|
||||
FileCode2Icon,
|
||||
Flag,
|
||||
GlobeOff,
|
||||
Mail,
|
||||
Network,
|
||||
RectangleEllipsis,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { ReverseProxyEvent } from "@/interfaces/ReverseProxy";
|
||||
|
||||
@@ -33,6 +42,11 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
|
||||
icon: <Binary size={12} />,
|
||||
label: "PIN Code",
|
||||
};
|
||||
case "header":
|
||||
return {
|
||||
icon: <FileCode2Icon size={12} />,
|
||||
label: "HTTP Headers",
|
||||
};
|
||||
case "link":
|
||||
case "magic_link":
|
||||
case "magic-link":
|
||||
@@ -40,6 +54,21 @@ export const ReverseProxyEventsAuthMethodCell = ({ event }: Props) => {
|
||||
icon: <Mail size={12} />,
|
||||
label: "Magic Link",
|
||||
};
|
||||
case "ip_restricted":
|
||||
return {
|
||||
icon: <Network size={12} />,
|
||||
label: "IP Restricted",
|
||||
};
|
||||
case "country_restricted":
|
||||
return {
|
||||
icon: <Flag size={12} />,
|
||||
label: "Country Restricted",
|
||||
};
|
||||
case "geo_unavailable":
|
||||
return {
|
||||
icon: <GlobeOff size={12} />,
|
||||
label: "Geo Unavailable",
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: null,
|
||||
|
||||
@@ -19,8 +19,17 @@ export const ReverseProxyEventsLocationIpCell = ({ event }: Props) => {
|
||||
const { getRegionText, isLoading } = useCountries();
|
||||
|
||||
const region = useMemo(() => {
|
||||
return getRegionText(event.country_code || "", event.city_name || "");
|
||||
}, [getRegionText, event.country_code, event.city_name]);
|
||||
return getRegionText(
|
||||
event.country_code || "",
|
||||
event.city_name || "",
|
||||
event.subdivision_code,
|
||||
);
|
||||
}, [
|
||||
getRegionText,
|
||||
event.country_code,
|
||||
event.city_name,
|
||||
event.subdivision_code,
|
||||
]);
|
||||
|
||||
return (
|
||||
<FullTooltip
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import Badge from "@components/Badge";
|
||||
import Button from "@components/Button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@components/HoverCard";
|
||||
import {
|
||||
FlagIcon,
|
||||
LucideIcon,
|
||||
NetworkIcon,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
WorkflowIcon,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { useMemo } from "react";
|
||||
import { useCountries } from "@/contexts/CountryProvider";
|
||||
import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { ReverseProxy } from "@/interfaces/ReverseProxy";
|
||||
|
||||
type RuleEntry = {
|
||||
key: string;
|
||||
label: string;
|
||||
Icon: LucideIcon;
|
||||
value: string;
|
||||
blocked?: boolean;
|
||||
};
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
};
|
||||
|
||||
export default function ReverseProxyAccessControlCell({
|
||||
reverseProxy,
|
||||
}: Readonly<Props>) {
|
||||
const { permission } = usePermissions();
|
||||
const { openModal } = useReverseProxies();
|
||||
const { countries } = useCountries();
|
||||
|
||||
const canConfigure = !!permission?.services?.update;
|
||||
const restrictions = reverseProxy.access_restrictions;
|
||||
|
||||
const ruleCount =
|
||||
(restrictions?.allowed_cidrs?.length ?? 0) +
|
||||
(restrictions?.blocked_cidrs?.length ?? 0) +
|
||||
(restrictions?.allowed_countries?.length ?? 0) +
|
||||
(restrictions?.blocked_countries?.length ?? 0);
|
||||
|
||||
const rulesBadge =
|
||||
ruleCount > 0 ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
disabled={!canConfigure}
|
||||
className={
|
||||
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldCheck size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>
|
||||
{ruleCount} {ruleCount === 1 ? "Rule" : "Rules"}
|
||||
</span>
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
const ruleGroups = useMemo(() => {
|
||||
const getCountryName = (code: string) => {
|
||||
const country = countries?.find((c) => c.country_code === code);
|
||||
return country?.country_name ?? code;
|
||||
};
|
||||
|
||||
const entries: RuleEntry[] = [];
|
||||
|
||||
if (restrictions?.allowed_countries?.length) {
|
||||
entries.push({
|
||||
key: "allowed-countries",
|
||||
label: "Allowed Countries",
|
||||
Icon: FlagIcon,
|
||||
value: restrictions.allowed_countries.map(getCountryName).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (restrictions?.blocked_countries?.length) {
|
||||
entries.push({
|
||||
key: "blocked-countries",
|
||||
label: "Blocked Countries",
|
||||
Icon: FlagIcon,
|
||||
value: restrictions.blocked_countries.map(getCountryName).join(", "),
|
||||
blocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
const allowedIps =
|
||||
restrictions?.allowed_cidrs?.filter((c) => c.endsWith("/32")) ?? [];
|
||||
const allowedCidrs =
|
||||
restrictions?.allowed_cidrs?.filter((c) => !c.endsWith("/32")) ?? [];
|
||||
const blockedIps =
|
||||
restrictions?.blocked_cidrs?.filter((c) => c.endsWith("/32")) ?? [];
|
||||
const blockedCidrs =
|
||||
restrictions?.blocked_cidrs?.filter((c) => !c.endsWith("/32")) ?? [];
|
||||
|
||||
if (allowedIps.length) {
|
||||
entries.push({
|
||||
key: "allowed-ips",
|
||||
label: allowedIps.length === 1 ? "Allowed IP" : "Allowed IPs",
|
||||
Icon: WorkflowIcon,
|
||||
value: allowedIps.map((c) => c.replace(/\/32$/, "")).join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (allowedCidrs.length) {
|
||||
entries.push({
|
||||
key: "allowed-cidrs",
|
||||
label: allowedCidrs.length === 1 ? "Allowed CIDR" : "Allowed CIDRs",
|
||||
Icon: NetworkIcon,
|
||||
value: allowedCidrs.join(", "),
|
||||
});
|
||||
}
|
||||
|
||||
if (blockedIps.length) {
|
||||
entries.push({
|
||||
key: "blocked-ips",
|
||||
label: blockedIps.length === 1 ? "Blocked IP" : "Blocked IPs",
|
||||
Icon: WorkflowIcon,
|
||||
value: blockedIps.map((c) => c.replace(/\/32$/, "")).join(", "),
|
||||
blocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (blockedCidrs.length) {
|
||||
entries.push({
|
||||
key: "blocked-cidrs",
|
||||
label: blockedCidrs.length === 1 ? "Blocked CIDR" : "Blocked CIDRs",
|
||||
Icon: NetworkIcon,
|
||||
value: blockedCidrs.join(", "),
|
||||
blocked: true,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [restrictions, countries]);
|
||||
|
||||
const showRulesHover = ruleGroups.length > 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={"flex"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (permission?.services?.update) {
|
||||
openModal({ proxy: reverseProxy, initialTab: "access-control" });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className={"flex items-center"}>
|
||||
{rulesBadge ? (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>{rulesBadge}</HoverCardTrigger>
|
||||
{showRulesHover && (
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
sideOffset={14}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-xs"}>
|
||||
{ruleGroups.map(({ key, label, Icon, value, blocked }) => (
|
||||
<div
|
||||
key={key}
|
||||
className={
|
||||
"flex justify-between gap-12 py-2 px-4 border-b border-nb-gray-920 last:border-b-0"
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
"flex items-start gap-2 font-medium whitespace-nowrap text-nb-gray-100 pt-0.5"
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
size={14}
|
||||
className={
|
||||
blocked ? "text-red-500" : "text-green-500"
|
||||
}
|
||||
/>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
className={"max-w-[200px] text-nb-gray-300 text-right"}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</HoverCard>
|
||||
) : (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
disabled={!canConfigure}
|
||||
className={
|
||||
"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"
|
||||
}
|
||||
>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>No Rules</span>
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"!rounded-l-none !px-3 !h-[34px]"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal({ proxy: reverseProxy, initialTab: "access-control" });
|
||||
}}
|
||||
disabled={!permission?.services?.update}
|
||||
aria-label="Configure access control"
|
||||
>
|
||||
<Settings size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -11,12 +11,13 @@ import { UserCountStack } from "@components/ui/MultipleGroups";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
Binary,
|
||||
FileCode2Icon,
|
||||
HelpCircle,
|
||||
LockKeyhole,
|
||||
LockOpenIcon,
|
||||
LucideIcon,
|
||||
RectangleEllipsis,
|
||||
Settings,
|
||||
ShieldCheck,
|
||||
ShieldOff,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -48,6 +49,12 @@ const AUTH_METHODS: {
|
||||
},
|
||||
];
|
||||
|
||||
const HEADER_AUTH_METHOD = {
|
||||
label: "HTTP Headers",
|
||||
hoverLabel: "HTTP Headers",
|
||||
Icon: FileCode2Icon,
|
||||
};
|
||||
|
||||
type Props = {
|
||||
reverseProxy: ReverseProxy;
|
||||
};
|
||||
@@ -59,7 +66,6 @@ export default function ReverseProxyAuthCell({
|
||||
const { openModal } = useReverseProxies();
|
||||
const { groups } = useGroups();
|
||||
|
||||
// L4 services don't support auth
|
||||
if (isL4Mode(reverseProxy.mode)) {
|
||||
return (
|
||||
<div className={"flex"}>
|
||||
@@ -83,6 +89,8 @@ export default function ReverseProxyAuthCell({
|
||||
const auth = reverseProxy.auth;
|
||||
|
||||
const enabled = AUTH_METHODS.filter((m) => auth?.[m.key]?.enabled);
|
||||
const hasHeaderAuths = (auth?.header_auths ?? []).some((h) => h.enabled);
|
||||
const authCount = enabled.length + (hasHeaderAuths ? 1 : 0);
|
||||
|
||||
const ssoGroups = auth?.bearer_auth?.enabled
|
||||
? (auth.bearer_auth.distribution_groups ?? [])
|
||||
@@ -90,103 +98,130 @@ export default function ReverseProxyAuthCell({
|
||||
.filter((g): g is Group => g != undefined)
|
||||
: [];
|
||||
|
||||
const showHoverContent =
|
||||
enabled.length > 1 || (enabled.length === 1 && auth?.bearer_auth?.enabled);
|
||||
const canConfigure = !!permission?.services?.update;
|
||||
const singleAuth =
|
||||
authCount === 1
|
||||
? enabled.length === 1
|
||||
? enabled[0]
|
||||
: HEADER_AUTH_METHOD
|
||||
: null;
|
||||
const SingleAuthIcon = singleAuth?.Icon ?? null;
|
||||
|
||||
const SingleIcon = enabled.length === 1 ? enabled[0].Icon : null;
|
||||
|
||||
const badgeContent = SingleIcon ? (
|
||||
<>
|
||||
<SingleIcon size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>{enabled[0].label}</span>
|
||||
</>
|
||||
) : enabled.length > 1 ? (
|
||||
<>
|
||||
<ShieldCheck size={12} className="text-green-400" />
|
||||
<span className={"font-medium text-xs"}>{enabled.length} Enabled</span>
|
||||
</>
|
||||
const authBadge = SingleAuthIcon ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
disabled={!canConfigure}
|
||||
className={"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"}
|
||||
>
|
||||
<SingleAuthIcon size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>{singleAuth!.label}</span>
|
||||
</Badge>
|
||||
) : authCount > 1 ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
disabled={!canConfigure}
|
||||
className={"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"}
|
||||
>
|
||||
<LockKeyhole size={12} className="text-green-500" />
|
||||
<span className={"font-medium text-xs"}>{authCount} Enabled</span>
|
||||
</Badge>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={"flex gap-3"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}}
|
||||
>
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>
|
||||
{badgeContent ? (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
useHover={false}
|
||||
className={"cursor-pointer"}
|
||||
>
|
||||
{badgeContent}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant={"gray"}>
|
||||
<ShieldOff size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>None</span>
|
||||
</Badge>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
{showHoverContent && (
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
sideOffset={14}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-xs"}>
|
||||
{enabled.map(({ key, hoverLabel, Icon }) => (
|
||||
<ListItem
|
||||
key={key}
|
||||
className={"py-0.5"}
|
||||
icon={<Icon size={14} />}
|
||||
label={hoverLabel}
|
||||
value={
|
||||
<div className={"text-green-500"}>
|
||||
{key === "bearer_auth" && ssoGroups.length === 0
|
||||
? "All Users"
|
||||
: "Enabled"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{key === "bearer_auth" && ssoGroups.length > 0 && (
|
||||
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
|
||||
{ssoGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={"flex gap-2 items-center justify-between"}
|
||||
>
|
||||
<GroupBadge group={group} />
|
||||
<ArrowRightIcon size={14} />
|
||||
<UserCountStack group={group} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</HoverCard>
|
||||
const showAuthHover =
|
||||
authCount > 1 || (authCount === 1 && (auth?.bearer_auth?.enabled || hasHeaderAuths));
|
||||
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}}
|
||||
className={"!px-3"}
|
||||
disabled={!permission?.services?.update}
|
||||
>
|
||||
<Settings size={12} />
|
||||
Configure
|
||||
</Button>
|
||||
return (
|
||||
<div className={"flex"} onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
if (permission?.services?.update) {
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}
|
||||
}}>
|
||||
<div className={"flex items-center"}>
|
||||
{authBadge ? (
|
||||
<HoverCard openDelay={200} closeDelay={100}>
|
||||
<HoverCardTrigger asChild={true}>{authBadge}</HoverCardTrigger>
|
||||
{showAuthHover && (
|
||||
<HoverCardContent
|
||||
className={"p-0"}
|
||||
sideOffset={14}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className={"text-xs"}>
|
||||
{enabled.map(({ key, hoverLabel, Icon }) => (
|
||||
<ListItem
|
||||
key={key}
|
||||
className={"py-0.5"}
|
||||
icon={<Icon size={14} />}
|
||||
label={hoverLabel}
|
||||
value={
|
||||
<div className={"text-green-500"}>
|
||||
{key === "bearer_auth" && ssoGroups.length === 0
|
||||
? "All Users"
|
||||
: "Enabled"}
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{key === "bearer_auth" && ssoGroups.length > 0 && (
|
||||
<div className={"flex flex-col gap-2 px-4 pt-2 pb-3"}>
|
||||
{ssoGroups.map((group) => (
|
||||
<div
|
||||
key={group.id}
|
||||
className={
|
||||
"flex gap-2 items-center justify-between"
|
||||
}
|
||||
>
|
||||
<GroupBadge group={group} />
|
||||
<ArrowRightIcon size={14} />
|
||||
<UserCountStack group={group} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ListItem>
|
||||
))}
|
||||
{hasHeaderAuths && (
|
||||
<ListItem
|
||||
className={"py-0.5"}
|
||||
icon={<FileCode2Icon size={14} />}
|
||||
label={HEADER_AUTH_METHOD.hoverLabel}
|
||||
value={
|
||||
<div className={"text-green-500"}>
|
||||
{(auth?.header_auths ?? []).filter((h) => h.enabled).length} Header{(auth?.header_auths ?? []).filter((h) => h.enabled).length !== 1 ? "s" : ""}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
)}
|
||||
</HoverCard>
|
||||
) : (
|
||||
<Badge
|
||||
variant={"gray"}
|
||||
disabled={!canConfigure}
|
||||
className={"cursor-pointer !rounded-r-none !border-r-0 !h-[34px] min-w-[100px] !justify-start hover:bg-nb-gray-930 transition-all"}
|
||||
>
|
||||
<LockOpenIcon size={12} className="text-red-500" />
|
||||
<span className={"font-medium text-xs"}>No Auth</span>
|
||||
</Badge>
|
||||
)}
|
||||
<Button
|
||||
size={"xs"}
|
||||
variant={"secondary"}
|
||||
className={"!rounded-l-none !px-3 !h-[34px]"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openModal({ proxy: reverseProxy, initialTab: "auth" });
|
||||
}}
|
||||
disabled={!permission?.services?.update}
|
||||
aria-label="Configure authentication"
|
||||
>
|
||||
<Settings size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@/interfaces/ReverseProxy";
|
||||
import ReverseProxyActionCell from "@/modules/reverse-proxy/table/ReverseProxyActionCell";
|
||||
import ReverseProxyActiveCell from "@/modules/reverse-proxy/table/ReverseProxyActiveCell";
|
||||
import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell";
|
||||
import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell";
|
||||
import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell";
|
||||
import ReverseProxyNameCell from "@/modules/reverse-proxy/table/ReverseProxyNameCell";
|
||||
@@ -90,6 +91,17 @@ const ReverseProxyColumns: ColumnDef<ReverseProxy>[] = [
|
||||
},
|
||||
cell: ({ row }) => <ReverseProxyAuthCell reverseProxy={row.original} />,
|
||||
},
|
||||
{
|
||||
id: "access_rules",
|
||||
header: ({ column }) => {
|
||||
return (
|
||||
<DataTableHeader column={column}>Access Control</DataTableHeader>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyAccessControlCell reverseProxy={row.original} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "",
|
||||
|
||||
@@ -461,7 +461,7 @@ export default function ReverseProxyTargetModal({
|
||||
<Label>Request Timeout</Label>
|
||||
<HelpText className={"mb-0"}>
|
||||
Max time to wait for a response as duration string
|
||||
(max 5m). <br /> Leave this field empty for no
|
||||
(e.g. 30s, 2m). <br /> Leave this field empty for no
|
||||
timeout.
|
||||
</HelpText>
|
||||
</div>
|
||||
@@ -487,7 +487,7 @@ export default function ReverseProxyTargetModal({
|
||||
<Label>Session Idle Timeout</Label>
|
||||
<HelpText className={"mb-0"}>
|
||||
How long a UDP session stays alive without traffic
|
||||
(max 10m). <br /> Defaults to 30s when empty.
|
||||
(e.g., 30s, 2m). <br /> Defaults to 30s when empty.
|
||||
</HelpText>
|
||||
</div>
|
||||
<Input
|
||||
|
||||
@@ -14,6 +14,7 @@ import { usePermissions } from "@/contexts/PermissionsProvider";
|
||||
import { useReverseProxies } from "@/contexts/ReverseProxiesProvider";
|
||||
import { ReverseProxyFlatTarget } from "@/interfaces/ReverseProxy";
|
||||
import ReverseProxyArrowCell from "@/modules/reverse-proxy/table/ReverseProxyArrowCell";
|
||||
import ReverseProxyAccessControlCell from "@/modules/reverse-proxy/table/ReverseProxyAccessControlCell";
|
||||
import ReverseProxyAuthCell from "@/modules/reverse-proxy/table/ReverseProxyAuthCell";
|
||||
import ReverseProxyClusterCell from "@/modules/reverse-proxy/table/ReverseProxyClusterCell";
|
||||
import ReverseProxyDestinationCell from "@/modules/reverse-proxy/table/ReverseProxyDestinationCell";
|
||||
@@ -111,6 +112,15 @@ const FlatTargetsTableColumns: ColumnDef<ReverseProxyFlatTarget>[] = [
|
||||
<ReverseProxyAuthCell reverseProxy={row.original.proxy} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "access_control",
|
||||
header: ({ column }) => (
|
||||
<DataTableHeader column={column}>Access Control</DataTableHeader>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<ReverseProxyAccessControlCell reverseProxy={row.original.proxy} />
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "actions",
|
||||
header: "",
|
||||
|
||||
@@ -7,43 +7,20 @@ import {
|
||||
|
||||
// Go time.ParseDuration format: one or more {number}{unit} pairs
|
||||
const DURATION_RE = /^(\d+(\.\d+)?(ns|us|µs|ms|s|m|h))+$/;
|
||||
const MAX_TIMEOUT_MS = 5 * 60 * 1000; // 5m
|
||||
const MAX_SESSION_IDLE_TIMEOUT_MS = 10 * 60 * 1000; // 10m
|
||||
|
||||
function parseDurationMs(duration: string): number {
|
||||
const units: Record<string, number> = {
|
||||
ns: 1e-6,
|
||||
us: 1e-3,
|
||||
µs: 1e-3,
|
||||
ms: 1,
|
||||
s: 1000,
|
||||
m: 60_000,
|
||||
h: 3_600_000,
|
||||
};
|
||||
let total = 0;
|
||||
for (const [, val, , unit] of duration.matchAll(
|
||||
/(\d+(\.\d+)?)(ns|us|µs|ms|s|m|h)/g,
|
||||
)) {
|
||||
total += parseFloat(val) * units[unit];
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
export function validateTimeout(timeout: string): string | undefined {
|
||||
if (!timeout) return undefined;
|
||||
if (!DURATION_RE.test(timeout))
|
||||
return 'Invalid duration, use e.g., "10s", "30s", "1m"';
|
||||
if (parseDurationMs(timeout) > MAX_TIMEOUT_MS)
|
||||
return "Timeout cannot exceed the maximum of 5m.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function validateSessionIdleTimeout(timeout: string): string | undefined {
|
||||
export function validateSessionIdleTimeout(
|
||||
timeout: string,
|
||||
): string | undefined {
|
||||
if (!timeout) return undefined;
|
||||
if (!DURATION_RE.test(timeout))
|
||||
return 'Invalid duration, use e.g., "30s", "2m", "5m"';
|
||||
if (parseDurationMs(timeout) > MAX_SESSION_IDLE_TIMEOUT_MS)
|
||||
return "Session idle timeout cannot exceed the maximum of 10m.";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user