Merge branch 'main' into jnfrati/ubi-proxy

This commit is contained in:
Nicolas Frati
2026-09-18 21:21:58 +02:00
committed by GitHub
200 changed files with 10888 additions and 1339 deletions
+10
View File
@@ -14,5 +14,15 @@ reviews:
- "!**/*.ts"
- "!**/*.js"
- "!**/*.svg"
pre_merge_checks:
custom_checks:
- name: "No attribution trailers"
mode: error
instructions: >-
Fail when the PR description or any commit message carries an
attribution trailer or footer: Co-Authored-By, Claude-Session,
Generated-By, or a "Generated with"/"Generated by" tool line.
Contributors own their contributions (AGENTS.md); ask for the
lines to be removed.
chat:
auto_reply: true
+26
View File
@@ -0,0 +1,26 @@
#!/bin/bash
# Refuses commit messages that carry attribution trailers. Contributors own
# their contributions (AGENTS.md, "No Co-Authored-By or tool-attribution
# trailers"); a trailer spreads that ownership onto a tool or a bystander.
msg_file="$1"
# Trailer keys in any casing, with any bullet or emoji in front.
trailers='^[^[:alnum:]]*(co-authored-by|claude-session|generated-by):'
# "Generated with/by" footers, including "Generated with <emoji> by".
footer='^[^[:alnum:]]*generated (with|by)( [^[:alnum:]]*by)? '
# A footer names a product, so a capitalized word must follow the phrase
# itself. Prose such as "generated by the protobuf compiler" stays legal.
tool='[Gg][Ee][Nn][Ee][Rr][Aa][Tt][Ee][Dd] ([Ww][Ii][Tt][Hh]|[Bb][Yy])( [^[:alnum:]]*[Bb][Yy])? [^[:alnum:]]*[A-Z]'
offending=$( {
grep -Ein "$trailers" "$msg_file"
grep -Ein "$footer" "$msg_file" | grep -E "$tool"
} | sort -un )
if [ -n "$offending" ]; then
echo "commit-msg: attribution trailers are not accepted in this repository:" >&2
printf '%s\n' "$offending" | sed 's/^/ /' >&2
echo "Remove them and commit again (see AGENTS.md)." >&2
exit 1
fi
+7 -5
View File
@@ -46,15 +46,17 @@ jobs:
run: git --no-pager diff --exit-code
- name: Test
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
# which fails to compile until the frontend has been built. The Wails UI
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
# before goreleaser.
# Exclude the client/ui package itself: its main.go uses //go:embed
# all:frontend/dist, which fails to compile until the frontend has been
# built, and its release pipeline runs `pnpm build` before goreleaser.
# The pattern is anchored so the subpackages (services, preferences,
# i18n, authsession) still run: they hold Go-side unit tests and need no
# frontend bundle.
# `go list -e` lets the listing succeed even though the embed fails to
# resolve; the grep then drops the broken package by path. Without -e,
# go list aborts with empty stdout and `go test` falls back to the repo
# root, which has no Go files.
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /client/testutil/privileged)
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -coverprofile=coverage.txt -tags 'devcert privileged' -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e '/client/ui$' -e /client/testutil/privileged)
- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f #v7.0.0
+72 -6
View File
@@ -160,9 +160,10 @@ jobs:
- name: Test
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
# which fails to compile until the frontend has been built. The Wails UI
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
# before goreleaser.
# which fails to compile until the frontend has been built, and its
# release pipeline runs `pnpm build` before goreleaser. The subpackages
# go with it because this runner's gtk4 is older than the wails runtime
# needs; the Client UI / Unit job below covers them instead.
# `go list -e` lets the listing succeed even though the embed fails to
# resolve; the grep then drops the broken package by path. Without -e,
# go list aborts with empty stdout and `go test` falls back to the repo
@@ -177,6 +178,35 @@ jobs:
slug: netbirdio/netbird
flags: unit,client
test_client_ui:
name: "Client UI / Unit"
# Pinned to 24.04 rather than the 22.04 the other client jobs use: the wails
# runtime's linux cgo layer needs GtkFileDialog, which arrived in gtk4 4.10,
# and jammy ships 4.6. Not ubuntu-latest, so a runner image rollover cannot
# move this out from under us.
runs-on: ubuntu-24.04
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Go
uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0
with:
go-version-file: "go.mod"
cache: false
- name: Install dependencies
run: sudo apt update && sudo apt install -y -q libgtk-4-dev libwebkitgtk-6.0-dev libsoup-3.0-dev
- name: Test
# client/ui itself stays out: its main.go embeds all:frontend/dist,
# which only exists after `pnpm build`. The subpackages carry the
# Go-side unit tests, including the window manager re-entrancy
# regression test, and need no frontend bundle.
run: CGO_ENABLED=1 go test -timeout 5m ./client/ui/authsession/... ./client/ui/i18n/... ./client/ui/preferences/... ./client/ui/services/...
test_client_on_docker:
name: "Client (Docker) / Unit"
needs: [build-cache]
@@ -211,6 +241,9 @@ jobs:
${{ runner.os }}-gotest-cache-
- name: Run tests in container
# Unlike the native job above, this one drops all of client/ui including
# the subpackages: the alpine container has no gtk4/webkitgtk, so the
# Wails application package they import would fail to link.
env:
HOST_GOCACHE: ${{ steps.go-env.outputs.cache_dir }}
HOST_GOMODCACHE: ${{ steps.go-env.outputs.modcache_dir }}
@@ -481,14 +514,32 @@ jobs:
if: matrix.store == 'mysql'
run: docker pull mlsmaycon/warmed-mysql:8
# The -json stream goes through tools/gotestsummary so the log shows one
# line per test, the output of failed tests, the head of a timeout panic
# with the still-running tests, and the slowest tests per package.
- name: Test
shell: bash
run: |
set -o pipefail
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
go test -tags=devcert -coverprofile=coverage.txt \
go test -json -tags=devcert -coverprofile=coverage.txt \
-exec "sudo --preserve-env=CI,NETBIRD_STORE_ENGINE" \
-timeout 20m ./management/... ./shared/management/...
-timeout 20m ./management/... ./shared/management/... \
| tee management-test-events.jsonl \
| go run ./tools/gotestsummary
# The summary trims long outputs; the raw stream keeps every line for
# the failures that need it. A green run has no use for it.
- name: Upload raw test events
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: management-unit-test-events-${{ matrix.store }}
path: management-test-events.jsonl
if-no-files-found: ignore
retention-days: 14
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
@@ -738,12 +789,27 @@ jobs:
- name: check git status
run: git --no-pager diff --exit-code
# Same summary as the unit job: a timeout here names the tests still
# running instead of ending in a goroutine dump.
- name: Test
shell: bash
run: |
set -o pipefail
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
CI=true \
mage integrationtest:all -gotestflags="-coverprofile=coverage.txt"
mage integrationtest:all -gotestflags="-json -coverprofile=coverage.txt" \
| tee management-integration-test-events.jsonl \
| go run ./tools/gotestsummary
- name: Upload raw test events
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
with:
name: management-integration-test-events-${{ matrix.store }}
path: management-integration-test-events.jsonl
if-no-files-found: ignore
retention-days: 14
- name: Upload coverage reports to Codecov
if: matrix.arch == 'amd64'
+7 -5
View File
@@ -66,15 +66,17 @@ jobs:
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
- name: Generate test script
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
# which fails to compile until the frontend has been built. The Wails UI
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
# before goreleaser.
# Exclude the client/ui package itself: its main.go uses //go:embed
# all:frontend/dist, which fails to compile until the frontend has been
# built, and its release pipeline runs `pnpm build` before goreleaser.
# The pattern is anchored so the subpackages (services, preferences,
# i18n, authsession) still run: they hold Go-side unit tests and need no
# frontend bundle.
# `go list -e` lets the listing succeed even though the embed fails to
# resolve; the Where-Object pipeline then drops the broken package by
# path. Without -e, go list aborts with empty stdout.
run: |
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui$' }
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
$cmd = "$goExe test -tags `"devcert privileged`" -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
+12 -2
View File
@@ -186,6 +186,11 @@ jobs:
run: bash shared/management/http/api/generate.sh
- name: check git status
run: git --no-pager diff --exit-code
- name: Generate RPM changelog from git tags
# nfpm embeds changelog.yml into the RPM; Red Hat software certification
# requires a changelog. Generated, not committed (see .gitignore).
# chglog is a go.mod tool directive, so go.sum pins it and its deps.
run: bash release_files/rpm-changelog.sh
- name: Set up QEMU
uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 #v4.1.0
- name: Set up Docker Buildx
@@ -287,10 +292,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"
+4
View File
@@ -35,3 +35,7 @@ vendor/
/netbird
client/netbird-electron/
management/server/types/testdata/
# generated by chglog in the release workflow, embedded into the RPM
changelog.yml
.chglog.yml
+60
View File
@@ -234,10 +234,33 @@ nfpms:
- netbird
formats:
- rpm
# The client verifies TLS to management and signal against the system trust
# store. Red Hat software certification (RPM Dependency Tracking) also
# rejects packages that declare no dependencies at all.
dependencies:
- ca-certificates
# Generated in CI by chglog from git tags; Red Hat certification requires an
# RPM changelog (RPM Version Handling subtest).
changelog: changelog.yml
# License, documentation and a config file so the RPM Provenance subtest sees
# %license, %doc and %config entries instead of a bare binary.
contents:
- src: LICENSE
dst: /usr/share/licenses/netbird/LICENSE
type: license
- src: README.md
dst: /usr/share/doc/netbird/README.md
type: doc
- src: release_files/netbird.sysconfig
dst: /etc/sysconfig/netbird
type: config|noreplace
scripts:
postinstall: "release_files/post_install.sh"
preremove: "release_files/pre_remove.sh"
rpm:
summary: NetBird client
group: Applications/Internet
packager: NetBird <dev@netbird.io>
signature:
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
dockers_v2:
@@ -289,6 +312,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:
+1 -1
View File
@@ -77,7 +77,7 @@ make lint # golangci-lint on files changed vs origin/main (also the p
make lint-all # full-repository lint, matches CI
make test-unit # host-safe unit tests, -tags devcert, no sudo
make test-privileged # privileged-tagged suite in a Docker container with NET_ADMIN
make setup-hooks # wire make lint into .githooks/pre-push
make setup-hooks # wire .githooks: pre-push runs make lint, commit-msg refuses attribution trailers
# Narrow runs
go test ./client/internal/dns/...
+4 -1
View File
@@ -1 +1,4 @@
See [AGENTS.md](AGENTS.md) for the agent guidelines in this repository.
The agent guidelines live in [AGENTS.md](AGENTS.md). It is imported here so
every session loads it in full rather than following a pointer.
@AGENTS.md
+2 -2
View File
@@ -23,8 +23,8 @@ lint-install: $(GOLANGCI_LINT)
# Setup git hooks for all developers
setup-hooks:
@git config core.hooksPath .githooks
@chmod +x .githooks/pre-push
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"
@chmod +x .githooks/pre-push .githooks/commit-msg
@echo "✅ Git hooks configured! Pre-push runs 'make lint'; commit-msg refuses attribution trailers"
# Host-safe unit tests: excludes the privileged-tagged tests (root / system-mutating).
# Runs as a normal user with no sudo and leaves host networking untouched.
+45
View File
@@ -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"]
+77
View File
@@ -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"
+136 -87
View File
@@ -6,6 +6,7 @@ import (
"net"
"net/netip"
"runtime"
"slices"
"strconv"
"sync"
"time"
@@ -17,17 +18,20 @@ import (
nberrors "github.com/netbirdio/netbird/client/errors"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
"github.com/netbirdio/netbird/client/internal/ebpf"
ebpfMgr "github.com/netbirdio/netbird/client/internal/ebpf/manager"
)
const (
customPort = 5053
// randomPortAttempts bounds the search for a port free on both protocols.
randomPortAttempts = 5
)
var (
defaultIP = netip.MustParseAddr("127.0.0.1")
customIP = netip.MustParseAddr("127.0.0.153")
// dnatProtocols are the protocols the port 53 redirect covers.
dnatProtocols = []firewall.Protocol{firewall.ProtocolUDP, firewall.ProtocolTCP}
)
type serviceViaListener struct {
@@ -40,9 +44,20 @@ type serviceViaListener struct {
listenPort uint16
listenerIsRunning bool
listenerFlagLock sync.Mutex
ebpfService ebpfMgr.Manager
firewall Firewall
tcpDNATConfigured bool
// dnatRules holds the port 53 redirects that are installed and not yet
// removed, so a removal that fails can be retried.
dnatRules []dnatRule
}
// dnatRule is a port 53 redirect as it was installed. The target is kept with
// the rule because the listener can come back on a different address or port,
// and a retried removal has to name the address and port the rule was added
// with, not the ones in use now.
type dnatRule struct {
protocol firewall.Protocol
ip netip.Addr
port uint16
}
func newServiceViaListener(wgIface WGIface, customAddr *netip.AddrPort, fw Firewall) *serviceViaListener {
@@ -112,34 +127,93 @@ func (s *serviceViaListener) Listen() error {
}
}()
// When eBPF redirects UDP port 53 to our listen port, TCP still needs
// a DNAT rule because eBPF only handles UDP.
if s.ebpfService != nil && s.firewall != nil && s.listenPort != DefaultPort {
if err := s.firewall.AddOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
log.Warnf("failed to add DNS TCP DNAT rule, TCP DNS on port 53 will not work: %v", err)
} else {
s.tcpDNATConfigured = true
log.Infof("added DNS TCP DNAT rule: %s:%d -> %s:%d", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
}
if s.listenPort != DefaultPort {
s.setupDNAT()
}
return nil
}
// setupDNAT redirects port 53 to the port the DNS server actually listens on.
// Both protocols must be redirected or none: RuntimePort reports port 53 only
// while the full redirect is in place, so a half-configured redirect would
// advertise a resolver that answers over one protocol.
func (s *serviceViaListener) setupDNAT() {
if s.firewall == nil {
log.Errorf("no firewall manager available to redirect DNS port %d to %d, "+
"clients pointed at %s will not reach the resolver", DefaultPort, s.listenPort, s.listenIP)
return
}
// Clear whatever an earlier removal left behind first. Those rules can point
// at an address or port this listener no longer uses, and they are matched
// before anything added now, so adding a redirect on top of one would keep
// sending port 53 traffic to the previous listener while reporting the
// redirect as complete. The rules stay recorded for a later attempt.
if err := s.removeDNAT(); err != nil {
log.Errorf("failed to remove stale DNS DNAT rules, leaving port %d redirected to the previous listener: %v",
DefaultPort, err)
return
}
for _, proto := range dnatProtocols {
if err := s.firewall.AddOutputDNAT(s.listenIP, proto, DefaultPort, s.listenPort); err != nil {
log.Errorf("failed to add DNS %s DNAT rule, DNS on port %d will not work: %v",
proto, DefaultPort, err)
if err := s.removeDNAT(); err != nil {
log.Warnf("failed to roll back DNS DNAT rules, retrying on stop: %v", err)
}
return
}
s.dnatRules = append(s.dnatRules, dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort})
}
log.Infof("added DNS DNAT rules: %s:%d -> %s:%d (UDP + TCP)", s.listenIP, DefaultPort, s.listenIP, s.listenPort)
}
// removeDNAT removes every installed port 53 redirect. A rule whose removal
// fails stays recorded so a later setup or Stop retries it, rather than leaving
// port 53 pointing at a resolver that is no longer listening.
func (s *serviceViaListener) removeDNAT() error {
if s.firewall == nil {
return nil
}
var merr *multierror.Error
var remaining []dnatRule
for _, rule := range s.dnatRules {
if err := s.firewall.RemoveOutputDNAT(rule.ip, rule.protocol, DefaultPort, rule.port); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove DNS %s DNAT rule for %s:%d: %w",
rule.protocol, rule.ip, rule.port, err))
remaining = append(remaining, rule)
}
}
s.dnatRules = remaining
return nberrors.FormatErrorOrNil(merr)
}
func (s *serviceViaListener) Stop() error {
s.listenerFlagLock.Lock()
defer s.listenerFlagLock.Unlock()
var merr *multierror.Error
// Redirects are removed even when the listener is already stopped, so that
// a removal which failed earlier is retried instead of leaving port 53
// pointing at a resolver that no longer listens.
if err := s.removeDNAT(); err != nil {
merr = multierror.Append(merr, err)
}
if !s.listenerIsRunning {
return nil
return nberrors.FormatErrorOrNil(merr)
}
s.listenerIsRunning = false
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
var merr *multierror.Error
if err := s.server.ShutdownContext(ctx); err != nil {
merr = multierror.Append(merr, fmt.Errorf("stop DNS UDP server: %w", err))
}
@@ -148,19 +222,6 @@ func (s *serviceViaListener) Stop() error {
merr = multierror.Append(merr, fmt.Errorf("stop DNS TCP server: %w", err))
}
if s.tcpDNATConfigured && s.firewall != nil {
if err := s.firewall.RemoveOutputDNAT(s.listenIP, firewall.ProtocolTCP, DefaultPort, s.listenPort); err != nil {
merr = multierror.Append(merr, fmt.Errorf("remove DNS TCP DNAT rule: %w", err))
}
s.tcpDNATConfigured = false
}
if s.ebpfService != nil {
if err := s.ebpfService.FreeDNSFwd(); err != nil {
merr = multierror.Append(merr, fmt.Errorf("stop traffic forwarder: %w", err))
}
}
return nberrors.FormatErrorOrNil(merr)
}
@@ -177,11 +238,23 @@ func (s *serviceViaListener) RuntimePort() int {
s.listenerFlagLock.Lock()
defer s.listenerFlagLock.Unlock()
if s.ebpfService != nil {
if s.redirectInstalled() {
return DefaultPort
} else {
return int(s.listenPort)
}
return int(s.listenPort)
}
// redirectInstalled reports whether every protocol is redirected from port 53
// to the address and port the listener currently serves. Rules left over from
// an earlier listener do not count.
func (s *serviceViaListener) redirectInstalled() bool {
for _, proto := range dnatProtocols {
current := dnatRule{protocol: proto, ip: s.listenIP, port: s.listenPort}
if !slices.Contains(s.dnatRules, current) {
return false
}
}
return true
}
func (s *serviceViaListener) RuntimeIP() netip.Addr {
@@ -190,30 +263,29 @@ func (s *serviceViaListener) RuntimeIP() netip.Addr {
// evalListenAddress figures out the listen address for the DNS server.
// IPv4-only: all peers have a v4 overlay address, and DNS config points to v4.
// First checks port 53 on WG interface or lo, then tries eBPF on a random port,
// then falls back to port 5053.
// Prefers port 53 on the overlay interface or lo, so no redirect is needed at
// all; when it is taken it falls back to port 5053 and then to a random free
// port, both of which need the port 53 redirect set up by setupDNAT.
func (s *serviceViaListener) evalListenAddress() (netip.Addr, uint16, error) {
if s.customAddr != nil {
return s.customAddr.Addr(), s.customAddr.Port(), nil
}
ip, ok := s.testFreePort(DefaultPort)
if ok {
if ip, ok := s.testFreePort(DefaultPort); ok {
return ip, DefaultPort, nil
}
ebpfSrv, port, ok := s.tryToUseeBPF()
if ok {
s.ebpfService = ebpfSrv
return s.wgInterface.Address().IP, port, nil
}
ip, ok = s.testFreePort(customPort)
if ok {
if ip, ok := s.testFreePort(customPort); ok {
return ip, customPort, nil
}
return netip.Addr{}, 0, fmt.Errorf("failed to find a free port for DNS server")
ip := s.wgInterface.Address().IP
port, err := s.randomFreePort(ip)
if err != nil {
return netip.Addr{}, 0, fmt.Errorf("find a free port for DNS server: %w", err)
}
return ip, port, nil
}
func (s *serviceViaListener) testFreePort(port int) (netip.Addr, bool) {
@@ -260,48 +332,25 @@ func (s *serviceViaListener) tryToBind(ip netip.Addr, port int) bool {
return true
}
// tryToUseeBPF decides whether to apply eBPF program to capture DNS traffic on port 53.
// This is needed because on some operating systems if we start a DNS server not on a default port 53,
// the domain name resolution won't work. So, in case we are running on Linux and picked a free
// port we should fall back to the eBPF solution that will capture traffic on port 53 and forward
// it to a local DNS server running on the chosen port.
func (s *serviceViaListener) tryToUseeBPF() (ebpfMgr.Manager, uint16, bool) {
if runtime.GOOS != "linux" {
return nil, 0, false
// randomFreePort returns a port that is free on ip for both UDP and TCP, since
// the DNS server binds both. The probe listeners are closed again, so the port
// is only likely, not guaranteed, to still be free when the server binds it.
func (s *serviceViaListener) randomFreePort(ip netip.Addr) (uint16, error) {
for range randomPortAttempts {
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
return 0, fmt.Errorf("bind random port: %w", err)
}
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
if err := probeListener.Close(); err != nil {
return 0, fmt.Errorf("free up probed port: %w", err)
}
if s.tryToBind(ip, int(port)) {
return port, nil
}
}
port, err := s.generateFreePort() //nolint:staticcheck,unused
if err != nil {
log.Warnf("failed to generate a free port for eBPF DNS forwarder server: %s", err)
return nil, 0, false
}
ebpfSrv := ebpf.GetEbpfManagerInstance()
err = ebpfSrv.LoadDNSFwd(s.wgInterface.Address().IP, int(port))
if err != nil {
log.Warnf("failed to load DNS forwarder eBPF program, error: %s", err)
return nil, 0, false
}
return ebpfSrv, port, true
}
func (s *serviceViaListener) generateFreePort() (uint16, error) {
ok := s.tryToBind(s.wgInterface.Address().IP, customPort)
if ok {
return customPort, nil
}
probeListener, err := net.ListenUDP("udp4", &net.UDPAddr{})
if err != nil {
log.Debugf("failed to bind random port for DNS: %s", err)
return 0, err
}
port := uint16(probeListener.LocalAddr().(*net.UDPAddr).Port)
if err = probeListener.Close(); err != nil {
log.Debugf("failed to free up DNS port: %s", err)
return 0, err
}
return port, nil
return 0, fmt.Errorf("no port free for UDP and TCP on %s after %d attempts", ip, randomPortAttempts)
}
@@ -1,6 +1,7 @@
package dns
import (
"errors"
"fmt"
"net"
"net/netip"
@@ -10,6 +11,8 @@ import (
"github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
firewall "github.com/netbirdio/netbird/client/firewall/manager"
)
func TestServiceViaListener_TCPAndUDP(t *testing.T) {
@@ -84,3 +87,133 @@ func TestServiceViaListener_TCPAndUDP(t *testing.T) {
require.NotEmpty(t, tcpResp.Answer)
assert.Contains(t, tcpResp.Answer[0].String(), "192.0.2.1", "TCP response should contain expected IP")
}
type dnatCall struct {
rule dnatRule
added bool
}
// fakeFirewall records DNAT calls and fails the ones named in addErrs/removeErrs.
type fakeFirewall struct {
calls []dnatCall
addErrs map[firewall.Protocol]error
removeErrs map[firewall.Protocol]error
}
func (f *fakeFirewall) AddOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
if err := f.addErrs[protocol]; err != nil {
return err
}
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}, added: true})
return nil
}
func (f *fakeFirewall) RemoveOutputDNAT(ip netip.Addr, protocol firewall.Protocol, _, translatedPort uint16) error {
if err := f.removeErrs[protocol]; err != nil {
return err
}
f.calls = append(f.calls, dnatCall{rule: dnatRule{protocol: protocol, ip: ip, port: translatedPort}})
return nil
}
func newDNATTestService(fw Firewall) *serviceViaListener {
return &serviceViaListener{
listenIP: netip.MustParseAddr("100.64.0.1"),
listenPort: customPort,
firewall: fw,
}
}
func TestSetupDNAT_BothProtocols(t *testing.T) {
svc := newDNATTestService(&fakeFirewall{})
svc.setupDNAT()
assert.Len(t, svc.dnatRules, len(dnatProtocols))
assert.Equal(t, DefaultPort, svc.RuntimePort(), "port 53 is advertised once both redirects are installed")
}
func TestSetupDNAT_RollsBackPartialRedirect(t *testing.T) {
fw := &fakeFirewall{addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
svc.setupDNAT()
assert.Empty(t, svc.dnatRules, "the UDP redirect installed before the failure must be rolled back")
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "an incomplete redirect must not advertise port 53")
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
assert.Contains(t, fw.calls, dnatCall{rule: udp}, "UDP removal should have been attempted")
}
// A rollback that fails must keep the rule recorded, so port 53 is not left
// redirected to a resolver that no longer listens.
func TestStop_RetriesFailedDNATRemoval(t *testing.T) {
fw := &fakeFirewall{
addErrs: map[firewall.Protocol]error{firewall.ProtocolTCP: errors.New("nftables busy")},
removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")},
}
svc := newDNATTestService(fw)
svc.setupDNAT()
udp := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: svc.listenPort}
require.Equal(t, []dnatRule{udp}, svc.dnatRules, "a failed rollback keeps the rule for a later retry")
require.Error(t, svc.Stop(), "the failing removal should be reported")
require.Equal(t, []dnatRule{udp}, svc.dnatRules)
delete(fw.removeErrs, firewall.ProtocolUDP)
require.NoError(t, svc.Stop(), "a later stop retries the removal")
assert.Empty(t, svc.dnatRules)
}
// A stale rule that cannot be removed is matched before anything added now, so
// no new redirect may be installed on top of it and port 53 must not be
// advertised as reaching this listener.
func TestSetupDNAT_AbortsWhileStaleRuleRemains(t *testing.T) {
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
stalePort := svc.listenPort
svc.setupDNAT()
require.Error(t, svc.Stop())
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
svc.listenPort = stalePort + 1
fw.calls = nil
svc.setupDNAT()
assert.Equal(t, []dnatRule{staleUDP}, svc.dnatRules, "the stale rule stays recorded for a later attempt")
for _, call := range fw.calls {
assert.False(t, call.added, "no redirect may be installed while a stale one is still in place")
}
assert.Equal(t, int(svc.listenPort), svc.RuntimePort(), "port 53 must not be advertised")
}
// A rule left behind by a failed removal must be removed with the address and
// port it was installed with, even when the listener has since moved to another
// port, and it must not count towards the redirect the new listener advertises.
func TestSetupDNAT_ClearsStaleRuleAfterPortChange(t *testing.T) {
fw := &fakeFirewall{removeErrs: map[firewall.Protocol]error{firewall.ProtocolUDP: errors.New("nftables busy")}}
svc := newDNATTestService(fw)
stalePort := svc.listenPort
svc.setupDNAT()
require.Error(t, svc.Stop())
staleUDP := dnatRule{protocol: firewall.ProtocolUDP, ip: svc.listenIP, port: stalePort}
require.Equal(t, []dnatRule{staleUDP}, svc.dnatRules)
delete(fw.removeErrs, firewall.ProtocolUDP)
svc.listenPort = stalePort + 1
fw.calls = nil
svc.setupDNAT()
assert.Contains(t, fw.calls, dnatCall{rule: staleUDP}, "the stale rule must be removed with its original port")
assert.Len(t, svc.dnatRules, len(dnatProtocols))
assert.Equal(t, DefaultPort, svc.RuntimePort(), "the new listener is fully redirected")
for _, rule := range svc.dnatRules {
assert.Equal(t, svc.listenPort, rule.port, "only rules for the current listener remain")
}
}
+28 -8
View File
@@ -1,5 +1,5 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build arm64be || armbe || mips || mips64 || mips64p32 || ppc64 || s390 || s390x || sparc || sparc64
//go:build mips || mips64 || ppc64 || s390x
package ebpf
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
type bpfSpecs struct {
bpfProgramSpecs
bpfMapSpecs
bpfVariableSpecs
}
// bpfSpecs contains programs before they are loaded into the kernel.
// bpfProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct {
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
// It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct {
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
}
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfVariableSpecs struct {
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
}
// bpfObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct {
bpfPrograms
bpfMaps
bpfVariables
}
func (o *bpfObjects) Close() error {
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct {
NbFeatures *ebpf.Map `ebpf:"nb_features"`
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
}
func (m *bpfMaps) Close() error {
return _BpfClose(
m.NbFeatures,
m.NbMapDnsIp,
m.NbMapDnsPort,
m.NbWgProxySettingsMap,
)
}
// bpfVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfVariables struct {
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
WgPort *ebpf.Variable `ebpf:"wg_port"`
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
Binary file not shown.
+28 -8
View File
@@ -1,5 +1,5 @@
// Code generated by bpf2go; DO NOT EDIT.
//go:build 386 || amd64 || amd64p32 || arm || arm64 || loong64 || mips64le || mips64p32le || mipsle || ppc64le || riscv64
//go:build 386 || amd64 || arm || arm64 || loong64 || mips64le || mipsle || ppc64le || riscv64 || wasm
package ebpf
@@ -47,9 +47,10 @@ func loadBpfObjects(obj interface{}, opts *ebpf.CollectionOptions) error {
type bpfSpecs struct {
bpfProgramSpecs
bpfMapSpecs
bpfVariableSpecs
}
// bpfSpecs contains programs before they are loaded into the kernel.
// bpfProgramSpecs contains programs before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfProgramSpecs struct {
@@ -61,17 +62,28 @@ type bpfProgramSpecs struct {
// It can be passed ebpf.CollectionSpec.Assign.
type bpfMapSpecs struct {
NbFeatures *ebpf.MapSpec `ebpf:"nb_features"`
NbMapDnsIp *ebpf.MapSpec `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.MapSpec `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.MapSpec `ebpf:"nb_wg_proxy_settings_map"`
}
// bpfVariableSpecs contains global variables before they are loaded into the kernel.
//
// It can be passed ebpf.CollectionSpec.Assign.
type bpfVariableSpecs struct {
FlagFeatureWgProxy *ebpf.VariableSpec `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.VariableSpec `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.VariableSpec `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.VariableSpec `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.VariableSpec `ebpf:"proxy_port"`
WgPort *ebpf.VariableSpec `ebpf:"wg_port"`
}
// bpfObjects contains all objects after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfObjects struct {
bpfPrograms
bpfMaps
bpfVariables
}
func (o *bpfObjects) Close() error {
@@ -86,20 +98,28 @@ func (o *bpfObjects) Close() error {
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfMaps struct {
NbFeatures *ebpf.Map `ebpf:"nb_features"`
NbMapDnsIp *ebpf.Map `ebpf:"nb_map_dns_ip"`
NbMapDnsPort *ebpf.Map `ebpf:"nb_map_dns_port"`
NbWgProxySettingsMap *ebpf.Map `ebpf:"nb_wg_proxy_settings_map"`
}
func (m *bpfMaps) Close() error {
return _BpfClose(
m.NbFeatures,
m.NbMapDnsIp,
m.NbMapDnsPort,
m.NbWgProxySettingsMap,
)
}
// bpfVariables contains all global variables after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
type bpfVariables struct {
FlagFeatureWgProxy *ebpf.Variable `ebpf:"flag_feature_wg_proxy"`
MapKeyFeatures *ebpf.Variable `ebpf:"map_key_features"`
MapKeyProxyPort *ebpf.Variable `ebpf:"map_key_proxy_port"`
MapKeyWgPort *ebpf.Variable `ebpf:"map_key_wg_port"`
ProxyPort *ebpf.Variable `ebpf:"proxy_port"`
WgPort *ebpf.Variable `ebpf:"wg_port"`
}
// bpfPrograms contains all programs after they have been loaded into the kernel.
//
// It can be passed to loadBpfObjects or ebpf.CollectionSpec.LoadAndAssign.
Binary file not shown.
@@ -1,52 +0,0 @@
package ebpf
import (
"encoding/binary"
"fmt"
"net/netip"
log "github.com/sirupsen/logrus"
)
const (
mapKeyDNSIP uint32 = 0
mapKeyDNSPort uint32 = 1
)
func (tf *GeneralManager) LoadDNSFwd(ip netip.Addr, dnsPort int) error {
log.Debugf("load eBPF DNS forwarder, watching addr: %s:53, redirect to port: %d", ip, dnsPort)
tf.lock.Lock()
defer tf.lock.Unlock()
err := tf.loadXdp()
if err != nil {
return err
}
if !ip.Is4() {
return fmt.Errorf("eBPF DNS forwarder only supports IPv4, got %s", ip)
}
ip4 := ip.As4()
err = tf.bpfObjs.NbMapDnsIp.Put(mapKeyDNSIP, binary.BigEndian.Uint32(ip4[:]))
if err != nil {
return err
}
err = tf.bpfObjs.NbMapDnsPort.Put(mapKeyDNSPort, uint16(dnsPort))
if err != nil {
return err
}
tf.setFeatureFlag(featureFlagDnsForwarder)
err = tf.bpfObjs.NbFeatures.Put(mapKeyFeatures, tf.featureFlags)
if err != nil {
return err
}
return nil
}
func (tf *GeneralManager) FreeDNSFwd() error {
log.Debugf("free ebpf DNS forwarder")
return tf.unsetFeatureFlag(featureFlagDnsForwarder)
}
+3 -4
View File
@@ -15,8 +15,7 @@ import (
const (
mapKeyFeatures uint32 = 0
featureFlagWGProxy = 0b00000001
featureFlagDnsForwarder = 0b00000010
featureFlagWGProxy = 0b00000001
)
var (
@@ -28,9 +27,9 @@ var (
// GeneralManager is used to load multiple eBPF programs with a custom check (if then) done in prog.c
// The manager simply adds a feature (byte) of each program to a map that is shared between the userspace and kernel.
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., dns_fwd.c and wg_proxy.c).
// When packet arrives, the C code checks for each feature (if it is set) and executes each enabled program (e.g., wg_proxy.c).
//
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include
//go:generate go run github.com/cilium/ebpf/cmd/bpf2go -cc clang-14 bpf src/prog.c -- -I /usr/x86_64-linux-gnu/include -include src/bpf_map_def.h
type GeneralManager struct {
lock sync.Mutex
link link.Link
@@ -7,33 +7,24 @@ import (
func TestManager_setFeatureFlag(t *testing.T) {
mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagWGProxy)
if mgr.featureFlags != 1 {
if mgr.featureFlags != featureFlagWGProxy {
t.Errorf("invalid feature state")
}
mgr.setFeatureFlag(featureFlagDnsForwarder)
if mgr.featureFlags != 3 {
t.Errorf("invalid feature state")
mgr.setFeatureFlag(featureFlagWGProxy)
if mgr.featureFlags != featureFlagWGProxy {
t.Errorf("setting a flag twice must be idempotent, got: %d", mgr.featureFlags)
}
}
func TestManager_unsetFeatureFlag(t *testing.T) {
mgr := GeneralManager{}
mgr.setFeatureFlag(featureFlagWGProxy)
mgr.setFeatureFlag(featureFlagDnsForwarder)
err := mgr.unsetFeatureFlag(featureFlagWGProxy)
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if mgr.featureFlags != 2 {
t.Errorf("invalid feature state, expected: %d, got: %d", 2, mgr.featureFlags)
}
err = mgr.unsetFeatureFlag(featureFlagDnsForwarder)
if err != nil {
t.Errorf("unexpected error: %s", err)
}
if mgr.featureFlags != 0 {
t.Errorf("invalid feature state, expected: %d, got: %d", 0, mgr.featureFlags)
}
@@ -0,0 +1,16 @@
// libbpf 1.0 removed struct bpf_map_def, but the programs here keep the legacy
// map definitions: they load on kernels built without BTF, which BTF-style
// (SEC(".maps")) definitions do not. Define the struct ourselves so the
// programs compile against current libbpf headers.
#ifndef NB_BPF_MAP_DEF_H
#define NB_BPF_MAP_DEF_H
struct bpf_map_def {
unsigned int type;
unsigned int key_size;
unsigned int value_size;
unsigned int max_entries;
unsigned int map_flags;
};
#endif
-67
View File
@@ -1,67 +0,0 @@
const __u32 map_key_dns_ip = 0;
const __u32 map_key_dns_port = 1;
struct bpf_map_def SEC("maps") nb_map_dns_ip = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u32),
.max_entries = 10,
};
struct bpf_map_def SEC("maps") nb_map_dns_port = {
.type = BPF_MAP_TYPE_ARRAY,
.key_size = sizeof(__u32),
.value_size = sizeof(__u16),
.max_entries = 10,
};
__be32 dns_ip = 0;
__be16 dns_port = 0;
// 13568 is 53 in big endian
__be16 GENERAL_DNS_PORT = 13568;
bool read_settings() {
__u16 *port_value;
__u32 *ip_value;
// read dns ip
ip_value = bpf_map_lookup_elem(&nb_map_dns_ip, &map_key_dns_ip);
if(!ip_value) {
return false;
}
dns_ip = htonl(*ip_value);
// read dns port
port_value = bpf_map_lookup_elem(&nb_map_dns_port, &map_key_dns_port);
if (!port_value) {
return false;
}
dns_port = htons(*port_value);
return true;
}
int xdp_dns_fwd(struct iphdr *ip, struct udphdr *udp) {
if (dns_port == 0) {
if(!read_settings()){
return XDP_PASS;
}
// bpf_printk("dns port: %d", ntohs(dns_port));
// bpf_printk("dns ip: %d", ntohl(dns_ip));
}
if (udp->dest == GENERAL_DNS_PORT && ip->daddr == dns_ip) {
udp->dest = dns_port;
// Clear the now-stale checksum; zero means "not computed" for IPv4.
udp->check = 0;
return XDP_PASS;
}
if (udp->source == dns_port && ip->saddr == dns_ip) {
udp->source = GENERAL_DNS_PORT;
udp->check = 0;
return XDP_PASS;
}
return XDP_PASS;
}
-6
View File
@@ -5,11 +5,9 @@
#include <netinet/in.h>
#include <linux/bpf.h>
#include <bpf/bpf_helpers.h>
#include "dns_fwd.c"
#include "wg_proxy.c"
const __u16 flag_feature_wg_proxy = 0b01;
const __u16 flag_feature_dns_fwd = 0b10;
const __u32 map_key_features = 0;
struct bpf_map_def SEC("maps") nb_features = {
@@ -48,10 +46,6 @@ int nb_xdp_prog(struct xdp_md *ctx) {
return XDP_PASS;
}
if (*features & flag_feature_dns_fwd) {
xdp_dns_fwd(ip, udp);
}
if (*features & flag_feature_wg_proxy) {
xdp_wg_proxy(ip, udp);
}
+14 -4
View File
@@ -1,8 +1,18 @@
# DNS forwarder
# XDP programs
The agent attach the XDP program to the lo device. We can not use fake address in eBPF because the
traffic does not appear in the eBPF program. The program capture the traffic on wg_ip:53 and
overwrite in it the destination port to 5053.
`prog.c` is attached to the `lo` device and dispatches to the features enabled in the
`nb_features` map. The only feature is the WireGuard proxy (`wg_proxy.c`): it rewrites
loopback UDP sent from the WireGuard listen port so it reaches the userspace relay proxy
port instead, and swaps the peer endpoint port into the source so the proxy can tell
peers apart.
Maps use the legacy `struct bpf_map_def` form, defined in `bpf_map_def.h` because libbpf
1.0 removed it. They load on kernels built without BTF, which BTF-style (`SEC(".maps")`)
definitions do not.
Regenerate the objects with `go generate ./client/internal/ebpf/ebpf/`; it needs
`clang-14`. Loading a regenerated object needs root, attaching it needs `bpf_link`
(kernel >= 5.7), and only one XDP program can own `lo` at a time.
# Debug
+1 -5
View File
@@ -1,11 +1,7 @@
package manager
import "net/netip"
// Manager is used to load multiple eBPF programs. E.g., current DNS programs and WireGuard proxy
// Manager is used to load multiple eBPF programs. E.g., the WireGuard proxy
type Manager interface {
LoadDNSFwd(ip netip.Addr, dnsPort int) error
FreeDNSFwd() error
LoadWgProxy(proxyPort, wgPort int) error
FreeWGProxy() error
}
+21 -7
View File
@@ -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)
+4 -4
View File
@@ -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)
}
+2 -2
View File
@@ -819,8 +819,8 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
// "none" would blank the UI at the exact moment it should say the session
// ended.
func (d *Status) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
d.mux.RLock()
defer d.mux.RUnlock()
return d.sessionExpiresAt
}
@@ -6,6 +6,7 @@ import (
"os/user"
"path/filepath"
"runtime"
"strconv"
log "github.com/sirupsen/logrus"
)
@@ -13,17 +14,21 @@ import (
const envSudoUser = "SUDO_USER"
var (
geteuid = os.Geteuid
lookupUser = user.Lookup
currentUser = user.Current
getegid = os.Getegid
geteuid = os.Geteuid
lookupUser = user.Lookup
)
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
// the user who ran sudo, not root: privileged flags force commands through
// sudo, and resolving profiles as root would silently switch the daemon to
// root's (default) profile instead of the invoking user's. Privilege decisions
// are not made here — those stay on the kernel credentials of the daemon
// connection, which SUDO_USER (a plain environment variable) can never
// influence; a forged value only selects a profile root could select anyway.
// root's (default) profile instead of the invoking user's. An unmapped positive
// process UID uses its numeric kernel identity; root, sudo lookup failures, and
// unavailable platform identities still fail closed. Privilege decisions stay
// on the kernel credentials of the daemon connection, which SUDO_USER (a plain
// environment variable) can never influence; a forged value only selects a
// profile root could select anyway.
func InvokingUser() (*user.User, error) {
if u, ok := sudoInvokingUser(); ok {
return u, nil
@@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) {
if sudoActive() {
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
}
return user.Current()
u, err := currentUser()
if err == nil {
return u, nil
}
uid := geteuid()
if uid <= 0 {
return nil, err
}
log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err)
uidString := strconv.Itoa(uid)
return &user.User{
Username: uidString,
Uid: uidString,
Gid: strconv.Itoa(getegid()),
}, nil
}
// IsPlainRoot reports that the process runs as root with no usable sudo
@@ -2,6 +2,7 @@ package profilemanager
import (
"errors"
"fmt"
"io/fs"
"os"
"os/user"
@@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
assert.Equal(t, current.Username, got.Username)
assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo")
}
func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) {
for _, uid := range []int{0, -1} {
t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) {
t.Setenv(envSudoUser, "")
lookupErr := errors.New("current user unavailable")
fakeUnmappedUser(t, uid, 0, lookupErr)
got, err := InvokingUser()
require.ErrorIs(t, err, lookupErr)
assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity")
})
}
}
func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
t.Setenv(envSudoUser, "")
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
profilesRoot := t.TempDir()
origDir := DefaultConfigPathDir
origOverride := ConfigDirOverride
DefaultConfigPathDir = profilesRoot
ConfigDirOverride = ""
t.Cleanup(func() {
DefaultConfigPathDir = origDir
ConfigDirOverride = origOverride
})
profileID := ID("0123456789abcdef0123456789abcdef")
got, err := (&Profile{ID: profileID}).FilePath()
require.NoError(t, err)
assert.Equal(t,
filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"),
got,
"profile path should use the numeric UID namespace",
)
entries, err := os.ReadDir(profilesRoot)
require.NoError(t, err)
require.Len(t, entries, 1, "only the numeric UID directory should be created")
assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric")
assert.True(t, entries[0].IsDir(), "profile namespace should be a directory")
}
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
@@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
fakeSudo(t, filepath.Join("/home", "misha"))
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
origCurrentUser := currentUser
currentUser = func() (*user.User, error) {
t.Fatal("currentUser must not be called after a sudo lookup failure")
return nil, errors.New("currentUser called unexpectedly")
}
t.Cleanup(func() { currentUser = origCurrentUser })
got, err := InvokingUser()
require.Error(t, err)
assert.Nil(t, got, "must not resolve to the root process user")
@@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) {
})
}
func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) {
t.Helper()
origCurrentUser := currentUser
origEuid := geteuid
origEgid := getegid
currentUser = func() (*user.User, error) { return nil, lookupErr }
geteuid = func() int { return uid }
getegid = func() int { return gid }
t.Cleanup(func() {
currentUser = origCurrentUser
geteuid = origEuid
getegid = origEgid
})
}
func assertNoEntries(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {
+9 -4
View File
@@ -1,6 +1,10 @@
package mdm
import "net/url"
import (
"net/url"
"github.com/netbirdio/netbird/util"
)
// PreSharedKeyRedactedSentinel is the redaction mask returned in place of a
// real pre-shared key; an incoming value equal to it is a round-trip echo,
@@ -44,8 +48,9 @@ func ConflictStringPtr(key string, p *string) ConflictCheck {
}
}
// ConflictURL builds a ConflictCheck for a URL-typed MDM key; both sides are
// normalized via CanonicalURL before comparison.
// ConflictURL builds a ConflictCheck for a URL-typed MDM key. The two sides are
// compared as the endpoints they address, not as strings: see
// util.SameServiceURL.
func ConflictURL(key, got string) ConflictCheck {
return ConflictCheck{
Key: key,
@@ -54,7 +59,7 @@ func ConflictURL(key, got string) ConflictCheck {
return true
}
want, ok := pol.GetString(key)
return ok && CanonicalURL(want) == CanonicalURL(got)
return ok && util.SameServiceURLStrings(want, got)
},
}
}
+40
View File
@@ -0,0 +1,40 @@
package mdm
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The same spellings, through the conflict check that decides whether a request
// is refused. An enforced URL restated in another spelling addresses the very
// server the policy names, so it must not be reported as a conflict.
func TestConflictURLComparesEndpoints(t *testing.T) {
policy := NewPolicy(map[string]any{KeyManagementURL: "https://mgmt.example.com"})
require.True(t, policy.HasKey(KeyManagementURL))
for _, restated := range []string{
"https://mgmt.example.com",
"https://mgmt.example.com:443",
"https://mgmt.example.com/",
"https://MGMT.example.com",
"https://mgmt.example.com:0443",
} {
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, restated)})
assert.Empty(t, conflicts, "%q is the enforced endpoint written differently", restated)
}
for _, diverging := range []string{
"https://other.example.com",
"http://mgmt.example.com",
"https://mgmt.example.com:8443",
"https://mgmt.example.com/other",
} {
conflicts := ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, diverging)})
assert.Equal(t, []string{KeyManagementURL}, conflicts, "%q addresses another endpoint", diverging)
}
// An unset field is not a request to change anything.
assert.Empty(t, ResolveConflicts(policy, []ConflictCheck{ConflictURL(KeyManagementURL, "")}))
}
+2
View File
@@ -235,6 +235,8 @@ func (p *Policy) GetBool(key string) (bool, bool) {
return t != 0, true
case int64:
return t != 0, true
case float64:
return t != 0, true
}
return false, false
}
+16 -1
View File
@@ -96,7 +96,8 @@ func TestPolicy_GetBool(t *testing.T) {
{"int64 nonzero", int64(2), true, true},
{"int64 zero", int64(0), false, true},
{"string garbage", "maybe", false, false},
{"float unsupported", 1.0, false, false},
{"float nonzero", 1.0, true, true},
{"float zero", 0.0, false, true},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
@@ -156,6 +157,20 @@ func TestPolicy_GetStringSlice(t *testing.T) {
})
}
// encoding/json decodes every JSON number into float64, so the mobile
// loaders never see int.
func TestJSONLoader_BoolFromNumber(t *testing.T) {
p := NewJSONLoader(func() string { return `{"blockInbound":1,"disableProfiles":0}` }).Load()
got, ok := p.GetBool(KeyBlockInbound)
assert.True(t, ok)
assert.True(t, got)
got, ok = p.GetBool(KeyDisableProfiles)
assert.True(t, ok)
assert.False(t, got)
}
func TestLoader_NilFetcherReturnsEmpty(t *testing.T) {
// Loader.Load with no fetcher (desktop construction) must degrade
// gracefully and never return nil; on linux loadPlatform is a stub
+2
View File
@@ -20,6 +20,7 @@ type Fields struct {
DisableMetricsCollection bool `json:"disableMetricsCollection"`
SplitTunnelMode bool `json:"splitTunnelMode"`
SplitTunnelApps bool `json:"splitTunnelApps"`
RemoteJobsAllowed bool `json:"allowRemoteJobs"`
DisableAdvancedView *bool `json:"disableAdvancedView"`
}
@@ -60,6 +61,7 @@ func BuildRestrictions(policy *Policy) Restrictions {
r.MDM.DisableMetricsCollection = policy.HasKey(KeyDisableMetricsCollection)
r.MDM.SplitTunnelMode = policy.HasKey(KeySplitTunnelMode)
r.MDM.SplitTunnelApps = policy.HasKey(KeySplitTunnelApps)
r.MDM.RemoteJobsAllowed = policy.HasKey(KeyRemoteJobsAllowed)
if v, ok := policy.GetBool(KeyAllowServerSSH); ok {
r.MDM.AllowServerSSH = &v
}
+18
View File
@@ -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>
+33 -24
View File
@@ -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

+7 -5
View File
@@ -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;
};
+63 -2
View File
@@ -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,
)}
+2 -2
View File
@@ -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
+25 -22
View File
@@ -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",
+15
View File
@@ -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"
},
+20
View File
@@ -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."
+15
View File
@@ -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"
},
+15
View File
@@ -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"
},
+15
View File
@@ -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"
},
+15
View File
@@ -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"
},
+15
View File
@@ -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": "管理サーバー"
},
+15
View File
@@ -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"
},
+15
View File
@@ -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": "Сервер управления"
},
+17 -2
View File
@@ -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": "Очікування авторизації…"
}
}
+15
View File
@@ -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": "管理服务器"
},
+9 -4
View File
@@ -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() {
@@ -100,7 +101,7 @@ func main() {
var tray *Tray
app := newApplication(func() {
if tray != nil {
tray.ShowWindow()
go tray.ShowWindow()
}
})
@@ -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,
},
+52 -1
View File
@@ -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
+99
View File
@@ -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) {}
+4
View File
@@ -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)
}
+194
View File
@@ -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)
}
}
File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More