Compare commits

...

4 Commits

Author SHA1 Message Date
Eduard Gert
0841caecbb Fix dns zone domain validation and peers last seen sort (#595)
Some checks failed
build and push / build_n_push (push) Has been cancelled
2026-03-25 17:50:22 +01:00
Eduard Gert
c7846760d1 Add reverse proxy auth headers (#593)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments

* Add auth header modal

* Remove password managers from auth headers

* fix unique id

* Remove gradient, fix button roundness

* update lucide, add additional event auth methods

* Clear existing header value on change
2026-03-25 14:31:36 +01:00
Viktor Liu
8c283b6ef9 Support optional subdomain for reverse proxy domains (#589) 2026-03-24 16:01:01 +01:00
Eduard Gert
34ae3b4da6 Add reverse proxy access rules (#592)
* Add reverse proxy access rules

* Fix coderabbit comments

* Fix coderabbit comments

* Fix coderabbit comments
2026-03-24 16:00:31 +01:00
25 changed files with 1445 additions and 175 deletions

8
package-lock.json generated
View File

@@ -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"

View File

@@ -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",

View File

@@ -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",

View File

@@ -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>

View File

@@ -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

View File

@@ -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,

View File

@@ -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);
}
};

View File

@@ -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>
);

View File

@@ -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],
);

View File

@@ -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";

View File

@@ -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]);

View File

@@ -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,
},
],
);

View 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>
);
};

View File

@@ -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>
);
}

View 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>
);
}

View File

@@ -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>

View File

@@ -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;

View File

@@ -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,

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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: "",

View File

@@ -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

View File

@@ -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: "",

View File

@@ -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;
}