mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-15 03:09:06 +02:00
Merge remote-tracking branch 'origin/main' into refactor/permissions-manager
This commit is contained in:
@@ -287,10 +287,15 @@ jobs:
|
||||
image_refs=()
|
||||
|
||||
tag_and_push() {
|
||||
local src="$1" img_name tag dst
|
||||
local src="$1" img_name tag dst variant=""
|
||||
img_name="${src%%:*}"
|
||||
# Client variants share a repository, so keep their tag suffixes.
|
||||
case "$src" in
|
||||
*-rootless-ubi-amd64) variant="-rootless-ubi" ;;
|
||||
*-rootless-amd64) variant="-rootless" ;;
|
||||
esac
|
||||
for tag in $(resolve_tags); do
|
||||
dst="${img_name}:${tag}"
|
||||
dst="${img_name}:${tag}${variant}"
|
||||
echo "Tagging ${src} -> ${dst}"
|
||||
docker tag "$src" "$dst"
|
||||
docker push "$dst"
|
||||
|
||||
@@ -289,6 +289,43 @@ dockers_v2:
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: netbird-rootless-ubi
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
- netbird
|
||||
images:
|
||||
- netbirdio/netbird
|
||||
- ghcr.io/netbirdio/netbird
|
||||
tags:
|
||||
- "{{ .Version }}-rootless-ubi"
|
||||
- "{{ if eq .Env.SKIP_PUBLISH \"false\" }}rootless-ubi-latest{{ end }}"
|
||||
dockerfile: client/Dockerfile-rootless.ubi
|
||||
extra_files:
|
||||
- client/netbird-entrypoint.sh
|
||||
platforms:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
build_args:
|
||||
VERSION: "{{ .Version }}"
|
||||
RELEASE: "{{ .Timestamp }}"
|
||||
hooks:
|
||||
pre:
|
||||
- cmd: 'sh client/collect-licenses.sh "{{ .ContextDir }}/licenses" amd64 arm64'
|
||||
env:
|
||||
- GOOS=linux
|
||||
- CGO_ENABLED=0
|
||||
labels:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
annotations:
|
||||
"org.opencontainers.image.created": "{{.Date}}"
|
||||
"org.opencontainers.image.title": "{{.ProjectName}}"
|
||||
"org.opencontainers.image.version": "{{.Version}}"
|
||||
"org.opencontainers.image.revision": "{{.FullCommit}}"
|
||||
"org.opencontainers.image.source": "{{.GitURL}}"
|
||||
"maintainer": "dev@netbird.io"
|
||||
- id: relay
|
||||
disable: "{{ .Env.SKIP_DOCKER_PUSH }}"
|
||||
ids:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
FROM registry.access.redhat.com/ubi9/ubi-minimal@sha256:7fbeae18dc9476399f565e68255f602a3374ea8614ba3d14843565131a13ff93
|
||||
|
||||
ARG TARGETPLATFORM
|
||||
ARG NETBIRD_BINARY=$TARGETPLATFORM/netbird
|
||||
ARG VERSION=dev
|
||||
ARG RELEASE=1
|
||||
|
||||
LABEL name="netbird-rootless" \
|
||||
maintainer="NetBird <dev@netbird.io>" \
|
||||
vendor="NetBird GmbH" \
|
||||
version="${VERSION}" \
|
||||
release="${RELEASE}" \
|
||||
summary="NetBird Rootless Client" \
|
||||
description="NetBird connects devices through an encrypted overlay using userspace networking without a TUN device or network administration capabilities."
|
||||
|
||||
RUN microdnf install -y bash ca-certificates && microdnf clean all
|
||||
|
||||
COPY --chmod=0555 client/netbird-entrypoint.sh /usr/local/bin/netbird-entrypoint.sh
|
||||
COPY --chmod=0555 ${NETBIRD_BINARY} /usr/local/bin/netbird
|
||||
COPY licenses/ /licenses/
|
||||
# Only application storage is group-writable for arbitrary non-root UIDs.
|
||||
# Runtime-created credentials keep the client's restrictive file modes.
|
||||
RUN mkdir -p /var/lib/netbird && \
|
||||
chown 1000:0 /var/lib/netbird && \
|
||||
chmod 0770 /var/lib/netbird && \
|
||||
chmod -R a+rX /licenses
|
||||
|
||||
WORKDIR /var/lib/netbird
|
||||
USER 1000:0
|
||||
|
||||
ENV \
|
||||
HOME="/var/lib/netbird" \
|
||||
NETBIRD_BIN="/usr/local/bin/netbird" \
|
||||
NB_USE_NETSTACK_MODE="true" \
|
||||
NB_ENABLE_NETSTACK_LOCAL_FORWARDING="true" \
|
||||
NB_CONFIG="/var/lib/netbird/config.json" \
|
||||
NB_STATE_DIR="/var/lib/netbird" \
|
||||
NB_DAEMON_ADDR="unix:///var/lib/netbird/netbird.sock" \
|
||||
NB_LOG_FILE="console,/var/lib/netbird/client.log" \
|
||||
NB_DISABLE_DNS="true" \
|
||||
NB_ENABLE_CAPTURE="false" \
|
||||
NB_ENTRYPOINT_SERVICE_TIMEOUT="30"
|
||||
|
||||
STOPSIGNAL SIGTERM
|
||||
ENTRYPOINT ["/usr/local/bin/netbird-entrypoint.sh"]
|
||||
@@ -0,0 +1,77 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
if [ "$#" -lt 2 ]; then
|
||||
printf '%s\n' "usage: $0 OUTPUT_DIRECTORY GOARCH..." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
repo_root=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
output_name=$(basename "$1")
|
||||
if [ -z "$output_name" ] || [ "$output_name" = "." ] ||
|
||||
[ "$output_name" = ".." ] || [ "$output_name" = "/" ]; then
|
||||
printf '%s\n' "OUTPUT_DIRECTORY must name a directory" >&2
|
||||
exit 2
|
||||
fi
|
||||
output_parent=$(CDPATH= cd -- "$(dirname "$1")" && pwd)
|
||||
output="$output_parent/$output_name"
|
||||
shift
|
||||
modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.modules.XXXXXX")
|
||||
sorted_modules=$(mktemp "${TMPDIR:-/tmp}/netbird-client-licenses.sorted.XXXXXX")
|
||||
trap 'rm -f "$modules" "$sorted_modules"' EXIT HUP INT TERM
|
||||
|
||||
if [ -e "$output" ] || [ -L "$output" ]; then
|
||||
printf 'output directory already exists: %s\n' "$output" >&2
|
||||
exit 1
|
||||
fi
|
||||
mkdir "$output"
|
||||
mkdir "$output/third_party"
|
||||
|
||||
cp "$repo_root/LICENSE" "$output/BSD-3-Clause.txt"
|
||||
|
||||
cd "$repo_root"
|
||||
for arch in "$@"; do
|
||||
GOOS=${GOOS:-linux} GOARCH="$arch" CGO_ENABLED=${CGO_ENABLED:-0} \
|
||||
go list -deps -f '{{with .Module}}{{if .Replace}}{{.Replace.Path}}{{"\t"}}{{.Replace.Version}}{{"\t"}}{{.Replace.Dir}}{{else}}{{.Path}}{{"\t"}}{{.Version}}{{"\t"}}{{.Dir}}{{end}}{{end}}' -tags load_wgnt_from_rsrc ./client >>"$modules"
|
||||
done
|
||||
LC_ALL=C sort -u "$modules" >"$sorted_modules"
|
||||
|
||||
goroot=$(go env GOROOT)
|
||||
for term in LICENSE PATENTS; do
|
||||
if [ ! -f "$goroot/$term" ]; then
|
||||
printf 'missing Go standard-library term: %s\n' "$goroot/$term" >&2
|
||||
exit 1
|
||||
fi
|
||||
cp "$goroot/$term" "$output/Go-$term"
|
||||
done
|
||||
|
||||
while IFS=' ' read -r module version module_dir; do
|
||||
[ -n "$module" ] || continue
|
||||
[ "$module" = "github.com/netbirdio/netbird" ] && continue
|
||||
|
||||
if [ -z "$version" ] || [ ! -d "$module_dir" ]; then
|
||||
printf 'cannot collect terms for module %s at version %s\n' "$module" "$version" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
destination="$output/third_party/$module/$version"
|
||||
mkdir -p "$destination"
|
||||
printf 'module: %s\nversion: %s\n' "$module" "$version" >"$destination/MODULE"
|
||||
|
||||
found=false
|
||||
for term in \
|
||||
"$module_dir"/LICENSE* "$module_dir"/License* "$module_dir"/license* \
|
||||
"$module_dir"/LICENCE* "$module_dir"/Licence* "$module_dir"/licence* \
|
||||
"$module_dir"/COPYING* "$module_dir"/Copying* "$module_dir"/copying* \
|
||||
"$module_dir"/NOTICE* "$module_dir"/Notice* "$module_dir"/notice* \
|
||||
"$module_dir"/PATENTS* "$module_dir"/Patents* "$module_dir"/patents*; do
|
||||
[ -f "$term" ] || continue
|
||||
cp "$term" "$destination/"
|
||||
found=true
|
||||
done
|
||||
|
||||
if [ "$found" = false ]; then
|
||||
printf 'no root license terms found for module %s at %s\n' "$module" "$module_dir" >&2
|
||||
exit 1
|
||||
fi
|
||||
done <"$sorted_modules"
|
||||
@@ -14,12 +14,14 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
"github.com/pion/ice/v4"
|
||||
"github.com/pion/stun/v3"
|
||||
log "github.com/sirupsen/logrus"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
|
||||
@@ -236,6 +238,12 @@ type Engine struct {
|
||||
|
||||
wgInterface WGIface
|
||||
|
||||
// wgDevice is a lock-free handle on the WireGuard device behind
|
||||
// wgInterface. Reaching the device through wgInterface requires
|
||||
// syncMsgMux, which handleSync holds while it adds and removes peers;
|
||||
// SetPerformance must stay reachable exactly when that work is stuck.
|
||||
wgDevice atomic.Pointer[wgdevice.Device]
|
||||
|
||||
udpMux *udpmux.UniversalUDPMuxDefault
|
||||
|
||||
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
|
||||
@@ -651,6 +659,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
|
||||
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
|
||||
return fmt.Errorf("up wg interface: %w", err)
|
||||
}
|
||||
e.wgDevice.Store(e.wgInterface.GetWGDevice())
|
||||
|
||||
// Set up notrack rules immediately after proxy is listening to prevent
|
||||
// conntrack entries from being created before the rules are in place
|
||||
@@ -2144,6 +2153,10 @@ func (e *Engine) close() {
|
||||
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
|
||||
|
||||
if e.wgInterface != nil {
|
||||
// Drop the handle before the close starts: a retune that loads it
|
||||
// afterwards would touch a device on its way out and report success
|
||||
// for an engine that is already gone.
|
||||
e.wgDevice.Store(nil)
|
||||
if err := e.wgInterface.Close(); err != nil {
|
||||
log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err)
|
||||
}
|
||||
@@ -2303,15 +2316,16 @@ type Performance struct {
|
||||
}
|
||||
|
||||
// SetPerformance applies the given tuning to this engine's live Device.
|
||||
//
|
||||
// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the
|
||||
// recovery path for a device whose pool is exhausted, and an exhausted pool
|
||||
// blocks peer removal inside handleSync, which holds syncMsgMux for as long as
|
||||
// it stays blocked. Taking the lock here would make the retune unreachable in
|
||||
// the one situation that needs it.
|
||||
func (e *Engine) SetPerformance(t Performance) error {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
if e.wgInterface == nil {
|
||||
return fmt.Errorf("wg interface not initialized")
|
||||
}
|
||||
dev := e.wgInterface.GetWGDevice()
|
||||
dev := e.wgDevice.Load()
|
||||
if dev == nil {
|
||||
return fmt.Errorf("wg device not initialized")
|
||||
return errors.New("wg device not initialized")
|
||||
}
|
||||
if t.PreallocatedBuffersPerPool != nil {
|
||||
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
|
||||
|
||||
@@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
for {
|
||||
select {
|
||||
case remoteOfferAnswer := <-h.remoteOffersCh:
|
||||
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
|
||||
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
@@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
|
||||
continue
|
||||
}
|
||||
case remoteOfferAnswer := <-h.remoteAnswerCh:
|
||||
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
|
||||
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
|
||||
|
||||
// Record signaling received for reconnection attempts
|
||||
if h.metricsStages != nil {
|
||||
@@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error {
|
||||
}
|
||||
|
||||
offer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
|
||||
h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP)
|
||||
|
||||
return h.signaler.SignalOffer(offer, h.config.Key)
|
||||
}
|
||||
|
||||
func (h *Handshaker) sendAnswer() error {
|
||||
answer := h.buildOfferAnswer()
|
||||
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
|
||||
h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP)
|
||||
|
||||
return h.signaler.SignalAnswer(answer, h.config.Key)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,25 @@
|
||||
<title>NetBird</title>
|
||||
<style>
|
||||
html, body { background: #181A1D; }
|
||||
html:not(.dark), html:not(.dark) body { background: #F3F3F3; }
|
||||
</style>
|
||||
<script>
|
||||
// Pre-paint theme guard: apply the last-known theme before first render
|
||||
// to avoid a flash of the wrong theme. ThemeContext keeps the mirror
|
||||
// fresh from the persisted preference and the Go-reported OS appearance.
|
||||
(function () {
|
||||
try {
|
||||
var pref = localStorage.getItem("nb-theme-pref") || "system";
|
||||
var dark;
|
||||
if (pref === "dark") dark = true;
|
||||
else if (pref === "light") dark = false;
|
||||
else dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
document.documentElement.classList.toggle("dark", dark);
|
||||
} catch (e) {
|
||||
/* keep the default dark class */
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -13,6 +13,7 @@ import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import "react-loading-skeleton/dist/skeleton.css";
|
||||
import { welcome } from "@/lib/welcome";
|
||||
import LoginWaitingForBrowserDialog from "@/modules/login/LoginWaitingForBrowserDialog.tsx";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext.tsx";
|
||||
import { initI18n } from "@/lib/i18n";
|
||||
import { initPlatform } from "@/lib/platform";
|
||||
import { initLogForwarding } from "@/lib/logs";
|
||||
@@ -35,30 +36,38 @@ Promise.all([
|
||||
]).finally(() => {
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<SkeletonTheme baseColor={"#25282d"} highlightColor={"#33373e"}>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path={"dialog"}>
|
||||
<Route
|
||||
path={"browser-login"}
|
||||
element={<LoginWaitingForBrowserDialog />}
|
||||
/>
|
||||
<Route path={"install-progress"} element={<UpdateInProgressDialog />} />
|
||||
<Route
|
||||
path={"session-expiration"}
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path={"welcome"} element={<WelcomeDialog />} />
|
||||
<Route path={"error"} element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path={"settings"} element={<SettingsPage />} />
|
||||
<Route path={"*"} element={<Navigate to={"/"} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
<ThemeProvider>
|
||||
<SkeletonTheme
|
||||
baseColor={"rgb(var(--skeleton-base))"}
|
||||
highlightColor={"rgb(var(--skeleton-highlight))"}
|
||||
>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path={"dialog"}>
|
||||
<Route
|
||||
path={"browser-login"}
|
||||
element={<LoginWaitingForBrowserDialog />}
|
||||
/>
|
||||
<Route
|
||||
path={"install-progress"}
|
||||
element={<UpdateInProgressDialog />}
|
||||
/>
|
||||
<Route
|
||||
path={"session-expiration"}
|
||||
element={<SessionExpirationDialog />}
|
||||
/>
|
||||
<Route path={"welcome"} element={<WelcomeDialog />} />
|
||||
<Route path={"error"} element={<ErrorDialog />} />
|
||||
</Route>
|
||||
<Route element={<AppLayout />}>
|
||||
<Route index element={<MainPage />} />
|
||||
<Route path={"settings"} element={<SettingsPage />} />
|
||||
<Route path={"*"} element={<Navigate to={"/"} replace />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</SkeletonTheme>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<svg width="133" height="23" viewBox="0 0 133 23" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_0_3)">
|
||||
<path d="M46.9438 7.5013C48.1229 8.64688 48.7082 10.3025 48.7082 12.4683V21.6663H46.1411V12.8362C46.1411 11.2809 45.7481 10.0851 44.9704 9.26566C44.1928 8.43783 43.1308 8.0281 41.7846 8.0281C40.4383 8.0281 39.3345 8.45455 38.5234 9.30747C37.7123 10.1604 37.3109 11.4063 37.3109 13.0369V21.6663H34.7188V6.06305H37.3109V8.28732C37.821 7.49294 38.5234 6.87416 39.4014 6.43934C40.2878 6.00452 41.2578 5.78711 42.3197 5.78711C44.2179 5.78711 45.7565 6.36408 46.9355 7.50966L46.9438 7.5013Z" fill="#1f2124"/>
|
||||
<path d="M67.1048 14.8344H54.6288C54.7208 16.373 55.2476 17.5771 56.2092 18.4384C57.1708 19.2997 58.3331 19.7345 59.6961 19.7345C60.8166 19.7345 61.7531 19.4753 62.4973 18.9485C63.2499 18.4301 63.7767 17.7277 64.0777 16.858H66.8706C66.4525 18.3548 65.6163 19.5756 64.3621 20.5205C63.1078 21.4571 61.5525 21.9337 59.6878 21.9337C58.2077 21.9337 56.8865 21.5992 55.7159 20.9386C54.5452 20.278 53.6337 19.3331 52.9648 18.1039C52.2958 16.8831 51.9697 15.4616 51.9697 13.8477C51.9697 12.2339 52.2958 10.8207 52.9397 9.60825C53.5836 8.39578 54.495 7.45924 55.6573 6.80702C56.828 6.15479 58.1659 5.82031 59.6878 5.82031C61.2096 5.82031 62.4806 6.14643 63.6178 6.79029C64.7551 7.43416 65.6331 8.32052 66.2518 9.44938C66.8706 10.5782 67.18 11.8576 67.18 13.2791C67.18 13.7725 67.1549 14.2909 67.0964 14.8428L67.1048 14.8344ZM63.8603 10.1769C63.4255 9.4661 62.8318 8.92258 62.0793 8.55465C61.3267 8.18673 60.4989 8.00277 59.5874 8.00277C58.2746 8.00277 57.1625 8.42086 56.2427 9.25705C55.3228 10.0932 54.796 11.2472 54.6623 12.7356H64.5126C64.5126 11.7489 64.2952 10.896 63.8603 10.1852V10.1769Z" fill="#1f2124"/>
|
||||
<path d="M73.7695 8.20355V17.4016C73.7695 18.1626 73.9284 18.6977 74.2545 19.0071C74.5806 19.3165 75.1409 19.4754 75.9352 19.4754H77.8418V21.6662H75.5088C74.0622 21.6662 72.9835 21.3317 72.2644 20.6711C71.5452 20.0105 71.1857 18.9151 71.1857 17.3933V8.19519H69.1621V6.0629H71.1857V2.13281H73.7779V6.0629H77.8501V8.19519H73.7779L73.7695 8.20355Z" fill="#1f2124"/>
|
||||
<path d="M85.9022 6.68902C86.9307 6.10369 88.093 5.80266 89.4058 5.80266C90.8106 5.80266 92.0732 6.13714 93.1937 6.79773C94.3142 7.46668 95.2006 8.39485 95.8444 9.59896C96.4883 10.8031 96.8144 12.2079 96.8144 13.7966C96.8144 15.3854 96.4883 16.7818 95.8444 18.011C95.2006 19.2486 94.3142 20.2018 93.1854 20.8875C92.0565 21.5732 90.7939 21.916 89.4141 21.916C88.0344 21.916 86.8805 21.6234 85.8687 21.0297C84.8569 20.4443 84.0876 19.6918 83.5775 18.7803V21.6568H80.9854V0.601562H83.5775V8.97182C84.1127 8.04365 84.8904 7.28272 85.9105 6.69738L85.9022 6.68902ZM93.4529 10.7362C92.9763 9.86654 92.3408 9.19759 91.5297 8.74605C90.7186 8.29451 89.8322 8.06037 88.8706 8.06037C87.909 8.06037 87.0394 8.29451 86.2366 8.75441C85.4255 9.22268 84.7817 9.89163 84.2967 10.778C83.8117 11.6643 83.5692 12.6845 83.5692 13.8384C83.5692 14.9924 83.8117 16.046 84.2967 16.9323C84.7817 17.8187 85.4255 18.4877 86.2366 18.9559C87.0394 19.4242 87.9174 19.65 88.8706 19.65C89.8239 19.65 90.727 19.4158 91.5297 18.9559C92.3324 18.4877 92.9763 17.8187 93.4529 16.9323C93.9296 16.046 94.1637 15.0091 94.1637 13.8134C94.1637 12.6176 93.9296 11.6142 93.4529 10.7362Z" fill="#1f2124"/>
|
||||
<path d="M100.318 3.01864C99.9749 2.67581 99.8076 2.25771 99.8076 1.76436C99.8076 1.27101 99.9749 0.852913 100.318 0.510076C100.661 0.167238 101.079 0 101.572 0C102.065 0 102.45 0.167238 102.784 0.510076C103.119 0.852913 103.286 1.27101 103.286 1.76436C103.286 2.25771 103.119 2.67581 102.784 3.01864C102.45 3.36148 102.049 3.52872 101.572 3.52872C101.095 3.52872 100.661 3.36148 100.318 3.01864ZM102.826 6.06237V21.6657H100.234V6.06237H102.826Z" fill="#1f2124"/>
|
||||
<path d="M111.773 6.52155C112.617 6.0282 113.646 5.77734 114.867 5.77734V8.45315H114.181C111.28 8.45315 109.825 10.0252 109.825 13.1776V21.6649H107.232V6.06165H109.825V8.5953C110.276 7.70058 110.928 7.00654 111.773 6.51319V6.52155Z" fill="#1f2124"/>
|
||||
<path d="M117.861 9.60732C118.505 8.40321 119.391 7.46668 120.52 6.80609C121.649 6.1455 122.92 5.81102 124.325 5.81102C125.537 5.81102 126.666 6.09533 127.711 6.64721C128.757 7.20746 129.551 7.94331 130.103 8.85475V0.601562H132.72V21.6735H130.103V18.7385C129.593 19.6667 128.832 20.436 127.828 21.0297C126.825 21.6317 125.646 21.9244 124.3 21.9244C122.953 21.9244 121.657 21.5816 120.528 20.8959C119.4 20.2102 118.513 19.257 117.869 18.0194C117.226 16.7818 116.899 15.377 116.899 13.805C116.899 12.233 117.226 10.8114 117.869 9.60732H117.861ZM129.392 10.7613C128.915 9.89163 128.28 9.22268 127.469 8.75441C126.658 8.28614 125.771 8.06037 124.81 8.06037C123.848 8.06037 122.962 8.28614 122.159 8.74605C121.356 9.20595 120.729 9.86654 120.253 10.7362C119.776 11.6058 119.542 12.6343 119.542 13.8134C119.542 14.9924 119.776 16.046 120.253 16.9323C120.729 17.8187 121.365 18.4877 122.159 18.9559C122.953 19.4242 123.84 19.65 124.81 19.65C125.78 19.65 126.666 19.4158 127.469 18.9559C128.272 18.4877 128.915 17.8187 129.392 16.9323C129.869 16.046 130.103 15.0175 130.103 13.8384C130.103 12.6594 129.869 11.6393 129.392 10.7613Z" fill="#1f2124"/>
|
||||
<path d="M21.4651 0.568359C17.8193 0.902835 16.0047 3.00167 15.3191 4.06363L4.66602 22.5183H17.5182L30.1949 0.568359H21.4651Z" fill="#F68330"/>
|
||||
<path d="M17.5265 22.5187L0 3.9302C0 3.9302 19.8177 -1.39633 21.7493 15.2188L17.5265 22.5187Z" fill="#F68330"/>
|
||||
<path d="M14.9255 4.75055L9.54883 14.0657L17.5177 22.5196L21.7405 15.2029C21.0715 9.49174 18.287 6.37276 14.9255 4.74219" fill="#F35E32"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_0_3">
|
||||
<rect width="132.72" height="22.5186" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.5 KiB |
@@ -11,12 +11,14 @@ type Props = HTMLAttributes<HTMLSpanElement> & {
|
||||
};
|
||||
|
||||
const VARIANT_CLASSES: Record<BadgeVariant, string> = {
|
||||
info: "bg-sky-900 border border-sky-700 text-sky-200",
|
||||
info: "bg-sky-100 border border-sky-300 text-sky-800 dark:bg-sky-900 dark:border-sky-700 dark:text-sky-200",
|
||||
neutral: "bg-nb-gray-900 border border-nb-gray-850 text-nb-gray-200",
|
||||
brand: "bg-netbird/15 border border-netbird/30 text-netbird",
|
||||
success: "bg-green-900 border border-green-700 text-green-200",
|
||||
warning: "bg-yellow-900 border border-yellow-700 text-yellow-200",
|
||||
danger: "bg-red-900 border border-red-700 text-red-200",
|
||||
brand: "bg-netbird/15 border border-netbird/30 text-netbird-700 dark:text-netbird",
|
||||
success:
|
||||
"bg-green-100 border border-green-300 text-green-800 dark:bg-green-900 dark:border-green-700 dark:text-green-200",
|
||||
warning:
|
||||
"bg-yellow-100 border border-yellow-300 text-yellow-800 dark:bg-yellow-900 dark:border-yellow-700 dark:text-yellow-200",
|
||||
danger: "bg-red-100 border border-red-300 text-red-800 dark:bg-red-900 dark:border-red-700 dark:text-red-200",
|
||||
};
|
||||
|
||||
export const Badge = forwardRef<HTMLSpanElement, Props>(function Badge(
|
||||
|
||||
@@ -81,7 +81,7 @@ export const CopyToClipboard = ({
|
||||
aria-live={"polite"}
|
||||
className={cn(
|
||||
"group/copy wails-no-draggable pointer-events-auto inline-flex cursor-default items-center gap-2 rounded-sm text-left outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -16,7 +16,7 @@ const menuItemVariants = cva("", {
|
||||
variant: {
|
||||
default:
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50 focus-visible:bg-nb-gray-900 focus-visible:text-nb-gray-50 data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-50",
|
||||
danger: "text-red-500 hover:bg-red-900/20 hover:text-red-500 focus-visible:bg-red-900/20 focus-visible:text-red-500",
|
||||
danger: "text-red-500 hover:bg-red-500/10 hover:text-red-500 focus-visible:bg-red-500/10 focus-visible:text-red-500 dark:hover:bg-red-900/20 dark:focus-visible:bg-red-900/20",
|
||||
},
|
||||
},
|
||||
defaultVariants: { variant: "default" },
|
||||
|
||||
@@ -97,9 +97,9 @@ export function LanguagePicker() {
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
@@ -157,7 +157,7 @@ export function LanguagePicker() {
|
||||
placeholder={t("settings.general.language.search")}
|
||||
aria-label={t("settings.general.language.search")}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-300",
|
||||
"w-full bg-transparent text-xs text-nb-gray-100 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-300",
|
||||
"border-none outline-none",
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cn } from "@/lib/cn";
|
||||
export type SquareIconVariant = "default" | "info" | "warning" | "danger";
|
||||
|
||||
const variantClass: Record<SquareIconVariant, string> = {
|
||||
default: "text-white",
|
||||
default: "text-nb-gray-50",
|
||||
info: "text-sky-400",
|
||||
warning: "text-netbird",
|
||||
danger: "text-red-500",
|
||||
@@ -27,7 +27,7 @@ export const SquareIcon = ({
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"flex h-11 w-11 items-center justify-center rounded-lg border border-nb-gray-900 bg-nb-gray-920",
|
||||
"flex h-11 w-11 items-center justify-center rounded-lg border border-nb-gray-800 bg-nb-gray-920 dark:border-nb-gray-900",
|
||||
variantClass[variant],
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, MonitorIcon, MoonIcon, SunMediumIcon, type LucideIcon } from "lucide-react";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuRadioGroup,
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/DropdownMenu";
|
||||
import { HelpText } from "@/components/typography/HelpText";
|
||||
import { Label } from "@/components/typography/Label";
|
||||
import { useTheme, type ThemePreference } from "@/contexts/ThemeContext";
|
||||
import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { cn } from "@/lib/cn";
|
||||
import { errorDialog, formatErrorMessage } from "@/lib/errors";
|
||||
|
||||
const OPTIONS: { value: ThemePreference; icon: LucideIcon; labelKey: string }[] = [
|
||||
{ value: "system", icon: MonitorIcon, labelKey: "settings.general.theme.system" },
|
||||
{ value: "light", icon: SunMediumIcon, labelKey: "settings.general.theme.light" },
|
||||
{ value: "dark", icon: MoonIcon, labelKey: "settings.general.theme.dark" },
|
||||
];
|
||||
|
||||
export function ThemePicker() {
|
||||
const { t } = useTranslation();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const isFocusVisible = useFocusVisible();
|
||||
|
||||
const current = OPTIONS.find((o) => o.value === theme) ?? OPTIONS[0];
|
||||
const CurrentIcon = current.icon;
|
||||
|
||||
const select = async (value: string) => {
|
||||
if (busy || value === theme) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
await setTheme(value as ThemePreference);
|
||||
} catch (e) {
|
||||
await errorDialog({
|
||||
Title: t("settings.error.saveTitle"),
|
||||
Message: formatErrorMessage(e),
|
||||
});
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={"flex items-center justify-between gap-6"}>
|
||||
<div className={"max-w-md flex-1"}>
|
||||
<Label as={"div"}>{t("settings.general.theme.label")}</Label>
|
||||
<HelpText margin={false}>{t("settings.general.theme.help")}</HelpText>
|
||||
</div>
|
||||
<div className={"shrink-0"}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type={"button"}
|
||||
tabIndex={0}
|
||||
disabled={busy}
|
||||
aria-label={t("settings.general.theme.label")}
|
||||
className={cn(
|
||||
"inline-flex h-[40px] min-w-[160px] items-center gap-2 px-3",
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:opacity-50",
|
||||
)}
|
||||
>
|
||||
<CurrentIcon
|
||||
size={16}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-200"}
|
||||
/>
|
||||
<span className={"flex-1 truncate text-left"}>
|
||||
{t(current.labelKey)}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={12}
|
||||
aria-hidden={"true"}
|
||||
className={"shrink-0 text-nb-gray-400"}
|
||||
/>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
align={"end"}
|
||||
className={"w-[var(--radix-dropdown-menu-trigger-width)]"}
|
||||
>
|
||||
<DropdownMenuRadioGroup value={theme} onValueChange={(v) => void select(v)}>
|
||||
{OPTIONS.map(({ value, icon: Icon, labelKey }) => (
|
||||
<DropdownMenuRadioItem key={value} value={value}>
|
||||
<Icon
|
||||
size={14}
|
||||
aria-hidden={"true"}
|
||||
className={"mr-2 shrink-0 text-nb-gray-300"}
|
||||
/>
|
||||
{t(labelKey)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -81,12 +81,12 @@ export const Tooltip = ({
|
||||
onPointerLeave={interactive ? scheduleClose : undefined}
|
||||
onPointerDownOutside={interactive ? undefined : (e) => e.preventDefault()}
|
||||
className={cn(
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-lg",
|
||||
"z-50 select-none text-xs text-nb-gray-100 shadow-sm dark:shadow-lg",
|
||||
"data-[state=delayed-open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=delayed-open]:fade-in-0",
|
||||
!interactive && "pointer-events-none",
|
||||
contentClassName ??
|
||||
"rounded-md border border-nb-gray-850 bg-nb-gray-900 px-2 py-1",
|
||||
"rounded-md border border-nb-gray-800 bg-white px-2 py-1 dark:border-nb-gray-850 dark:bg-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
{content}
|
||||
|
||||
@@ -46,12 +46,12 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
<Tabs.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"group flex w-full cursor-default items-center gap-3 rounded-lg px-2 py-2.5 text-left outline-none",
|
||||
"group flex w-full cursor-default items-center gap-3 rounded-md border border-transparent px-2 py-2.5 text-left outline-none dark:border-0",
|
||||
"transition-colors duration-150",
|
||||
"data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
"data-[state=active]:border-nb-gray-800 data-[state=active]:bg-white dark:data-[state=active]:bg-nb-gray-930",
|
||||
"data-[state=inactive]:hover:bg-nb-gray-850 dark:data-[state=inactive]:hover:bg-nb-gray-935",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -61,13 +61,15 @@ const Trigger = forwardRef<HTMLButtonElement, TriggerProps>(function VerticalTab
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"ml-2 shrink-0 transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
"text-nb-gray-350 dark:text-nb-gray-400",
|
||||
"group-data-[state=active]:text-nb-gray-100",
|
||||
)}
|
||||
/>
|
||||
<span
|
||||
className={cn(
|
||||
"min-w-0 truncate text-sm font-medium transition-colors duration-150",
|
||||
"text-nb-gray-400 group-data-[state=active]:text-nb-gray-100",
|
||||
"text-nb-gray-350 dark:text-nb-gray-400",
|
||||
"group-data-[state=active]:font-semibold group-data-[state=active]:text-nb-gray-100 dark:group-data-[state=active]:font-medium",
|
||||
)}
|
||||
>
|
||||
{title}
|
||||
|
||||
@@ -24,71 +24,74 @@ const buttonVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-white dark:focus:ring-zinc-800/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-gray-700/30 dark:bg-nb-gray dark:text-gray-400 dark:hover:bg-zinc-800/50 dark:hover:text-nb-gray-50 dark:focus:ring-zinc-800/50",
|
||||
],
|
||||
primary: [
|
||||
"dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-white disabled:dark:bg-nb-gray-900",
|
||||
"enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50",
|
||||
"dark:text-gray-100 dark:ring-offset-neutral-950/50 dark:focus:ring-netbird-600/50 enabled:dark:bg-netbird enabled:dark:hover:bg-netbird-500/80 enabled:dark:hover:text-nb-gray-50 disabled:dark:bg-nb-gray-900",
|
||||
"enabled:bg-netbird enabled:text-white enabled:hover:bg-netbird-500 enabled:focus:ring-netbird-400/50 disabled:bg-nb-gray-700",
|
||||
],
|
||||
secondary: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:bg-nb-gray-910 dark:hover:text-white",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:border-nb-gray-700 hover:bg-nb-gray-950 hover:text-black focus:ring-nb-gray-500/50 focus:ring-offset-0",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20 dark:focus:ring-offset-1",
|
||||
"dark:border-gray-700/40 dark:bg-nb-gray-920 dark:text-gray-400 dark:hover:border-gray-700/40 dark:hover:bg-nb-gray-910 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
secondaryLighter: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-white",
|
||||
"dark:border-gray-700/70 dark:bg-nb-gray-900/70 dark:text-gray-400 dark:hover:bg-nb-gray-800/60 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
subtle: [
|
||||
"border-nb-gray-200 bg-nb-gray-50 text-nb-gray-900 hover:bg-nb-gray-100 focus:ring-nb-gray-200/60",
|
||||
"border-neutral-200 bg-neutral-50 text-neutral-900 hover:bg-neutral-100 focus:ring-neutral-200/60",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-nb-gray-200/40",
|
||||
"dark:border-nb-gray-200 dark:bg-nb-gray-50 dark:text-nb-gray-900 dark:hover:bg-nb-gray-100 dark:hover:text-nb-gray-950",
|
||||
],
|
||||
input: [
|
||||
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:text-gray-400 dark:hover:bg-nb-gray-900/80",
|
||||
],
|
||||
dropdown: [
|
||||
"border-neutral-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-nb-gray-900 dark:bg-nb-gray-900/40 dark:text-gray-400 dark:hover:bg-nb-gray-900/50",
|
||||
],
|
||||
dotted: [
|
||||
"border-dashed border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-dashed border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:ring-offset-neutral-950/50 dark:focus:ring-neutral-500/20",
|
||||
"dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-white",
|
||||
"dark:border-gray-500/40 dark:bg-nb-gray-900/30 dark:text-gray-400 dark:hover:bg-nb-gray-900/50 dark:hover:text-nb-gray-50",
|
||||
],
|
||||
tertiary: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-gray-700/40 dark:bg-white dark:text-gray-800 dark:hover:bg-neutral-200 dark:focus:ring-zinc-800/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
white: [
|
||||
"border-white bg-white text-gray-800 outline-none hover:bg-neutral-200 focus:ring-white/50 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"border-white bg-white text-neutral-800 outline-none hover:bg-neutral-200 focus:ring-white/50 dark:text-gray-800 disabled:dark:bg-nb-gray-920 disabled:dark:text-nb-gray-300",
|
||||
"disabled:dark:border-nb-gray-900 disabled:dark:bg-nb-gray-900 disabled:dark:text-nb-gray-300",
|
||||
],
|
||||
outline: [
|
||||
"border-gray-200 bg-white text-gray-900 hover:bg-gray-100 hover:text-black focus:ring-zinc-200/50",
|
||||
"border-neutral-200 bg-white text-neutral-900 hover:bg-neutral-100 hover:text-black focus:ring-neutral-200/50",
|
||||
"dark:border-netbird dark:bg-transparent dark:text-netbird dark:hover:bg-nb-gray-900/30 dark:focus:ring-zinc-800/50",
|
||||
],
|
||||
"danger-outline": [
|
||||
"bg-transparent text-red-600 enabled:hover:bg-red-50 enabled:focus:ring-red-200/50",
|
||||
"dark:bg-transparent dark:text-red-500 enabled:dark:hover:border-red-800/50 enabled:hover:dark:bg-red-950/50 enabled:dark:focus:bg-red-950/40 enabled:dark:focus:ring-red-800/20",
|
||||
],
|
||||
"danger-text": [
|
||||
"rounded-sm !px-0 !py-0 !shadow-none focus:ring-red-500/30 dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
|
||||
"rounded-sm border-transparent bg-transparent !px-0 !py-0 text-red-600 !shadow-none hover:text-red-700 focus:ring-red-500/30",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-red-500 dark:ring-offset-neutral-950/50 dark:hover:text-red-600",
|
||||
],
|
||||
"default-outline": [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:border-nb-gray-800/50 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
|
||||
"data-[state=open]:dark:border-nb-gray-800/50 data-[state=open]:dark:bg-nb-gray-900/30 data-[state=open]:dark:text-white",
|
||||
"ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20",
|
||||
"border-transparent bg-transparent text-nb-gray-400 hover:border-nb-gray-800/50 hover:bg-nb-gray-900/30 hover:text-nb-gray-50",
|
||||
"data-[state=open]:border-nb-gray-800/50 data-[state=open]:bg-nb-gray-900/30 data-[state=open]:text-nb-gray-50",
|
||||
],
|
||||
ghost: [
|
||||
"dark:ring-offset-nb-gray-950/50 dark:focus:ring-nb-gray-500/20",
|
||||
"dark:border-transparent dark:bg-transparent dark:text-nb-gray-400 dark:hover:bg-nb-gray-900/30 dark:hover:text-white",
|
||||
"ring-offset-nb-gray-950/50 focus:ring-nb-gray-500/20",
|
||||
"border-transparent bg-transparent text-nb-gray-400 hover:bg-nb-gray-900/30 hover:text-nb-gray-50",
|
||||
],
|
||||
danger: [
|
||||
"bg-red-600 text-red-50 hover:bg-red-700 focus:bg-red-700 focus:ring-red-700/20",
|
||||
"dark:bg-red-600 dark:text-red-100 dark:hover:border-red-800/50 hover:dark:bg-red-700 dark:focus:bg-red-700 dark:focus:ring-red-700/20",
|
||||
],
|
||||
},
|
||||
|
||||
@@ -24,7 +24,7 @@ export const IconButton = forwardRef<HTMLButtonElement, Props>(function IconButt
|
||||
"flex h-10 w-10 cursor-default items-center justify-center rounded-lg outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-300",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -23,7 +23,7 @@ const Overlay = forwardRef<ElementRef<typeof DialogPrimitive.Overlay>, OverlayPr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"fixed inset-0 z-50 grid items-center justify-items-center overflow-y-auto px-10 py-16",
|
||||
"bg-black/60",
|
||||
"bg-black/25 dark:bg-black/60",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
exitAnimation &&
|
||||
"data-[state=closed]:animate-out data-[state=closed]:fade-out-0",
|
||||
@@ -67,7 +67,7 @@ export const Content = forwardRef<ElementRef<typeof DialogPrimitive.Content>, Co
|
||||
className={cn(
|
||||
"relative z-[52] mx-auto w-full outline-none ring-0",
|
||||
"focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray py-7 shadow-2xl",
|
||||
"rounded-lg border border-nb-gray-800 bg-nb-gray-940 py-7 shadow-2xl dark:border-nb-gray-900 dark:bg-nb-gray",
|
||||
"data-[state=open]:animate-in data-[state=open]:fade-in-0",
|
||||
"data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1",
|
||||
exitAnimation &&
|
||||
|
||||
@@ -32,19 +32,19 @@ const inputVariants = cva("", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: [
|
||||
"border-neutral-200 placeholder:text-neutral-500 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 placeholder:text-nb-gray-600 dark:border-nb-gray-700 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
darker: [
|
||||
"border-neutral-300 placeholder:text-neutral-500 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-300 placeholder:text-nb-gray-600 dark:border-nb-gray-800 dark:bg-nb-gray-920 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-neutral-200/20 focus-visible:ring-neutral-300/10 dark:ring-offset-neutral-950/50 dark:focus-visible:ring-neutral-500/20",
|
||||
],
|
||||
error: [
|
||||
"border-neutral-200 text-red-500 placeholder:text-neutral-500 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 text-red-500 placeholder:text-nb-gray-600 dark:border-red-500 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-red-500/10 focus-visible:ring-red-500/10 dark:ring-offset-red-500/10 dark:focus-visible:ring-red-500/10",
|
||||
],
|
||||
warning: [
|
||||
"border-neutral-200 text-orange-400 placeholder:text-neutral-500 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"border-neutral-200 text-orange-400 placeholder:text-nb-gray-600 dark:border-orange-400 dark:bg-nb-gray-900 dark:placeholder:text-neutral-400/70",
|
||||
"ring-offset-orange-400/10 focus-visible:ring-orange-400/10 dark:ring-offset-orange-400/10 dark:focus-visible:ring-orange-400/10",
|
||||
],
|
||||
},
|
||||
@@ -158,7 +158,7 @@ function NumberStepper({
|
||||
className={cn(
|
||||
"flex h-[40px] shrink-0 flex-col overflow-hidden",
|
||||
"rounded-r-md border border-l-0",
|
||||
"border-neutral-200 dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
"border-neutral-200 bg-white dark:border-nb-gray-700 dark:bg-nb-gray-900",
|
||||
error && "dark:border-red-500",
|
||||
disabled && "pointer-events-none opacity-40",
|
||||
)}
|
||||
@@ -274,7 +274,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={() => setShowPassword((s) => !s)}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto text-nb-gray-400 transition-colors hover:text-nb-gray-50 dark:text-nb-gray-300 dark:hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("common.togglePasswordVisibility")}
|
||||
aria-pressed={showPassword}
|
||||
>
|
||||
@@ -303,7 +305,9 @@ export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onCopy}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto text-nb-gray-400 transition-colors hover:text-nb-gray-50 dark:text-nb-gray-300 dark:hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("common.copy")}
|
||||
>
|
||||
{copied ? (
|
||||
|
||||
@@ -33,7 +33,7 @@ export const SearchInput = forwardRef<HTMLInputElement, Props>(function SearchIn
|
||||
spellCheck={false}
|
||||
{...props}
|
||||
className={cn(
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-400",
|
||||
"w-full bg-transparent text-sm text-nb-gray-200 placeholder:text-nb-gray-600 dark:placeholder:text-nb-gray-400",
|
||||
"border-none outline-none",
|
||||
disabled && "cursor-not-allowed",
|
||||
className,
|
||||
|
||||
@@ -36,7 +36,7 @@ export default function FancyToggleSwitch({
|
||||
|
||||
if (loading) {
|
||||
const shimmer =
|
||||
"text-transparent select-none rounded bg-[#25282d] box-decoration-clone animate-pulse";
|
||||
"text-transparent select-none rounded bg-nb-gray-920 box-decoration-clone animate-pulse";
|
||||
return (
|
||||
<div
|
||||
role={"status"}
|
||||
@@ -58,7 +58,9 @@ export default function FancyToggleSwitch({
|
||||
<div className={"mt-2 pr-1"}>
|
||||
<div
|
||||
aria-hidden={"true"}
|
||||
className={"h-[24px] w-[44px] animate-pulse rounded-full bg-[#25282d]"}
|
||||
className={
|
||||
"h-[24px] w-[44px] animate-pulse rounded-full bg-nb-gray-920"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -20,7 +20,7 @@ export const SwitchItem = ({ value, children, className }: Props) => {
|
||||
className={cn(
|
||||
"relative inline-flex items-center justify-center gap-1 rounded-md px-3.5 py-2 text-xs font-semibold",
|
||||
"cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
active
|
||||
? "text-nb-gray-100"
|
||||
: "text-nb-gray-400 hover:text-nb-gray-200 active:text-nb-gray-100",
|
||||
@@ -30,7 +30,9 @@ export const SwitchItem = ({ value, children, className }: Props) => {
|
||||
{active && (
|
||||
<motion.span
|
||||
layoutId={layoutId}
|
||||
className={"absolute inset-0 rounded-md bg-nb-gray-700"}
|
||||
className={
|
||||
"absolute inset-0 rounded-md bg-white shadow-sm dark:bg-nb-gray-700 dark:shadow-none"
|
||||
}
|
||||
transition={{ type: "spring", stiffness: 500, damping: 35 }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -48,7 +48,7 @@ export const SwitchItemGroup = ({
|
||||
aria-label={ariaLabel}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
className={cn(
|
||||
"flex shrink-0 overflow-hidden rounded-lg border border-nb-gray-850 bg-nb-gray-910 p-1",
|
||||
"flex shrink-0 overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-910 p-1 dark:border-nb-gray-850",
|
||||
disabled && "pointer-events-none opacity-50",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -18,8 +18,8 @@ const switchVariants = cva("", {
|
||||
default: [
|
||||
"dark:data-[state=checked]:bg-netbird dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
"dark:data-[state=checked]:hover:bg-netbird-500 dark:data-[state=unchecked]:hover:bg-nb-gray-600",
|
||||
"data-[state=checked]:bg-neutral-900 data-[state=unchecked]:bg-neutral-200",
|
||||
"data-[state=checked]:hover:bg-neutral-800 data-[state=unchecked]:hover:bg-neutral-300",
|
||||
"data-[state=checked]:bg-netbird data-[state=unchecked]:bg-nb-gray-700",
|
||||
"data-[state=checked]:hover:bg-netbird-500 data-[state=unchecked]:hover:bg-nb-gray-600/60",
|
||||
],
|
||||
"red-green": [
|
||||
"dark:data-[state=checked]:bg-red-600 dark:data-[state=unchecked]:bg-nb-gray-700",
|
||||
@@ -52,7 +52,7 @@ const ToggleSwitch = React.forwardRef<
|
||||
disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
className={cn(
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"wails-no-draggable peer inline-flex shrink-0 cursor-default items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
switchVariants({ size, variant }),
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ type Props = {
|
||||
export const HelpText = ({ children, margin = true, className, disabled = false }: Props) => (
|
||||
<span
|
||||
className={cn(
|
||||
"block text-[.81rem] font-light tracking-wide transition-all duration-300 dark:text-nb-gray-300",
|
||||
"block text-[.81rem] font-light tracking-wide text-nb-gray-300 transition-all duration-300",
|
||||
margin && "mb-2",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
className,
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { Events } from "@wailsio/runtime";
|
||||
import { Preferences, Theme } from "@bindings/services";
|
||||
import { type Theme as ThemePref, type UIPreferences } from "@bindings/preferences/models.js";
|
||||
|
||||
export type ThemePreference = "system" | "light" | "dark";
|
||||
|
||||
const PREF_KEY = "nb-theme-pref";
|
||||
const SYSTEM_KEY = "nb-system-dark";
|
||||
|
||||
const isPreference = (v: unknown): v is ThemePreference =>
|
||||
v === "system" || v === "light" || v === "dark";
|
||||
|
||||
const initialSystemDark = (): boolean => {
|
||||
try {
|
||||
const mirrored = localStorage.getItem(SYSTEM_KEY);
|
||||
if (mirrored !== null) return mirrored === "true";
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches;
|
||||
};
|
||||
|
||||
type ThemeContextValue = {
|
||||
theme: ThemePreference;
|
||||
resolvedDark: boolean;
|
||||
setTheme: (theme: ThemePreference) => Promise<void>;
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||
|
||||
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
|
||||
const [theme, setThemeState] = useState<ThemePreference>(() => {
|
||||
try {
|
||||
const mirrored = localStorage.getItem(PREF_KEY);
|
||||
if (isPreference(mirrored)) return mirrored;
|
||||
} catch {
|
||||
/* fall through */
|
||||
}
|
||||
return "system";
|
||||
});
|
||||
const [systemDark, setSystemDark] = useState<boolean>(initialSystemDark);
|
||||
const themeRef = useRef(theme);
|
||||
themeRef.current = theme;
|
||||
// Blocks the initial Preferences.Get snapshot from overwriting newer updates.
|
||||
const supersededRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
Preferences.Get()
|
||||
.then((prefs) => {
|
||||
if (cancelled || supersededRef.current) return;
|
||||
if (isPreference(prefs?.theme)) setThemeState(prefs.theme);
|
||||
})
|
||||
.catch((err: unknown) => console.warn("[ThemeContext] load preferences failed", err));
|
||||
Theme.SystemDarkMode()
|
||||
.then((dark) => {
|
||||
if (!cancelled) setSystemDark(dark);
|
||||
})
|
||||
.catch((err: unknown) => console.warn("[ThemeContext] SystemDarkMode failed", err));
|
||||
|
||||
// Cross-window sync: a flip in the settings window reaches every window.
|
||||
const offPrefs = Events.On("netbird:preferences:changed", (e: { data?: UIPreferences }) => {
|
||||
if (isPreference(e.data?.theme)) {
|
||||
supersededRef.current = true;
|
||||
setThemeState(e.data.theme);
|
||||
}
|
||||
});
|
||||
const offSystem = Events.On(
|
||||
"netbird:system-theme:changed",
|
||||
(e: { data?: { dark?: boolean } }) => {
|
||||
if (typeof e.data?.dark === "boolean") setSystemDark(e.data.dark);
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
offPrefs();
|
||||
offSystem();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const resolvedDark = theme === "dark" || (theme === "system" && systemDark);
|
||||
|
||||
// Apply the class and refresh the pre-paint mirror (index.html reads it).
|
||||
useEffect(() => {
|
||||
document.documentElement.classList.toggle("dark", resolvedDark);
|
||||
try {
|
||||
localStorage.setItem(PREF_KEY, theme);
|
||||
localStorage.setItem(SYSTEM_KEY, String(systemDark));
|
||||
} catch {
|
||||
/* mirror is best-effort */
|
||||
}
|
||||
}, [theme, systemDark, resolvedDark]);
|
||||
|
||||
// Optimistic; reverts on persist failure so UI matches the stored pref.
|
||||
const setTheme = useCallback(async (next: ThemePreference) => {
|
||||
const prev = themeRef.current;
|
||||
supersededRef.current = true;
|
||||
setThemeState(next);
|
||||
try {
|
||||
await Preferences.SetTheme(next as ThemePref);
|
||||
} catch (err) {
|
||||
setThemeState(prev);
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ThemeContextValue>(
|
||||
() => ({ theme, resolvedDark, setTheme }),
|
||||
[theme, resolvedDark, setTheme],
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
};
|
||||
|
||||
export const useTheme = () => {
|
||||
const ctx = useContext(ThemeContext);
|
||||
if (!ctx) throw new Error("useTheme must be used inside ThemeProvider");
|
||||
return ctx;
|
||||
};
|
||||
@@ -16,6 +16,66 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* nb-gray channels (space-separated RGB) consumed by tailwind.config.ts.
|
||||
:root is the inverted light ramp — high stops are surfaces (near-white),
|
||||
low stops are text (near-black); .dark restores the original dark ramp.
|
||||
Surfaces stay darker than content cards to preserve their separation. */
|
||||
:root {
|
||||
--nb-gray-DEFAULT: 243 243 243;
|
||||
--nb-gray-50: 26 26 26;
|
||||
--nb-gray-100: 33 33 33;
|
||||
--nb-gray-200: 50 50 50;
|
||||
--nb-gray-250: 58 58 58;
|
||||
--nb-gray-300: 77 77 77;
|
||||
--nb-gray-350: 92 92 92;
|
||||
--nb-gray-400: 108 108 108;
|
||||
--nb-gray-500: 135 135 135;
|
||||
--nb-gray-600: 154 154 154;
|
||||
--nb-gray-700: 209 209 209;
|
||||
--nb-gray-800: 226 226 226;
|
||||
--nb-gray-850: 234 234 234;
|
||||
--nb-gray-900: 238 238 238;
|
||||
--nb-gray-910: 240 240 240;
|
||||
--nb-gray-920: 243 243 243;
|
||||
--nb-gray-925: 245 245 245;
|
||||
--nb-gray-930: 246 246 246;
|
||||
--nb-gray-935: 248 248 248;
|
||||
--nb-gray-940: 250 250 250;
|
||||
--nb-gray-950: 252 252 252;
|
||||
--nb-gray-960: 255 255 255;
|
||||
|
||||
--skeleton-base: 238 238 238;
|
||||
--skeleton-highlight: 247 247 247;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--nb-gray-DEFAULT: 24 26 29;
|
||||
--nb-gray-50: 244 246 247;
|
||||
--nb-gray-100: 228 231 233;
|
||||
--nb-gray-200: 203 210 214;
|
||||
--nb-gray-250: 183 192 198;
|
||||
--nb-gray-300: 163 173 181;
|
||||
--nb-gray-350: 143 156 168;
|
||||
--nb-gray-400: 124 137 148;
|
||||
--nb-gray-500: 97 110 121;
|
||||
--nb-gray-600: 83 93 103;
|
||||
--nb-gray-700: 71 78 87;
|
||||
--nb-gray-800: 63 68 75;
|
||||
--nb-gray-850: 54 59 64;
|
||||
--nb-gray-900: 46 50 56;
|
||||
--nb-gray-910: 43 47 51;
|
||||
--nb-gray-920: 37 40 45;
|
||||
--nb-gray-925: 30 33 35;
|
||||
--nb-gray-930: 37 40 44;
|
||||
--nb-gray-935: 31 33 36;
|
||||
--nb-gray-940: 28 30 33;
|
||||
--nb-gray-950: 24 26 29;
|
||||
--nb-gray-960: 22 24 27;
|
||||
|
||||
--skeleton-base: 37 40 45;
|
||||
--skeleton-highlight: 51 55 62;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
@@ -28,8 +88,9 @@ body,
|
||||
* MacBackdropTranslucent (main.go) and TitleBarHiddenInset, which on macOS
|
||||
* lets the desktop wallpaper bleed through any non-opaque pixel. A 90%
|
||||
* body alpha meant two machines with different wallpapers saw different
|
||||
* effective backgrounds. Matching Wails' BackgroundColour (#181A1D / nb-gray
|
||||
* DEFAULT) here keeps things consistent regardless of the OS backdrop.
|
||||
* effective backgrounds. Matching the per-theme Wails BackgroundColour
|
||||
* (services.CurrentWindowBackgroundColour, nb-gray DEFAULT in both ramps)
|
||||
* keeps things consistent regardless of the OS backdrop.
|
||||
*/
|
||||
body {
|
||||
@apply bg-nb-gray font-sans text-nb-gray-200 antialiased;
|
||||
|
||||
@@ -19,7 +19,7 @@ export const AppRightPanel = ({ children, overlay, overlayOpen = false, classNam
|
||||
<div
|
||||
className={cn(
|
||||
"wails-no-draggable relative m-5",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
|
||||
"flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden rounded-xl rounded-br-2xl",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -10,8 +10,8 @@ export const formatBytes = (bytes: number, decimals: number = 2): string => {
|
||||
|
||||
export const latencyColor = (ms: number): string => {
|
||||
if (ms <= 0) return "text-nb-gray-400";
|
||||
if (ms < 100) return "text-green-400";
|
||||
return "text-yellow-400";
|
||||
if (ms < 100) return "text-green-600 dark:text-green-400";
|
||||
return "text-yellow-600 dark:text-yellow-400";
|
||||
};
|
||||
|
||||
export const formatRelative = (unixSeconds: number, nowMs: number = Date.now()): string | null => {
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useFocusVisible } from "@/hooks/useFocusVisible";
|
||||
import { Check as CheckIcon, ChevronDownIcon, Copy as CopyIcon } from "lucide-react";
|
||||
import * as Popover from "@radix-ui/react-popover";
|
||||
import netbirdFullLogo from "@/assets/logos/netbird-full.svg";
|
||||
import netbirdFullLogoLight from "@/assets/logos/netbird-full-light.svg";
|
||||
|
||||
enum ConnectionState {
|
||||
Disconnected = "disconnected",
|
||||
@@ -224,10 +225,16 @@ export const MainConnectionStatusSwitch = () => {
|
||||
className={cn("flex h-full w-full flex-col items-center gap-4", "relative")}
|
||||
style={{ top: contentTop("11.7rem") }}
|
||||
>
|
||||
<img
|
||||
src={netbirdFullLogoLight}
|
||||
alt={"NetBird"}
|
||||
className={"wails-no-draggable mb-4 h-7 w-auto select-none dark:hidden"}
|
||||
draggable={false}
|
||||
/>
|
||||
<img
|
||||
src={netbirdFullLogo}
|
||||
alt={"NetBird"}
|
||||
className={"wails-no-draggable mb-4 h-7 w-auto select-none"}
|
||||
className={"wails-no-draggable mb-4 hidden h-7 w-auto select-none dark:block"}
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
@@ -321,7 +328,7 @@ const LocalIpLine = ({ ip, ipv6, show }: { ip: string; ipv6: string; show: boole
|
||||
className={cn(
|
||||
"group relative inline-flex cursor-default items-center rounded-sm outline-none",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"transition-colors",
|
||||
)}
|
||||
>
|
||||
@@ -395,7 +402,7 @@ const IpRow = ({ value }: { value: string }) => {
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
|
||||
|
||||
@@ -156,14 +156,14 @@ const ExitNodeTriggerCard = forwardRef<HTMLButtonElement, TriggerProps>(
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
"flex w-full items-center gap-3 rounded-xl p-2.5 pr-5 text-left outline-none",
|
||||
"border border-nb-gray-920 bg-nb-gray-940",
|
||||
"border border-nb-gray-800 bg-nb-gray-940 dark:border-nb-gray-920",
|
||||
"transition-colors duration-150",
|
||||
"wails-no-draggable",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
disabled
|
||||
? "cursor-not-allowed opacity-60"
|
||||
: "cursor-default hover:border-nb-gray-900 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-900 data-[state=open]:bg-nb-gray-935",
|
||||
: "cursor-default hover:border-nb-gray-700 hover:bg-nb-gray-935 data-[state=open]:border-nb-gray-700 data-[state=open]:bg-nb-gray-935 dark:hover:border-nb-gray-900 dark:data-[state=open]:border-nb-gray-900",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -74,7 +74,9 @@ export const MainHeader = () => {
|
||||
<IconButton
|
||||
icon={MoreVertical}
|
||||
iconClassName={"text-nb-gray-200 wails-no-draggable"}
|
||||
className={"select-none"}
|
||||
className={
|
||||
"select-none hover:bg-nb-gray-800 data-[state=open]:bg-nb-gray-800 dark:hover:bg-nb-gray-900 dark:data-[state=open]:bg-nb-gray-900"
|
||||
}
|
||||
aria-label={t("header.menu.open")}
|
||||
aria-haspopup={"menu"}
|
||||
aria-expanded={menuOpen}
|
||||
|
||||
@@ -108,7 +108,7 @@ export const Navigation = () => {
|
||||
"outline-none transition-all",
|
||||
isFirst && "rounded-tl-xl",
|
||||
isLast && "rounded-tr-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
isActive ? "text-netbird" : "text-nb-gray-400 hover:text-nb-gray-300",
|
||||
isDisabled ? "cursor-not-allowed opacity-50" : "cursor-default",
|
||||
)}
|
||||
|
||||
@@ -43,7 +43,7 @@ export const NetworkFilters = ({ value, onChange, counts, disabled }: Props) =>
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
|
||||
@@ -223,7 +223,7 @@ export const Networks = () => {
|
||||
"text-xs font-medium text-nb-gray-100",
|
||||
"border border-nb-gray-900 bg-nb-gray-920 hover:border-nb-gray-850 hover:bg-nb-gray-910",
|
||||
"wails-no-draggable cursor-pointer outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{bulkLabel}
|
||||
@@ -358,7 +358,7 @@ const NetworkRow = ({ network: n, index, onKeyDown, onToggle, setRowRef }: Netwo
|
||||
onKeyDown={handleKey}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-pointer outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
/>
|
||||
<ResourceIconBadge type={resourceTypeOf(n)} />
|
||||
@@ -396,7 +396,8 @@ const ResourceIconBadge = ({ type }: { type: ResourceType }) => {
|
||||
aria-hidden={"true"}
|
||||
className={cn(
|
||||
"mt-[0.25rem] flex h-9 w-9 shrink-0 items-center justify-center rounded-md",
|
||||
"border border-nb-gray-900 bg-nb-gray-920 text-nb-gray-300",
|
||||
"border border-nb-gray-800 bg-white text-nb-gray-300 transition-colors group-hover:border-nb-gray-700",
|
||||
"dark:border-nb-gray-900 dark:bg-nb-gray-920 dark:group-hover:border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<Icon size={14} />
|
||||
@@ -461,7 +462,7 @@ const DomainSubtitle = ({ domain, ips, onKeyDown }: DomainSubtitleProps) => {
|
||||
keepOpenOnClick
|
||||
contentClassName={cn(
|
||||
"max-h-72 max-w-[18rem] overflow-auto",
|
||||
"rounded-lg border border-nb-gray-900 bg-nb-gray-935",
|
||||
"rounded-lg border border-nb-gray-800 bg-white dark:border-nb-gray-900 dark:bg-nb-gray-935",
|
||||
"p-2 pr-4",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -54,9 +54,9 @@ const DASH = "-";
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
return "bg-green-500 dark:bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
@@ -195,7 +195,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
)}
|
||||
>
|
||||
@@ -235,7 +235,7 @@ export const PeerDetailPanel = ({ transition = DEFAULT_TRANSITION }: Props) => {
|
||||
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md",
|
||||
"text-nb-gray-300 hover:bg-nb-gray-910 hover:text-nb-gray-100",
|
||||
"cursor-default outline-none transition-colors",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
)}
|
||||
@@ -468,7 +468,7 @@ const ResourcesPopover = ({ networks }: { networks: string[] }) => {
|
||||
"border border-nb-gray-900",
|
||||
"py-1 pl-2.5 pr-2 text-xs font-medium text-nb-gray-300",
|
||||
"wails-no-draggable cursor-default outline-none transition-all",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
)}
|
||||
>
|
||||
{networks.length}
|
||||
@@ -530,7 +530,7 @@ const ResourceRow = ({ value }: { value: string }) => {
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900 hover:text-nb-gray-50",
|
||||
"cursor-default outline-none transition-colors",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<span className={"min-w-0 truncate font-mono text-[0.75rem]"}>{value}</span>
|
||||
|
||||
@@ -43,7 +43,7 @@ export const PeerFilters = ({ value, onChange, counts, disabled }: Props) => {
|
||||
"inline-flex h-9 items-center gap-1.5 rounded-md px-2",
|
||||
"text-sm text-nb-gray-200",
|
||||
"outline-none transition-colors duration-150 hover:bg-nb-gray-900 data-[state=open]:bg-nb-gray-900",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"disabled:pointer-events-none disabled:opacity-50",
|
||||
"wails-no-draggable cursor-default",
|
||||
)}
|
||||
|
||||
@@ -22,9 +22,9 @@ const isOnline = (connStatus: string) => connStatus === "Connected";
|
||||
const dotClass = (connStatus: string): string => {
|
||||
switch (connStatus) {
|
||||
case "Connected":
|
||||
return "bg-green-400";
|
||||
return "bg-green-500 dark:bg-green-400";
|
||||
case "Connecting":
|
||||
return "bg-yellow-300 animate-pulse-slow";
|
||||
return "bg-yellow-500 animate-pulse-slow dark:bg-yellow-300";
|
||||
default:
|
||||
return "bg-nb-gray-500";
|
||||
}
|
||||
@@ -287,7 +287,7 @@ const PeerRow = ({ peer, index, onKeyDown, onSelect, setRowRef }: PeerRowProps)
|
||||
onKeyDown={handleKey}
|
||||
className={cn(
|
||||
"absolute inset-0 cursor-default outline-none",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
/>
|
||||
<Tooltip content={statusLabel} side={"left"}>
|
||||
|
||||
@@ -92,7 +92,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => {
|
||||
listRef.current?.focus();
|
||||
}}
|
||||
className={cn(
|
||||
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-900 bg-nb-gray-935 p-1 text-nb-gray-200 shadow-lg",
|
||||
"wails-no-draggable z-50 min-w-64 select-none overflow-hidden rounded-lg border border-nb-gray-800 bg-nb-gray-950 p-1 text-nb-gray-200 shadow-lg dark:border-nb-gray-900 dark:bg-nb-gray-935",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out",
|
||||
"data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
|
||||
"data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
|
||||
@@ -211,11 +211,11 @@ const ProfileTriggerButton = forwardRef<HTMLButtonElement, ProfileTriggerButtonP
|
||||
aria-haspopup={"listbox"}
|
||||
className={cn(
|
||||
"wails-no-draggable flex h-10 cursor-default select-none items-center gap-2 rounded-lg px-3 outline-none",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-900",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent",
|
||||
"text-nb-gray-200 hover:bg-nb-gray-800 dark:hover:bg-nb-gray-900",
|
||||
"data-[state=open]:bg-nb-gray-800 dark:data-[state=open]:bg-nb-gray-900",
|
||||
"disabled:opacity-50 disabled:hover:bg-transparent dark:disabled:hover:bg-transparent",
|
||||
isFocusVisible &&
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"wails-no-draggable transition-colors duration-150",
|
||||
className,
|
||||
)}
|
||||
|
||||
@@ -177,7 +177,7 @@ export function ProfilesTab() {
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border border-nb-gray-900 bg-nb-gray-930/60",
|
||||
"overflow-hidden rounded-xl border border-nb-gray-800 bg-nb-gray-930/60 dark:border-nb-gray-900",
|
||||
)}
|
||||
>
|
||||
<ProfilesTable
|
||||
@@ -411,7 +411,7 @@ const ProfileRow = ({
|
||||
"outline-none",
|
||||
isFirst && "rounded-t-xl",
|
||||
isLast && "rounded-b-xl",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-white/60",
|
||||
"focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-nb-gray-50/60",
|
||||
)}
|
||||
>
|
||||
<td
|
||||
@@ -560,7 +560,7 @@ const RowMoreMenu = ({
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"data-[state=open]:bg-nb-gray-900 data-[state=open]:text-nb-gray-100",
|
||||
)}
|
||||
>
|
||||
@@ -654,7 +654,7 @@ const ActionIconButton = ({
|
||||
className={cn(
|
||||
"inline-flex h-9 w-9 cursor-default items-center justify-center rounded-md outline-none",
|
||||
"transition-colors duration-150",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
variant === "danger"
|
||||
? "text-nb-gray-400 hover:bg-red-500/10 hover:text-red-500"
|
||||
: "text-nb-gray-400 hover:bg-nb-gray-900 hover:text-nb-gray-100",
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Browser } from "@wailsio/runtime";
|
||||
import { BookOpen, MessageSquareText, MessagesSquare } from "lucide-react";
|
||||
import netbirdFull from "@/assets/logos/netbird-full.svg";
|
||||
import netbirdFullLight from "@/assets/logos/netbird-full-light.svg";
|
||||
|
||||
// Brand glyphs from simpleicons.org (lucide deprecated its brand icons).
|
||||
const GithubIcon = (props: SVGProps<SVGSVGElement>) => (
|
||||
@@ -90,7 +91,16 @@ export function SettingsAbout() {
|
||||
"mx-auto flex min-h-[calc(100vh-12rem)] max-w-2xl flex-col items-center justify-center gap-4"
|
||||
}
|
||||
>
|
||||
<img src={netbirdFull} alt={t("common.netbird")} className={"h-7 w-auto"} />
|
||||
<img
|
||||
src={netbirdFullLight}
|
||||
alt={t("common.netbird")}
|
||||
className={"h-7 w-auto dark:hidden"}
|
||||
/>
|
||||
<img
|
||||
src={netbirdFull}
|
||||
alt={t("common.netbird")}
|
||||
className={"hidden h-7 w-auto dark:block"}
|
||||
/>
|
||||
<div className={"flex flex-col items-center gap-0.5 text-center"}>
|
||||
<button
|
||||
type={"button"}
|
||||
@@ -139,7 +149,7 @@ export function SettingsAbout() {
|
||||
tabIndex={0}
|
||||
onClick={() => openUrl(url)}
|
||||
className={
|
||||
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
"inline-flex items-center gap-1.5 rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
}
|
||||
>
|
||||
<Icon aria-hidden={"true"} className={iconClassName ?? "h-3.5 w-3.5"} />
|
||||
@@ -157,7 +167,7 @@ export function SettingsAbout() {
|
||||
tabIndex={0}
|
||||
onClick={() => openUrl(link.url)}
|
||||
className={
|
||||
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
"rounded-sm decoration-[0.5px] underline-offset-4 outline-none transition hover:text-nb-gray-100 hover:underline focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940"
|
||||
}
|
||||
>
|
||||
{link.label}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useAutostartSetting, useSettings } from "@/contexts/SettingsContext.tsx
|
||||
import { ManagementServerSwitch } from "@/components/ManagementServerSwitch.tsx";
|
||||
import { ManagementMode, useManagementUrl } from "@/hooks/useManagementUrl.ts";
|
||||
import { LanguagePicker } from "@/components/LanguagePicker.tsx";
|
||||
import { ThemePicker } from "@/components/ThemePicker.tsx";
|
||||
import { useRestrictions } from "@/contexts/RestrictionsContext.tsx";
|
||||
import { useKeepConnectedOnQuit } from "@/hooks/useKeepConnectedOnQuit.ts";
|
||||
|
||||
@@ -36,6 +37,7 @@ export function SettingsGeneral() {
|
||||
<>
|
||||
<SectionGroup title={t("settings.general.section.general")}>
|
||||
<LanguagePicker />
|
||||
<ThemePicker />
|
||||
<FancyToggleSwitch
|
||||
value={!config.disableNotifications}
|
||||
onChange={(v) => setField("disableNotifications", !v)}
|
||||
|
||||
@@ -16,7 +16,7 @@ export const SectionGroup = ({
|
||||
{...(disabled ? { inert: "" } : {})}
|
||||
className={cn(
|
||||
"mb-8 rounded-md px-1 outline-none last:mb-1",
|
||||
"focus-visible:ring-2 focus-visible:ring-white/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
"focus-visible:ring-2 focus-visible:ring-nb-gray-50/60 focus-visible:ring-offset-2 focus-visible:ring-offset-nb-gray-940",
|
||||
disabled && "pointer-events-none opacity-30",
|
||||
)}
|
||||
>
|
||||
@@ -33,7 +33,7 @@ export const SettingsBottomBar = ({ children }: { children: ReactNode }) => (
|
||||
<div className={"absolute bottom-0 left-0 w-full"}>
|
||||
<div
|
||||
className={
|
||||
"flex w-full justify-end gap-3 border-t border-nb-gray-920 bg-nb-gray-940 px-8 py-5"
|
||||
"flex w-full justify-end gap-3 border-t border-nb-gray-800 bg-nb-gray-940 px-8 py-5 dark:border-nb-gray-920"
|
||||
}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -99,7 +99,7 @@ export function SettingsTroubleshooting() {
|
||||
"rounded-md border bg-white dark:bg-nb-gray-900",
|
||||
"border-neutral-200 dark:border-nb-gray-700",
|
||||
"cursor-default text-xs font-semibold text-nb-gray-100 outline-none",
|
||||
"hover:border-nb-gray-600 data-[state=open]:border-nb-gray-600",
|
||||
"hover:border-nb-gray-700 data-[state=open]:border-nb-gray-700 dark:hover:border-nb-gray-600 dark:data-[state=open]:border-nb-gray-600",
|
||||
)}
|
||||
>
|
||||
{t(`settings.troubleshooting.anonymize.${anonymizeLevel}`)}
|
||||
@@ -277,7 +277,10 @@ function DoneResult({
|
||||
};
|
||||
return (
|
||||
<CenteredPanel>
|
||||
<SquareIcon icon={CircleCheckBig} className={"[&_svg]:text-green-500"} />
|
||||
<SquareIcon
|
||||
icon={CircleCheckBig}
|
||||
className={"bg-white dark:bg-nb-gray-920 [&_svg]:text-green-500"}
|
||||
/>
|
||||
|
||||
<div className={"flex max-w-sm flex-col items-center gap-2"}>
|
||||
<DialogHeading className={"text-balance"}>
|
||||
@@ -326,7 +329,9 @@ function DoneResult({
|
||||
<button
|
||||
type={"button"}
|
||||
onClick={onRevealPath}
|
||||
className={"pointer-events-auto transition-all hover:text-white"}
|
||||
className={
|
||||
"pointer-events-auto transition-all hover:text-nb-gray-50"
|
||||
}
|
||||
aria-label={t("settings.troubleshooting.done.openFileLocation")}
|
||||
>
|
||||
<FolderOpen size={16} aria-hidden={"true"} />
|
||||
@@ -339,7 +344,7 @@ function DoneResult({
|
||||
<div
|
||||
role={"alert"}
|
||||
className={
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-300"
|
||||
"rounded-md border border-red-500/30 bg-red-500/10 px-3 py-2 text-xs text-red-700 dark:text-red-300"
|
||||
}
|
||||
>
|
||||
{result.uploadFailureReason
|
||||
|
||||
@@ -10,29 +10,32 @@ const config: Config = {
|
||||
},
|
||||
extend: {
|
||||
colors: {
|
||||
// nb-gray is theme-aware: channels come from CSS variables in
|
||||
// globals.css (:root = light ramp, .dark = original dark ramp). The
|
||||
// rgb(var(...) / <alpha-value>) form keeps opacity modifiers working.
|
||||
"nb-gray": {
|
||||
DEFAULT: "#181A1D",
|
||||
50: "#f4f6f7",
|
||||
100: "#e4e7e9",
|
||||
200: "#cbd2d6",
|
||||
250: "#b7c0c6",
|
||||
300: "#a3adb5",
|
||||
350: "#8f9ca8",
|
||||
400: "#7c8994",
|
||||
500: "#616e79",
|
||||
600: "#535d67",
|
||||
700: "#474e57",
|
||||
800: "#3f444b",
|
||||
850: "#363b40",
|
||||
900: "#2e3238",
|
||||
910: "#2b2f33",
|
||||
920: "#25282d",
|
||||
925: "#1e2123",
|
||||
930: "#25282c",
|
||||
935: "#1f2124",
|
||||
940: "#1c1e21",
|
||||
950: "#181a1d",
|
||||
960: "#16181b",
|
||||
DEFAULT: "rgb(var(--nb-gray-DEFAULT) / <alpha-value>)",
|
||||
50: "rgb(var(--nb-gray-50) / <alpha-value>)",
|
||||
100: "rgb(var(--nb-gray-100) / <alpha-value>)",
|
||||
200: "rgb(var(--nb-gray-200) / <alpha-value>)",
|
||||
250: "rgb(var(--nb-gray-250) / <alpha-value>)",
|
||||
300: "rgb(var(--nb-gray-300) / <alpha-value>)",
|
||||
350: "rgb(var(--nb-gray-350) / <alpha-value>)",
|
||||
400: "rgb(var(--nb-gray-400) / <alpha-value>)",
|
||||
500: "rgb(var(--nb-gray-500) / <alpha-value>)",
|
||||
600: "rgb(var(--nb-gray-600) / <alpha-value>)",
|
||||
700: "rgb(var(--nb-gray-700) / <alpha-value>)",
|
||||
800: "rgb(var(--nb-gray-800) / <alpha-value>)",
|
||||
850: "rgb(var(--nb-gray-850) / <alpha-value>)",
|
||||
900: "rgb(var(--nb-gray-900) / <alpha-value>)",
|
||||
910: "rgb(var(--nb-gray-910) / <alpha-value>)",
|
||||
920: "rgb(var(--nb-gray-920) / <alpha-value>)",
|
||||
925: "rgb(var(--nb-gray-925) / <alpha-value>)",
|
||||
930: "rgb(var(--nb-gray-930) / <alpha-value>)",
|
||||
935: "rgb(var(--nb-gray-935) / <alpha-value>)",
|
||||
940: "rgb(var(--nb-gray-940) / <alpha-value>)",
|
||||
950: "rgb(var(--nb-gray-950) / <alpha-value>)",
|
||||
960: "rgb(var(--nb-gray-960) / <alpha-value>)",
|
||||
},
|
||||
gray: {
|
||||
50: "#F9FAFB",
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Keine Sprachen gefunden."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Erscheinungsbild"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Hell, Dunkel oder die Systemeinstellung verwenden."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "System"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Hell"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Dunkel"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Management-Server"
|
||||
},
|
||||
|
||||
@@ -759,6 +759,26 @@
|
||||
"message": "No languages match.",
|
||||
"description": "Shown when no languages match the search."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Theme",
|
||||
"description": "Label for the appearance/theme picker."
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Choose light or dark, or follow your system appearance.",
|
||||
"description": "Helper text for the theme picker."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "System",
|
||||
"description": "Theme option: follow the OS appearance."
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Light",
|
||||
"description": "Theme option: light appearance."
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Dark",
|
||||
"description": "Theme option: dark appearance."
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Management Server",
|
||||
"description": "Label for the management-server selector."
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Ningún idioma coincide."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Elija el tema claro u oscuro, o siga la apariencia del sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Claro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Oscuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Servidor de gestión"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Aucune langue ne correspond."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Thème"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Choisissez le thème clair ou sombre, ou suivez l'apparence du système."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Système"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Clair"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Sombre"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Serveur de gestion"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nincs találat."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Téma"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Válassza a világos vagy sötét témát, vagy kövesse a rendszer beállítását."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Rendszer"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Világos"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Sötét"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Felügyeleti szerver"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nessuna lingua corrisponde."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Scelga il tema chiaro o scuro, oppure segua l'aspetto del sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Chiaro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Scuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Server di gestione"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "一致する言語がありません。"
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "テーマ"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "ライト、ダーク、またはシステムの外観に従います。"
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "システム"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "ライト"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "ダーク"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "管理サーバー"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Nenhum idioma corresponde."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Tema"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Escolha claro, escuro ou siga a aparência do sistema."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Sistema"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Claro"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Escuro"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Servidor de gerenciamento"
|
||||
},
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Языки не найдены."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Тема"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Выберите светлую или тёмную тему либо следуйте системной."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Системная"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Светлая"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Тёмная"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Сервер управления"
|
||||
},
|
||||
|
||||
@@ -569,6 +569,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "Не знайдено жодної мови."
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "Тема"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "Виберіть світлу чи темну тему або використовуйте системні налаштування."
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "Системна"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "Світла"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "Темна"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "Сервер керування"
|
||||
},
|
||||
@@ -764,7 +779,7 @@
|
||||
"settings.troubleshooting.anonymize.help": {
|
||||
"message": "Приховує IP-адреси, домени та інші конфіденційні дані."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"settings.troubleshooting.anonymize.info": {
|
||||
"message": "«Стандартний» залишає внутрішні адреси IPv4 та імена пірів читабельними для служби підтримки. «Суворий» додатково анонімізує приватні (RFC 1918), CGNAT- та link-local-адреси, імена пірів і публічні ключі WireGuard. Однакові значення замінюються тим самим псевдонімом, тож піри залишаються розрізнюваними. Використовуйте «Суворий», якщо ділитеся архівом за межами організації."
|
||||
},
|
||||
"settings.troubleshooting.anonymize.none": {
|
||||
@@ -1370,7 +1385,7 @@
|
||||
"settings.ssh.privilege.oneWayInverted": {
|
||||
"message": "Ви можете увімкнути це, але щоб вимкнути знову, знадобиться {actor}:"
|
||||
},
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"settings.ssh.privilege.authorizePending": {
|
||||
"message": "Очікування авторизації…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -571,6 +571,21 @@
|
||||
"settings.general.language.empty": {
|
||||
"message": "没有匹配的语言。"
|
||||
},
|
||||
"settings.general.theme.label": {
|
||||
"message": "主题"
|
||||
},
|
||||
"settings.general.theme.help": {
|
||||
"message": "选择浅色、深色或跟随系统外观。"
|
||||
},
|
||||
"settings.general.theme.system": {
|
||||
"message": "跟随系统"
|
||||
},
|
||||
"settings.general.theme.light": {
|
||||
"message": "浅色"
|
||||
},
|
||||
"settings.general.theme.dark": {
|
||||
"message": "深色"
|
||||
},
|
||||
"settings.general.management.label": {
|
||||
"message": "管理服务器"
|
||||
},
|
||||
|
||||
+8
-3
@@ -77,6 +77,7 @@ func init() {
|
||||
application.RegisterEvent[authsession.Warning](services.EventSessionWarning)
|
||||
application.RegisterEvent[updater.State](updater.EventStateChanged)
|
||||
application.RegisterEvent[preferences.UIPreferences](preferences.EventPreferencesChanged)
|
||||
application.RegisterEvent[services.SystemTheme](services.EventSystemThemeChanged)
|
||||
}
|
||||
|
||||
func main() {
|
||||
@@ -123,6 +124,9 @@ func main() {
|
||||
|
||||
bundle, prefStore, localizer := buildI18n(app)
|
||||
|
||||
// Before any window exists so creation-time backgrounds are already themed.
|
||||
app.RegisterService(application.NewService(services.NewTheme(app, prefStore)))
|
||||
|
||||
// After bundle + prefStore: both are used to localise daemon errors.
|
||||
settings := services.NewSettings(conn, bundle, prefStore, daemonAddr)
|
||||
connection := services.NewConnection(conn, bundle, prefStore)
|
||||
@@ -354,6 +358,7 @@ func newMainWindow(app *application.App, prefStore *preferences.Store, wm *servi
|
||||
if prefStore.Get().ViewMode == preferences.ViewModeAdvanced {
|
||||
initialWidth = 900
|
||||
}
|
||||
appearance := services.CurrentAppearance()
|
||||
window := app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "main",
|
||||
Title: "NetBird",
|
||||
@@ -363,13 +368,13 @@ func newMainWindow(app *application.App, prefStore *preferences.Store, wm *servi
|
||||
// drop new windows top-left unless asked.
|
||||
InitialPosition: application.WindowCentered,
|
||||
Hidden: true,
|
||||
BackgroundColour: services.WindowBackgroundColour,
|
||||
BackgroundColour: services.WindowBackgroundColour(appearance),
|
||||
URL: startURL,
|
||||
DisableResize: true,
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
Mac: services.AppleMacOSAppearanceOptions(),
|
||||
Windows: services.MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: services.AppleMacOSAppearanceOptions(appearance),
|
||||
Windows: services.MicrosoftWindowsAppearanceOptions(appearance),
|
||||
Linux: application.LinuxWindow{
|
||||
Icon: iconWindow,
|
||||
},
|
||||
|
||||
@@ -49,10 +49,34 @@ func (v ViewMode) IsValid() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Theme is the preferred UI appearance: follow the OS ("system") or force
|
||||
// "light"/"dark".
|
||||
type Theme string
|
||||
|
||||
const (
|
||||
ThemeSystem Theme = "system"
|
||||
ThemeLight Theme = "light"
|
||||
ThemeDark Theme = "dark"
|
||||
)
|
||||
|
||||
// DefaultTheme applies when no file exists or its theme is empty/unknown.
|
||||
const DefaultTheme = ThemeSystem
|
||||
|
||||
var ErrUnsupportedTheme = errors.New("unsupported theme")
|
||||
|
||||
func (t Theme) IsValid() bool {
|
||||
switch t {
|
||||
case ThemeSystem, ThemeLight, ThemeDark:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UIPreferences is rewritten in full on every change; there are no partial updates.
|
||||
type UIPreferences struct {
|
||||
Language i18n.LanguageCode `json:"language"`
|
||||
ViewMode ViewMode `json:"viewMode"`
|
||||
Theme Theme `json:"theme"`
|
||||
OnboardingCompleted bool `json:"onboardingCompleted"`
|
||||
// AutostartInitialized records that the one-time autostart default
|
||||
// decision has run for this OS user. It only ever transitions to true
|
||||
@@ -105,7 +129,7 @@ func NewStore(validator LanguageValidator, emitter Emitter) (*Store, error) {
|
||||
path: path,
|
||||
validator: validator,
|
||||
emitter: emitter,
|
||||
current: UIPreferences{ViewMode: DefaultViewMode},
|
||||
current: UIPreferences{ViewMode: DefaultViewMode, Theme: DefaultTheme},
|
||||
}
|
||||
|
||||
if err := s.load(); err != nil {
|
||||
@@ -146,6 +170,30 @@ func (s *Store) SetViewMode(mode ViewMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetTheme validates, persists, and broadcasts. No-op if unchanged.
|
||||
func (s *Store) SetTheme(theme Theme) error {
|
||||
if !theme.IsValid() {
|
||||
return fmt.Errorf("%w: %q", ErrUnsupportedTheme, theme)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if s.current.Theme == theme {
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
next := s.current
|
||||
next.Theme = theme
|
||||
if err := s.persistLocked(next); err != nil {
|
||||
s.mu.Unlock()
|
||||
return fmt.Errorf("persist preferences: %w", err)
|
||||
}
|
||||
s.current = next
|
||||
s.mu.Unlock()
|
||||
|
||||
s.broadcast(next)
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetOnboardingCompleted persists the welcome-window dismissal. No-op if unchanged.
|
||||
func (s *Store) SetOnboardingCompleted(done bool) error {
|
||||
s.mu.Lock()
|
||||
@@ -288,6 +336,9 @@ func (s *Store) load() error {
|
||||
if !loaded.ViewMode.IsValid() {
|
||||
loaded.ViewMode = DefaultViewMode
|
||||
}
|
||||
if !loaded.Theme.IsValid() {
|
||||
loaded.Theme = DefaultTheme
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.current = loaded
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//go:build linux && cgo && !android && !ios
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// setAppAppearance points GTK at the light or dark variant of the current theme
|
||||
// so the decorations match the webview. Without it a forced Light theme keeps
|
||||
// dark decorations on a dark desktop, and the reverse.
|
||||
//
|
||||
// The theme name is switched, not just gtk-application-prefer-dark-theme:
|
||||
// desktops such as Ubuntu implement dark mode as a separate theme (Yaru-dark),
|
||||
// which that flag cannot lighten. The flag is still set for themes that do
|
||||
// carry both variants under one name. Both are per-process settings, so this
|
||||
// changes only our own decorations; GTK re-reads the desktop value on a change,
|
||||
// which is why Theme.apply re-asserts. Must run on the main thread.
|
||||
//
|
||||
// GTK styling is app-wide, which is why this is separate from
|
||||
// setWindowAppearance: it must be applied even when no window exists yet, since
|
||||
// windows created later inherit it rather than carrying it in their options.
|
||||
func setAppAppearance(dark bool) {
|
||||
target := baseGtkTheme(gtkThemeName())
|
||||
if dark {
|
||||
if variant, ok := darkGtkVariant(target); ok {
|
||||
target = variant
|
||||
}
|
||||
}
|
||||
// An unknown name would leave GTK with no theme at all, so fall back to
|
||||
// changing nothing and let the prefer-dark flag do what it can.
|
||||
if target != "" && !gtkThemeExists(target) {
|
||||
target = ""
|
||||
}
|
||||
applyGtkTheme(target, dark)
|
||||
}
|
||||
|
||||
// baseGtkTheme strips a dark-variant suffix, so "Yaru-dark" becomes "Yaru".
|
||||
func baseGtkTheme(name string) string {
|
||||
for _, suffix := range []string{"-dark", "-Dark"} {
|
||||
if len(name) > len(suffix) && strings.EqualFold(name[len(name)-len(suffix):], suffix) {
|
||||
return name[:len(name)-len(suffix)]
|
||||
}
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// darkGtkVariant reports the installed dark counterpart of a base theme name.
|
||||
// Themes that carry both variants under one name have none, and rely on
|
||||
// gtk-application-prefer-dark-theme instead.
|
||||
func darkGtkVariant(base string) (string, bool) {
|
||||
if base == "" {
|
||||
return "", false
|
||||
}
|
||||
for _, suffix := range []string{"-dark", "-Dark"} {
|
||||
if candidate := base + suffix; gtkThemeExists(candidate) {
|
||||
return candidate, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// gtkThemeExists reports whether a theme of that name is installed, searching
|
||||
// the same locations GTK does.
|
||||
func gtkThemeExists(name string) bool {
|
||||
if name == "" {
|
||||
return false
|
||||
}
|
||||
for _, dir := range gtkThemeDirs() {
|
||||
if info, err := os.Stat(filepath.Join(dir, name)); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func gtkThemeDirs() []string {
|
||||
var dirs []string
|
||||
if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".themes"))
|
||||
}
|
||||
if dataHome := os.Getenv("XDG_DATA_HOME"); dataHome != "" {
|
||||
dirs = append(dirs, filepath.Join(dataHome, "themes"))
|
||||
} else if home, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(home, ".local", "share", "themes"))
|
||||
}
|
||||
dataDirs := os.Getenv("XDG_DATA_DIRS")
|
||||
if dataDirs == "" {
|
||||
dataDirs = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, dir := range strings.Split(dataDirs, ":") {
|
||||
if dir != "" {
|
||||
dirs = append(dirs, filepath.Join(dir, "themes"))
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
//go:build linux && cgo && !android && !ios
|
||||
|
||||
package services
|
||||
|
||||
/*
|
||||
// The GTK major version is the only difference between the two Linux builds, so
|
||||
// it is selected by these two directives rather than by keeping a second copy of
|
||||
// this file per version: the C below and the Go wrappers under it are identical
|
||||
// for GTK3 and GTK4, and both resolve <gtk/gtk.h> through pkg-config.
|
||||
#cgo gtk3 pkg-config: gtk+-3.0
|
||||
#cgo !gtk3 pkg-config: gtk4
|
||||
#include <stdlib.h>
|
||||
#include <gtk/gtk.h>
|
||||
|
||||
static char *nbGetGtkThemeName(void) {
|
||||
GtkSettings *settings = gtk_settings_get_default();
|
||||
if (settings == NULL) {
|
||||
return NULL;
|
||||
}
|
||||
char *name = NULL;
|
||||
g_object_get(settings, "gtk-theme-name", &name, NULL);
|
||||
return name;
|
||||
}
|
||||
|
||||
// name may be NULL to leave the theme name untouched.
|
||||
static void nbSetGtkTheme(const char *name, int dark) {
|
||||
GtkSettings *settings = gtk_settings_get_default();
|
||||
if (settings == NULL) {
|
||||
return;
|
||||
}
|
||||
if (name != NULL && name[0] != '\0') {
|
||||
g_object_set(settings, "gtk-theme-name", name, NULL);
|
||||
}
|
||||
g_object_set(settings, "gtk-application-prefer-dark-theme", dark ? TRUE : FALSE, NULL);
|
||||
}
|
||||
|
||||
static void nbFreeGtkString(char *s) { g_free(s); }
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import "unsafe"
|
||||
|
||||
func gtkThemeName() string {
|
||||
c := C.nbGetGtkThemeName()
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
defer C.nbFreeGtkString(c)
|
||||
return C.GoString(c)
|
||||
}
|
||||
|
||||
func applyGtkTheme(name string, dark bool) {
|
||||
var cName *C.char
|
||||
if name != "" {
|
||||
cName = C.CString(name)
|
||||
defer C.free(unsafe.Pointer(cName))
|
||||
}
|
||||
var forced C.int
|
||||
if dark {
|
||||
forced = 1
|
||||
}
|
||||
C.nbSetGtkTheme(cName, forced)
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//go:build !(linux && cgo)
|
||||
|
||||
package services
|
||||
|
||||
// setAppAppearance is a no-op where the platform has no app-wide appearance to
|
||||
// set; macOS and Windows theme each window instead, via setWindowAppearance.
|
||||
func setAppAppearance(bool) {}
|
||||
@@ -31,6 +31,10 @@ func (s *Preferences) SetViewMode(_ context.Context, mode preferences.ViewMode)
|
||||
return s.store.SetViewMode(mode)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetTheme(_ context.Context, theme preferences.Theme) error {
|
||||
return s.store.SetTheme(theme)
|
||||
}
|
||||
|
||||
func (s *Preferences) SetOnboardingCompleted(_ context.Context, done bool) error {
|
||||
return s.store.SetOnboardingCompleted(done)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
//go:build !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/application"
|
||||
"github.com/wailsapp/wails/v3/pkg/events"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// EventSystemThemeChanged fires when the OS appearance flips, payload SystemTheme.
|
||||
// The frontend resolves the "system" preference against it.
|
||||
const EventSystemThemeChanged = "netbird:system-theme:changed"
|
||||
|
||||
// SystemTheme is the EventSystemThemeChanged payload.
|
||||
type SystemTheme struct {
|
||||
Dark bool `json:"dark"`
|
||||
}
|
||||
|
||||
// Theme keeps native window background colours in step with the persisted
|
||||
// theme preference so no window flashes the wrong surface before the webview
|
||||
// paints. The frontend applies the matching .dark class via ThemeContext.
|
||||
type Theme struct {
|
||||
app *application.App
|
||||
store *preferences.Store
|
||||
// mu serializes apply: concurrent callers could otherwise enqueue a stale
|
||||
// pref's native updates after a newer one's.
|
||||
mu sync.Mutex
|
||||
// started gates the main-thread dispatch in apply: Run installs the platform
|
||||
// layer InvokeAsync needs, and the store subscription can fire before that.
|
||||
started atomic.Bool
|
||||
}
|
||||
|
||||
// NewTheme wires the store subscription and OS theme-change listener. Call
|
||||
// before any window is created so creation-time colours are already themed.
|
||||
func NewTheme(app *application.App, store *preferences.Store) *Theme {
|
||||
t := &Theme{app: app, store: store}
|
||||
pref := store.Get().Theme
|
||||
setAppearance(pref, resolveDark(pref, app.Env.IsDarkMode()))
|
||||
|
||||
// Window creation resolves through this rather than the seed above, which
|
||||
// is wrong until Run installs the platform layer: Env.IsDarkMode reports
|
||||
// light before that, so a "system" launch on a dark OS would build the
|
||||
// first window light. The ApplicationStarted apply below cannot be relied
|
||||
// on to land first because Wails runs each listener in its own goroutine.
|
||||
// One store read backs both fields, so the snapshot is always self-consistent.
|
||||
setAppearanceResolver(func() Appearance {
|
||||
p := t.store.Get().Theme
|
||||
return Appearance{Pref: p, Dark: resolveDark(p, t.app.Env.IsDarkMode())}
|
||||
})
|
||||
|
||||
ch, _ := store.Subscribe()
|
||||
go func() {
|
||||
var last preferences.Theme
|
||||
for p := range ch {
|
||||
if p.Theme == last {
|
||||
continue
|
||||
}
|
||||
last = p.Theme
|
||||
t.apply()
|
||||
}
|
||||
}()
|
||||
|
||||
// Re-apply on every OS flip, not just for ThemeSystem: Windows re-evaluates
|
||||
// process-level theme state on WM_SETTINGCHANGE, so a forced theme has to be
|
||||
// re-asserted or the native chrome drifts to the OS appearance. The event's
|
||||
// own IsDarkMode is deliberately unused: Wails runs each application event
|
||||
// handler in its own goroutine, so two rapid flips race, and apply re-reads
|
||||
// the appearance under mu instead.
|
||||
app.Event.OnApplicationEvent(events.Common.ThemeChanged, func(*application.ApplicationEvent) {
|
||||
t.apply()
|
||||
})
|
||||
|
||||
// Startup is split in two because Wails runs every application-event
|
||||
// listener in its own goroutine, so a listener cannot be ordered against the
|
||||
// one that opens the first-launch window. Hooks can: they run sequentially,
|
||||
// in registration order, and all of them before any listener is spawned.
|
||||
//
|
||||
// The app-wide GTK theme goes in the hook because it is the part a window
|
||||
// must not be created without. On Linux it draws the decorations and
|
||||
// application.LinuxWindow carries no theme of its own, so a window built
|
||||
// before it lands shows OS-coloured decorations until it does. It is applied
|
||||
// synchronously for the same reason -- returning from the hook has to mean
|
||||
// the theme is live. This relies on the listener below existing: Wails skips
|
||||
// an event's hooks entirely when it has no listeners.
|
||||
app.Event.RegisterApplicationEventHook(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
t.started.Store(true)
|
||||
t.syncAppAppearance()
|
||||
})
|
||||
|
||||
// The rest of the startup apply. Env.IsDarkMode is a stub until the platform
|
||||
// layer is up, so re-resolve once the app has started or a "system" launch on
|
||||
// a light OS stays seeded dark.
|
||||
app.Event.OnApplicationEvent(events.Common.ApplicationStarted, func(*application.ApplicationEvent) {
|
||||
t.apply()
|
||||
})
|
||||
|
||||
return t
|
||||
}
|
||||
|
||||
// syncAppAppearance applies the app-wide appearance and waits for the UI thread
|
||||
// to have done it. Use it where a window is about to be created and must not be
|
||||
// built against the OS appearance: apply dispatches its own native work
|
||||
// asynchronously, so on Linux the GTK theme behind the decorations can otherwise
|
||||
// land after the window exists.
|
||||
//
|
||||
// No-op before the app has started, where InvokeSync has no platform layer to
|
||||
// dispatch to. Reads the appearance under mu like apply, so the two cannot
|
||||
// interleave into a torn update.
|
||||
func (t *Theme) syncAppAppearance() {
|
||||
if !t.started.Load() {
|
||||
return
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
pref := t.store.Get().Theme
|
||||
dark := resolveDark(pref, t.app.Env.IsDarkMode())
|
||||
setAppearance(pref, dark)
|
||||
application.InvokeSync(func() { setAppAppearance(dark) })
|
||||
}
|
||||
|
||||
// SystemDarkMode reports the OS appearance; bound so the frontend can resolve
|
||||
// the "system" preference from the same source as the native layer.
|
||||
func (t *Theme) SystemDarkMode(_ context.Context) (bool, error) {
|
||||
return t.app.Env.IsDarkMode(), nil
|
||||
}
|
||||
|
||||
// resolveDark maps a preference to an effective appearance against a system
|
||||
// reading the caller already took.
|
||||
func resolveDark(pref preferences.Theme, systemDark bool) bool {
|
||||
switch pref {
|
||||
case preferences.ThemeDark:
|
||||
return true
|
||||
case preferences.ThemeLight:
|
||||
return false
|
||||
default:
|
||||
return systemDark
|
||||
}
|
||||
}
|
||||
|
||||
// apply recomputes the effective appearance, re-tints every live window
|
||||
// (including the macOS NSWindow appearance so the frame matches the webview)
|
||||
// and publishes the system appearance the frontend resolves "system" against.
|
||||
//
|
||||
// Everything runs under mu and reads the appearance here rather than taking it
|
||||
// from a caller, so a later apply always carries the fresher state and the
|
||||
// frontend event is ordered by the same lock as the native assignments. Emit
|
||||
// only appends to a FIFO mailbox, so holding mu across it cannot block.
|
||||
//
|
||||
// The OS is read exactly once per update and the resolved value is passed on to
|
||||
// the background and the native chrome, so those cannot land on either side of
|
||||
// an OS flip that happens mid-apply. The event carries the raw system reading,
|
||||
// not the resolved one, because the frontend resolves "system" itself.
|
||||
func (t *Theme) apply() {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
pref := t.store.Get().Theme
|
||||
systemDark := t.app.Env.IsDarkMode()
|
||||
dark := resolveDark(pref, systemDark)
|
||||
setAppearance(pref, dark)
|
||||
t.app.Event.Emit(EventSystemThemeChanged, SystemTheme{Dark: systemDark})
|
||||
|
||||
// Before Run there is no platform layer for InvokeAsync to dispatch to.
|
||||
// Windows created later read the globals set above.
|
||||
if !t.started.Load() {
|
||||
return
|
||||
}
|
||||
|
||||
colour := windowBackgroundColour(dark)
|
||||
// Re-tint on the UI thread and resolve each native handle there. Window
|
||||
// teardown (markAsDestroyed then impl.close) runs as UI-thread work too, so
|
||||
// a window closed meanwhile is either gone from GetAll or yields a nil
|
||||
// handle -- never a freed handle the OS may already have reused.
|
||||
application.InvokeAsync(func() {
|
||||
// App-wide first, and unconditionally: on Linux this is the GTK theme
|
||||
// that draws the decorations, and it must be set even with no window
|
||||
// open because later windows inherit it instead of carrying it.
|
||||
setAppAppearance(dark)
|
||||
for _, w := range t.app.Window.GetAll() {
|
||||
if w == nil {
|
||||
continue
|
||||
}
|
||||
w.SetBackgroundColour(colour)
|
||||
setWindowAppearance(w.NativeWindow(), pref, dark)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package services
|
||||
|
||||
/*
|
||||
#cgo CFLAGS: -x objective-c
|
||||
#cgo LDFLAGS: -framework AppKit
|
||||
|
||||
#import <AppKit/AppKit.h>
|
||||
|
||||
// forced < 0: follow the OS (appearance nil); 0: light; 1: dark.
|
||||
//
|
||||
// Assigns directly rather than dispatching: Theme.apply already runs this on
|
||||
// the main thread. Deferring would outlive the caller's check that the window
|
||||
// is alive, and the __bridge cast does not retain it, so the block could touch
|
||||
// a freed NSWindow.
|
||||
static void nbSetWindowAppearance(void *nsWindow, int forced) {
|
||||
NSWindow *window = (__bridge NSWindow *)nsWindow;
|
||||
if (forced < 0) {
|
||||
window.appearance = nil;
|
||||
} else {
|
||||
NSAppearanceName name = forced == 1 ? NSAppearanceNameDarkAqua : NSAppearanceNameAqua;
|
||||
window.appearance = [NSAppearance appearanceNamed:name];
|
||||
}
|
||||
}
|
||||
*/
|
||||
import "C"
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance pins the NSWindow appearance to the forced theme, or
|
||||
// hands it back to the OS for ThemeSystem. Without this, a window created
|
||||
// under one OS appearance keeps its dark/light frame after a manual theme
|
||||
// flip, leaving a mismatched border around the webview. Must run on the main
|
||||
// thread, which Theme.apply guarantees.
|
||||
//
|
||||
// The resolved appearance is unused: for ThemeSystem a nil NSAppearance lets
|
||||
// AppKit track the OS itself, which cannot drift from a snapshot we took.
|
||||
func setWindowAppearance(nsWindow unsafe.Pointer, pref preferences.Theme, _ bool) {
|
||||
if nsWindow == nil {
|
||||
return
|
||||
}
|
||||
forced := C.int(-1)
|
||||
switch pref {
|
||||
case preferences.ThemeLight:
|
||||
forced = 0
|
||||
case preferences.ThemeDark:
|
||||
forced = 1
|
||||
}
|
||||
C.nbSetWindowAppearance(nsWindow, forced)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
//go:build !darwin && !windows && !android && !ios && !freebsd && !js
|
||||
|
||||
package services
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance is a no-op wherever there is no per-window appearance to
|
||||
// set, which is every target this file covers. On Linux the appearance is real
|
||||
// but app-wide, so setAppAppearance owns it instead; on the remaining Unix
|
||||
// targets there is no native theming to apply at all and setAppAppearance is
|
||||
// itself a stub (appappearance_other.go).
|
||||
func setWindowAppearance(unsafe.Pointer, preferences.Theme, bool) {}
|
||||
@@ -0,0 +1,54 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"unsafe"
|
||||
|
||||
"github.com/wailsapp/wails/v3/pkg/w32"
|
||||
|
||||
"github.com/netbirdio/netbird/client/ui/preferences"
|
||||
)
|
||||
|
||||
// setWindowAppearance re-themes a live window's chrome; Wails only does this
|
||||
// itself on OS flips for SystemDefault windows.
|
||||
//
|
||||
// Must run on the UI thread, which Theme.apply guarantees: the uxtheme and
|
||||
// repaint calls behind w32.SetTheme belong to the window's thread, and hwnd is
|
||||
// only known live while we hold that thread. Re-dispatching here would let the
|
||||
// window be destroyed first and hand these writes a reused handle.
|
||||
//
|
||||
// dark is the appearance Theme.apply already resolved. Re-reading the OS here
|
||||
// would let the chrome land on the other side of an OS flip from the window
|
||||
// background and the webview.
|
||||
func setWindowAppearance(hwnd unsafe.Pointer, _ preferences.Theme, dark bool) {
|
||||
if hwnd == nil || !w32.SupportsThemes() || w32.IsCurrentlyHighContrastMode() {
|
||||
return
|
||||
}
|
||||
|
||||
h := uintptr(hwnd)
|
||||
w32.SetTheme(h, dark)
|
||||
|
||||
// After SetTheme, not before: its menu helper regates dark on the
|
||||
// process-level ShouldAppsUseDarkMode and rewrites the per-window opt-in
|
||||
// with that gated value, so forcing Dark on a light OS would lose it --
|
||||
// and builds below 18985 need the opt-in for the pre-20H1 dark frame. The
|
||||
// gated menu theme name is left alone on purpose: these windows carry no
|
||||
// native menu, and popup-menu text follows the process policy, so forcing
|
||||
// it dark gives dark text on dark.
|
||||
if w32.AllowDarkModeForWindow != nil {
|
||||
w32.AllowDarkModeForWindow(h, dark)
|
||||
}
|
||||
|
||||
chrome := microsoftWindowsLightTheme
|
||||
if dark {
|
||||
chrome = microsoftWindowsDarkTheme
|
||||
}
|
||||
if chrome.TitleBarColour != nil {
|
||||
w32.SetTitleBarColour(h, *chrome.TitleBarColour)
|
||||
}
|
||||
if chrome.TitleTextColour != nil {
|
||||
w32.SetTitleTextColour(h, *chrome.TitleTextColour)
|
||||
}
|
||||
if chrome.BorderColour != nil {
|
||||
w32.SetBorderColour(h, *chrome.BorderColour)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"net/url"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -36,39 +37,123 @@ const paintedFallback = 2 * time.Second
|
||||
|
||||
const headlessTeardownDelay = 2 * time.Second
|
||||
|
||||
var WindowBackgroundColour = application.NewRGB(24, 26, 29) // bg-nb-gray-950
|
||||
// Window background per effective appearance. Both match the body background
|
||||
// (bg-nb-gray DEFAULT) in globals.css so opaque native pixels and the webview
|
||||
// paint the same surface; keep the three in sync.
|
||||
var (
|
||||
windowBackgroundDark = application.NewRGB(24, 26, 29) // dark nb-gray DEFAULT
|
||||
windowBackgroundLight = application.NewRGB(243, 243, 243) // light nb-gray DEFAULT
|
||||
)
|
||||
|
||||
// Appearance is one view of the theme state: the preference and the appearance
|
||||
// it resolves to. Take it once per window with CurrentAppearance and pass the
|
||||
// same value to every option builder -- Pref drives the macOS frame while Dark
|
||||
// drives the background and the Windows chrome, so reading them separately can
|
||||
// build a window with a new background behind the previous native frame.
|
||||
type Appearance struct {
|
||||
Pref preferences.Theme
|
||||
Dark bool
|
||||
}
|
||||
|
||||
// storedAppearance is the snapshot maintained by services.Theme, published as
|
||||
// one value so the pair can never tear. It is the fallback for window creation
|
||||
// until resolveAppearance is installed.
|
||||
var storedAppearance atomic.Value // Appearance
|
||||
|
||||
// resolveAppearance re-resolves against the live OS state. Theme installs it so
|
||||
// window creation never reads a stale seed: app.Env.IsDarkMode reports light
|
||||
// until Run installs the platform layer, and Wails runs every
|
||||
// ApplicationStarted listener in its own goroutine, so a startup window can be
|
||||
// created before Theme's listener has corrected the seed.
|
||||
var resolveAppearance atomic.Value // func() Appearance
|
||||
|
||||
func init() {
|
||||
storedAppearance.Store(Appearance{Pref: preferences.DefaultTheme, Dark: true})
|
||||
}
|
||||
|
||||
func setAppearance(pref preferences.Theme, dark bool) {
|
||||
storedAppearance.Store(Appearance{Pref: pref, Dark: dark})
|
||||
}
|
||||
|
||||
func setAppearanceResolver(f func() Appearance) { resolveAppearance.Store(f) }
|
||||
|
||||
// CurrentAppearance returns the snapshot every window creation must build from.
|
||||
func CurrentAppearance() Appearance {
|
||||
if f, _ := resolveAppearance.Load().(func() Appearance); f != nil {
|
||||
return f()
|
||||
}
|
||||
a, _ := storedAppearance.Load().(Appearance)
|
||||
return a
|
||||
}
|
||||
|
||||
// WindowBackgroundColour returns the background for a snapshot; use it for
|
||||
// every WebviewWindowOptions.BackgroundColour.
|
||||
func WindowBackgroundColour(a Appearance) application.RGBA {
|
||||
return windowBackgroundColour(a.Dark)
|
||||
}
|
||||
|
||||
// windowBackgroundColour maps a resolved appearance to its window background.
|
||||
func windowBackgroundColour(dark bool) application.RGBA {
|
||||
if dark {
|
||||
return windowBackgroundDark
|
||||
}
|
||||
return windowBackgroundLight
|
||||
}
|
||||
|
||||
// WindowHeight is shared by the main and Settings windows.
|
||||
const WindowHeight = 660
|
||||
|
||||
// Wails reads CustomTheme colours as 0x00BBGGRR (RGB byte order reversed).
|
||||
var microsoftWindowsTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00211E1C),
|
||||
var microsoftWindowsDarkTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00211E1C), // #1C1E21 nb-gray-940
|
||||
TitleBarColour: u32ptr(0x00211E1C),
|
||||
TitleTextColour: u32ptr(0x00E9E7E4),
|
||||
TitleTextColour: u32ptr(0x00E9E7E4), // #E4E7E9 nb-gray-100
|
||||
}
|
||||
|
||||
// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica + dark + custom title bar).
|
||||
func MicrosoftWindowsAppearanceOptions() application.WindowsWindow {
|
||||
var microsoftWindowsLightTheme = &application.WindowTheme{
|
||||
BorderColour: u32ptr(0x00F3F3F3), // #F3F3F3 light nb-gray DEFAULT
|
||||
TitleBarColour: u32ptr(0x00F3F3F3),
|
||||
TitleTextColour: u32ptr(0x00212121), // #212121 light nb-gray-100
|
||||
}
|
||||
|
||||
// MicrosoftWindowsAppearanceOptions is the shared Windows chrome (Mica +
|
||||
// custom title bar), resolved at creation; setWindowAppearance re-themes live
|
||||
// windows on later changes. Never SystemDefault: Wails gives those windows a
|
||||
// SystemThemeChanged handler that re-themes chrome from the OS appearance,
|
||||
// which outlives a switch to a forced theme and fights it on the next OS flip.
|
||||
// Both CustomTheme slots hold one colour set for the same reason.
|
||||
func MicrosoftWindowsAppearanceOptions(a Appearance) application.WindowsWindow {
|
||||
theme, chrome := application.Light, microsoftWindowsLightTheme
|
||||
if a.Dark {
|
||||
theme, chrome = application.Dark, microsoftWindowsDarkTheme
|
||||
}
|
||||
return application.WindowsWindow{
|
||||
BackdropType: application.Mica,
|
||||
Theme: application.Dark,
|
||||
Theme: theme,
|
||||
CustomTheme: application.ThemeSettings{
|
||||
DarkModeActive: microsoftWindowsTheme,
|
||||
DarkModeInactive: microsoftWindowsTheme,
|
||||
LightModeActive: microsoftWindowsTheme,
|
||||
LightModeInactive: microsoftWindowsTheme,
|
||||
DarkModeActive: chrome,
|
||||
DarkModeInactive: chrome,
|
||||
LightModeActive: chrome,
|
||||
LightModeInactive: chrome,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AppleMacOSAppearanceOptions is the shared macOS chrome; FullScreenNone keeps the fixed-size layout.
|
||||
func AppleMacOSAppearanceOptions() application.MacWindow {
|
||||
func AppleMacOSAppearanceOptions(a Appearance) application.MacWindow {
|
||||
appearance := application.DefaultAppearance
|
||||
switch a.Pref {
|
||||
case preferences.ThemeLight:
|
||||
appearance = application.NSAppearanceNameAqua
|
||||
case preferences.ThemeDark:
|
||||
appearance = application.NSAppearanceNameDarkAqua
|
||||
}
|
||||
return application.MacWindow{
|
||||
InvisibleTitleBarHeight: 38,
|
||||
Backdrop: application.MacBackdropNormal,
|
||||
TitleBar: application.MacTitleBarHiddenInset,
|
||||
CollectionBehavior: application.MacWindowCollectionBehaviorFullScreenNone,
|
||||
Appearance: appearance,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +167,7 @@ func LinuxAppearanceOptions(icon []byte) application.LinuxWindow {
|
||||
|
||||
// DialogWindowOptions is the baseline for every auxiliary dialog window; callers override per-dialog.
|
||||
func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.WebviewWindowOptions {
|
||||
a := CurrentAppearance()
|
||||
return application.WebviewWindowOptions{
|
||||
Name: name,
|
||||
Title: title,
|
||||
@@ -93,10 +179,10 @@ func DialogWindowOptions(name, title, url string, linuxIcon []byte) application.
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
BackgroundColour: WindowBackgroundColour(a),
|
||||
URL: url,
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: AppleMacOSAppearanceOptions(a),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(a),
|
||||
Linux: LinuxAppearanceOptions(linuxIcon),
|
||||
}
|
||||
}
|
||||
@@ -164,6 +250,7 @@ func NewWindowManager(app *application.App, mainWindow *application.WebviewWindo
|
||||
}
|
||||
|
||||
func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
a := CurrentAppearance()
|
||||
w := s.app.Window.NewWithOptions(application.WebviewWindowOptions{
|
||||
Name: "settings",
|
||||
Title: s.title("window.title.settings"),
|
||||
@@ -174,10 +261,10 @@ func (s *WindowManager) newSettingsWindow() *application.WebviewWindow {
|
||||
MinimiseButtonState: application.ButtonHidden,
|
||||
MaximiseButtonState: application.ButtonHidden,
|
||||
CloseButtonState: application.ButtonEnabled,
|
||||
BackgroundColour: WindowBackgroundColour,
|
||||
BackgroundColour: WindowBackgroundColour(a),
|
||||
URL: "/#/settings",
|
||||
Mac: AppleMacOSAppearanceOptions(),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(),
|
||||
Mac: AppleMacOSAppearanceOptions(a),
|
||||
Windows: MicrosoftWindowsAppearanceOptions(a),
|
||||
Linux: LinuxAppearanceOptions(s.linuxIcon),
|
||||
})
|
||||
w.RegisterHook(events.Common.WindowClosing, func(_ *application.WindowEvent) {
|
||||
|
||||
+123
-24
@@ -17,6 +17,13 @@ import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// The two KDE files that decide the panel's appearance. Both live in the user
|
||||
// config dir, so one directory watch covers them (see watchKdeConfig).
|
||||
const (
|
||||
kdeglobalsFile = "kdeglobals"
|
||||
plasmarcFile = "plasmarc"
|
||||
)
|
||||
|
||||
// startTrayTheme seeds t.panelDark and repaints on colour-scheme flips. Must
|
||||
// run before the first applyIcon so the initial paint uses the right silhouette.
|
||||
func (t *Tray) startTrayTheme() {
|
||||
@@ -35,50 +42,142 @@ func isKDE() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// kdeglobalsPath returns the user kdeglobals path. We read only this file, not
|
||||
// the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme here, and a
|
||||
// missing Complementary group falls back to the portal.
|
||||
func kdeglobalsPath() string {
|
||||
// kdeConfigPath locates one of KDE's user config files. We read only the user
|
||||
// file, not the full XDG_CONFIG_DIRS cascade: Plasma writes the active scheme
|
||||
// and style there, and anything missing falls back to the portal.
|
||||
func kdeConfigPath(name string) string {
|
||||
if dir := os.Getenv("XDG_CONFIG_HOME"); dir != "" {
|
||||
return filepath.Join(dir, "kdeglobals")
|
||||
return filepath.Join(dir, name)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(home, ".config", "kdeglobals")
|
||||
return filepath.Join(home, ".config", name)
|
||||
}
|
||||
|
||||
// kdePanelIsDark reports whether the KDE Plasma panel is dark by the luma of
|
||||
// its "Complementary" background (the colour Plasma paints the tray with). ok
|
||||
// is false when this isn't KDE or the colour can't be read, so the caller falls
|
||||
// through to the portal/GTK path.
|
||||
func kdeglobalsPath() string { return kdeConfigPath(kdeglobalsFile) }
|
||||
func plasmarcPath() string { return kdeConfigPath(plasmarcFile) }
|
||||
|
||||
// kdePanelIsDark reports whether the KDE Plasma panel the tray icon sits on is
|
||||
// dark. ok is false when this isn't KDE or neither source was conclusive, so the
|
||||
// caller falls through to the portal/GTK path.
|
||||
func kdePanelIsDark() (dark, ok bool) {
|
||||
if !isKDE() {
|
||||
return false, false
|
||||
}
|
||||
path := kdeglobalsPath()
|
||||
if path == "" {
|
||||
return false, false
|
||||
// A pinned Plasma style paints the panel itself, so it outranks the
|
||||
// application colour scheme: a style with dark colours under a Light scheme
|
||||
// is still a dark panel and still needs the white silhouette.
|
||||
if dark, ok := plasmaStyleIsDark(); ok {
|
||||
return dark, true
|
||||
}
|
||||
rgb, ok := readKdeComplementaryBackground(path)
|
||||
// The default style follows the colour scheme, so decide by the luma of the
|
||||
// window background Plasma derives the panel from. Deliberately not
|
||||
// Complementary: that group is dark under Breeze *and* BreezeLight
|
||||
// (42,46,50 measured on Plasma 6.7.4), so reading it kept the white icon on
|
||||
// a light panel, where it is all but invisible.
|
||||
rgb, ok := readKdeColour(kdeglobalsPath(), "[Colors:Window]")
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
|
||||
}
|
||||
|
||||
// readKdeComplementaryBackground parses kdeglobals for
|
||||
// [Colors:Complementary] BackgroundNormal and returns its R,G,B (0-255).
|
||||
func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
|
||||
// plasmaStyleIsDark reports the appearance the pinned Plasma style paints the
|
||||
// panel with, decided by the style's own colours rather than by its name. A
|
||||
// style that ships a colours file overrides the colour scheme for the shell, and
|
||||
// nothing requires the name to admit it: breeze-dark happens to, but a style
|
||||
// named neutrally can carry dark colours just as well (measured on Plasma 6.7.4:
|
||||
// panel luma 38 while kdeglobals and the portal both reported light).
|
||||
//
|
||||
// ok is false when no style is pinned, when the pinned style ships no colours --
|
||||
// the "default" style, which is exactly the case that follows the colour scheme
|
||||
// -- and when its colours cannot be read; all three fall through to kdeglobals.
|
||||
func plasmaStyleIsDark() (dark, ok bool) {
|
||||
name, found := readIniValue(plasmarcPath(), "[Theme]", "name")
|
||||
if !found || name == "" {
|
||||
return false, false
|
||||
}
|
||||
if !isBareStyleName(name) {
|
||||
log.Debugf("tray theme: ignoring plasma style name %q, not a bare directory name", name)
|
||||
return false, false
|
||||
}
|
||||
for _, dir := range plasmaStyleDirs(name) {
|
||||
rgb, found := readKdeColour(filepath.Join(dir, "colors"), "[Colors:Window]")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
return isDarkRGB(rgb[0], rgb[1], rgb[2]), true
|
||||
}
|
||||
return false, false
|
||||
}
|
||||
|
||||
// isBareStyleName reports whether name is safe to index a directory with. The
|
||||
// name comes from a config file and is joined into a path, so it has to be a
|
||||
// single ordinary element: "../" in it would point the read anywhere, and ".",
|
||||
// ".." and "/" all survive filepath.Base(filepath.Clean(name)) unchanged, so
|
||||
// they need rejecting by name -- ".." alone resolves a level above desktoptheme.
|
||||
func isBareStyleName(name string) bool {
|
||||
if name == "" || name == "." || name == ".." || filepath.IsAbs(name) {
|
||||
return false
|
||||
}
|
||||
return name == filepath.Base(filepath.Clean(name))
|
||||
}
|
||||
|
||||
// plasmaStyleDirs lists where a Plasma style of that name may live, in the order
|
||||
// Plasma itself resolves them: the user data dir first, so a local style shadows
|
||||
// a system one of the same name.
|
||||
func plasmaStyleDirs(name string) []string {
|
||||
var dirs []string
|
||||
for _, base := range xdgDataDirs() {
|
||||
dirs = append(dirs, filepath.Join(base, "plasma", "desktoptheme", name))
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// xdgDataDirs returns XDG_DATA_HOME (or its default) followed by XDG_DATA_DIRS.
|
||||
func xdgDataDirs() []string {
|
||||
var dirs []string
|
||||
if home := os.Getenv("XDG_DATA_HOME"); home != "" {
|
||||
dirs = append(dirs, home)
|
||||
} else if h, err := os.UserHomeDir(); err == nil {
|
||||
dirs = append(dirs, filepath.Join(h, ".local", "share"))
|
||||
}
|
||||
system := os.Getenv("XDG_DATA_DIRS")
|
||||
if system == "" {
|
||||
system = "/usr/local/share:/usr/share"
|
||||
}
|
||||
for _, dir := range strings.Split(system, ":") {
|
||||
if dir != "" {
|
||||
dirs = append(dirs, dir)
|
||||
}
|
||||
}
|
||||
return dirs
|
||||
}
|
||||
|
||||
// readKdeColour reads group's BackgroundNormal as an R,G,B triple.
|
||||
func readKdeColour(path, group string) (rgb [3]uint8, ok bool) {
|
||||
val, found := readIniValue(path, group, "BackgroundNormal")
|
||||
if !found {
|
||||
return rgb, false
|
||||
}
|
||||
return parseRGB(val)
|
||||
}
|
||||
|
||||
// readIniValue returns key's value inside group from a KDE-style INI file.
|
||||
// group carries its own brackets, e.g. "[Colors:Window]".
|
||||
func readIniValue(path, group, key string) (value string, ok bool) {
|
||||
if path == "" {
|
||||
return "", false
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: kdeglobals open failed, using portal: %v", err)
|
||||
return rgb, false
|
||||
log.Debugf("tray theme: %s open failed, using portal: %v", filepath.Base(path), err)
|
||||
return "", false
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
|
||||
const group = "[Colors:Complementary]"
|
||||
inGroup := false
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
@@ -90,13 +189,13 @@ func readKdeComplementaryBackground(path string) (rgb [3]uint8, ok bool) {
|
||||
if !inGroup {
|
||||
continue
|
||||
}
|
||||
key, val, found := strings.Cut(line, "=")
|
||||
if !found || strings.TrimSpace(key) != "BackgroundNormal" {
|
||||
k, v, found := strings.Cut(line, "=")
|
||||
if !found || strings.TrimSpace(k) != key {
|
||||
continue
|
||||
}
|
||||
return parseRGB(strings.TrimSpace(val))
|
||||
return strings.TrimSpace(v), true
|
||||
}
|
||||
return rgb, false
|
||||
return "", false
|
||||
}
|
||||
|
||||
// parseRGB parses KDE's "r,g,b" colour triple into bytes.
|
||||
|
||||
@@ -8,49 +8,346 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestReadKdeComplementaryBackground(t *testing.T) {
|
||||
// Mirrors the KDE test VM's kdeglobals: Window light, Complementary dark.
|
||||
// The tray sits on the panel, which Plasma paints from Complementary, so
|
||||
// the panel is dark even though the global color-scheme is Light.
|
||||
content := `[Colors:Window]
|
||||
BackgroundNormal=239,240,241
|
||||
|
||||
[Colors:Complementary]
|
||||
// Values measured on Plasma 6.7.4 (Fedora 44) under each Breeze scheme. The
|
||||
// Complementary group is dark under both, which is why it can't decide the
|
||||
// panel; Window tracks it.
|
||||
const (
|
||||
kdeglobalsLight = `[Colors:Complementary]
|
||||
BackgroundAlternate=27,30,32
|
||||
BackgroundNormal=42,46,50
|
||||
|
||||
[Colors:Window]
|
||||
BackgroundNormal=239,240,241
|
||||
|
||||
[General]
|
||||
ColorSchemeHash=0be804dba87e3512aeb4be3d78ed981f59f0f2f4
|
||||
`
|
||||
path := filepath.Join(t.TempDir(), "kdeglobals")
|
||||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||||
kdeglobalsDark = `[Colors:Complementary]
|
||||
BackgroundNormal=32,35,38
|
||||
|
||||
[Colors:Window]
|
||||
BackgroundNormal=32,35,38
|
||||
|
||||
[General]
|
||||
ColorScheme=BreezeDark
|
||||
`
|
||||
)
|
||||
|
||||
// plasmaStyle installs a Plasma style of that name under XDG_DATA_HOME. An
|
||||
// empty colours body installs the style without a colours file, which is what
|
||||
// the stock "default" style looks like.
|
||||
func plasmaStyle(t *testing.T, name, colours string) string {
|
||||
t.Helper()
|
||||
data := t.TempDir()
|
||||
dir := filepath.Join(data, "plasma", "desktoptheme", name)
|
||||
if err := os.MkdirAll(dir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if colours != "" {
|
||||
if err := os.WriteFile(filepath.Join(dir, "colors"), []byte(colours), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
t.Setenv("XDG_DATA_HOME", data)
|
||||
// Keep the system dirs out of it so an installed breeze-dark cannot answer.
|
||||
t.Setenv("XDG_DATA_DIRS", filepath.Join(data, "empty"))
|
||||
return data
|
||||
}
|
||||
|
||||
rgb, ok := readKdeComplementaryBackground(path)
|
||||
if !ok {
|
||||
t.Fatal("expected to find Complementary BackgroundNormal")
|
||||
// kdeConfig points the KDE readers at a temp dir holding the given files, and
|
||||
// makes isKDE report KDE. An empty body skips the file.
|
||||
func kdeConfig(t *testing.T, kdeglobals, plasmarc string) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
for name, body := range map[string]string{kdeglobalsFile: kdeglobals, plasmarcFile: plasmarc} {
|
||||
if body == "" {
|
||||
continue
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if rgb != [3]uint8{42, 46, 50} {
|
||||
t.Fatalf("rgb = %v, want [42 46 50]", rgb)
|
||||
}
|
||||
if !isDarkRGB(rgb[0], rgb[1], rgb[2]) {
|
||||
t.Fatal("panel colour 42,46,50 should be dark")
|
||||
}
|
||||
// The Window background (what color-scheme reflects) is light — the bug
|
||||
// this fix addresses is picking the icon from that instead of the panel.
|
||||
if isDarkRGB(239, 240, 241) {
|
||||
t.Fatal("window colour 239,240,241 should be light")
|
||||
t.Setenv("XDG_CONFIG_HOME", dir)
|
||||
t.Setenv("XDG_CURRENT_DESKTOP", "KDE")
|
||||
// Point the Plasma style lookup at empty dirs so a style installed on the
|
||||
// host cannot answer for a test that did not install one itself. Tests that
|
||||
// want a style call plasmaStyle, which overrides these.
|
||||
t.Setenv("XDG_DATA_HOME", filepath.Join(dir, "empty-data-home"))
|
||||
t.Setenv("XDG_DATA_DIRS", filepath.Join(dir, "empty-data-dirs"))
|
||||
}
|
||||
|
||||
// The reported bug: a Light global scheme left the panel reported dark, so the
|
||||
// tray kept the white silhouette on a light panel.
|
||||
func TestKdePanelIsDarkFollowsColourScheme(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kdeglobals string
|
||||
wantDark bool
|
||||
}{
|
||||
{"light scheme", kdeglobalsLight, false},
|
||||
{"dark scheme", kdeglobalsDark, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, tc.kdeglobals, "")
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer from kdeglobals")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadKdeComplementaryBackgroundMissingGroup(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "kdeglobals")
|
||||
if err := os.WriteFile(path, []byte("[Colors:Window]\nBackgroundNormal=1,2,3\n"), 0o600); err != nil {
|
||||
// A pinned Plasma style paints the panel regardless of the colour scheme, so it
|
||||
// has to outrank it in both directions. The styles are installed into the test's
|
||||
// own XDG_DATA_HOME: reading whatever the host happens to ship would make the
|
||||
// result depend on the machine.
|
||||
func TestKdePanelIsDarkPinnedStyleOutranksScheme(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
kdeglobals string
|
||||
style string
|
||||
styleColours string
|
||||
wantDark bool
|
||||
}{
|
||||
{"dark style, light scheme", kdeglobalsLight, "breeze-dark", kdeglobalsDark, true},
|
||||
{"light style, dark scheme", kdeglobalsDark, "breeze-light", kdeglobalsLight, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, tc.kdeglobals, "[Theme]\nname="+tc.style+"\n")
|
||||
plasmaStyle(t, tc.style, tc.styleColours)
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer from the pinned style")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// "default" fixes nothing, so the colour scheme still decides.
|
||||
func TestKdePanelIsDarkDefaultStyleDefersToScheme(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=default\n")
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected the colour scheme to answer")
|
||||
}
|
||||
if dark {
|
||||
t.Fatal("default style on a Light scheme is a light panel")
|
||||
}
|
||||
}
|
||||
|
||||
// No colours and no style: stay inconclusive so readDarkMode uses the portal
|
||||
// rather than guessing.
|
||||
func TestKdePanelIsDarkInconclusive(t *testing.T) {
|
||||
t.Run("no window group", func(t *testing.T) {
|
||||
kdeConfig(t, "[Colors:Complementary]\nBackgroundNormal=42,46,50\n", "")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok without a Window group")
|
||||
}
|
||||
})
|
||||
t.Run("no kde files", func(t *testing.T) {
|
||||
kdeConfig(t, "", "")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok with no kdeglobals at all")
|
||||
}
|
||||
})
|
||||
t.Run("not kde", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsDark, "")
|
||||
t.Setenv("XDG_CURRENT_DESKTOP", "ubuntu:GNOME")
|
||||
if _, ok := kdePanelIsDark(); ok {
|
||||
t.Fatal("expected not-ok off KDE")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The panel follows the pinned style's own colours, not its name. Reproduces
|
||||
// the measured case: a neutrally named style shipping dark colours while
|
||||
// kdeglobals reports light.
|
||||
func TestPlasmaStyleIsDarkUsesStyleColours(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
style string
|
||||
colours string
|
||||
wantDark bool
|
||||
}{
|
||||
{"neutral name, dark colours", "nbtestneutral", kdeglobalsDark, true},
|
||||
{"neutral name, light colours", "nbtestneutral", kdeglobalsLight, false},
|
||||
{"name says dark, colours are light", "midnight-dark", kdeglobalsLight, false},
|
||||
{"name says light, colours are dark", "daylight", kdeglobalsDark, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+tc.style+"\n")
|
||||
plasmaStyle(t, tc.style, tc.colours)
|
||||
dark, ok := plasmaStyleIsDark()
|
||||
if !ok {
|
||||
t.Fatal("a style shipping colours should be conclusive")
|
||||
}
|
||||
if dark != tc.wantDark {
|
||||
t.Fatalf("dark = %v, want %v", dark, tc.wantDark)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// End to end through kdePanelIsDark: the style's colours must beat kdeglobals.
|
||||
func TestKdePanelIsDarkStyleColoursBeatColourScheme(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=nbtestneutral\n")
|
||||
plasmaStyle(t, "nbtestneutral", kdeglobalsDark)
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok {
|
||||
t.Fatal("expected a conclusive answer")
|
||||
}
|
||||
if !dark {
|
||||
t.Fatal("a dark-coloured style on a Light scheme is a dark panel")
|
||||
}
|
||||
}
|
||||
|
||||
// A style with no colours file is the "default" case: it follows the scheme.
|
||||
func TestPlasmaStyleIsDarkNoColoursFile(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=default\n")
|
||||
plasmaStyle(t, "default", "")
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("a style without colours must defer to the colour scheme")
|
||||
}
|
||||
// and the whole resolution then lands on the light scheme
|
||||
dark, ok := kdePanelIsDark()
|
||||
if !ok || dark {
|
||||
t.Fatalf("kdePanelIsDark() = (%v, %v), want (false, true)", dark, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlasmaStyleIsDarkInconclusive(t *testing.T) {
|
||||
t.Run("no plasmarc", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "")
|
||||
plasmaStyle(t, "unused", kdeglobalsDark)
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok with no plasmarc")
|
||||
}
|
||||
})
|
||||
t.Run("empty style name", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=\n")
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok for an empty style name")
|
||||
}
|
||||
})
|
||||
t.Run("style not installed", func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname=absent\n")
|
||||
plasmaStyle(t, "somethingelse", kdeglobalsDark)
|
||||
if _, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatal("expected not-ok when the style is not installed")
|
||||
}
|
||||
})
|
||||
// ... but a leading dot in an ordinary name is fine.
|
||||
for _, good := range []string{".hidden", "..."} {
|
||||
t.Run("accepts "+good, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+good+"\n")
|
||||
plasmaStyle(t, good, kdeglobalsDark)
|
||||
dark, ok := plasmaStyleIsDark()
|
||||
if !ok || !dark {
|
||||
t.Fatalf("plasmaStyleIsDark() = (%v, %v) for %q, want (true, true)", dark, ok, good)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A traversing name must be rejected outright, not merely fail to find a file.
|
||||
// Each case plants colours at exactly the path the unguarded lookup would read,
|
||||
// so removing the name check makes plasmaStyleIsDark answer from the planted
|
||||
// file and these fail. Without the planted file the test would pass either way.
|
||||
func TestPlasmaStyleIsDarkRejectsPlantedEscape(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
// where filepath.Join(data, "plasma", "desktoptheme", name) lands
|
||||
escaped []string
|
||||
}{
|
||||
{"..", []string{"plasma"}},
|
||||
{".", []string{"plasma", "desktoptheme"}},
|
||||
{"/", []string{"plasma", "desktoptheme"}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
kdeConfig(t, kdeglobalsLight, "[Theme]\nname="+tc.name+"\n")
|
||||
data := plasmaStyle(t, "unused", kdeglobalsLight)
|
||||
|
||||
target := filepath.Join(append([]string{data}, tc.escaped...)...)
|
||||
// Guard the fixture itself: if Join ever stops landing here the
|
||||
// test would go quietly vacuous again.
|
||||
want := filepath.Clean(filepath.Join(data, "plasma", "desktoptheme", tc.name))
|
||||
if target != want {
|
||||
t.Fatalf("fixture targets %q but the lookup resolves %q", target, want)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(target, "colors"), []byte(kdeglobalsDark), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if dark, ok := plasmaStyleIsDark(); ok {
|
||||
t.Fatalf("plasmaStyleIsDark() = (%v, true) for %q: the name must be rejected, "+
|
||||
"not resolved against %s", dark, tc.name, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The rejection condition on its own, so every case is checked whether or not a
|
||||
// file happens to exist at the path it would resolve to.
|
||||
func TestIsBareStyleName(t *testing.T) {
|
||||
for _, bad := range []string{"", ".", "..", "/", "//", "/etc", "../../../../etc", "a/b", "a/", "./x", "../x"} {
|
||||
if isBareStyleName(bad) {
|
||||
t.Errorf("isBareStyleName(%q) = true, want false", bad)
|
||||
}
|
||||
}
|
||||
for _, good := range []string{"breeze-dark", "default", ".hidden", "...", "Breeze Dark", "a.b"} {
|
||||
if !isBareStyleName(good) {
|
||||
t.Errorf("isBareStyleName(%q) = false, want true", good)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A local style of the same name shadows the system one, as in Plasma.
|
||||
func TestPlasmaStyleDirsPreferUserData(t *testing.T) {
|
||||
t.Setenv("XDG_DATA_HOME", "/home/someone/.local/share")
|
||||
t.Setenv("XDG_DATA_DIRS", "/usr/local/share:/usr/share")
|
||||
got := plasmaStyleDirs("breeze-dark")
|
||||
want := []string{
|
||||
"/home/someone/.local/share/plasma/desktoptheme/breeze-dark",
|
||||
"/usr/local/share/plasma/desktoptheme/breeze-dark",
|
||||
"/usr/share/plasma/desktoptheme/breeze-dark",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("plasmaStyleDirs() = %v, want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("plasmaStyleDirs()[%d] = %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIniValue(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), kdeglobalsFile)
|
||||
if err := os.WriteFile(path, []byte(kdeglobalsLight), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := readKdeComplementaryBackground(path); ok {
|
||||
t.Fatal("expected not-ok when Complementary group is absent")
|
||||
// The same key exists in two groups, so a group-blind reader would return
|
||||
// whichever came first.
|
||||
if v, ok := readIniValue(path, "[Colors:Window]", "BackgroundNormal"); !ok || v != "239,240,241" {
|
||||
t.Fatalf("Window BackgroundNormal = %q ok=%v, want \"239,240,241\" true", v, ok)
|
||||
}
|
||||
if v, ok := readIniValue(path, "[Colors:Complementary]", "BackgroundNormal"); !ok || v != "42,46,50" {
|
||||
t.Fatalf("Complementary BackgroundNormal = %q ok=%v, want \"42,46,50\" true", v, ok)
|
||||
}
|
||||
if _, ok := readIniValue(path, "[Colors:Window]", "ColorSchemeHash"); ok {
|
||||
t.Fatal("a key from another group should not be found")
|
||||
}
|
||||
if _, ok := readIniValue(filepath.Join(t.TempDir(), "absent"), "[Theme]", "name"); ok {
|
||||
t.Fatal("a missing file should not be found")
|
||||
}
|
||||
if _, ok := readIniValue("", "[Theme]", "name"); ok {
|
||||
t.Fatal("an empty path should not be found")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,8 +374,8 @@ func TestIsDarkRGB(t *testing.T) {
|
||||
if isDarkRGB(255, 255, 255) {
|
||||
t.Fatal("white is light")
|
||||
}
|
||||
if !isDarkRGB(42, 46, 50) {
|
||||
t.Fatal("Breeze panel grey is dark")
|
||||
if !isDarkRGB(32, 35, 38) {
|
||||
t.Fatal("BreezeDark window grey is dark")
|
||||
}
|
||||
if isDarkRGB(239, 240, 241) {
|
||||
t.Fatal("Breeze window grey is light")
|
||||
|
||||
@@ -3,9 +3,10 @@
|
||||
package main
|
||||
|
||||
// Sources: the freedesktop Settings portal's SettingChanged signal, and on KDE
|
||||
// the kdeglobals file (the portal's color-scheme doesn't track the panel's
|
||||
// Complementary colour — see readDarkMode). The dark/light decision lives in
|
||||
// tray_theme_linux.go; this file owns the session-bus connection and subscriptions.
|
||||
// the kdeglobals and plasmarc files (a pinned Plasma style fixes the panel's
|
||||
// appearance without touching the portal's color-scheme — see readDarkMode).
|
||||
// The dark/light decision lives in tray_theme_linux.go; this file owns the
|
||||
// session-bus connection and subscriptions.
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
@@ -66,9 +67,9 @@ func startThemeWatcher(onChange func()) *themeWatcher {
|
||||
// Keep the connection: the seeded darkMode value is still useful.
|
||||
}
|
||||
|
||||
// The portal's signal doesn't track KDE's panel Complementary colour.
|
||||
// The portal's signal says nothing about a pinned Plasma style.
|
||||
if isKDE() {
|
||||
w.watchKdeglobals()
|
||||
w.watchKdeConfig()
|
||||
}
|
||||
|
||||
log.Infof("tray theme: panel dark mode = %v", w.IsDark())
|
||||
@@ -88,12 +89,11 @@ func (w *themeWatcher) IsDark() bool {
|
||||
|
||||
// readDarkMode resolves whether the panel the tray icon sits on is dark.
|
||||
//
|
||||
// On KDE the freedesktop color-scheme is the application preference, not the
|
||||
// panel's: Plasma paints its panel from the Breeze "Complementary" group, which
|
||||
// stays dark even under a Light global scheme, so we read the panel background
|
||||
// from kdeglobals first and decide by its luma. Off KDE the color-scheme portal
|
||||
// is the source; on "no preference" (0) or when unavailable we fall back to
|
||||
// GTK_THEME (":dark" suffix ⇒ dark), then default to dark.
|
||||
// KDE goes first because a pinned Plasma style decides the panel on its own,
|
||||
// independently of the application colour scheme the portal reports; with no
|
||||
// style pinned that check defers to KDE's own colour files. Off KDE the
|
||||
// color-scheme portal is the source; on "no preference" (0) or when unavailable
|
||||
// we fall back to GTK_THEME (":dark" suffix ⇒ dark), then default to dark.
|
||||
func (w *themeWatcher) readDarkMode() bool {
|
||||
if dark, ok := kdePanelIsDark(); ok {
|
||||
return dark
|
||||
@@ -160,8 +160,8 @@ func (w *themeWatcher) loop(sigs chan *dbus.Signal) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Re-resolve via readDarkMode, not the signal value: under KDE the panel
|
||||
// colour comes from kdeglobals, so the signal value would be wrong.
|
||||
// Re-resolve via readDarkMode, not the signal value: under KDE a pinned
|
||||
// Plasma style overrides it, so the signal value would be wrong.
|
||||
w.update()
|
||||
}
|
||||
}
|
||||
@@ -179,22 +179,23 @@ func (w *themeWatcher) update() {
|
||||
}
|
||||
}
|
||||
|
||||
// watchKdeglobals watches the parent directory, not the file: KDE rewrites
|
||||
// kdeglobals atomically (write-temp + rename), which would drop an inotify watch
|
||||
// on the original inode. Filtering by name re-arms implicitly.
|
||||
func (w *themeWatcher) watchKdeglobals() {
|
||||
// watchKdeConfig repaints on writes to either KDE file that decides the panel
|
||||
// appearance. It watches their parent directory, not the files: KDE rewrites
|
||||
// them atomically (write-temp + rename), which would drop an inotify watch on
|
||||
// the original inode. Filtering by name re-arms implicitly.
|
||||
func (w *themeWatcher) watchKdeConfig() {
|
||||
path := kdeglobalsPath()
|
||||
if path == "" {
|
||||
return
|
||||
}
|
||||
dir, name := filepath.Split(path)
|
||||
dir := filepath.Dir(path)
|
||||
|
||||
fw, err := fsnotify.NewWatcher()
|
||||
if err != nil {
|
||||
log.Debugf("tray theme: kdeglobals watcher unavailable, theme is static: %v", err)
|
||||
log.Debugf("tray theme: KDE config watcher unavailable, theme is static: %v", err)
|
||||
return
|
||||
}
|
||||
if err := fw.Add(filepath.Clean(dir)); err != nil {
|
||||
if err := fw.Add(dir); err != nil {
|
||||
log.Debugf("tray theme: watching %s failed, theme is static: %v", dir, err)
|
||||
_ = fw.Close()
|
||||
return
|
||||
@@ -208,10 +209,14 @@ func (w *themeWatcher) watchKdeglobals() {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if filepath.Base(event.Name) != name {
|
||||
switch filepath.Base(event.Name) {
|
||||
case kdeglobalsFile, plasmarcFile:
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename) == 0 {
|
||||
// Remove counts: deleting plasmarc unpins the Plasma style, which
|
||||
// hands the decision back to the colour scheme and can flip it.
|
||||
if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
|
||||
continue
|
||||
}
|
||||
w.update()
|
||||
@@ -219,7 +224,7 @@ func (w *themeWatcher) watchKdeglobals() {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
log.Debugf("tray theme: kdeglobals watch error: %v", err)
|
||||
log.Debugf("tray theme: KDE config watch error: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
@@ -205,7 +205,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance
|
||||
metricsServer: metricsServer,
|
||||
}
|
||||
|
||||
_, tlsSupport, err := handleTLSConfig(cfg)
|
||||
tlsConfig, tlsSupport, err := handleTLSConfig(cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to setup TLS config: %w", err)
|
||||
}
|
||||
@@ -214,7 +214,7 @@ func createAllServers(ctx context.Context, cfg *CombinedConfig) (*serverInstance
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := servers.createManagementServer(ctx, cfg); err != nil {
|
||||
if err := servers.createManagementServer(ctx, cfg, tlsConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -264,7 +264,7 @@ func (s *serverInstances) createRelayServer(cfg *CombinedConfig, tlsSupport bool
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig) error {
|
||||
func (s *serverInstances) createManagementServer(ctx context.Context, cfg *CombinedConfig, tlsConfig *tls.Config) error {
|
||||
if !cfg.Management.Enabled {
|
||||
return nil
|
||||
}
|
||||
@@ -297,7 +297,7 @@ func (s *serverInstances) createManagementServer(ctx context.Context, cfg *Combi
|
||||
|
||||
LogConfigInfo(mgmtConfig)
|
||||
|
||||
s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig)
|
||||
s.mgmtSrv, err = createManagementServer(cfg, mgmtConfig, tlsConfig)
|
||||
if err != nil {
|
||||
cleanupSTUNListeners(s.stunListeners)
|
||||
return fmt.Errorf("failed to create management server: %w", err)
|
||||
@@ -513,7 +513,7 @@ func handleTLSConfig(cfg *CombinedConfig) (*tls.Config, bool, error) {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (mgmtServer.Server, error) {
|
||||
func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config, tlsConfig *tls.Config) (mgmtServer.Server, error) {
|
||||
mgmt := cfg.Management
|
||||
|
||||
// Extract port from listen address
|
||||
@@ -542,6 +542,7 @@ func createManagementServer(cfg *CombinedConfig, mgmtConfig *nbconfig.Config) (m
|
||||
AutoResolveDomains: true,
|
||||
MgmtPort: mgmtPort,
|
||||
MgmtMetricsPort: cfg.Server.MetricsPort,
|
||||
TLSConfig: tlsConfig,
|
||||
DisableMetrics: mgmt.DisableAnonymousMetrics,
|
||||
DisableGeoliteUpdate: mgmt.DisableGeoliteUpdate,
|
||||
// Always enable user deletion from IDP in combined server (embedded IdP is always enabled)
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
A new custom domain name is converted to lowercase ASCII (punycode), with a
|
||||
trailing dot removed, before availability and DNS validation checks. Invalid
|
||||
names and wildcard registrations are rejected before storage.
|
||||
|
||||
A custom domain registration must complete validation within 48 hours of
|
||||
creation. Retrying validation does not extend this window. Once validation
|
||||
succeeds, the registration is exempt from this expiration policy.
|
||||
|
||||
Management removes expired, unvalidated registrations at startup and every
|
||||
60 minutes. While management is running, removal normally occurs between
|
||||
48 and 49 hours after registration. Validation is refused after the 48-hour
|
||||
deadline even if cleanup has not yet removed the registration.
|
||||
|
||||
Removal releases the name for a new registration. The new registration must
|
||||
complete its own validation. Its account does not inherit validation or
|
||||
services from the expired registration.
|
||||
|
||||
The original account receives a system activity event named
|
||||
`CustomDomainValidationExpired`, displayed as "Unvalidated domain registration
|
||||
expired". The event includes the domain name, original registration ID, and
|
||||
validation deadline.
|
||||
|
||||
On upgrade, existing unvalidated registrations receive a 48-hour validation
|
||||
window. Restarting management does not extend a previously assigned deadline.
|
||||
|
||||
Registrations with existing services, including services using subdomains, are
|
||||
retained for operator review. Management logs their account and domain IDs so
|
||||
an operator can identify and resolve those dependencies before cleanup.
|
||||
@@ -6,6 +6,16 @@ import (
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
type Encrypter interface {
|
||||
EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error)
|
||||
}
|
||||
|
||||
type DefaultEncrypter struct{}
|
||||
|
||||
func (e DefaultEncrypter) EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) {
|
||||
return EncryptMessage(remotePubKey, ourPrivateKey, message)
|
||||
}
|
||||
|
||||
// EncryptMessage encrypts a body of the given protobuf Message
|
||||
func EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) {
|
||||
byteResp, err := pb.Marshal(message)
|
||||
|
||||
@@ -343,6 +343,6 @@ replace github.com/dexidp/dex/api/v2 => github.com/netbirdio/dex/api/v2 v2.0.0-2
|
||||
|
||||
replace github.com/mailru/easyjson => github.com/netbirdio/easyjson v0.9.0
|
||||
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78
|
||||
replace github.com/wailsapp/wails/v3 => github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1
|
||||
|
||||
tool go.uber.org/mock/mockgen
|
||||
|
||||
@@ -490,8 +490,8 @@ github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9ax
|
||||
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45 h1:ujgviVYmx243Ksy7NdSwrdGPSRNE3pb8kEDSpH0QuAQ=
|
||||
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250805121659-6b4ac470ca45/go.mod h1:5/sjFmLb8O96B5737VCqhHyGRzNFIaN/Bu7ZodXc3qQ=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78 h1:B/jRv24jnFeoA+VccxoCx6K94PUgsqR9wnshpeu9M+8=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260825085513-5f07a01f7a78/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1 h1:n5aXV/U6I9bLc+yWN088TyVR4OfF64Gy+L6Hrffc+n4=
|
||||
github.com/netbirdio/wails/v3 v3.0.0-beta.3.0.20260902163841-4a71f7b1d9e1/go.mod h1:/6QR46/nhGCSADHbS++XtDb9dkTnenTHlGskTPRo9S0=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a h1:3CWK+yTvRKOcC0Q8VCTGy4l60TEb27CQVS7LkMxwjmw=
|
||||
github.com/netbirdio/wireguard-go v0.0.0-20260628102922-2834bebf6c1a/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
|
||||
@@ -808,8 +808,9 @@ server:
|
||||
|
||||
# Trust X-Forwarded-* only from the Traefik container's static address. Both
|
||||
# keys must stay in step with the ipv4_address pinned in docker-compose.yml:
|
||||
# trustedPeers decides whether forwarded headers are read at all, and leaving
|
||||
# it unset falls back to 0.0.0.0/0.
|
||||
# trustedPeers decides whether forwarded headers are read at all. Leaving it
|
||||
# unset trusts nothing and records Traefik's own address as every peer's
|
||||
# connection IP.
|
||||
reverseProxy:
|
||||
trustedPeers:
|
||||
- "${TRAEFIK_IP}/32"
|
||||
|
||||
@@ -153,6 +153,7 @@ check_domain_resolves() {
|
||||
# NETBIRD_TRAEFIK_CERTRESOLVER external-Traefik cert resolver (type 1)
|
||||
# NETBIRD_BIND_LOCALHOST_ONLY true/false (default true, types 2-5)
|
||||
# NETBIRD_EXTERNAL_PROXY_NETWORK docker network to join (types 2-4)
|
||||
# NETBIRD_TRUSTED_PEERS reverse proxy address management sees (default: built-in Traefik's IP, empty for types 1-5)
|
||||
# NETBIRD_NON_INTERACTIVE true forces unattended mode even with a TTY
|
||||
|
||||
# tty_available succeeds only when we may prompt: never when the operator has
|
||||
@@ -459,6 +460,8 @@ initialize_default_values() {
|
||||
MANAGEMENT_HOST_PORT="8081" # Combined server port (management + signal + relay)
|
||||
BIND_LOCALHOST_ONLY="true"
|
||||
EXTERNAL_PROXY_NETWORK=""
|
||||
TRUSTED_PEERS="" # Address the reverse proxy connects to management from
|
||||
|
||||
|
||||
# Traefik static IP within the internal bridge network
|
||||
TRAEFIK_IP="172.30.0.10"
|
||||
@@ -519,6 +522,7 @@ apply_agent_network_preset() {
|
||||
REVERSE_PROXY_TYPE="0"
|
||||
ENABLE_PROXY="true"
|
||||
ENABLE_CROWDSEC="false"
|
||||
TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}"
|
||||
|
||||
TRAEFIK_ACME_EMAIL=$(resolve NETBIRD_LETSENCRYPT_EMAIL required read_traefik_acme_email)
|
||||
|
||||
@@ -573,6 +577,21 @@ configure_reverse_proxy() {
|
||||
4) EXTERNAL_PROXY_NETWORK=$(resolve NETBIRD_EXTERNAL_PROXY_NETWORK "" read_proxy_docker_network "Caddy") ;;
|
||||
*) ;; # No network prompt for other options
|
||||
esac
|
||||
|
||||
# Only the bundled Traefik has an address we know at render time. External proxies
|
||||
# must supply the address their proxy reaches management from.
|
||||
if [[ "$REVERSE_PROXY_TYPE" == "0" ]]; then
|
||||
TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-$TRAEFIK_IP/32}"
|
||||
else
|
||||
TRUSTED_PEERS="${NETBIRD_TRUSTED_PEERS:-}"
|
||||
if [[ -z "$TRUSTED_PEERS" ]]; then
|
||||
echo "" > /dev/stderr
|
||||
echo "Note: reverseProxy.trustedPeers is unset, so NetBird will use the address your" > /dev/stderr
|
||||
echo "proxy connects from as each peer's connection IP. To record real client IPs," > /dev/stderr
|
||||
echo "set NETBIRD_TRUSTED_PEERS to your proxy's address (e.g. 172.20.0.5/32) and re-run." > /dev/stderr
|
||||
echo "" > /dev/stderr
|
||||
fi
|
||||
fi
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -1033,6 +1052,7 @@ server:
|
||||
reverseProxy:
|
||||
trustedHTTPProxies:
|
||||
- "$TRAEFIK_IP/32"
|
||||
$(render_trusted_peers)
|
||||
|
||||
store:
|
||||
engine: "sqlite"
|
||||
@@ -1041,6 +1061,12 @@ EOF
|
||||
return 0
|
||||
}
|
||||
|
||||
render_trusted_peers() {
|
||||
if [[ -n "$TRUSTED_PEERS" ]]; then
|
||||
printf ' trustedPeers:\n - "%s"' "$TRUSTED_PEERS"
|
||||
fi
|
||||
}
|
||||
|
||||
render_dashboard_env() {
|
||||
cat <<EOF
|
||||
# Endpoints
|
||||
@@ -1453,6 +1479,9 @@ location ~ ^/(relay|ws-proxy/) {
|
||||
# Native gRPC (signal + management)
|
||||
location ~ ^/(signalexchange\.SignalExchange|management\.ManagementService)/ {
|
||||
grpc_pass grpc://${server_addr};
|
||||
# Overwrite rather than pass through: without this the client's own
|
||||
# x-forwarded-for metadata reaches NetBird as the connection IP.
|
||||
grpc_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||
grpc_read_timeout 1d;
|
||||
grpc_send_timeout 1d;
|
||||
grpc_socket_keepalive on;
|
||||
|
||||
@@ -34,9 +34,7 @@
|
||||
"ReverseProxy": {
|
||||
"TrustedHTTPProxies": [],
|
||||
"TrustedHTTPProxiesCount": 0,
|
||||
"TrustedPeers": [
|
||||
"0.0.0.0/0"
|
||||
]
|
||||
"TrustedPeers": []
|
||||
},
|
||||
"DisableDefaultPolicy": $NETBIRD_MGMT_DISABLE_DEFAULT_POLICY,
|
||||
"Datadir": "",
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
// ValidationTTL is the time available to validate a custom domain registration.
|
||||
const ValidationTTL = 48 * time.Hour
|
||||
|
||||
// ID identifies a custom domain registration.
|
||||
type ID string
|
||||
|
||||
type Type string
|
||||
|
||||
const (
|
||||
@@ -8,12 +16,13 @@ const (
|
||||
)
|
||||
|
||||
type Domain struct {
|
||||
ID string `gorm:"unique;primaryKey;autoIncrement"`
|
||||
Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts.
|
||||
AccountID string `gorm:"index"`
|
||||
TargetCluster string // The proxy cluster this domain should be validated against
|
||||
Type Type `gorm:"-"`
|
||||
Validated bool
|
||||
ID string `gorm:"unique;primaryKey;autoIncrement"`
|
||||
Domain string `gorm:"unique"` // Domain records must be unique, this avoids domain reuse across accounts.
|
||||
AccountID string `gorm:"index"`
|
||||
TargetCluster string // The proxy cluster this domain should be validated against
|
||||
Type Type `gorm:"-"`
|
||||
Validated bool
|
||||
ValidationExpiresAt *time.Time `gorm:"index"`
|
||||
// SupportsCustomPorts is populated at query time for free domains from the
|
||||
// proxy cluster capabilities. Not persisted.
|
||||
SupportsCustomPorts *bool `gorm:"-"`
|
||||
@@ -36,7 +45,12 @@ func (d *Domain) EventMeta() map[string]any {
|
||||
}
|
||||
}
|
||||
|
||||
// Copy returns a copy with an independent validation deadline.
|
||||
func (d *Domain) Copy() *Domain {
|
||||
dCopy := *d
|
||||
if d.ValidationExpiresAt != nil {
|
||||
expiresAt := *d.ValidationExpiresAt
|
||||
dCopy.ValidationExpiresAt = &expiresAt
|
||||
}
|
||||
return &dCopy
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
)
|
||||
|
||||
const (
|
||||
validationCleanupInterval = 60 * time.Minute
|
||||
validationCleanupBatch = 100
|
||||
)
|
||||
|
||||
// RunValidationCleanup removes expired registrations on startup and hourly until cancellation.
|
||||
func (m Manager) RunValidationCleanup(ctx context.Context) {
|
||||
ticker := time.NewTicker(validationCleanupInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
m.cleanupExpiredDomains(ctx, time.Now().UTC())
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Manager) cleanupExpiredDomains(ctx context.Context, now time.Time) {
|
||||
var afterID domain.ID
|
||||
for ctx.Err() == nil {
|
||||
domains, err := m.store.GetExpiredCustomDomains(ctx, now, afterID, validationCleanupBatch)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
log.WithContext(ctx).WithError(err).Error("list expired custom domain registrations")
|
||||
}
|
||||
return
|
||||
}
|
||||
for _, d := range domains {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
}
|
||||
m.deleteExpiredDomain(ctx, d, now)
|
||||
afterID = domain.ID(d.ID)
|
||||
}
|
||||
if len(domains) < validationCleanupBatch {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m Manager) deleteExpiredDomain(ctx context.Context, d *domain.Domain, now time.Time) {
|
||||
deleted, err := m.store.DeleteExpiredCustomDomain(ctx, d, now)
|
||||
if err != nil {
|
||||
if ctx.Err() == nil {
|
||||
log.WithContext(ctx).WithFields(log.Fields{"accountID": d.AccountID, "domainID": d.ID}).
|
||||
WithError(err).Warn("could not expire custom domain registration")
|
||||
}
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
return
|
||||
}
|
||||
meta := d.EventMeta()
|
||||
if d.ValidationExpiresAt != nil {
|
||||
meta["validation_expires_at"] = d.ValidationExpiresAt.UTC().Format(time.RFC3339)
|
||||
}
|
||||
m.accountManager.StoreEvent(ctx, activity.SystemInitiator, d.ID, d.AccountID,
|
||||
activity.CustomDomainValidationExpired, meta)
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"testing/synctest"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/domain"
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
"github.com/netbirdio/netbird/management/server/mock_server"
|
||||
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||
)
|
||||
|
||||
func TestValidateDomain_ExpiredRegistration(t *testing.T) {
|
||||
env := setupDomainTest(t)
|
||||
ctx := context.Background()
|
||||
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "expired.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
expiresAt := time.Now().Add(-time.Second)
|
||||
db := env.store.(*nbstore.SqlStore).GetDB()
|
||||
require.NoError(t, db.Model(&domain.Domain{}).Where("id = ?", d.ID).
|
||||
Update("validation_expires_at", expiresAt).Error)
|
||||
env.resolver.set("validation.expired.example.com", testCluster)
|
||||
|
||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
||||
|
||||
stored := storedDomain(t, env.store, accountA, d.Domain)
|
||||
require.NotNil(t, stored)
|
||||
assert.False(t, stored.Validated, "an expired registration must not become usable before cleanup runs")
|
||||
}
|
||||
|
||||
func TestCreateDomain_ValidationDeadline(t *testing.T) {
|
||||
env := setupClockDomainTest(t)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
createdAt := time.Now().UTC()
|
||||
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "pending.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, d.ValidationExpiresAt)
|
||||
assert.Equal(t, createdAt.Add(48*time.Hour), *d.ValidationExpiresAt, "new registrations get 48 hours")
|
||||
|
||||
time.Sleep(time.Hour)
|
||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
||||
stored := storedDomain(t, env.store, accountA, d.Domain)
|
||||
require.NotNil(t, stored)
|
||||
require.NotNil(t, stored.ValidationExpiresAt)
|
||||
assert.WithinDuration(t, *d.ValidationExpiresAt, *stored.ValidationExpiresAt, 0, "failed validation must not extend the deadline")
|
||||
})
|
||||
}
|
||||
|
||||
func TestCleanupExpiredDomains_Boundaries(t *testing.T) {
|
||||
env := setupDomainTest(t)
|
||||
events := captureDomainEvents(env)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC().Truncate(time.Second)
|
||||
tests := []struct {
|
||||
name string
|
||||
expiresAt time.Time
|
||||
validated bool
|
||||
deleted bool
|
||||
}{
|
||||
{"expired", now.Add(-time.Second), false, true},
|
||||
{"deadline", now, false, true},
|
||||
{"pending", now.Add(time.Second), false, false},
|
||||
{"validated", now.Add(-time.Hour), true, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
d := createExpiringDomain(t, env, tt.name+".example.com", tt.expiresAt)
|
||||
if tt.validated {
|
||||
require.NoError(t, env.store.(*nbstore.SqlStore).GetDB().Model(d).Update("validated", true).Error)
|
||||
}
|
||||
env.manager.cleanupExpiredDomains(ctx, now)
|
||||
stored := storedDomain(t, env.store, accountA, d.Domain)
|
||||
if !tt.deleted {
|
||||
assert.NotNil(t, stored, "pending and validated registrations must survive cleanup")
|
||||
return
|
||||
}
|
||||
assert.Nil(t, stored, "expired unused registrations must be removed")
|
||||
replacement, err := env.manager.CreateDomain(ctx, accountB, accountBUser, d.Domain, testCluster)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, d.ID, replacement.ID, "the released name must receive a fresh registration")
|
||||
assert.False(t, replacement.Validated, "the new account must validate its own registration")
|
||||
})
|
||||
}
|
||||
got := events.get()
|
||||
require.Len(t, got, 2, "only successful expiration deletions emit events")
|
||||
for _, event := range got {
|
||||
assert.Equal(t, activity.CustomDomainValidationExpired, event.Activity, "use the requested expiration event")
|
||||
assert.Equal(t, activity.SystemInitiator, event.InitiatorID, "cleanup is attributed to the system")
|
||||
assert.Equal(t, accountA, event.AccountID, "expiration belongs to the original account")
|
||||
assert.NotEmpty(t, event.TargetID, "retain the deleted domain ID")
|
||||
assert.NotEmpty(t, event.Meta["domain"], "retain the deleted domain name")
|
||||
assert.NotEmpty(t, event.Meta["validation_expires_at"], "include the validation deadline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCleanupExpiredDomains_ContinuesPastProtectedBatch(t *testing.T) {
|
||||
env := setupDomainTest(t)
|
||||
ctx := context.Background()
|
||||
now := time.Now().UTC()
|
||||
for i := range validationCleanupBatch {
|
||||
d := createExpiringDomain(t, env, fmt.Sprintf("protected-%d.example.com", i), now.Add(-time.Hour))
|
||||
require.NoError(t, env.store.CreateService(ctx, &rpservice.Service{
|
||||
ID: fmt.Sprintf("service-%d", i), AccountID: accountA, Domain: "app." + d.Domain,
|
||||
}))
|
||||
}
|
||||
unprotected := createExpiringDomain(t, env, "unused.example.com", now.Add(-time.Hour))
|
||||
env.manager.cleanupExpiredDomains(ctx, now)
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, unprotected.Domain), "protected registrations must not starve later batches")
|
||||
remaining, err := env.store.ListCustomDomains(ctx, accountA)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, remaining, validationCleanupBatch, "all registrations with dependent services must survive")
|
||||
}
|
||||
|
||||
func TestCleanupExpiredDomains_ConcurrentWorkers(t *testing.T) {
|
||||
env := setupDomainTest(t)
|
||||
events := captureDomainEvents(env)
|
||||
now := time.Now().UTC()
|
||||
d := createExpiringDomain(t, env, "concurrent.example.com", now.Add(-time.Hour))
|
||||
var workers sync.WaitGroup
|
||||
for range 2 {
|
||||
workers.Go(func() { env.manager.cleanupExpiredDomains(context.Background(), now) })
|
||||
}
|
||||
workers.Wait()
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "one worker must remove the expired registration")
|
||||
assert.Len(t, events.get(), 1, "only the worker that deletes the row may emit the event")
|
||||
}
|
||||
|
||||
func TestRunValidationCleanup_HourlyAndRestart(t *testing.T) {
|
||||
env := setupClockDomainTest(t)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
events := captureDomainEvents(env)
|
||||
now := time.Now().UTC()
|
||||
startup := createExpiringDomain(t, env, "startup.example.com", now.Add(-time.Hour))
|
||||
hourly := createExpiringDomain(t, env, "hourly.example.com", now.Add(time.Minute))
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
env.manager.RunValidationCleanup(ctx)
|
||||
}()
|
||||
synctest.Wait()
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, startup.Domain), "startup must collect overdue registrations")
|
||||
time.Sleep(59 * time.Minute)
|
||||
synctest.Wait()
|
||||
assert.NotNil(t, storedDomain(t, env.store, accountA, hourly.Domain), "cleanup must wait for the 60-minute interval")
|
||||
time.Sleep(time.Minute)
|
||||
synctest.Wait()
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, hourly.Domain), "the hourly scan must collect expired registrations")
|
||||
cancel()
|
||||
<-done
|
||||
|
||||
offline := createExpiringDomain(t, env, "offline.example.com", time.Now().UTC().Add(time.Minute))
|
||||
time.Sleep(2 * time.Hour)
|
||||
assert.NotNil(t, storedDomain(t, env.store, accountA, offline.Domain), "a stopped worker must not continue deleting")
|
||||
ctx, cancel = context.WithCancel(context.Background())
|
||||
done = make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
env.manager.RunValidationCleanup(ctx)
|
||||
}()
|
||||
synctest.Wait()
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, offline.Domain), "restart must use the persisted deadline")
|
||||
cancel()
|
||||
<-done
|
||||
assert.Len(t, events.get(), 3, "each deletion should emit an expiration event")
|
||||
})
|
||||
}
|
||||
|
||||
type blockingDomainResolver struct {
|
||||
started chan struct{}
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (r blockingDomainResolver) LookupCNAME(context.Context, string) (string, error) {
|
||||
close(r.started)
|
||||
<-r.release
|
||||
return testCluster + ".", nil
|
||||
}
|
||||
|
||||
func TestValidateDomain_DeadlinePassesDuringLookup(t *testing.T) {
|
||||
for _, cleanup := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("cleanup=%t", cleanup), func(t *testing.T) {
|
||||
env := setupClockDomainTest(t)
|
||||
synctest.Test(t, func(t *testing.T) {
|
||||
events := captureDomainEvents(env)
|
||||
ctx := context.Background()
|
||||
d, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "late.example.com", testCluster)
|
||||
require.NoError(t, err)
|
||||
resolver := blockingDomainResolver{started: make(chan struct{}), release: make(chan struct{})}
|
||||
env.manager.validator.Resolver = resolver
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer close(done)
|
||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, d.ID)
|
||||
}()
|
||||
<-resolver.started
|
||||
time.Sleep(48 * time.Hour)
|
||||
if cleanup {
|
||||
env.manager.cleanupExpiredDomains(ctx, time.Now().UTC())
|
||||
_, err = env.store.CreateCustomDomain(ctx, accountB, d.Domain, testCluster, false)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
close(resolver.release)
|
||||
<-done
|
||||
owner := accountA
|
||||
if cleanup {
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, d.Domain), "late validation must not restore the old claim")
|
||||
owner = accountB
|
||||
}
|
||||
stored := storedDomain(t, env.store, owner, d.Domain)
|
||||
require.NotNil(t, stored)
|
||||
assert.False(t, stored.Validated, "late validation must not validate either claim")
|
||||
for _, event := range events.get() {
|
||||
assert.NotEqual(t, activity.DomainValidated, event.Activity, "a rejected write must not emit a validation event")
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func setupClockDomainTest(t *testing.T) *domainTestEnv {
|
||||
t.Helper()
|
||||
// Network driver watchers cannot share cancellation channels across synctest bubbles.
|
||||
// Store boundary and concurrency tests still exercise the selected database engine.
|
||||
t.Setenv("NETBIRD_STORE_ENGINE", "sqlite")
|
||||
return setupDomainTest(t)
|
||||
}
|
||||
|
||||
func createExpiringDomain(t *testing.T, env *domainTestEnv, name string, expiresAt time.Time) *domain.Domain {
|
||||
t.Helper()
|
||||
d, err := env.store.CreateCustomDomain(context.Background(), accountA, name, testCluster, false)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, env.store.(*nbstore.SqlStore).GetDB().Model(d).Update("validation_expires_at", expiresAt).Error)
|
||||
d.ValidationExpiresAt = &expiresAt
|
||||
return d
|
||||
}
|
||||
|
||||
type domainEvents struct {
|
||||
mu sync.Mutex
|
||||
events []*activity.Event
|
||||
}
|
||||
|
||||
func captureDomainEvents(env *domainTestEnv) *domainEvents {
|
||||
events := &domainEvents{}
|
||||
env.manager.accountManager = &mock_server.MockAccountManager{
|
||||
StoreEventFunc: func(_ context.Context, initiator, target, account string, code activity.ActivityDescriber, meta map[string]any) {
|
||||
if code == activity.DomainAdded {
|
||||
return
|
||||
}
|
||||
events.mu.Lock()
|
||||
defer events.mu.Unlock()
|
||||
events.events = append(events.events, &activity.Event{
|
||||
InitiatorID: initiator, TargetID: target, AccountID: account,
|
||||
Activity: code.(activity.Activity), Meta: meta,
|
||||
})
|
||||
},
|
||||
}
|
||||
return events
|
||||
}
|
||||
|
||||
func (e *domainEvents) get() []*activity.Event {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
return append([]*activity.Event(nil), e.events...)
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
nbstore "github.com/netbirdio/netbird/management/server/store"
|
||||
"github.com/netbirdio/netbird/management/server/types"
|
||||
nbdomain "github.com/netbirdio/netbird/shared/management/domain"
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
@@ -29,6 +31,8 @@ type store interface {
|
||||
CreateCustomDomain(ctx context.Context, accountID string, domainName string, targetCluster string, validated bool) (*domain.Domain, error)
|
||||
UpdateCustomDomain(ctx context.Context, accountID string, d *domain.Domain) (*domain.Domain, error)
|
||||
DeleteCustomDomain(ctx context.Context, accountID string, domainID string) error
|
||||
GetExpiredCustomDomains(ctx context.Context, now time.Time, afterID domain.ID, limit int) ([]*domain.Domain, error)
|
||||
DeleteExpiredCustomDomain(ctx context.Context, d *domain.Domain, now time.Time) (bool, error)
|
||||
}
|
||||
|
||||
type proxyManager interface {
|
||||
@@ -93,12 +97,13 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
|
||||
// Add custom domains.
|
||||
for _, d := range domains {
|
||||
cd := &domain.Domain{
|
||||
ID: d.ID,
|
||||
Domain: d.Domain,
|
||||
AccountID: accountID,
|
||||
TargetCluster: d.TargetCluster,
|
||||
Type: domain.TypeCustom,
|
||||
Validated: d.Validated,
|
||||
ID: d.ID,
|
||||
Domain: d.Domain,
|
||||
AccountID: accountID,
|
||||
TargetCluster: d.TargetCluster,
|
||||
Type: domain.TypeCustom,
|
||||
Validated: d.Validated,
|
||||
ValidationExpiresAt: d.ValidationExpiresAt,
|
||||
}
|
||||
if d.TargetCluster != "" {
|
||||
cd.SupportsCustomPorts = m.proxyManager.ClusterSupportsCustomPorts(ctx, d.TargetCluster)
|
||||
@@ -113,7 +118,17 @@ func (m Manager) GetDomains(ctx context.Context, accountID, userID string) ([]*d
|
||||
return ret, nil
|
||||
}
|
||||
|
||||
// CreateDomain registers a normalized custom domain and attempts DNS validation.
|
||||
func (m Manager) CreateDomain(ctx context.Context, accountID, userID, domainName, targetCluster string) (*domain.Domain, error) {
|
||||
parsed, err := nbdomain.FromString(strings.TrimSuffix(domainName, "."))
|
||||
if err != nil {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invalid domain: %v", err)
|
||||
}
|
||||
domainName = parsed.PunycodeString()
|
||||
if !nbdomain.IsValidDomainNoWildcard(domainName) {
|
||||
return nil, status.Errorf(status.InvalidArgument, "invalid domain format")
|
||||
}
|
||||
|
||||
// Verify the target cluster is in the available clusters for this account
|
||||
allowList, err := m.getClusterAllowList(ctx, accountID)
|
||||
if err != nil {
|
||||
@@ -197,6 +212,14 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
}).WithError(err).Error("get custom domain from store")
|
||||
return
|
||||
}
|
||||
if d.Validated {
|
||||
return
|
||||
}
|
||||
if d.ValidationExpiresAt == nil || !time.Now().Before(*d.ValidationExpiresAt) {
|
||||
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
|
||||
Debug("custom domain validation window has expired")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate only against the domain's target cluster
|
||||
targetCluster := d.TargetCluster
|
||||
@@ -217,20 +240,21 @@ func (m Manager) ValidateDomain(ctx context.Context, accountID, userID, domainID
|
||||
}).Info("validating domain against target cluster")
|
||||
|
||||
if m.validator.IsValid(context.Background(), d.Domain, []string{targetCluster}) {
|
||||
log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).Info("domain validated successfully")
|
||||
d.Validated = true
|
||||
if _, err := m.store.UpdateCustomDomain(context.Background(), accountID, d); err != nil {
|
||||
log.WithFields(log.Fields{
|
||||
entry := log.WithFields(log.Fields{
|
||||
"accountID": accountID,
|
||||
"domainID": domainID,
|
||||
"domain": d.Domain,
|
||||
}).WithError(err).Error("update custom domain in store")
|
||||
}).WithError(err)
|
||||
if sErr, ok := status.FromError(err); ok && sErr.Type() == status.PreconditionFailed {
|
||||
entry.Debug("custom domain registration is no longer pending validation")
|
||||
return
|
||||
}
|
||||
entry.Error("update custom domain in store")
|
||||
return
|
||||
}
|
||||
log.WithFields(log.Fields{"accountID": accountID, "domainID": domainID}).
|
||||
Info("custom domain validated successfully")
|
||||
|
||||
m.accountManager.StoreEvent(context.Background(), userID, domainID, accountID, activity.DomainValidated, d.EventMeta())
|
||||
} else {
|
||||
|
||||
@@ -268,11 +268,8 @@ func TestStore_DuplicateDomainRejectedByIndexAsConflict(t *testing.T) {
|
||||
assert.Equal(t, status.AlreadyExists, sErr.Type(), "a lost race is a 409, not a 500")
|
||||
}
|
||||
|
||||
// Validation runs asynchronously, so it can finish after the domain was
|
||||
// deleted and then write a stale row back. gorm's Save falls back to an insert
|
||||
// when an update affects no rows, which would resurrect the domain as
|
||||
// validated; UpdateCustomDomain avoids that by selecting explicit columns.
|
||||
// This pins that behaviour, since dropping the Select would reintroduce it.
|
||||
// A validation finishing after deletion must reject the stale write, without
|
||||
// restoring the registration or reporting successful validation.
|
||||
func TestUpdateCustomDomain_DoesNotResurrectDeletedDomain(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
@@ -287,11 +284,9 @@ func TestUpdateCustomDomain_DoesNotResurrectDeletedDomain(t *testing.T) {
|
||||
require.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"), "the domain should be gone")
|
||||
|
||||
// What an in-flight validation would write once its CNAME check succeeded.
|
||||
// The write has to succeed for the assertion below to mean anything: a
|
||||
// rejected write would leave the domain absent for the wrong reason.
|
||||
stale.Validated = true
|
||||
_, err = env.store.UpdateCustomDomain(ctx, accountA, stale)
|
||||
require.NoError(t, err, "the update itself must succeed, so absence is not just a failed write")
|
||||
require.Error(t, err, "a deleted registration must reject a late validation")
|
||||
|
||||
assert.Nil(t, storedDomain(t, env.store, accountA, "racy.example.com"),
|
||||
"a late validation write must not recreate a deleted domain")
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -208,6 +209,14 @@ func (s *stubStore) DeleteCustomDomain(context.Context, string, string) error {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
func (s *stubStore) GetExpiredCustomDomains(context.Context, time.Time, domain.ID, int) ([]*domain.Domain, error) {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
func (s *stubStore) DeleteExpiredCustomDomain(context.Context, *domain.Domain, time.Time) (bool, error) {
|
||||
panic("not used in allow-list tests")
|
||||
}
|
||||
|
||||
// TestGetClusterAllowList_DedicatedGatewayAddressExcluded pins invariant (B)'s
|
||||
// chokepoint: a self-addressed settings pin reserves the account's gateway
|
||||
// address, so it is dropped from the allow list — which, because the
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
package manager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/shared/management/status"
|
||||
)
|
||||
|
||||
func TestCreateDomain_NormalizesName(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
input string
|
||||
canonical string
|
||||
}{
|
||||
{"mixed case", "Apps.Example.COM", "apps.example.com"},
|
||||
{"unicode", "münchen.example.com", "xn--mnchen-3ya.example.com"},
|
||||
{"trailing dot", "apps.example.com.", "apps.example.com"},
|
||||
{"underscore", "My_App.example.com", "my_app.example.com"},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
env.resolver.set("validation."+tt.canonical, testCluster)
|
||||
|
||||
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, tt.input, testCluster)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.canonical, created.Domain, "the response must use the normalized name")
|
||||
assert.True(t, created.Validated, "the CNAME lookup must use the normalized name")
|
||||
stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.canonical, stored.Domain, "the database must retain the normalized name")
|
||||
|
||||
_, err = env.manager.CreateDomain(ctx, accountB, accountBUser, tt.canonical, testCluster)
|
||||
require.Error(t, err)
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "an equivalent name must return a typed conflict")
|
||||
assert.Equal(t, status.AlreadyExists, sErr.Type(), "normalization must precede the availability check")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateDomain_NormalizedNameCanValidateLater(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
created, err := env.manager.CreateDomain(ctx, accountA, accountAUser, "Apps.Example.COM.", testCluster)
|
||||
require.NoError(t, err)
|
||||
require.False(t, created.Validated, "a missing CNAME must leave the normalized registration pending")
|
||||
|
||||
env.resolver.set("validation.apps.example.com", testCluster)
|
||||
env.manager.ValidateDomain(ctx, accountA, accountAUser, created.ID)
|
||||
stored, err := env.store.GetCustomDomain(ctx, accountA, created.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "apps.example.com", stored.Domain, "retrying validation must retain the normalized name")
|
||||
assert.True(t, stored.Validated, "later validation must look up the normalized name")
|
||||
}
|
||||
|
||||
func TestCreateDomain_RejectsInvalidName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
env := setupDomainTest(t)
|
||||
for _, name := range []string{
|
||||
"", ".", "app..example.com", "app.example.com..", "-app.example.com",
|
||||
"app%.example.com", "app!.example.com", "*.example.com", "app example.com",
|
||||
"https://example.com", strings.Repeat("a", 64) + ".example.com",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
// A matching DNS response must not make a malformed name acceptable.
|
||||
env.resolver.set("validation."+name, testCluster)
|
||||
_, err := env.manager.CreateDomain(ctx, accountA, accountAUser, name, testCluster)
|
||||
require.Error(t, err)
|
||||
sErr, ok := status.FromError(err)
|
||||
require.True(t, ok, "invalid names must return a typed client error")
|
||||
assert.Equal(t, status.InvalidArgument, sErr.Type(), "malformed names must be rejected before storage")
|
||||
})
|
||||
}
|
||||
stored, err := env.store.ListCustomDomains(ctx, accountA)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, stored, "invalid registration attempts must not reserve any names")
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import (
|
||||
rpservice "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
|
||||
networkmapdb "github.com/netbirdio/netbird/management/internals/network_map_db"
|
||||
networkmapdbfactory "github.com/netbirdio/netbird/management/internals/network_map_db/factory"
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
|
||||
"github.com/netbirdio/netbird/management/server/activity"
|
||||
activitystore "github.com/netbirdio/netbird/management/server/activity/store"
|
||||
@@ -111,7 +112,8 @@ func (s *BaseServer) NetworkMapStore() *networkmapdb.NetworkMapDBStoreImpl {
|
||||
s.Config.StoreConfig.Engine,
|
||||
s.Config.Datadir,
|
||||
s.IntegratedValidator(),
|
||||
s.SettingsManager())
|
||||
s.SettingsManager(),
|
||||
)
|
||||
// networkmap db store supports postgres and sqlite backends only
|
||||
// for other backends a fallback is used, so NotSupportedStoreEngineError
|
||||
// is not a fatal error
|
||||
@@ -180,24 +182,7 @@ func (s *BaseServer) RateLimiter() *middleware.APIRateLimiter {
|
||||
|
||||
func (s *BaseServer) GRPCServer() *grpc.Server {
|
||||
return Create(s, func() *grpc.Server {
|
||||
trustedPeers := s.Config.ReverseProxy.TrustedPeers
|
||||
defaultTrustedPeers := []netip.Prefix{netip.MustParsePrefix("0.0.0.0/0"), netip.MustParsePrefix("::/0")}
|
||||
if len(trustedPeers) == 0 || slices.Equal[[]netip.Prefix](trustedPeers, defaultTrustedPeers) {
|
||||
log.WithContext(context.Background()).Warn("TrustedPeers are configured to default value '0.0.0.0/0', '::/0'. This allows connection IP spoofing.")
|
||||
trustedPeers = defaultTrustedPeers
|
||||
}
|
||||
trustedHTTPProxies := s.Config.ReverseProxy.TrustedHTTPProxies
|
||||
trustedProxiesCount := s.Config.ReverseProxy.TrustedHTTPProxiesCount
|
||||
if len(trustedHTTPProxies) > 0 && trustedProxiesCount > 0 {
|
||||
log.WithContext(context.Background()).Warn("TrustedHTTPProxies and TrustedHTTPProxiesCount both are configured. " +
|
||||
"This is not recommended way to extract X-Forwarded-For. Consider using one of these options.")
|
||||
}
|
||||
realipOpts := []realip.Option{
|
||||
realip.WithTrustedPeers(trustedPeers),
|
||||
realip.WithTrustedProxies(trustedHTTPProxies),
|
||||
realip.WithTrustedProxiesCount(trustedProxiesCount),
|
||||
realip.WithHeaders([]string{realip.XForwardedFor, realip.XRealIp}),
|
||||
}
|
||||
realipOpts := realIPOptions(s.Config.ReverseProxy)
|
||||
proxyUnary, proxyStream, proxyAuthClose := nbgrpc.NewProxyAuthInterceptors(s.Store())
|
||||
s.proxyAuthClose = proxyAuthClose
|
||||
gRPCOpts := []grpc.ServerOption{
|
||||
@@ -333,7 +318,7 @@ func (s *BaseServer) AccessLogsManager() accesslogs.Manager {
|
||||
})
|
||||
}
|
||||
|
||||
func loadTLSConfig(certFile string, certKey string) (*tls.Config, error) {
|
||||
func loadTLSConfig(certFile, certKey string) (*tls.Config, error) {
|
||||
// Load server's certificate and private key
|
||||
serverCert, err := tls.LoadX509KeyPair(certFile, certKey)
|
||||
if err != nil {
|
||||
@@ -380,3 +365,34 @@ func streamInterceptor(
|
||||
wrapped.WrappedContext = context.WithValue(ctx, nbContext.RequestIDKey, reqID)
|
||||
return handler(srv, wrapped)
|
||||
}
|
||||
|
||||
// realIPOptions builds the real-IP middleware options from the reverse proxy config.
|
||||
//
|
||||
// TrustedPeers controls which transport peers are allowed to supply forwarded-IP
|
||||
// headers. If empty, forwarded headers are ignored and the transport peer address
|
||||
// is used directly. Operators terminating connections at a reverse proxy should
|
||||
// configure TrustedPeers with that proxy's address or network.
|
||||
//
|
||||
// Only X-Forwarded-For is trusted. X-Real-IP contains a single client-supplied
|
||||
// address with no proxy chain to validate, and none of the reverse proxies we ship
|
||||
// use it on the gRPC path.
|
||||
func realIPOptions(cfg nbconfig.ReverseProxy) []realip.Option {
|
||||
if idx := slices.IndexFunc(cfg.TrustedPeers, func(p netip.Prefix) bool { return p.Bits() == 0 }); idx >= 0 {
|
||||
log.WithContext(context.Background()).Warnf("TrustedPeers contains the default route %s, which trusts "+
|
||||
"X-Forwarded-For from every client and allows connection IP spoofing. Set TrustedPeers to the address "+
|
||||
"of your reverse proxy, or leave it empty to use the connection's source address.", cfg.TrustedPeers[idx])
|
||||
}
|
||||
if cfg.TrustedHTTPProxiesCount > 0 {
|
||||
log.WithContext(context.Background()).Warn(
|
||||
"TrustedHTTPProxiesCount skips X-Forwarded-For entries by position before TrustedHTTPProxies filters by address. " +
|
||||
"An incorrect count may skip the real client IP and produce an incorrect source address.",
|
||||
)
|
||||
}
|
||||
|
||||
return []realip.Option{
|
||||
realip.WithTrustedPeers(cfg.TrustedPeers),
|
||||
realip.WithTrustedProxies(cfg.TrustedHTTPProxies),
|
||||
realip.WithTrustedProxiesCount(cfg.TrustedHTTPProxiesCount),
|
||||
realip.WithHeaders([]string{realip.XForwardedFor}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/realip"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
"google.golang.org/grpc/metadata"
|
||||
"google.golang.org/protobuf/types/known/emptypb"
|
||||
|
||||
nbconfig "github.com/netbirdio/netbird/management/internals/server/config"
|
||||
)
|
||||
|
||||
const (
|
||||
realIPProbeMethod = "/netbird.test.RealIPProbe/Probe"
|
||||
realIPProbeStreamMethod = "/netbird.test.RealIPProbe/ProbeStream"
|
||||
)
|
||||
|
||||
// realIPProbe records the real IP the middleware derived for each call.
|
||||
type realIPProbe struct {
|
||||
got chan string
|
||||
}
|
||||
|
||||
func (p *realIPProbe) record(ctx context.Context) {
|
||||
addr, _ := realip.FromContext(ctx)
|
||||
p.got <- addr.String()
|
||||
}
|
||||
|
||||
func (p *realIPProbe) wait(t *testing.T) string {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case got := <-p.got:
|
||||
return got
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("timed out waiting for probe")
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func startProbeServer(t *testing.T, cfg nbconfig.ReverseProxy) (*grpc.ClientConn, *realIPProbe) {
|
||||
t.Helper()
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
|
||||
probe := &realIPProbe{got: make(chan string, 1)}
|
||||
opts := realIPOptions(cfg)
|
||||
srv := grpc.NewServer(
|
||||
grpc.ChainUnaryInterceptor(realip.UnaryServerInterceptorOpts(opts...)),
|
||||
grpc.ChainStreamInterceptor(realip.StreamServerInterceptorOpts(opts...)),
|
||||
)
|
||||
srv.RegisterService(&grpc.ServiceDesc{
|
||||
ServiceName: "netbird.test.RealIPProbe",
|
||||
HandlerType: (*any)(nil),
|
||||
Methods: []grpc.MethodDesc{{
|
||||
MethodName: "Probe",
|
||||
Handler: func(_ any, ctx context.Context, dec func(any) error, interceptor grpc.UnaryServerInterceptor) (any, error) {
|
||||
req := new(emptypb.Empty)
|
||||
if err := dec(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handler := func(ctx context.Context, _ any) (any, error) {
|
||||
probe.record(ctx)
|
||||
return &emptypb.Empty{}, nil
|
||||
}
|
||||
if interceptor == nil {
|
||||
return handler(ctx, req)
|
||||
}
|
||||
return interceptor(ctx, req, &grpc.UnaryServerInfo{FullMethod: realIPProbeMethod}, handler)
|
||||
},
|
||||
}},
|
||||
Streams: []grpc.StreamDesc{{
|
||||
StreamName: "ProbeStream",
|
||||
ServerStreams: true,
|
||||
Handler: func(_ any, stream grpc.ServerStream) error {
|
||||
probe.record(stream.Context())
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
}, probe)
|
||||
|
||||
go func() { _ = srv.Serve(listener) }()
|
||||
t.Cleanup(srv.Stop)
|
||||
|
||||
conn, err := grpc.NewClient(listener.Addr().String(), grpc.WithTransportCredentials(insecure.NewCredentials()))
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
|
||||
return conn, probe
|
||||
}
|
||||
|
||||
func callUnary(t *testing.T, conn *grpc.ClientConn, probe *realIPProbe, kv ...string) string {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, kv...)
|
||||
require.NoError(t, conn.Invoke(ctx, realIPProbeMethod, &emptypb.Empty{}, &emptypb.Empty{}))
|
||||
|
||||
return probe.wait(t)
|
||||
}
|
||||
|
||||
func callStream(t *testing.T, conn *grpc.ClientConn, probe *realIPProbe, kv ...string) string {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
ctx = metadata.AppendToOutgoingContext(ctx, kv...)
|
||||
desc := &grpc.StreamDesc{StreamName: "ProbeStream", ServerStreams: true}
|
||||
stream, err := conn.NewStream(ctx, desc, realIPProbeStreamMethod)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, stream.CloseSend())
|
||||
require.ErrorIs(t, stream.RecvMsg(&emptypb.Empty{}), io.EOF)
|
||||
|
||||
return probe.wait(t)
|
||||
}
|
||||
|
||||
func assertRealIP(t *testing.T, cfg nbconfig.ReverseProxy, want string, kv ...string) {
|
||||
t.Helper()
|
||||
|
||||
conn, probe := startProbeServer(t, cfg)
|
||||
t.Run("unary", func(t *testing.T) {
|
||||
assert.Equal(t, want, callUnary(t, conn, probe, kv...))
|
||||
})
|
||||
t.Run("stream", func(t *testing.T) {
|
||||
assert.Equal(t, want, callStream(t, conn, probe, kv...))
|
||||
})
|
||||
}
|
||||
|
||||
func TestRealIPDefaultIgnoresClientForwardedHeaders(t *testing.T) {
|
||||
assertRealIP(t, nbconfig.ReverseProxy{}, "127.0.0.1",
|
||||
realip.XForwardedFor, "203.0.113.44",
|
||||
realip.XRealIp, "203.0.113.44",
|
||||
)
|
||||
}
|
||||
|
||||
func TestRealIPUntrustedPeerIgnoresForwardedHeaders(t *testing.T) {
|
||||
cfg := nbconfig.ReverseProxy{TrustedPeers: []netip.Prefix{netip.MustParsePrefix("10.9.8.7/32")}}
|
||||
|
||||
assertRealIP(t, cfg, "127.0.0.1",
|
||||
realip.XForwardedFor, "203.0.113.44",
|
||||
realip.XRealIp, "203.0.113.44",
|
||||
)
|
||||
}
|
||||
|
||||
func TestRealIPTrustedPeerHonoursForwardedHeaders(t *testing.T) {
|
||||
cfg := nbconfig.ReverseProxy{TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")}}
|
||||
|
||||
assertRealIP(t, cfg, "203.0.113.44",
|
||||
realip.XForwardedFor, "203.0.113.44",
|
||||
realip.XRealIp, "203.0.113.44",
|
||||
)
|
||||
}
|
||||
|
||||
func TestRealIPIgnoresXRealIPWhenProxyCountIsSet(t *testing.T) {
|
||||
cfg := nbconfig.ReverseProxy{
|
||||
TrustedPeers: []netip.Prefix{netip.MustParsePrefix("127.0.0.1/32")},
|
||||
TrustedHTTPProxiesCount: 1,
|
||||
}
|
||||
|
||||
assertRealIP(t, cfg, "127.0.0.1", realip.XRealIp, "203.0.113.44")
|
||||
}
|
||||
@@ -66,7 +66,8 @@ type BaseServer struct {
|
||||
disableLegacyManagementPort bool
|
||||
autoResolveDomains bool
|
||||
|
||||
proxyAuthClose func()
|
||||
proxyAuthClose func()
|
||||
domainCleanupStop func()
|
||||
|
||||
// grpcExtensions holds additional gRPC services, interceptors, and shutdown
|
||||
// hooks registered by external modules via RegisterGRPCExtension. Populated
|
||||
@@ -74,6 +75,7 @@ type BaseServer struct {
|
||||
grpcExtensions []GRPCExtension
|
||||
|
||||
listener net.Listener
|
||||
tlsConfig *tls.Config
|
||||
certManager *autocert.Manager
|
||||
update *version.Update
|
||||
|
||||
@@ -94,6 +96,7 @@ type Config struct {
|
||||
DisableGeoliteUpdate bool
|
||||
UserDeleteFromIDPEnabled bool
|
||||
AutoResolveDomains bool
|
||||
TLSConfig *tls.Config
|
||||
}
|
||||
|
||||
// NewServer initializes and configures a new Server instance
|
||||
@@ -110,6 +113,7 @@ func NewServer(cfg *Config) *BaseServer {
|
||||
disableLegacyManagementPort: cfg.DisableLegacyManagementPort,
|
||||
mgmtMetricsPort: cfg.MgmtMetricsPort,
|
||||
autoResolveDomains: cfg.AutoResolveDomains,
|
||||
tlsConfig: cfg.TLSConfig,
|
||||
}
|
||||
s.container[ContainerKeyBaseServer] = s
|
||||
|
||||
@@ -139,21 +143,9 @@ func (s *BaseServer) Start(ctx context.Context) error {
|
||||
}
|
||||
s.EphemeralManager().LoadInitialPeers(srvCtx)
|
||||
|
||||
var tlsConfig *tls.Config
|
||||
tlsEnabled := false
|
||||
if s.Config.HttpConfig.LetsEncryptDomain != "" {
|
||||
s.certManager, err = encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err)
|
||||
}
|
||||
tlsEnabled = true
|
||||
} else if s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "" {
|
||||
tlsConfig, err = loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey)
|
||||
if err != nil {
|
||||
log.WithContext(srvCtx).Errorf("cannot load TLS credentials: %v", err)
|
||||
return err
|
||||
}
|
||||
tlsEnabled = true
|
||||
tlsEnabled, err := s.setupTLS(srvCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
installationID, err := getInstallationID(srvCtx, s.Store())
|
||||
@@ -215,8 +207,8 @@ func (s *BaseServer) Start(ctx context.Context) error {
|
||||
log.WithContext(ctx).Infof("running HTTP server (LetsEncrypt challenge handler): %s", cml.Addr().String())
|
||||
s.serveHTTP(ctx, cml, s.certManager.HTTPHandler(nil))
|
||||
}
|
||||
case tlsConfig != nil:
|
||||
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), tlsConfig)
|
||||
case s.tlsConfig != nil:
|
||||
s.listener, err = tls.Listen("tcp", fmt.Sprintf(":%d", s.mgmtPort), s.tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed creating TLS listener on port %d: %v", s.mgmtPort, err)
|
||||
}
|
||||
@@ -236,14 +228,59 @@ func (s *BaseServer) Start(ctx context.Context) error {
|
||||
s.update.SetOnUpdateListener(func() {
|
||||
log.WithContext(ctx).Infof("your management version, \"%s\", is outdated, a new management version is available. Learn more here: https://github.com/netbirdio/netbird/releases", version.NetbirdVersion())
|
||||
})
|
||||
s.startDomainCleanup(srvCtx)
|
||||
|
||||
return nil
|
||||
}
|
||||
func (s *BaseServer) startDomainCleanup(ctx context.Context) {
|
||||
if s.domainCleanupStop != nil {
|
||||
return
|
||||
}
|
||||
mgr := s.ReverseProxyDomainManager()
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
done := make(chan struct{})
|
||||
s.domainCleanupStop = func() {
|
||||
cancel()
|
||||
<-done
|
||||
}
|
||||
go func() {
|
||||
defer close(done)
|
||||
mgr.RunValidationCleanup(ctx)
|
||||
}()
|
||||
}
|
||||
|
||||
// setupTLS resolves the listener's TLS source: an injected config wins over the HttpConfig certificate settings
|
||||
func (s *BaseServer) setupTLS(ctx context.Context) (bool, error) {
|
||||
switch {
|
||||
case s.tlsConfig != nil:
|
||||
return true, nil
|
||||
case s.Config.HttpConfig.LetsEncryptDomain != "":
|
||||
certManager, err := encryption.CreateCertManager(s.Config.Datadir, s.Config.HttpConfig.LetsEncryptDomain)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed creating LetsEncrypt cert manager: %v", err)
|
||||
}
|
||||
s.certManager = certManager
|
||||
return true, nil
|
||||
case s.Config.HttpConfig.CertFile != "" && s.Config.HttpConfig.CertKey != "":
|
||||
tlsConfig, err := loadTLSConfig(s.Config.HttpConfig.CertFile, s.Config.HttpConfig.CertKey)
|
||||
if err != nil {
|
||||
log.WithContext(ctx).Errorf("cannot load TLS credentials: %v", err)
|
||||
return false, err
|
||||
}
|
||||
s.tlsConfig = tlsConfig
|
||||
return true, nil
|
||||
default:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Stop attempts a graceful shutdown, waiting up to 5 seconds for active connections to finish
|
||||
func (s *BaseServer) Stop() error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if s.domainCleanupStop != nil {
|
||||
s.domainCleanupStop()
|
||||
}
|
||||
|
||||
s.IntegratedValidator().Stop(ctx)
|
||||
if s.GeoLocationManager() != nil {
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/netbirdio/netbird/encryption"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/management/server/telemetry"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func PeerUpdateHandlerFactory(
|
||||
peerKey wgtypes.Key,
|
||||
updates chan *network_map.UpdateMessage,
|
||||
secretsManager SecretsManager,
|
||||
srv proto.ManagementService_SyncServer,
|
||||
cleanupfunc func()) *PeerUpdateHandler {
|
||||
return &PeerUpdateHandler{
|
||||
peerKey: peerKey,
|
||||
updates: updates,
|
||||
secretsManager: secretsManager,
|
||||
srv: srv,
|
||||
encrypter: encryption.DefaultEncrypter{},
|
||||
debouncer: NewUpdateDebouncer(1000 * time.Millisecond),
|
||||
cleanupFunc: cleanupfunc,
|
||||
}
|
||||
}
|
||||
|
||||
// PeerUpdateHandler sends updates to the connected peer until the updates channel is closed.
|
||||
// It implements a backpressure mechanism that sends the first update immediately,
|
||||
// then debounces subsequent rapid updates, ensuring only the latest update is sent
|
||||
// after a quiet period.
|
||||
type PeerUpdateHandler struct {
|
||||
peerKey wgtypes.Key
|
||||
updates chan *network_map.UpdateMessage
|
||||
appMetrics telemetry.AppMetrics
|
||||
secretsManager SecretsManager
|
||||
srv syncSender
|
||||
encrypter encryption.Encrypter
|
||||
debouncer Debouncer
|
||||
cleanupFunc func()
|
||||
}
|
||||
|
||||
func (pu *PeerUpdateHandler) WithMetrics(appMetrics telemetry.AppMetrics) *PeerUpdateHandler {
|
||||
pu.appMetrics = appMetrics
|
||||
return pu
|
||||
}
|
||||
|
||||
//go:generate go tool mockgen -source=./peer_update_handler.go -destination=./sync_sender_mock.go -package=grpc
|
||||
type syncSender interface {
|
||||
Send(*proto.EncryptedMessage) error
|
||||
Context() context.Context
|
||||
}
|
||||
|
||||
func (pu *PeerUpdateHandler) HandleUpdates(ctx context.Context) error {
|
||||
log.WithContext(ctx).Tracef("starting to handle updates for peer %s", pu.peerKey.String())
|
||||
|
||||
defer pu.debouncer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
// condition when there are some updates
|
||||
// todo set the updates channel size to 1
|
||||
case update, open := <-pu.updates:
|
||||
if pu.appMetrics != nil {
|
||||
pu.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(pu.updates) + 1)
|
||||
}
|
||||
|
||||
if !open {
|
||||
log.WithContext(ctx).Debugf("updates channel for peer %s was closed", pu.peerKey.String())
|
||||
pu.cleanupFunc()
|
||||
return nil
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Tracef("received an update for peer %s", pu.peerKey.String())
|
||||
if pu.debouncer.ProcessUpdate(update) {
|
||||
// Send immediately (first update or after quiet period)
|
||||
if err := pu.SendUpdate(ctx, update); err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", pu.peerKey.String(), err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Timer expired - quiet period reached, send pending updates if any
|
||||
case <-pu.debouncer.TimerChannel():
|
||||
pendingUpdates := pu.debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) == 0 {
|
||||
continue
|
||||
}
|
||||
log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), pu.peerKey.String())
|
||||
for _, pendingUpdate := range pendingUpdates {
|
||||
if err := pu.SendUpdate(ctx, pendingUpdate); err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", pu.peerKey.String(), err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// condition when client <-> server connection has been terminated
|
||||
case <-pu.srv.Context().Done():
|
||||
// happens when connection drops, e.g. client disconnects
|
||||
log.WithContext(ctx).Debugf("stream of peer %s has been closed", pu.peerKey.String())
|
||||
pu.cleanupFunc()
|
||||
return pu.srv.Context().Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (pu *PeerUpdateHandler) SendUpdate(ctx context.Context, update *network_map.UpdateMessage) error {
|
||||
key, err := pu.secretsManager.GetWGKey()
|
||||
if err != nil {
|
||||
pu.cleanupFunc()
|
||||
return status.Errorf(codes.Internal, "failed processing update message")
|
||||
}
|
||||
|
||||
encryptedResp, err := pu.encrypter.EncryptMessage(pu.peerKey, key, update.Update)
|
||||
if err != nil {
|
||||
pu.cleanupFunc()
|
||||
return status.Errorf(codes.Internal, "failed processing update message")
|
||||
}
|
||||
err = pu.srv.Send(&proto.EncryptedMessage{
|
||||
WgPubKey: key.PublicKey().String(),
|
||||
Body: encryptedResp,
|
||||
})
|
||||
if err != nil {
|
||||
pu.cleanupFunc()
|
||||
return status.Errorf(codes.Internal, "failed sending update message")
|
||||
}
|
||||
log.WithContext(ctx).Tracef("sent an update to peer %s", pu.peerKey.String())
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package grpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "github.com/golang/protobuf/proto" //nolint
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
"github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"go.uber.org/mock/gomock"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
func TestSendPeerUpdates_FirstUpdate(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
secretsManager := NewMockSecretsManager(ctrl)
|
||||
updateDebouncer := NewMockDebouncer(ctrl)
|
||||
syncSender := NewMocksyncSender(ctrl)
|
||||
|
||||
pu := PeerUpdateHandler{
|
||||
peerKey: mustGenerateKey(t),
|
||||
updates: make(chan *network_map.UpdateMessage),
|
||||
secretsManager: secretsManager,
|
||||
encrypter: testEncrypter{},
|
||||
debouncer: updateDebouncer,
|
||||
srv: syncSender,
|
||||
cleanupFunc: func() {},
|
||||
}
|
||||
|
||||
msg := network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{Version: 1},
|
||||
}
|
||||
|
||||
timeCh := make(chan time.Time)
|
||||
srvCtx := context.TODO()
|
||||
srvKey := mustGenerateKey(t)
|
||||
// mock a first update, should send it right away
|
||||
updateDebouncer.EXPECT().ProcessUpdate(gomock.Eq(&msg)).Return(true)
|
||||
updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh)
|
||||
syncSender.EXPECT().Context().AnyTimes().Return(srvCtx)
|
||||
secretsManager.EXPECT().GetWGKey().Return(srvKey, nil)
|
||||
syncSender.EXPECT().Send(pbMatcher{x: &proto.EncryptedMessage{WgPubKey: srvKey.PublicKey().String(), Body: mustMarshal(t, &msg)}})
|
||||
updateDebouncer.EXPECT().Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck
|
||||
pu.updates <- &msg
|
||||
close(pu.updates)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSendPeerUpdates_TimerUpdate(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
secretsManager := NewMockSecretsManager(ctrl)
|
||||
updateDebouncer := NewMockDebouncer(ctrl)
|
||||
syncSender := NewMocksyncSender(ctrl)
|
||||
|
||||
pu := PeerUpdateHandler{
|
||||
peerKey: mustGenerateKey(t),
|
||||
updates: make(chan *network_map.UpdateMessage),
|
||||
secretsManager: secretsManager,
|
||||
encrypter: testEncrypter{},
|
||||
debouncer: updateDebouncer,
|
||||
srv: syncSender,
|
||||
cleanupFunc: func() {},
|
||||
}
|
||||
|
||||
msg := network_map.UpdateMessage{
|
||||
Update: &proto.SyncResponse{Version: 1},
|
||||
}
|
||||
|
||||
timeCh := make(chan time.Time)
|
||||
srvCtx := context.TODO()
|
||||
srvKey := mustGenerateKey(t)
|
||||
updateDebouncer.EXPECT().GetPendingUpdates().Return([]*network_map.UpdateMessage{&msg})
|
||||
updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh)
|
||||
syncSender.EXPECT().Context().AnyTimes().Return(srvCtx)
|
||||
secretsManager.EXPECT().GetWGKey().Return(srvKey, nil)
|
||||
syncSender.EXPECT().Send(pbMatcher{x: &proto.EncryptedMessage{WgPubKey: srvKey.PublicKey().String(), Body: mustMarshal(t, &msg)}})
|
||||
updateDebouncer.EXPECT().Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck
|
||||
timeCh <- time.Now()
|
||||
close(pu.updates)
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func TestSendPeerUpdates_ServerContextDone(t *testing.T) {
|
||||
ctrl := gomock.NewController(t)
|
||||
secretsManager := NewMockSecretsManager(ctrl)
|
||||
updateDebouncer := NewMockDebouncer(ctrl)
|
||||
syncSender := NewMocksyncSender(ctrl)
|
||||
|
||||
pu := PeerUpdateHandler{
|
||||
peerKey: mustGenerateKey(t),
|
||||
updates: make(chan *network_map.UpdateMessage),
|
||||
secretsManager: secretsManager,
|
||||
encrypter: testEncrypter{},
|
||||
debouncer: updateDebouncer,
|
||||
srv: syncSender,
|
||||
cleanupFunc: func() {},
|
||||
}
|
||||
|
||||
timeCh := make(chan time.Time)
|
||||
srvCtx, cancel := context.WithCancel(context.TODO())
|
||||
updateDebouncer.EXPECT().TimerChannel().AnyTimes().Return(timeCh)
|
||||
syncSender.EXPECT().Context().AnyTimes().Return(srvCtx)
|
||||
updateDebouncer.EXPECT().Stop()
|
||||
|
||||
var wg sync.WaitGroup
|
||||
wg.Go(func() { pu.HandleUpdates(context.TODO()) }) //nolint:errcheck
|
||||
cancel()
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
func mustGenerateKey(t *testing.T) wgtypes.Key {
|
||||
t.Helper()
|
||||
k, err := wgtypes.GenerateKey()
|
||||
assert.NoError(t, err)
|
||||
return k
|
||||
}
|
||||
|
||||
func mustMarshal(t *testing.T, msg *network_map.UpdateMessage) []byte {
|
||||
t.Helper()
|
||||
r, err := pb.Marshal(msg.Update)
|
||||
assert.NoError(t, err)
|
||||
return r
|
||||
}
|
||||
|
||||
type testEncrypter struct{}
|
||||
|
||||
func (testEncrypter) EncryptMessage(remotePubKey wgtypes.Key, ourPrivateKey wgtypes.Key, message pb.Message) ([]byte, error) {
|
||||
return pb.Marshal(message)
|
||||
}
|
||||
|
||||
type pbMatcher struct {
|
||||
x pb.Message
|
||||
}
|
||||
|
||||
func (pbm pbMatcher) Matches(x any) bool {
|
||||
msg, ok := x.(pb.Message)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return pb.Equal(pbm.x, msg)
|
||||
}
|
||||
|
||||
func (pbm pbMatcher) String() string {
|
||||
return fmt.Sprintf("is equal to %s (%T)", pbm.x, pbm.x)
|
||||
}
|
||||
@@ -337,7 +337,8 @@ func (s *Server) Sync(req *proto.EncryptedMessage, srv proto.ManagementService_S
|
||||
|
||||
s.syncSem.Add(-1)
|
||||
|
||||
return s.handleUpdates(ctx, accountID, peerKey, peer, updates, srv, syncStart)
|
||||
return PeerUpdateHandlerFactory(peerKey, updates, s.secretsManager, srv, func() { s.cancelPeerRoutines(ctx, accountID, peer, syncStart) }).
|
||||
WithMetrics(s.appMetrics).HandleUpdates(ctx)
|
||||
}
|
||||
|
||||
func (s *Server) handleHandshake(ctx context.Context, srv proto.ManagementService_JobServer) (wgtypes.Key, error) {
|
||||
@@ -404,91 +405,6 @@ func (s *Server) sendJobsLoop(ctx context.Context, accountID string, peerKey wgt
|
||||
}
|
||||
}
|
||||
|
||||
// handleUpdates sends updates to the connected peer until the updates channel is closed.
|
||||
// It implements a backpressure mechanism that sends the first update immediately,
|
||||
// then debounces subsequent rapid updates, ensuring only the latest update is sent
|
||||
// after a quiet period.
|
||||
func (s *Server) handleUpdates(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, updates chan *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error {
|
||||
log.WithContext(ctx).Tracef("starting to handle updates for peer %s", peerKey.String())
|
||||
|
||||
// Create a debouncer for this peer connection
|
||||
debouncer := NewUpdateDebouncer(1000 * time.Millisecond)
|
||||
defer debouncer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
// condition when there are some updates
|
||||
// todo set the updates channel size to 1
|
||||
case update, open := <-updates:
|
||||
if s.appMetrics != nil {
|
||||
s.appMetrics.GRPCMetrics().UpdateChannelQueueLength(len(updates) + 1)
|
||||
}
|
||||
|
||||
if !open {
|
||||
log.WithContext(ctx).Debugf("updates channel for peer %s was closed", peerKey.String())
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
return nil
|
||||
}
|
||||
|
||||
log.WithContext(ctx).Tracef("received an update for peer %s", peerKey.String())
|
||||
if debouncer.ProcessUpdate(update) {
|
||||
// Send immediately (first update or after quiet period)
|
||||
if err := s.sendUpdate(ctx, accountID, peerKey, peer, update, srv, streamStartTime); err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Timer expired - quiet period reached, send pending updates if any
|
||||
case <-debouncer.TimerChannel():
|
||||
pendingUpdates := debouncer.GetPendingUpdates()
|
||||
if len(pendingUpdates) == 0 {
|
||||
continue
|
||||
}
|
||||
log.WithContext(ctx).Debugf("sending %d debounced update(s) for peer %s", len(pendingUpdates), peerKey.String())
|
||||
for _, pendingUpdate := range pendingUpdates {
|
||||
if err := s.sendUpdate(ctx, accountID, peerKey, peer, pendingUpdate, srv, streamStartTime); err != nil {
|
||||
log.WithContext(ctx).Debugf("error while sending an update to peer %s: %v", peerKey.String(), err)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// condition when client <-> server connection has been terminated
|
||||
case <-srv.Context().Done():
|
||||
// happens when connection drops, e.g. client disconnects
|
||||
log.WithContext(ctx).Debugf("stream of peer %s has been closed", peerKey.String())
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
return srv.Context().Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// sendUpdate encrypts the update message using the peer key and the server's wireguard key,
|
||||
// then sends the encrypted message to the connected peer via the sync server.
|
||||
func (s *Server) sendUpdate(ctx context.Context, accountID string, peerKey wgtypes.Key, peer *nbpeer.Peer, update *network_map.UpdateMessage, srv proto.ManagementService_SyncServer, streamStartTime time.Time) error {
|
||||
key, err := s.secretsManager.GetWGKey()
|
||||
if err != nil {
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
return status.Errorf(codes.Internal, "failed processing update message")
|
||||
}
|
||||
|
||||
encryptedResp, err := encryption.EncryptMessage(peerKey, key, update.Update)
|
||||
if err != nil {
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
return status.Errorf(codes.Internal, "failed processing update message")
|
||||
}
|
||||
err = srv.Send(&proto.EncryptedMessage{
|
||||
WgPubKey: key.PublicKey().String(),
|
||||
Body: encryptedResp,
|
||||
})
|
||||
if err != nil {
|
||||
s.cancelPeerRoutines(ctx, accountID, peer, streamStartTime)
|
||||
return status.Errorf(codes.Internal, "failed sending update message")
|
||||
}
|
||||
log.WithContext(ctx).Tracef("sent an update to peer %s", peerKey.String())
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendJob encrypts the update message using the peer key and the server's wireguard key,
|
||||
// then sends the encrypted message to the connected peer via the sync server.
|
||||
func (s *Server) sendJob(ctx context.Context, peerKey wgtypes.Key, job *job.Event, srv proto.ManagementService_JobServer) error {
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./peer_update_handler.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=./peer_update_handler.go -destination=./sync_sender_mock.go -package=grpc
|
||||
//
|
||||
|
||||
// Package grpc is a generated GoMock package.
|
||||
package grpc
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
proto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MocksyncSender is a mock of syncSender interface.
|
||||
type MocksyncSender struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MocksyncSenderMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MocksyncSenderMockRecorder is the mock recorder for MocksyncSender.
|
||||
type MocksyncSenderMockRecorder struct {
|
||||
mock *MocksyncSender
|
||||
}
|
||||
|
||||
// NewMocksyncSender creates a new mock instance.
|
||||
func NewMocksyncSender(ctrl *gomock.Controller) *MocksyncSender {
|
||||
mock := &MocksyncSender{ctrl: ctrl}
|
||||
mock.recorder = &MocksyncSenderMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MocksyncSender) EXPECT() *MocksyncSenderMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// Context mocks base method.
|
||||
func (m *MocksyncSender) Context() context.Context {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Context")
|
||||
ret0, _ := ret[0].(context.Context)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Context indicates an expected call of Context.
|
||||
func (mr *MocksyncSenderMockRecorder) Context() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Context", reflect.TypeOf((*MocksyncSender)(nil).Context))
|
||||
}
|
||||
|
||||
// Send mocks base method.
|
||||
func (m *MocksyncSender) Send(arg0 *proto.EncryptedMessage) error {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "Send", arg0)
|
||||
ret0, _ := ret[0].(error)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// Send indicates an expected call of Send.
|
||||
func (mr *MocksyncSenderMockRecorder) Send(arg0 any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Send", reflect.TypeOf((*MocksyncSender)(nil).Send), arg0)
|
||||
}
|
||||
@@ -25,6 +25,8 @@ import (
|
||||
const defaultDuration = 12 * time.Hour
|
||||
|
||||
// SecretsManager used to manage TURN and relay secrets
|
||||
//
|
||||
//go:generate go tool mockgen -source=./token_mgr.go -destination=./token_mgr_mock.go -package=grpc
|
||||
type SecretsManager interface {
|
||||
GenerateTurnToken() (*Token, error)
|
||||
GenerateRelayToken() (*Token, error)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./token_mgr.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=./token_mgr.go -destination=./token_mgr_mock.go -package=grpc
|
||||
//
|
||||
|
||||
// Package grpc is a generated GoMock package.
|
||||
package grpc
|
||||
|
||||
import (
|
||||
context "context"
|
||||
reflect "reflect"
|
||||
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
wgtypes "golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// MockSecretsManager is a mock of SecretsManager interface.
|
||||
type MockSecretsManager struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockSecretsManagerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockSecretsManagerMockRecorder is the mock recorder for MockSecretsManager.
|
||||
type MockSecretsManagerMockRecorder struct {
|
||||
mock *MockSecretsManager
|
||||
}
|
||||
|
||||
// NewMockSecretsManager creates a new mock instance.
|
||||
func NewMockSecretsManager(ctrl *gomock.Controller) *MockSecretsManager {
|
||||
mock := &MockSecretsManager{ctrl: ctrl}
|
||||
mock.recorder = &MockSecretsManagerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockSecretsManager) EXPECT() *MockSecretsManagerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// CancelRefresh mocks base method.
|
||||
func (m *MockSecretsManager) CancelRefresh(peerKey string) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "CancelRefresh", peerKey)
|
||||
}
|
||||
|
||||
// CancelRefresh indicates an expected call of CancelRefresh.
|
||||
func (mr *MockSecretsManagerMockRecorder) CancelRefresh(peerKey any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelRefresh", reflect.TypeOf((*MockSecretsManager)(nil).CancelRefresh), peerKey)
|
||||
}
|
||||
|
||||
// GenerateRelayToken mocks base method.
|
||||
func (m *MockSecretsManager) GenerateRelayToken() (*Token, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GenerateRelayToken")
|
||||
ret0, _ := ret[0].(*Token)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GenerateRelayToken indicates an expected call of GenerateRelayToken.
|
||||
func (mr *MockSecretsManagerMockRecorder) GenerateRelayToken() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateRelayToken", reflect.TypeOf((*MockSecretsManager)(nil).GenerateRelayToken))
|
||||
}
|
||||
|
||||
// GenerateTurnToken mocks base method.
|
||||
func (m *MockSecretsManager) GenerateTurnToken() (*Token, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GenerateTurnToken")
|
||||
ret0, _ := ret[0].(*Token)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GenerateTurnToken indicates an expected call of GenerateTurnToken.
|
||||
func (mr *MockSecretsManagerMockRecorder) GenerateTurnToken() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GenerateTurnToken", reflect.TypeOf((*MockSecretsManager)(nil).GenerateTurnToken))
|
||||
}
|
||||
|
||||
// GetWGKey mocks base method.
|
||||
func (m *MockSecretsManager) GetWGKey() (wgtypes.Key, error) {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetWGKey")
|
||||
ret0, _ := ret[0].(wgtypes.Key)
|
||||
ret1, _ := ret[1].(error)
|
||||
return ret0, ret1
|
||||
}
|
||||
|
||||
// GetWGKey indicates an expected call of GetWGKey.
|
||||
func (mr *MockSecretsManagerMockRecorder) GetWGKey() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWGKey", reflect.TypeOf((*MockSecretsManager)(nil).GetWGKey))
|
||||
}
|
||||
|
||||
// SetupRefresh mocks base method.
|
||||
func (m *MockSecretsManager) SetupRefresh(ctx context.Context, accountID, peerKey string) {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "SetupRefresh", ctx, accountID, peerKey)
|
||||
}
|
||||
|
||||
// SetupRefresh indicates an expected call of SetupRefresh.
|
||||
func (mr *MockSecretsManagerMockRecorder) SetupRefresh(ctx, accountID, peerKey any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetupRefresh", reflect.TypeOf((*MockSecretsManager)(nil).SetupRefresh), ctx, accountID, peerKey)
|
||||
}
|
||||
@@ -6,6 +6,14 @@ import (
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
)
|
||||
|
||||
//go:generate go tool mockgen -source=./update_debouncer.go -destination=./update_debouncer_mock.go -package=grpc
|
||||
type Debouncer interface {
|
||||
Stop()
|
||||
TimerChannel() <-chan time.Time
|
||||
ProcessUpdate(update *network_map.UpdateMessage) bool
|
||||
GetPendingUpdates() []*network_map.UpdateMessage
|
||||
}
|
||||
|
||||
// UpdateDebouncer implements a backpressure mechanism that:
|
||||
// - Sends the first update immediately
|
||||
// - Coalesces rapid subsequent network map updates (only latest matters)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Code generated by MockGen. DO NOT EDIT.
|
||||
// Source: ./update_debouncer.go
|
||||
//
|
||||
// Generated by this command:
|
||||
//
|
||||
// mockgen -source=./update_debouncer.go -destination=./update_debouncer_mock.go -package=grpc
|
||||
//
|
||||
|
||||
// Package grpc is a generated GoMock package.
|
||||
package grpc
|
||||
|
||||
import (
|
||||
reflect "reflect"
|
||||
time "time"
|
||||
|
||||
network_map "github.com/netbirdio/netbird/management/internals/controllers/network_map"
|
||||
gomock "go.uber.org/mock/gomock"
|
||||
)
|
||||
|
||||
// MockDebouncer is a mock of Debouncer interface.
|
||||
type MockDebouncer struct {
|
||||
ctrl *gomock.Controller
|
||||
recorder *MockDebouncerMockRecorder
|
||||
isgomock struct{}
|
||||
}
|
||||
|
||||
// MockDebouncerMockRecorder is the mock recorder for MockDebouncer.
|
||||
type MockDebouncerMockRecorder struct {
|
||||
mock *MockDebouncer
|
||||
}
|
||||
|
||||
// NewMockDebouncer creates a new mock instance.
|
||||
func NewMockDebouncer(ctrl *gomock.Controller) *MockDebouncer {
|
||||
mock := &MockDebouncer{ctrl: ctrl}
|
||||
mock.recorder = &MockDebouncerMockRecorder{mock}
|
||||
return mock
|
||||
}
|
||||
|
||||
// EXPECT returns an object that allows the caller to indicate expected use.
|
||||
func (m *MockDebouncer) EXPECT() *MockDebouncerMockRecorder {
|
||||
return m.recorder
|
||||
}
|
||||
|
||||
// GetPendingUpdates mocks base method.
|
||||
func (m *MockDebouncer) GetPendingUpdates() []*network_map.UpdateMessage {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "GetPendingUpdates")
|
||||
ret0, _ := ret[0].([]*network_map.UpdateMessage)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// GetPendingUpdates indicates an expected call of GetPendingUpdates.
|
||||
func (mr *MockDebouncerMockRecorder) GetPendingUpdates() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPendingUpdates", reflect.TypeOf((*MockDebouncer)(nil).GetPendingUpdates))
|
||||
}
|
||||
|
||||
// ProcessUpdate mocks base method.
|
||||
func (m *MockDebouncer) ProcessUpdate(update *network_map.UpdateMessage) bool {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "ProcessUpdate", update)
|
||||
ret0, _ := ret[0].(bool)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// ProcessUpdate indicates an expected call of ProcessUpdate.
|
||||
func (mr *MockDebouncerMockRecorder) ProcessUpdate(update any) *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ProcessUpdate", reflect.TypeOf((*MockDebouncer)(nil).ProcessUpdate), update)
|
||||
}
|
||||
|
||||
// Stop mocks base method.
|
||||
func (m *MockDebouncer) Stop() {
|
||||
m.ctrl.T.Helper()
|
||||
m.ctrl.Call(m, "Stop")
|
||||
}
|
||||
|
||||
// Stop indicates an expected call of Stop.
|
||||
func (mr *MockDebouncerMockRecorder) Stop() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Stop", reflect.TypeOf((*MockDebouncer)(nil).Stop))
|
||||
}
|
||||
|
||||
// TimerChannel mocks base method.
|
||||
func (m *MockDebouncer) TimerChannel() <-chan time.Time {
|
||||
m.ctrl.T.Helper()
|
||||
ret := m.ctrl.Call(m, "TimerChannel")
|
||||
ret0, _ := ret[0].(<-chan time.Time)
|
||||
return ret0
|
||||
}
|
||||
|
||||
// TimerChannel indicates an expected call of TimerChannel.
|
||||
func (mr *MockDebouncerMockRecorder) TimerChannel() *gomock.Call {
|
||||
mr.mock.ctrl.T.Helper()
|
||||
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TimerChannel", reflect.TypeOf((*MockDebouncer)(nil).TimerChannel))
|
||||
}
|
||||
@@ -284,6 +284,9 @@ const (
|
||||
// AgentNetworkSettingsDeleted indicates that a user deleted the Agent Network account settings, releasing the endpoint
|
||||
AgentNetworkSettingsDeleted Activity = 142
|
||||
|
||||
// CustomDomainValidationExpired indicates that an unvalidated domain registration expired.
|
||||
CustomDomainValidationExpired Activity = 143
|
||||
|
||||
AccountDeleted Activity = 99999
|
||||
)
|
||||
|
||||
@@ -461,9 +464,10 @@ var activityMap = map[Activity]Code{
|
||||
AccountMetricsPushEnabled: {"Account metrics push enabled", "account.setting.metrics.push.enable"},
|
||||
AccountMetricsPushDisabled: {"Account metrics push disabled", "account.setting.metrics.push.disable"},
|
||||
|
||||
DomainAdded: {"Domain added", "domain.add"},
|
||||
DomainDeleted: {"Domain deleted", "domain.delete"},
|
||||
DomainValidated: {"Domain validated", "domain.validate"},
|
||||
DomainAdded: {"Domain added", "domain.add"},
|
||||
DomainDeleted: {"Domain deleted", "domain.delete"},
|
||||
DomainValidated: {"Domain validated", "domain.validate"},
|
||||
CustomDomainValidationExpired: {"Unvalidated domain registration expired", "domain.validation.expire"},
|
||||
}
|
||||
|
||||
// StringCode returns a string code of the activity
|
||||
|
||||
@@ -165,16 +165,16 @@ func (store *Store) Get(ctx context.Context, accountID string, offset, limit int
|
||||
return store.processResult(ctx, events)
|
||||
}
|
||||
|
||||
// Save an event in the SQLite events table end encrypt the "email" element in meta map
|
||||
func (store *Store) Save(_ context.Context, event *activity.Event) (*activity.Event, error) {
|
||||
// Save persists an activity event and encrypts deleted user details using the caller's context.
|
||||
func (store *Store) Save(ctx context.Context, event *activity.Event) (*activity.Event, error) {
|
||||
eventCopy := event.Copy()
|
||||
meta, err := store.saveDeletedUserEmailAndNameInEncrypted(eventCopy)
|
||||
meta, err := store.saveDeletedUserEmailAndNameInEncrypted(ctx, eventCopy)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
eventCopy.Meta = meta
|
||||
|
||||
if err = store.db.Create(eventCopy).Error; err != nil {
|
||||
if err = store.db.WithContext(ctx).Create(eventCopy).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ func (store *Store) Save(_ context.Context, event *activity.Event) (*activity.Ev
|
||||
|
||||
// saveDeletedUserEmailAndNameInEncrypted if the meta contains email and name then store it in encrypted way and delete
|
||||
// this item from meta map
|
||||
func (store *Store) saveDeletedUserEmailAndNameInEncrypted(event *activity.Event) (map[string]any, error) {
|
||||
func (store *Store) saveDeletedUserEmailAndNameInEncrypted(ctx context.Context, event *activity.Event) (map[string]any, error) {
|
||||
email, ok := event.Meta["email"]
|
||||
if !ok {
|
||||
return event.Meta, nil
|
||||
@@ -211,7 +211,7 @@ func (store *Store) saveDeletedUserEmailAndNameInEncrypted(event *activity.Event
|
||||
}
|
||||
deletedUser.Name = encryptedName
|
||||
|
||||
err = store.db.Clauses(clause.OnConflict{
|
||||
err = store.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"email", "name"}),
|
||||
}).Create(deletedUser).Error
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user