mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-12 03:39:55 +00:00
update ssh and advanced settings tabs
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { cva, VariantProps } from "class-variance-authority";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { ChevronDown, ChevronUp, Eye, EyeOff } from "lucide-react";
|
||||
import {
|
||||
forwardRef,
|
||||
InputHTMLAttributes,
|
||||
ReactNode,
|
||||
useId,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
@@ -73,17 +74,44 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const isPasswordType = type === "password";
|
||||
const inputType = isPasswordType && showPassword ? "text" : type;
|
||||
const isNumber = type === "number";
|
||||
|
||||
const reactId = useId();
|
||||
const inputId =
|
||||
id ?? (label ? `input-${reactId}` : undefined);
|
||||
|
||||
const internalRef = useRef<HTMLInputElement | null>(null);
|
||||
const setRefs = (el: HTMLInputElement | null) => {
|
||||
internalRef.current = el;
|
||||
if (typeof ref === "function") ref(el);
|
||||
else if (ref) (ref as React.MutableRefObject<HTMLInputElement | null>).current = el;
|
||||
};
|
||||
|
||||
const stepBy = (delta: 1 | -1) => {
|
||||
const el = internalRef.current;
|
||||
if (!el || el.disabled || el.readOnly) return;
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype,
|
||||
"value",
|
||||
)?.set;
|
||||
const stepAttr = el.step !== "" ? Number(el.step) : 1;
|
||||
const step = Number.isFinite(stepAttr) && stepAttr > 0 ? stepAttr : 1;
|
||||
const min = el.min !== "" ? Number(el.min) : -Infinity;
|
||||
const max = el.max !== "" ? Number(el.max) : Infinity;
|
||||
const current = el.value === "" ? 0 : Number(el.value);
|
||||
let next = (Number.isFinite(current) ? current : 0) + delta * step;
|
||||
if (next < min) next = min;
|
||||
if (next > max) next = max;
|
||||
setter?.call(el, String(next));
|
||||
el.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
};
|
||||
|
||||
const passwordToggle =
|
||||
isPasswordType && showPasswordToggle ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className="hover:text-white transition-all"
|
||||
className="hover:text-white transition-all pointer-events-auto"
|
||||
aria-label="Toggle password visibility"
|
||||
>
|
||||
{showPassword ? <EyeOff size={18} /> : <Eye size={18} />}
|
||||
@@ -91,6 +119,7 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
) : null;
|
||||
|
||||
const suffix = passwordToggle || customSuffix;
|
||||
const showStepper = isNumber;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full min-w-0">
|
||||
@@ -125,37 +154,75 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
</div>
|
||||
)}
|
||||
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
ref={ref}
|
||||
{...props}
|
||||
className={cn(
|
||||
inputVariants({
|
||||
variant: error ? "error" : variant,
|
||||
}),
|
||||
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm",
|
||||
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
customPrefix && "!border-l-0 !rounded-l-none",
|
||||
suffix && "!pr-16",
|
||||
icon && "!pl-10",
|
||||
"border",
|
||||
props.readOnly &&
|
||||
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<div className="relative flex flex-grow min-w-0">
|
||||
<input
|
||||
id={inputId}
|
||||
type={inputType}
|
||||
ref={setRefs}
|
||||
{...props}
|
||||
className={cn(
|
||||
inputVariants({
|
||||
variant: error ? "error" : variant,
|
||||
}),
|
||||
"flex h-[40px] w-full rounded-md bg-white px-3 py-2 text-sm",
|
||||
"file:bg-transparent file:text-sm file:font-medium file:border-0",
|
||||
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2",
|
||||
"disabled:cursor-not-allowed disabled:opacity-40",
|
||||
customPrefix && "!border-l-0 !rounded-l-none",
|
||||
suffix && "!pr-9",
|
||||
icon && "!pl-10",
|
||||
"border",
|
||||
props.readOnly &&
|
||||
"!bg-nb-gray-920 text-nb-gray-400 !border-nb-gray-800",
|
||||
showStepper &&
|
||||
"!rounded-r-none [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none [-moz-appearance:textfield]",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
|
||||
{suffix && (
|
||||
{suffix && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-3 leading-[0] select-none pointer-events-none",
|
||||
props.disabled && "opacity-30",
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showStepper && (
|
||||
<div
|
||||
className={cn(
|
||||
"absolute right-0 top-0 h-full flex items-center text-xs dark:text-nb-gray-300 pr-4 leading-[0] select-none",
|
||||
props.disabled && "opacity-30",
|
||||
"flex flex-col h-[40px] shrink-0 overflow-hidden",
|
||||
"border border-l-0 rounded-r-md",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
props.disabled && "opacity-40 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
{suffix}
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Increase"
|
||||
onClick={() => stepBy(1)}
|
||||
className="flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default"
|
||||
>
|
||||
<ChevronUp size={12} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Decrease"
|
||||
onClick={() => stepBy(-1)}
|
||||
className={cn(
|
||||
"flex-1 flex items-center justify-center w-9 hover:bg-nb-gray-800 transition-colors text-nb-gray-300 cursor-default",
|
||||
"border-t border-neutral-200 dark:border-nb-gray-700",
|
||||
)}
|
||||
>
|
||||
<ChevronDown size={12} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const MainRightSide = ({ children }: Props) => {
|
||||
className={cn(
|
||||
"wails-no-draggable",
|
||||
"bg-nb-gray-935 border border-nb-gray-910",
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl ",
|
||||
"flex-1 min-h-0 min-w-0 flex flex-col rounded-xl rounded-br-2xl overflow-hidden",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import Button from "@/components/Button";
|
||||
import { HelpText } from "@/components/HelpText";
|
||||
import { Input } from "@/components/Input";
|
||||
import { Label } from "@/components/Label";
|
||||
@@ -5,53 +7,111 @@ import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
|
||||
export function SettingsAdvanced() {
|
||||
const { config, setField } = useSettings();
|
||||
const { config, saveFields } = useSettings();
|
||||
|
||||
const [draft, setDraft] = useState({
|
||||
interfaceName: config.interfaceName,
|
||||
wireguardPort: config.wireguardPort,
|
||||
mtu: config.mtu,
|
||||
preSharedKey: config.preSharedKey,
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Re-sync the draft when the underlying config changes from elsewhere (e.g. reload).
|
||||
useEffect(() => {
|
||||
setDraft({
|
||||
interfaceName: config.interfaceName,
|
||||
wireguardPort: config.wireguardPort,
|
||||
mtu: config.mtu,
|
||||
preSharedKey: config.preSharedKey,
|
||||
});
|
||||
}, [config.interfaceName, config.wireguardPort, config.mtu, config.preSharedKey]);
|
||||
|
||||
const isDirty =
|
||||
draft.interfaceName !== config.interfaceName ||
|
||||
draft.wireguardPort !== config.wireguardPort ||
|
||||
draft.mtu !== config.mtu ||
|
||||
draft.preSharedKey !== config.preSharedKey;
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!isDirty || saving) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveFields(draft);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Security"}>
|
||||
<div>
|
||||
<Label as={"div"}>Pre-shared key</Label>
|
||||
<HelpText>
|
||||
Optional WireGuard pre-shared key for an extra layer of
|
||||
symmetric encryption. Must match the value configured
|
||||
on every peer in the network.
|
||||
</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
showPasswordToggle
|
||||
value={config.preSharedKey}
|
||||
onChange={(e) =>
|
||||
setField("preSharedKey", e.target.value)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Interface"}>
|
||||
<Input
|
||||
label={"Name"}
|
||||
value={config.interfaceName}
|
||||
onChange={(e) => setField("interfaceName", e.target.value)}
|
||||
value={draft.interfaceName}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, interfaceName: e.target.value }))}
|
||||
/>
|
||||
<div className={"grid grid-cols-2 gap-4"}>
|
||||
<Input
|
||||
label={"WireGuard Port"}
|
||||
label={"Port"}
|
||||
type={"number"}
|
||||
value={config.wireguardPort}
|
||||
value={draft.wireguardPort}
|
||||
onChange={(e) =>
|
||||
setField("wireguardPort", Number(e.target.value))
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
wireguardPort: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<Input
|
||||
label={"MTU"}
|
||||
type={"number"}
|
||||
value={config.mtu}
|
||||
value={draft.mtu}
|
||||
onChange={(e) =>
|
||||
setField("mtu", Number(e.target.value))
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
mtu: Number(e.target.value),
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Security"}>
|
||||
<div>
|
||||
<Label as={"div"}>Pre-shared Key</Label>
|
||||
<HelpText>
|
||||
Optional WireGuard PSK for extra symmetric encryption. Not the same as a
|
||||
NetBird Setup Key. Set the same value on every peer, otherwise they can't
|
||||
connect to each other.
|
||||
</HelpText>
|
||||
<Input
|
||||
type={"password"}
|
||||
showPasswordToggle
|
||||
placeholder={"kQv0qF3oQpJYdgD5mC9hL7sB2xZ8nT4eU6wY1aR3jK0="}
|
||||
value={draft.preSharedKey}
|
||||
onChange={(e) =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
preSharedKey: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</SectionGroup>
|
||||
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div className={"w-full flex justify-end px-8 py-5 border-t border-nb-gray-910"}>
|
||||
<Button
|
||||
variant={"primary"}
|
||||
size={"md"}
|
||||
disabled={!isDirty || saving}
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ type SettingsContextValue = {
|
||||
config: Config;
|
||||
setField: <K extends keyof Config>(k: K, v: Config[K]) => void;
|
||||
saveField: <K extends keyof Config>(k: K, v: Config[K]) => Promise<void>;
|
||||
saveFields: (partial: Partial<Config>) => Promise<void>;
|
||||
saveNow: () => Promise<void>;
|
||||
};
|
||||
|
||||
@@ -113,11 +114,26 @@ const useSettingsState = () => {
|
||||
[config, save],
|
||||
);
|
||||
|
||||
return { config, error, setField, saveField, saveNow };
|
||||
const saveFields = useCallback(
|
||||
async (partial: Partial<Config>) => {
|
||||
if (!config) return;
|
||||
if (saveTimer.current) {
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = null;
|
||||
}
|
||||
const next = { ...config, ...partial };
|
||||
setConfig(next);
|
||||
await save(next);
|
||||
},
|
||||
[config, save],
|
||||
);
|
||||
|
||||
return { config, error, setField, saveField, saveFields, saveNow };
|
||||
};
|
||||
|
||||
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
const { config, error, setField, saveField, saveNow } = useSettingsState();
|
||||
const { config, error, setField, saveField, saveFields, saveNow } =
|
||||
useSettingsState();
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -127,7 +143,13 @@ export const SettingsProvider = ({ children }: { children: ReactNode }) => {
|
||||
<div className={"p-6 text-sm text-nb-gray-500"}>Loading…</div>
|
||||
) : (
|
||||
<SettingsContext.Provider
|
||||
value={{ config, setField, saveField, saveNow }}
|
||||
value={{
|
||||
config,
|
||||
setField,
|
||||
saveField,
|
||||
saveFields,
|
||||
saveNow,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
|
||||
@@ -5,100 +5,116 @@ import { Label } from "@/components/Label";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { SectionGroup } from "@/modules/settings/SettingsSection.tsx";
|
||||
import { useSettings } from "@/modules/settings/SettingsContext.tsx";
|
||||
import { type ChangeEvent, useEffect, useState } from "react";
|
||||
|
||||
export function SettingsSSH() {
|
||||
const { config, setField } = useSettings();
|
||||
const sshOff = !config.serverSshAllowed;
|
||||
const isSSHServerEnabled = config.serverSshAllowed;
|
||||
const [jwtTtlInput, setJwtTtlInput] = useState(String(config.sshJwtCacheTtl));
|
||||
|
||||
// Keep the local input in sync when the config changes from elsewhere
|
||||
useEffect(() => {
|
||||
setJwtTtlInput(String(config.sshJwtCacheTtl));
|
||||
}, [config.sshJwtCacheTtl]);
|
||||
|
||||
const handleJwtTtlChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const v = e.target.value;
|
||||
setJwtTtlInput(v);
|
||||
if (v === "") return;
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n) && n >= 0) {
|
||||
setField("sshJwtCacheTtl", n);
|
||||
}
|
||||
};
|
||||
|
||||
const handleJwtTtlBlur = () => {
|
||||
if (jwtTtlInput === "") {
|
||||
setJwtTtlInput("0");
|
||||
setField("sshJwtCacheTtl", 0);
|
||||
return;
|
||||
}
|
||||
const n = Number(jwtTtlInput);
|
||||
if (!Number.isFinite(n) || n < 0) {
|
||||
setJwtTtlInput(String(config.sshJwtCacheTtl));
|
||||
}
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<SectionGroup title={"Server"}>
|
||||
<FancyToggleSwitch
|
||||
value={config.serverSshAllowed}
|
||||
onChange={(v) => setField("serverSshAllowed", v)}
|
||||
label={"Allow SSH"}
|
||||
label={"Enable SSH Server"}
|
||||
helpText={
|
||||
"Run the NetBird SSH server on this host so other peers can connect to it."
|
||||
}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Capabilities"}>
|
||||
<SectionGroup title={"Capabilities"} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRoot}
|
||||
onChange={(v) => setField("enableSshRoot", v)}
|
||||
label={"Allow root login"}
|
||||
label={"Allow Root Login"}
|
||||
helpText={
|
||||
"Permit incoming SSH sessions to authenticate as root."
|
||||
"Let peers sign in as the root user. Disable to require a non-privileged account."
|
||||
}
|
||||
disabled={sshOff}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshSftp}
|
||||
onChange={(v) => setField("enableSshSftp", v)}
|
||||
label={"Enable SFTP"}
|
||||
helpText={"Allow file transfers over the NetBird SSH server."}
|
||||
disabled={sshOff}
|
||||
label={"Allow SFTP"}
|
||||
helpText={"Transfer files securely using native SFTP or SCP clients."}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshLocalPortForwarding}
|
||||
onChange={(v) => setField("enableSshLocalPortForwarding", v)}
|
||||
label={"Local port forwarding"}
|
||||
label={"Local Port Forwarding"}
|
||||
helpText={
|
||||
"Allow clients to forward local ports through this host."
|
||||
"Let connecting peers tunnel local ports to services reachable from this host."
|
||||
}
|
||||
disabled={sshOff}
|
||||
/>
|
||||
<FancyToggleSwitch
|
||||
value={config.enableSshRemotePortForwarding}
|
||||
onChange={(v) =>
|
||||
setField("enableSshRemotePortForwarding", v)
|
||||
}
|
||||
label={"Remote port forwarding"}
|
||||
onChange={(v) => setField("enableSshRemotePortForwarding", v)}
|
||||
label={"Remote Port Forwarding"}
|
||||
helpText={
|
||||
"Allow clients to expose remote ports back through this host."
|
||||
"Let connecting peers expose ports on this host back to their own machine."
|
||||
}
|
||||
disabled={sshOff}
|
||||
/>
|
||||
</SectionGroup>
|
||||
|
||||
<SectionGroup title={"Authentication"}>
|
||||
<SectionGroup title={"Authentication"} disabled={!isSSHServerEnabled}>
|
||||
<FancyToggleSwitch
|
||||
value={config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", v)}
|
||||
label={"Disable SSH auth"}
|
||||
value={!config.disableSshAuth}
|
||||
onChange={(v) => setField("disableSshAuth", !v)}
|
||||
label={"Enable JWT Authentication"}
|
||||
helpText={
|
||||
"Skip JWT authentication for incoming SSH sessions. Insecure — diagnostics only."
|
||||
"Verify each SSH session against your IdP for user identity and audit. Disable to rely on network ACL policies only, useful when no IdP is available."
|
||||
}
|
||||
disabled={sshOff}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center gap-6",
|
||||
sshOff && "opacity-50 pointer-events-none",
|
||||
"flex items-center gap-6 justify-between",
|
||||
config.disableSshAuth && "opacity-50 pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<div className={"flex-1 max-w-md"}>
|
||||
<Label as={"div"}>JWT cache TTL</Label>
|
||||
<Label as={"div"}>JWT Cache TTL (Seconds)</Label>
|
||||
<HelpText margin={false}>
|
||||
How long verified JWTs are cached before
|
||||
re-validation. Shorter values increase load on the
|
||||
management server; longer values delay revocation.
|
||||
How long this client caches a JWT before prompting again on outgoing SSH
|
||||
connections. Set to 0 to disable caching and authenticate on every
|
||||
connection.
|
||||
</HelpText>
|
||||
</div>
|
||||
<div className={"w-32 shrink-0"}>
|
||||
<div className={"w-40 shrink-0"}>
|
||||
<Input
|
||||
type={"number"}
|
||||
value={config.sshJwtCacheTtl}
|
||||
onChange={(e) =>
|
||||
setField(
|
||||
"sshJwtCacheTtl",
|
||||
Number(e.target.value),
|
||||
)
|
||||
}
|
||||
customSuffix={
|
||||
<span className={"text-nb-gray-400"}>s</span>
|
||||
}
|
||||
disabled={sshOff}
|
||||
min={0}
|
||||
value={jwtTtlInput}
|
||||
onChange={handleJwtTtlChange}
|
||||
onBlur={handleJwtTtlBlur}
|
||||
customSuffix={"Seconds"}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { cn } from "@/lib/cn";
|
||||
|
||||
export const SectionGroup = ({ title, children }: { title: string; children: ReactNode }) => (
|
||||
<section className={"mb-8 px-1"}>
|
||||
export const SectionGroup = ({
|
||||
title,
|
||||
children,
|
||||
disabled = false,
|
||||
}: {
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
disabled?: boolean;
|
||||
}) => (
|
||||
<section className={cn("mb-8 px-1", disabled && "opacity-50 pointer-events-none")}>
|
||||
<h2 className={"text-xs uppercase tracking-wider text-nb-gray-400 mb-4 font-semibold"}>
|
||||
{title}
|
||||
</h2>
|
||||
|
||||
@@ -102,10 +102,10 @@ func main() {
|
||||
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Title: "NetBird",
|
||||
Width: 880,
|
||||
Height: 520,
|
||||
MinWidth: 880,
|
||||
MinHeight: 520,
|
||||
Width: 925,
|
||||
MinWidth: 925,
|
||||
Height: 600,
|
||||
MinHeight: 600,
|
||||
Hidden: false,
|
||||
BackgroundColour: application.NewRGB(24, 26, 29),
|
||||
URL: "/",
|
||||
|
||||
Reference in New Issue
Block a user