mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-16 04:31:28 +02:00
Compare commits
44 Commits
l10n_main2
...
agent-netw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cd7e27027 | ||
|
|
0783605690 | ||
|
|
16544dbc58 | ||
|
|
f458c1f265 | ||
|
|
ec6f1b8c27 | ||
|
|
2cfe14d7ec | ||
|
|
85dd335836 | ||
|
|
5544761b47 | ||
|
|
1d372bb634 | ||
|
|
e290769df1 | ||
|
|
58c09ead21 | ||
|
|
c5503fdc7f | ||
|
|
6b69f5c05d | ||
|
|
db9fcf39ef | ||
|
|
52faa202b2 | ||
|
|
f5ce0bc65a | ||
|
|
77e5ac776b | ||
|
|
12546e231c | ||
|
|
052cf5a748 | ||
|
|
dc91325ac1 | ||
|
|
1796b2a1d8 | ||
|
|
dd87760f3d | ||
|
|
95a458801c | ||
|
|
14f9f8ce22 | ||
|
|
63d16a7cd4 | ||
|
|
fa691721d0 | ||
|
|
d46e2574a7 | ||
|
|
649a867cd3 | ||
|
|
a39b3c4af4 | ||
|
|
b1337f09d0 | ||
|
|
08e187ccb7 | ||
|
|
93cdf64a19 | ||
|
|
c04fb1c388 | ||
|
|
5353cab54f | ||
|
|
a64a417ea5 | ||
|
|
652c5c8b68 | ||
|
|
4d2b8b407b | ||
|
|
03e02c86ce | ||
|
|
789d416215 | ||
|
|
6415215126 | ||
|
|
1ae352a08d | ||
|
|
d928bcb630 | ||
|
|
47b2667653 | ||
|
|
875dda1708 |
@@ -3,7 +3,7 @@
|
||||
[branches]
|
||||
main = "main"
|
||||
perennials = []
|
||||
perennial-regex = ""
|
||||
perennial-regex = "^release-"
|
||||
|
||||
[create]
|
||||
new-branch-type = "feature"
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Check License Dependencies
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, "release-*"]
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
|
||||
1
.github/workflows/frontend-ui.yml
vendored
1
.github/workflows/frontend-ui.yml
vendored
@@ -10,6 +10,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
paths:
|
||||
- "client/ui/frontend/**"
|
||||
- "client/ui/i18n/**"
|
||||
|
||||
1
.github/workflows/golang-test-darwin.yml
vendored
1
.github/workflows/golang-test-darwin.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
1
.github/workflows/golang-test-freebsd.yml
vendored
1
.github/workflows/golang-test-freebsd.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
1
.github/workflows/golang-test-linux.yml
vendored
1
.github/workflows/golang-test-linux.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
1
.github/workflows/golang-test-windows.yml
vendored
1
.github/workflows/golang-test-windows.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
|
||||
1
.github/workflows/install-script-test.yml
vendored
1
.github/workflows/install-script-test.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "release_files/install.sh"
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
15
.github/workflows/release.yml
vendored
15
.github/workflows/release.yml
vendored
@@ -6,6 +6,7 @@ on:
|
||||
- "v*"
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
@@ -254,15 +255,23 @@ jobs:
|
||||
id: tag_and_push_images
|
||||
if: |
|
||||
(github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository) ||
|
||||
(github.event_name == 'push' && github.ref == 'refs/heads/main')
|
||||
(github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-')))
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
# $GITHUB_REF / $GITHUB_EVENT_NAME are read from the runner
|
||||
# environment rather than substituted into this script with the
|
||||
# workflow expression syntax: branch names may legally contain
|
||||
# $(…), and interpolating github.ref would execute it.
|
||||
resolve_tags() {
|
||||
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
|
||||
if [[ "$GITHUB_EVENT_NAME" == "pull_request" ]]; then
|
||||
echo "pr-${{ github.event.pull_request.number }}"
|
||||
else
|
||||
elif [[ "$GITHUB_REF" == "refs/heads/main" ]]; then
|
||||
echo "main sha-$(git rev-parse --short HEAD)"
|
||||
else
|
||||
# Release branches get an immutable sha-* tag only — the floating
|
||||
# "main" tag must never move from a release branch.
|
||||
echo "sha-$(git rev-parse --short HEAD)"
|
||||
fi
|
||||
}
|
||||
|
||||
|
||||
16
.github/workflows/sync-tag.yml
vendored
16
.github/workflows/sync-tag.yml
vendored
@@ -9,21 +9,9 @@ concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
# Receiving workflows (cloud sync-tag, mobile bump-netbird) expect the short
|
||||
# tag form (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref.
|
||||
# The receiving bump-netbird workflows expect the short tag form
|
||||
# (e.g. v0.30.0), not refs/tags/v0.30.0 — github.ref_name, not github.ref.
|
||||
jobs:
|
||||
trigger_sync_tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger release tag sync
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
with:
|
||||
workflow: sync-tag.yml
|
||||
ref: main
|
||||
repo: ${{ secrets.UPSTREAM_REPO }}
|
||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||
|
||||
trigger_android_bump:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
paths:
|
||||
- "infrastructure_files/**"
|
||||
|
||||
42
.github/workflows/ui-translations.yml
vendored
Normal file
42
.github/workflows/ui-translations.yml
vendored
Normal file
@@ -0,0 +1,42 @@
|
||||
name: UI Translations
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "client/ui/i18n/locales/**"
|
||||
- "client/ui/i18n/check-translations.mjs"
|
||||
- ".github/workflows/ui-translations.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "client/ui/i18n/locales/**"
|
||||
- "client/ui/i18n/check-translations.mjs"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
check-translations:
|
||||
name: Check translation key parity
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
# English (en) is the source of truth for translation keys; every other
|
||||
# locale declared in _index.json must carry the exact same key set.
|
||||
- name: Check translation key parity
|
||||
run: node client/ui/i18n/check-translations.mjs
|
||||
1
.github/workflows/wasm-build-validation.yml
vendored
1
.github/workflows/wasm-build-validation.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- "release-*"
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
|
||||
@@ -468,6 +468,13 @@ checksum:
|
||||
- glob: ./infrastructure_files/migrate-to-enterprise.sh
|
||||
|
||||
release:
|
||||
# The signing pipeline (netbirdio/sign-pipelines, dispatched by
|
||||
# trigger_signer) marks the release latest once the Windows and macOS
|
||||
# artifacts are signed. Without this override goreleaser marks it latest
|
||||
# at publish time, while those artifacts are still unsigned.
|
||||
make_latest: false
|
||||
# Mark x.y.z-rc.* and other prerelease tags as prereleases on GitHub.
|
||||
prerelease: auto
|
||||
extra_files:
|
||||
- glob: ./infrastructure_files/getting-started-with-zitadel.sh
|
||||
- glob: ./release_files/install.sh
|
||||
|
||||
@@ -144,3 +144,11 @@ uploads:
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
method: PUT
|
||||
|
||||
release:
|
||||
# Uploads into the release created by the main .goreleaser.yaml run.
|
||||
# make_latest stays false everywhere: the signing pipeline
|
||||
# (netbirdio/sign-pipelines) marks the release latest after the Windows
|
||||
# and macOS artifacts are signed.
|
||||
make_latest: false
|
||||
prerelease: auto
|
||||
|
||||
@@ -43,3 +43,11 @@ checksum:
|
||||
name_template: "{{ .ProjectName }}_darwin_checksums.txt"
|
||||
changelog:
|
||||
disable: true
|
||||
|
||||
release:
|
||||
# Uploads into the release created by the main .goreleaser.yaml run.
|
||||
# make_latest stays false everywhere: the signing pipeline
|
||||
# (netbirdio/sign-pipelines) marks the release latest after the Windows
|
||||
# and macOS artifacts are signed.
|
||||
make_latest: false
|
||||
prerelease: auto
|
||||
|
||||
@@ -43,19 +43,17 @@ archives:
|
||||
- netbird-ui-gtk3
|
||||
|
||||
nfpms:
|
||||
# Same package_name as the GTK4 packages -- the two are mutually-exclusive
|
||||
# alternatives served from separate repo paths (see uploads below); a given
|
||||
# distro points at exactly one of them. The file names must still differ:
|
||||
# the Debian pool is shared storage keyed by file name, so a default-named
|
||||
# gtk3 .deb would overwrite the stable one.
|
||||
# Mutually-exclusive alternative to the GTK4 netbird-ui package -- both
|
||||
# ship the same /usr/bin/netbird-ui from the shared stable/yum repos, so
|
||||
# this one carries its own name and conflicts with the GTK4 package.
|
||||
- maintainer: Netbird <dev@netbird.io>
|
||||
description: Netbird client UI.
|
||||
homepage: https://netbird.io/
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_deb_gtk3
|
||||
package_name: netbird-ui
|
||||
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
package_name: netbird-ui-gtk3
|
||||
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
@@ -67,6 +65,10 @@ nfpms:
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
conflicts:
|
||||
- netbird-ui
|
||||
replaces:
|
||||
- netbird-ui
|
||||
dependencies:
|
||||
- netbird (>= 0.75.0)
|
||||
- libgtk-3-0
|
||||
@@ -79,8 +81,8 @@ nfpms:
|
||||
license: BSD-3-Clause
|
||||
vendor: NetBird
|
||||
id: netbird_ui_rpm_gtk3
|
||||
package_name: netbird-ui
|
||||
file_name_template: "{{ .PackageName }}-gtk3_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
package_name: netbird-ui-gtk3
|
||||
file_name_template: "{{ .PackageName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"
|
||||
builds:
|
||||
- netbird-ui-gtk3
|
||||
formats:
|
||||
@@ -92,6 +94,10 @@ nfpms:
|
||||
dst: /usr/share/applications/org.wails.netbird.desktop
|
||||
- src: client/ui/build/appicon.png
|
||||
dst: /usr/share/pixmaps/netbird.png
|
||||
# No `replaces` here: nfpm maps it to rpm Obsoletes, which would make
|
||||
# dnf swap installed GTK4 netbird-ui packages for this one on upgrade.
|
||||
conflicts:
|
||||
- netbird-ui
|
||||
dependencies:
|
||||
- netbird >= 0.75.0
|
||||
- (gtk3 or libgtk-3-0)
|
||||
@@ -111,32 +117,28 @@ changelog:
|
||||
disable: true
|
||||
|
||||
uploads:
|
||||
# The gtk3 packages reuse the netbird-ui package name, so they live in
|
||||
# dedicated repo paths (deb distribution `gtk3`, yum path `yum-gtk3`) that
|
||||
# legacy distros point their repo config at.
|
||||
#
|
||||
# GoReleaser derives the credential env var from the upload name, so these
|
||||
# would look for UPLOAD_DEBIAN-GTK3_SECRET / UPLOAD_YUM-GTK3_SECRET. The
|
||||
# release workflow only exports UPLOAD_DEBIAN_SECRET / UPLOAD_YUM_SECRET, and
|
||||
# a missing secret is a silent skip rather than a failure -- the packages
|
||||
# reached the GitHub release but never the package repositories. Point
|
||||
# `password` at the exported vars so both uploads authenticate.
|
||||
- name: debian-gtk3
|
||||
- name: debian
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_deb_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=gtk3;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
target: https://pkgs.wiretrustee.com/debian/pool/{{ .ArtifactName }};deb.distribution=stable;deb.component=main;deb.architecture={{ if .Arm }}armhf{{ else }}{{ .Arch }}{{ end }};deb.package=
|
||||
username: dev@wiretrustee.com
|
||||
password: "{{ .Env.UPLOAD_DEBIAN_SECRET }}"
|
||||
method: PUT
|
||||
|
||||
- name: yum-gtk3
|
||||
- name: yum
|
||||
skip: "{{ .Env.SKIP_PUBLISH }}"
|
||||
ids:
|
||||
- netbird_ui_rpm_gtk3
|
||||
mode: archive
|
||||
target: https://pkgs.wiretrustee.com/yum-gtk3/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
target: https://pkgs.wiretrustee.com/yum/{{ .Arch }}{{ if .Arm }}{{ .Arm }}{{ end }}
|
||||
username: dev@wiretrustee.com
|
||||
password: "{{ .Env.UPLOAD_YUM_SECRET }}"
|
||||
method: PUT
|
||||
|
||||
release:
|
||||
# Uploads into the release created by the main .goreleaser.yaml run.
|
||||
# make_latest stays false everywhere: the signing pipeline
|
||||
# (netbirdio/sign-pipelines) marks the release latest after the Windows
|
||||
# and macOS artifacts are signed.
|
||||
make_latest: false
|
||||
prerelease: auto
|
||||
|
||||
@@ -112,6 +112,7 @@ aligns with our security standards and design expectations.
|
||||
- [Test suite](#test-suite)
|
||||
- [Checklist before submitting a PR](#checklist-before-submitting-a-pr)
|
||||
- [When we close a PR](#when-we-close-a-pr)
|
||||
- [Translations](#translations)
|
||||
- [Other project repositories](#other-project-repositories)
|
||||
- [Contributor License Agreement](#contributor-license-agreement)
|
||||
|
||||
@@ -612,6 +613,17 @@ A closed PR is not a rejected idea. Take it back to the
|
||||
[discussion](https://github.com/netbirdio/netbird/discussions), settle the
|
||||
approach, and reopen the work from there.
|
||||
|
||||
## Translations
|
||||
|
||||
Desktop UI translations are not contributed through pull requests. Translate on
|
||||
[Crowdin](https://crowdin.com/project/netbird) instead: no ticket needed, just
|
||||
join the project and pick your language. Crowdin syncs with this repository and
|
||||
opens the service PRs itself, so hand-edited locale files would conflict with
|
||||
the next sync. Style, terminology, and review guidance live in
|
||||
[client/ui/i18n/TRANSLATING.md](client/ui/i18n/TRANSLATING.md). To request a
|
||||
language the project does not offer yet, ask on the Crowdin project page or in
|
||||
a [discussion](https://github.com/netbirdio/netbird/discussions).
|
||||
|
||||
## Other project repositories
|
||||
|
||||
NetBird project is composed of 3 main repositories:
|
||||
|
||||
@@ -40,6 +40,35 @@ You can then use this private endpoint to configure your AI agents, whether that
|
||||
Full step-by-step setup:
|
||||
**https://docs.netbird.io/agent-network/quickstart**
|
||||
|
||||
## Client settings that don't follow the endpoint
|
||||
|
||||
Most of an agent's traffic follows the base URL you hand it, but a few
|
||||
client-side checks call their vendor directly and never reach the proxy. On a
|
||||
network that blocks direct egress they fail even though inference works, so
|
||||
they are worth setting once when you roll the endpoint out.
|
||||
|
||||
For Claude Code:
|
||||
|
||||
- **Fast mode** checks availability against `api.anthropic.com` rather than the
|
||||
configured base URL. Set `CLAUDE_CODE_SKIP_FAST_MODE_ORG_CHECK=1` when the
|
||||
agent authenticates with `ANTHROPIC_AUTH_TOKEN` alone (the usual shape when
|
||||
the proxy injects the real provider key) or when a TLS-inspecting proxy
|
||||
answers the check itself. Set
|
||||
`CLAUDE_CODE_SKIP_FAST_MODE_NETWORK_ERRORS=1` when the network refuses the
|
||||
connection outright. Fast mode is an Anthropic-API feature, so it is
|
||||
unavailable on a Bedrock- or Vertex-backed endpoint whatever you set.
|
||||
- **Model discovery** is off by default. Set
|
||||
`CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY=1` for the picker to list the
|
||||
models your policies authorise; the proxy filters the response to that set.
|
||||
The client gives discovery a three-second budget and treats any redirect as
|
||||
a failure, so the endpoint must serve `/v1/models` directly.
|
||||
- **The WebFetch domain safety check** also calls `api.anthropic.com` directly
|
||||
and is unaffected by the variables above.
|
||||
|
||||
Allowing direct egress to `api.anthropic.com` covers the network cases but not
|
||||
the credential one, where the check reaches Anthropic and is rejected because
|
||||
the agent presents a proxy-issued key.
|
||||
|
||||
## Architecture
|
||||
|
||||
Agent Network is built on two existing NetBird capabilities:
|
||||
|
||||
@@ -204,8 +204,9 @@ func (a *Auth) foregroundGetTokenInfo(authClient *auth.Auth, urlOpener URLOpener
|
||||
return nil, fmt.Errorf("failed to get OAuth flow: %v", err)
|
||||
}
|
||||
|
||||
// An empty hint is deliberate, not a fallback: a fresh or logged-out profile
|
||||
// leaves the choice to the IdP, which is how accounts get switched.
|
||||
// An empty hint is deliberate, not a fallback: a fresh profile leaves the
|
||||
// choice to the IdP. Switching accounts is done by switching or removing
|
||||
// profiles, not by logging out — logout keeps the email.
|
||||
if a.cfgPath != "" {
|
||||
if hint := readProfileEmail(a.cfgPath); hint != "" {
|
||||
if setter, ok := oAuthFlow.(loginHintSetter); ok {
|
||||
|
||||
@@ -22,7 +22,8 @@ type Profile struct {
|
||||
ID string
|
||||
Name string
|
||||
// Email is the account this profile last logged in with, "" if it never
|
||||
// completed an SSO login or was logged out. See profile_state.go.
|
||||
// completed an SSO login. Kept across logouts; cleared when the profile is
|
||||
// removed. See profile_state.go.
|
||||
Email string
|
||||
IsActive bool
|
||||
}
|
||||
@@ -200,11 +201,9 @@ func (pm *ProfileManager) LogoutProfile(id string) error {
|
||||
return fmt.Errorf("failed to save config: %w", err)
|
||||
}
|
||||
|
||||
// Not fatal: a stale hint costs an account switch, not the logout itself.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to clear stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
// The stored account email is kept on purpose, matching the desktop and CLI
|
||||
// logout semantics: the next login passes it as the login_hint so the IdP
|
||||
// preselects the account. Removing the profile is what deletes it.
|
||||
log.Infof("logged out from profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
@@ -224,11 +223,24 @@ func (pm *ProfileManager) RenameProfile(id string, newName string) error {
|
||||
|
||||
// RemoveProfile deletes a profile
|
||||
func (pm *ProfileManager) RemoveProfile(id string) error {
|
||||
configPath, err := pm.getProfileConfigPath(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Use ServiceManager (removes profile from profiles/ directory)
|
||||
if err := pm.serviceMgr.RemoveProfile(profilemanager.ID(id), androidUsername); err != nil {
|
||||
return fmt.Errorf("failed to remove profile: %w", err)
|
||||
}
|
||||
|
||||
// The account file is this package's, not the ServiceManager's, so it must
|
||||
// go here. The default profile has a fixed filename, so a recreated one
|
||||
// would otherwise inherit the deleted profile's email as its login_hint.
|
||||
// Not fatal: the profile itself is gone.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
log.Warnf("failed to remove stored account email for profile %s: %v", id, err)
|
||||
}
|
||||
|
||||
log.Infof("removed profile: %s", id)
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -90,10 +90,10 @@ func writeProfileEmail(configPath string, email string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeProfileEmail drops the stored account email. Called on logout: while the
|
||||
// email is on disk it goes out as a login_hint, which would steer the next login
|
||||
// straight back into the account just logged out of. Mirrors the desktop UI's
|
||||
// RemoveProfileState call.
|
||||
// removeProfileEmail drops the stored account email. Called on profile removal,
|
||||
// not on logout: a logged-out profile keeps its email so the next login passes
|
||||
// it as the login_hint, matching the desktop and CLI semantics. Mirrors the
|
||||
// desktop UI's RemoveProfileState call.
|
||||
func removeProfileEmail(configPath string) error {
|
||||
accountPath, err := profileAccountPathFor(configPath)
|
||||
if err != nil {
|
||||
|
||||
@@ -127,10 +127,10 @@ func TestWriteThenReadProfileEmail(t *testing.T) {
|
||||
t.Fatalf("remove: %v", err)
|
||||
}
|
||||
if got := readProfileEmail(configPath); got != "" {
|
||||
t.Errorf("expected no email after logout, got %q", got)
|
||||
t.Errorf("expected no email after removal, got %q", got)
|
||||
}
|
||||
|
||||
// Logout may run on a never-logged-in profile, so a second remove must pass.
|
||||
// Removal may run on a never-logged-in profile, so a second remove must pass.
|
||||
if err := removeProfileEmail(configPath); err != nil {
|
||||
t.Fatalf("second remove should be a no-op: %v", err)
|
||||
}
|
||||
|
||||
@@ -305,6 +305,12 @@ func (a *Anonymizer) AnonymizeDomain(domain string) string {
|
||||
return domain
|
||||
}
|
||||
|
||||
// A reverse zone names an address prefix, so it follows the address rules,
|
||||
// which also keeps its digit labels intact.
|
||||
if zone, ok := a.anonymizeReverseZone(baseDomain); ok {
|
||||
return withTrailingDot(zone, hasDot)
|
||||
}
|
||||
|
||||
if suffix := protectedSuffix(baseDomain); suffix != "" {
|
||||
if a.level < LevelStrict || baseDomain == suffix || suffix == infraDomain {
|
||||
return domain
|
||||
@@ -405,6 +411,10 @@ func (a *Anonymizer) AnonymizeString(str string) string {
|
||||
ipv4Regex := regexp.MustCompile(`\b(?:[0-9]{1,3}\.){3}[0-9]{1,3}\b`)
|
||||
ipv6Regex := regexp.MustCompile(`\b([0-9a-fA-F:]+:+[0-9a-fA-F]{0,4})(?:%[0-9a-zA-Z]+)?(?:\/[0-9]{1,3})?(?::[0-9]{1,5})?\b`)
|
||||
|
||||
// Reverse zones go first and are then held out of the passes below: their
|
||||
// labels are digits, which the address patterns would otherwise consume.
|
||||
str, restoreZones := a.replaceReverseZones(str)
|
||||
|
||||
str = ipv4Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
str = ipv6Regex.ReplaceAllStringFunc(str, a.AnonymizeIPString)
|
||||
|
||||
@@ -425,7 +435,7 @@ func (a *Anonymizer) AnonymizeString(str string) string {
|
||||
str = wgKeyRegex.ReplaceAllStringFunc(str, a.AnonymizeWGKey)
|
||||
}
|
||||
|
||||
return str
|
||||
return restoreZones(str)
|
||||
}
|
||||
|
||||
// sortedDomains returns the domain mappings longest-first, so a full-FQDN
|
||||
|
||||
174
client/anonymize/reverse_zone.go
Normal file
174
client/anonymize/reverse_zone.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package anonymize
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"net/netip"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
reverseZoneSuffixV4 = ".in-addr.arpa"
|
||||
reverseZoneSuffixV6 = ".ip6.arpa"
|
||||
|
||||
v6Nibbles = 32
|
||||
v4Octets = 4
|
||||
)
|
||||
|
||||
// reverseZoneRegexes match a reverse zone or a full reverse name in free text.
|
||||
// They are applied before the address passes of AnonymizeString, whose IPv4
|
||||
// pattern would otherwise consume the digit labels of a zone and replace parts
|
||||
// of it with unrelated addresses.
|
||||
var reverseZoneRegexes = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(?:[0-9]{1,3}\.){1,4}in-addr\.arpa\b`),
|
||||
regexp.MustCompile(`(?:[0-9a-fA-F]\.){1,32}ip6\.arpa\b`),
|
||||
}
|
||||
|
||||
// anonymizeReverseZone maps a reverse zone to the zone of the anonymized form
|
||||
// of the prefix it encodes, so it follows the address rules rather than the
|
||||
// domain ones: the zone of an address that is preserved is preserved too, and
|
||||
// the zone of one that is replaced names the replacement. This keeps a reverse
|
||||
// zone recognizable as such, and consistent with the addresses it belongs to
|
||||
// elsewhere in the same output. It reports false for anything that is not a
|
||||
// reverse zone.
|
||||
func (a *Anonymizer) anonymizeReverseZone(domain string) (string, bool) {
|
||||
prefix, labelCount, suffix, ok := parseReverseZone(domain)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
|
||||
anonymized := a.AnonymizeIP(prefix)
|
||||
if anonymized == prefix {
|
||||
return domain, true
|
||||
}
|
||||
|
||||
return reverseZoneName(anonymized, labelCount) + suffix, true
|
||||
}
|
||||
|
||||
// replaceReverseZones anonymizes every reverse zone in str and swaps each one
|
||||
// for a placeholder, returning a function that puts the anonymized zones back.
|
||||
// The placeholders carry no dots, digits or colons, so no later pass matches
|
||||
// them.
|
||||
func (a *Anonymizer) replaceReverseZones(str string) (string, func(string) string) {
|
||||
var zones []string
|
||||
|
||||
for _, re := range reverseZoneRegexes {
|
||||
str = re.ReplaceAllStringFunc(str, func(match string) string {
|
||||
zone, ok := a.anonymizeReverseZone(match)
|
||||
if !ok {
|
||||
return match
|
||||
}
|
||||
|
||||
zones = append(zones, zone)
|
||||
return reverseZonePlaceholder(len(zones) - 1)
|
||||
})
|
||||
}
|
||||
|
||||
if len(zones) == 0 {
|
||||
return str, func(s string) string { return s }
|
||||
}
|
||||
|
||||
return str, func(s string) string {
|
||||
for i, zone := range zones {
|
||||
s = strings.ReplaceAll(s, reverseZonePlaceholder(i), zone)
|
||||
}
|
||||
return s
|
||||
}
|
||||
}
|
||||
|
||||
func reverseZonePlaceholder(index int) string {
|
||||
return "\x00reversezone" + strconv.Itoa(index) + "\x00"
|
||||
}
|
||||
|
||||
// parseReverseZone turns a reverse zone into the address of the prefix its
|
||||
// labels spell backwards, padding the absent low-order part with zeroes, and
|
||||
// returns the label count and zone suffix so the name can be rebuilt.
|
||||
func parseReverseZone(domain string) (netip.Addr, int, string, bool) {
|
||||
lower := strings.ToLower(domain)
|
||||
|
||||
switch {
|
||||
case strings.HasSuffix(lower, reverseZoneSuffixV4):
|
||||
labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV4), ".")
|
||||
addr, ok := reverseZoneAddrV4(labels)
|
||||
return addr, len(labels), reverseZoneSuffixV4, ok
|
||||
case strings.HasSuffix(lower, reverseZoneSuffixV6):
|
||||
labels := strings.Split(strings.TrimSuffix(lower, reverseZoneSuffixV6), ".")
|
||||
addr, ok := reverseZoneAddrV6(labels)
|
||||
return addr, len(labels), reverseZoneSuffixV6, ok
|
||||
default:
|
||||
return netip.Addr{}, 0, "", false
|
||||
}
|
||||
}
|
||||
|
||||
func reverseZoneAddrV4(labels []string) (netip.Addr, bool) {
|
||||
if len(labels) == 0 || len(labels) > v4Octets {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
var octets [v4Octets]byte
|
||||
for i, label := range labels {
|
||||
octet, err := strconv.ParseUint(label, 10, 8)
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
octets[len(labels)-1-i] = byte(octet)
|
||||
}
|
||||
|
||||
return netip.AddrFrom4(octets), true
|
||||
}
|
||||
|
||||
func reverseZoneAddrV6(labels []string) (netip.Addr, bool) {
|
||||
if len(labels) == 0 || len(labels) > v6Nibbles {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
nibbles := make([]byte, 0, v6Nibbles)
|
||||
for i := len(labels) - 1; i >= 0; i-- {
|
||||
if len(labels[i]) != 1 || !isHexDigit(labels[i][0]) {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
nibbles = append(nibbles, labels[i][0])
|
||||
}
|
||||
for len(nibbles) < v6Nibbles {
|
||||
nibbles = append(nibbles, '0')
|
||||
}
|
||||
|
||||
var groups []string
|
||||
for i := 0; i < len(nibbles); i += 4 {
|
||||
groups = append(groups, string(nibbles[i:i+4]))
|
||||
}
|
||||
|
||||
addr, err := netip.ParseAddr(strings.Join(groups, ":"))
|
||||
if err != nil {
|
||||
return netip.Addr{}, false
|
||||
}
|
||||
|
||||
return addr, true
|
||||
}
|
||||
|
||||
// reverseZoneName spells the first labelCount labels of addr backwards, the
|
||||
// inverse of parseReverseZone, without the zone suffix.
|
||||
func reverseZoneName(addr netip.Addr, labelCount int) string {
|
||||
labels := make([]string, 0, labelCount)
|
||||
|
||||
if addr.Is4() {
|
||||
octets := addr.As4()
|
||||
for i := labelCount - 1; i >= 0; i-- {
|
||||
labels = append(labels, strconv.Itoa(int(octets[i])))
|
||||
}
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
address := addr.As16()
|
||||
nibbles := hex.EncodeToString(address[:])
|
||||
for i := labelCount - 1; i >= 0; i-- {
|
||||
labels = append(labels, string(nibbles[i]))
|
||||
}
|
||||
|
||||
return strings.Join(labels, ".")
|
||||
}
|
||||
|
||||
func isHexDigit(c byte) bool {
|
||||
return c >= '0' && c <= '9' || c >= 'a' && c <= 'f' || c >= 'A' && c <= 'F'
|
||||
}
|
||||
171
client/anonymize/reverse_zone_test.go
Normal file
171
client/anonymize/reverse_zone_test.go
Normal file
@@ -0,0 +1,171 @@
|
||||
package anonymize
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newLeveledAnonymizer(level Level) *Anonymizer {
|
||||
a := NewAnonymizer(DefaultAddresses())
|
||||
a.SetLevel(level)
|
||||
return a
|
||||
}
|
||||
|
||||
// TestAnonymizeDomainReverseZone covers reverse zones going through the address
|
||||
// rules instead of the domain ones, so a zone stays a zone and an address that
|
||||
// is preserved keeps the zone that names it.
|
||||
func TestAnonymizeDomainReverseZone(t *testing.T) {
|
||||
// 100.64.0.0/10 is the overlay range, which is CGNAT: preserved at the
|
||||
// default level and replaced from the internal pool at the strict one
|
||||
const overlayZone = "64.100.in-addr.arpa"
|
||||
|
||||
t.Run("overlay zone preserved at the default level", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
assert.Equal(t, overlayZone, a.AnonymizeDomain(overlayZone), "should keep the zone of a preserved address")
|
||||
})
|
||||
|
||||
t.Run("private zone preserved at the default level", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
assert.Equal(t, "168.192.in-addr.arpa", a.AnonymizeDomain("168.192.in-addr.arpa"), "should keep the zone of a private address")
|
||||
})
|
||||
|
||||
t.Run("overlay zone replaced at the strict level", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelStrict)
|
||||
|
||||
got := a.AnonymizeDomain(overlayZone)
|
||||
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
|
||||
assert.NotEqual(t, overlayZone, got, "should replace the encoded prefix")
|
||||
assert.Len(t, strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV4), "."), 2,
|
||||
"should keep the label count, got %q", got)
|
||||
})
|
||||
|
||||
t.Run("public zone replaced at the default level", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
got := a.AnonymizeDomain("113.0.203.in-addr.arpa")
|
||||
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV4), "should stay a reverse zone, got %q", got)
|
||||
assert.NotEqual(t, "113.0.203.in-addr.arpa", got, "should replace a public prefix")
|
||||
})
|
||||
|
||||
t.Run("zone of an address keeps that address mapping", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
anonymizedAddr := a.AnonymizeIPString("203.0.113.7")
|
||||
got := a.AnonymizeDomain("7.113.0.203.in-addr.arpa")
|
||||
|
||||
octets := strings.Split(anonymizedAddr, ".")
|
||||
want := octets[3] + "." + octets[2] + "." + octets[1] + "." + octets[0] + reverseZoneSuffixV4
|
||||
assert.Equal(t, want, got, "should name the same replacement as the address itself")
|
||||
})
|
||||
|
||||
t.Run("ipv6 nibble labels stay single digits", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
|
||||
got := a.AnonymizeDomain(zone)
|
||||
|
||||
require.True(t, strings.HasSuffix(got, reverseZoneSuffixV6), "should stay a reverse zone, got %q", got)
|
||||
labels := strings.Split(strings.TrimSuffix(got, reverseZoneSuffixV6), ".")
|
||||
assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
|
||||
for _, label := range labels {
|
||||
assert.Len(t, label, 1, "nibble label %q should stay a single digit", label)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("trailing dot is kept", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
assert.Equal(t, "64.100.in-addr.arpa.", a.AnonymizeDomain("64.100.in-addr.arpa."), "should keep the trailing dot")
|
||||
})
|
||||
|
||||
t.Run("a domain that only looks like a zone is anonymized as a domain", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
got := a.AnonymizeDomain("not-a-zone.in-addr.arpa")
|
||||
assert.NotContains(t, got, "in-addr.arpa", "should fall back to domain anonymization")
|
||||
})
|
||||
}
|
||||
|
||||
// TestAnonymizeStringReverseZone verifies that a zone inside free text, such as
|
||||
// a DNS log line, is not chewed up by the address passes. The IPv4 pattern
|
||||
// matches any run of dotted digits, which a reverse zone is made of.
|
||||
func TestAnonymizeStringReverseZone(t *testing.T) {
|
||||
t.Run("ipv6 zone survives the address passes", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
zone := "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6
|
||||
got := a.AnonymizeString("question: domain=" + zone + " type=PTR")
|
||||
|
||||
assert.Contains(t, got, "type=PTR", "should keep the rest of the line")
|
||||
assert.NotContains(t, got, "198.51.100", "should not rewrite nibble labels as an address")
|
||||
|
||||
labels := strings.Split(strings.TrimSuffix(strings.TrimPrefix(got, "question: domain="), reverseZoneSuffixV6+" type=PTR"), ".")
|
||||
assert.Len(t, labels, 28, "should keep every nibble label, got %q", got)
|
||||
})
|
||||
|
||||
t.Run("preserved ipv4 zone is untouched", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
line := "reverse zone 64.100.in-addr.arpa registered"
|
||||
assert.Equal(t, line, a.AnonymizeString(line), "should keep the zone of a preserved address")
|
||||
})
|
||||
|
||||
t.Run("public ipv4 zone is replaced consistently", func(t *testing.T) {
|
||||
a := newLeveledAnonymizer(LevelDefault)
|
||||
|
||||
got := a.AnonymizeString("zone 113.0.203.in-addr.arpa and address 203.0.113.7")
|
||||
assert.NotContains(t, got, "113.0.203.in-addr.arpa", "should replace the zone")
|
||||
assert.NotContains(t, got, "203.0.113.7", "should replace the address")
|
||||
assert.Contains(t, got, reverseZoneSuffixV4, "should keep the zone suffix")
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseReverseZone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
zone string
|
||||
addr string
|
||||
labels int
|
||||
}{
|
||||
{name: "v4 two labels", zone: "0.100" + reverseZoneSuffixV4, addr: "100.0.0.0", labels: 2},
|
||||
{name: "v4 three labels", zone: "1.168.192" + reverseZoneSuffixV4, addr: "192.168.1.0", labels: 3},
|
||||
{name: "v4 full address", zone: "7.113.0.203" + reverseZoneSuffixV4, addr: "203.0.113.7", labels: 4},
|
||||
{
|
||||
name: "v6 prefix",
|
||||
zone: "0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.0.2.0.0.0" + reverseZoneSuffixV6,
|
||||
addr: "2::",
|
||||
labels: 28,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
addr, labels, suffix, ok := parseReverseZone(tc.zone)
|
||||
require.True(t, ok, "should decode the reverse zone")
|
||||
assert.Equal(t, tc.addr, addr.String(), "should decode to the encoded prefix")
|
||||
assert.Equal(t, tc.labels, labels, "should count the labels")
|
||||
assert.Equal(t, tc.zone, reverseZoneName(addr, labels)+suffix, "should re-encode to the original zone")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseReverseZoneRejectsNonZones(t *testing.T) {
|
||||
tests := []string{
|
||||
"example.com",
|
||||
"in-addr.arpa",
|
||||
"x.100" + reverseZoneSuffixV4,
|
||||
"256" + reverseZoneSuffixV4,
|
||||
"1.2.3.4.5" + reverseZoneSuffixV4,
|
||||
"ab" + reverseZoneSuffixV6,
|
||||
"g" + reverseZoneSuffixV6,
|
||||
}
|
||||
|
||||
for _, zone := range tests {
|
||||
t.Run(zone, func(t *testing.T) {
|
||||
_, _, _, ok := parseReverseZone(zone)
|
||||
assert.False(t, ok, "should reject %q", zone)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
@@ -121,7 +120,7 @@ func doDaemonLogin(ctx context.Context, cmd *cobra.Command, providedSetupKey str
|
||||
loginRequest := proto.LoginRequest{
|
||||
SetupKey: providedSetupKey,
|
||||
ManagementUrl: managementURL,
|
||||
IsUnixDesktopClient: isUnixRunningDesktop(),
|
||||
IsUnixDesktopClient: util.HasGraphicalSession(),
|
||||
Hostname: hostName,
|
||||
DnsLabels: dnsLabelsReq,
|
||||
ProfileName: &handle,
|
||||
@@ -189,7 +188,8 @@ func doExtendSession(ctx context.Context, cmd *cobra.Command) error {
|
||||
|
||||
client := proto.NewDaemonServiceClient(conn)
|
||||
|
||||
req := &proto.RequestExtendAuthSessionRequest{}
|
||||
// the CLI runs in the user's session, the daemon does not: tell it what we can see
|
||||
req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: util.HasGraphicalSession()}
|
||||
// Pre-fill the IdP login hint from the active profile so the user
|
||||
// doesn't have to retype their email. Best-effort: we still proceed
|
||||
// without a hint if the lookup fails.
|
||||
@@ -408,7 +408,7 @@ func foregroundGetTokenInfo(ctx context.Context, cmd *cobra.Command, config *pro
|
||||
hint = profileState.Email
|
||||
}
|
||||
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isUnixRunningDesktop(), false, hint)
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, util.HasGraphicalSession(), false, hint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -458,14 +458,6 @@ func openURL(cmd *cobra.Command, verificationURIComplete, userCode string, noBro
|
||||
}
|
||||
}
|
||||
|
||||
// isUnixRunningDesktop checks if a Linux OS is running desktop environment
|
||||
func isUnixRunningDesktop() bool {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||
return false
|
||||
}
|
||||
return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
|
||||
}
|
||||
|
||||
func setEnvAndFlags(cmd *cobra.Command) error {
|
||||
SetFlagsFromEnvVars(rootCmd)
|
||||
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal"
|
||||
"github.com/netbirdio/netbird/client/internal/peer"
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
nbnet "github.com/netbirdio/netbird/client/net"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/client/server"
|
||||
"github.com/netbirdio/netbird/client/system"
|
||||
"github.com/netbirdio/netbird/shared/management/domain"
|
||||
@@ -626,7 +626,7 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
NatExternalIPs: natExternalIPs,
|
||||
CleanNATExternalIPs: natExternalIPs != nil && len(natExternalIPs) == 0,
|
||||
CustomDNSAddress: customDNSAddressConverted,
|
||||
IsUnixDesktopClient: isUnixRunningDesktop(),
|
||||
IsUnixDesktopClient: util.HasGraphicalSession(),
|
||||
Hostname: hostName,
|
||||
ExtraIFaceBlacklist: extraIFaceBlackList,
|
||||
DnsLabels: dnsLabels,
|
||||
|
||||
@@ -42,6 +42,7 @@ type aclManager struct {
|
||||
optionalEntries map[string][]entry
|
||||
ipsetStore *ipsetStore
|
||||
v6 bool
|
||||
ipsetSupported bool
|
||||
|
||||
stateManager *statemanager.Manager
|
||||
}
|
||||
@@ -60,6 +61,8 @@ func newAclManager(iptablesClient *iptables.IPTables, wgIface iFaceMapper) (*acl
|
||||
func (m *aclManager) init(stateManager *statemanager.Manager) error {
|
||||
m.stateManager = stateManager
|
||||
|
||||
m.ipsetSupported = m.probeIPSetSupport()
|
||||
|
||||
m.seedInitialEntries()
|
||||
m.seedInitialOptionalEntries()
|
||||
|
||||
@@ -91,6 +94,12 @@ func (m *aclManager) AddPeerFiltering(
|
||||
if m.v6 && ipsetName != "" {
|
||||
ipsetName += "-v6"
|
||||
}
|
||||
// When the kernel lacks the required ipset hash module, fall back to
|
||||
// per-IP iptables rules (pre-0.68 behavior) so ACLs keep working instead
|
||||
// of silently leaving the chain empty.
|
||||
if ipsetName != "" && !m.ipsetSupported {
|
||||
ipsetName = ""
|
||||
}
|
||||
proto := protoForFamily(protocol, m.v6)
|
||||
specs := filterRuleSpecs(ip, proto, sPort, dPort, action, ipsetName)
|
||||
|
||||
@@ -498,6 +507,40 @@ func transformIPsetName(ipsetName string, sPort, dPort *firewall.Port, action fi
|
||||
}
|
||||
}
|
||||
|
||||
// probeIPSetSupport checks whether the kernel can create the ipset type used for
|
||||
// ACL rules. On kernels lacking the required ipset hash module, ipset creation
|
||||
// fails (e.g. "invalid argument"), which would otherwise leave the ACL chain
|
||||
// empty and silently drop all policy-permitted inbound traffic. When unsupported,
|
||||
// the manager falls back to per-IP iptables rules.
|
||||
func (m *aclManager) probeIPSetSupport() bool {
|
||||
// Use a unique name so concurrent processes don't collide and we only ever
|
||||
// destroy the set we created ourselves. ipset names are limited to 31 chars,
|
||||
// so use a short random suffix.
|
||||
probeName := "nb-probe-" + uuid.New().String()[:8]
|
||||
|
||||
opts := ipset.CreateOptions{
|
||||
Replace: true,
|
||||
}
|
||||
if m.v6 {
|
||||
opts.Family = ipset.FamilyIPV6
|
||||
}
|
||||
|
||||
if err := ipset.Create(probeName, ipset.TypeHashNet, opts); err != nil {
|
||||
log.Warnf("ipset is not available (failed to create probe set: %v); "+
|
||||
"falling back to per-IP iptables ACL rules. Ensure the kernel provides "+
|
||||
"the ipset hash:net module (ip_set_hash_net) for better performance with large rule sets", err)
|
||||
return false
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := ipset.Destroy(probeName); err != nil {
|
||||
log.Debugf("destroy ipset probe set %q: %v", probeName, err)
|
||||
}
|
||||
}()
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *aclManager) createIPSet(name string) error {
|
||||
opts := ipset.CreateOptions{
|
||||
Replace: true,
|
||||
|
||||
240
client/firewall/iptables/dnat_refcount_linux_test.go
Normal file
240
client/firewall/iptables/dnat_refcount_linux_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
//go:build privileged
|
||||
|
||||
package iptables
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
func iptRefcountIfaceV4() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("10.20.0.1"),
|
||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func iptRefcountIfaceDual() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("10.20.0.1"),
|
||||
Network: netip.MustParsePrefix("10.20.0.0/24"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newIptRefcountManager(t *testing.T, dual bool) *Manager {
|
||||
t.Helper()
|
||||
var ifMock *iFaceMock
|
||||
if dual {
|
||||
ifMock = iptRefcountIfaceDual()
|
||||
} else {
|
||||
ifMock = iptRefcountIfaceV4()
|
||||
}
|
||||
m, err := Create(ifMock, iface.DefaultMTU)
|
||||
require.NoError(t, err, "create manager")
|
||||
require.NoError(t, m.Init(nil), "init manager")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, m.Close(nil), "close manager")
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func iptDnatV4(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("10.20.0.2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
func iptDnatV6(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestIptablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
|
||||
// (called on every network-map update) holds at most one reference per family
|
||||
// and a single DisableRouting drops both back to zero.
|
||||
func TestIptablesRouting_RepeatedEnableSingleReference(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
require.NoError(t, m.EnableRouting(), "first enable")
|
||||
require.NoError(t, m.EnableRouting(), "second enable")
|
||||
require.NoError(t, m.EnableRouting(), "third enable")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
|
||||
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "disable")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
|
||||
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
|
||||
}
|
||||
|
||||
// TestIptablesRouting_DisableKeepsDNATReference verifies that an unpaired
|
||||
// DisableRouting does not release references held by active DNAT rules.
|
||||
func TestIptablesRouting_DisableKeepsDNATReference(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9095))
|
||||
require.NoError(t, err, "add v6 dnat")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "unpaired disable")
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_RefcountBalancedV4 covers a Balanced Add/Delete pair on v4.
|
||||
func TestIptablesDNAT_RefcountBalancedV4(t *testing.T) {
|
||||
m := newIptRefcountManager(t, false)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV4(7081))
|
||||
require.NoError(t, err, "add v4 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
r2, err := m.AddDNATRule(iptDnatV4(7082))
|
||||
require.NoError(t, err, "add v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 2, v4, "v4 refcount after second add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount after second delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_RefcountBalancedV6 checks the v6 path increments v6 only and
|
||||
// decrements back to zero.
|
||||
func TestIptablesDNAT_RefcountBalancedV6(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
require.NotNil(t, m.router6, "v6 router")
|
||||
require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9081))
|
||||
require.NoError(t, err, "add v6 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first add")
|
||||
|
||||
r2, err := m.AddDNATRule(iptDnatV6(9082))
|
||||
require.NoError(t, err, "add v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 2, v6, "v6 refcount after second add")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first delete")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2))
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount after second delete")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DuplicateAddNoLeak verifies the duplicate-rule path returns
|
||||
// without bumping the refcount.
|
||||
func TestIptablesDNAT_DuplicateAddNoLeak(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
rule := iptDnatV4(7083)
|
||||
r1, err := m.AddDNATRule(rule)
|
||||
require.NoError(t, err)
|
||||
v4, _ := state.Counts()
|
||||
assert.Equal(t, 1, v4)
|
||||
|
||||
_, err = m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "duplicate add")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "duplicate add must not increment")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single delete must drop to zero")
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DeleteMissingNoUnderflow verifies Delete on an unknown rule
|
||||
// neither errors nor releases the refcount.
|
||||
func TestIptablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
phantom := iptDnatV4(7099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
phantom6 := iptDnatV6(9099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV4(7100))
|
||||
require.NoError(t, err)
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
}
|
||||
|
||||
// TestIptablesDNAT_DoubleDeleteNoUnderflow verifies a second Delete on the same
|
||||
// rule is a no-op.
|
||||
func TestIptablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
|
||||
m := newIptRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(iptDnatV6(9083))
|
||||
require.NoError(t, err)
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "double delete must not underflow")
|
||||
}
|
||||
@@ -89,7 +89,7 @@ func (m *Manager) createIPv6Components(wgIface iFaceMapper, mtu uint16) error {
|
||||
}
|
||||
|
||||
// Share the same IP forwarding state with the v4 router, since
|
||||
// EnableIPForwarding controls both v4 and v6 sysctls.
|
||||
// Forwarding refcounter is per-family but shared between v4 and v6 routers.
|
||||
m.router6.ipFwdState = m.router.ipFwdState
|
||||
|
||||
m.aclMgr6, err = newAclManager(ip6Client, wgIface)
|
||||
@@ -402,17 +402,12 @@ func (m *Manager) SetLogLevel(log.Level) {
|
||||
}
|
||||
|
||||
func (m *Manager) EnableRouting() error {
|
||||
if err := m.router.ipFwdState.RequestForwarding(); err != nil {
|
||||
return fmt.Errorf("enable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
// v6 only when the overlay actually has v6.
|
||||
return m.router.ipFwdState.RequestRouting(m.router6 != nil)
|
||||
}
|
||||
|
||||
func (m *Manager) DisableRouting() error {
|
||||
if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
return fmt.Errorf("disable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
return m.router.ipFwdState.ReleaseRouting()
|
||||
}
|
||||
|
||||
// AddDNATRule adds a DNAT rule
|
||||
|
||||
@@ -291,3 +291,40 @@ func TestIptablesCreatePerformance(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIptablesACLIPSetFallback verifies that when the kernel lacks ipset support,
|
||||
// the ACL manager falls back to per-IP iptables rules (-s <ip>) instead of
|
||||
// silently leaving the chain empty. See discussion #6125.
|
||||
func TestIptablesACLIPSetFallback(t *testing.T) {
|
||||
ipv4Client, err := iptables.NewWithProtocol(iptables.ProtocolIPv4)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Use Create()/Init() so the router-owned chains (chainRTFWDIN/OUT) are
|
||||
// created before the ACL manager's createDefaultChains() references them.
|
||||
manager, err := Create(ifaceMock, iface.DefaultMTU)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, manager.Init(nil))
|
||||
|
||||
aclMgr := manager.aclMgr
|
||||
// Simulate a kernel without the ipset hash module.
|
||||
aclMgr.ipsetSupported = false
|
||||
|
||||
defer func() {
|
||||
require.NoError(t, manager.Close(nil))
|
||||
}()
|
||||
|
||||
ip := netip.MustParseAddr("10.20.0.42")
|
||||
port := &fw.Port{Values: []uint16{22}}
|
||||
|
||||
rules, err := aclMgr.AddPeerFiltering(nil, ip.AsSlice(), "tcp", nil, port, fw.ActionAccept, "nb0000001")
|
||||
require.NoError(t, err, "AddPeerFiltering should succeed via fallback")
|
||||
require.NotEmpty(t, rules)
|
||||
|
||||
rule := rules[0].(*Rule)
|
||||
require.Empty(t, rule.ipsetName, "fallback rule must not reference an ipset")
|
||||
require.Contains(t, strings.Join(rule.specs, " "), "-s 10.20.0.42", "fallback rule must match by source IP")
|
||||
require.NotContains(t, strings.Join(rule.specs, " "), "--match-set", "fallback rule must not use ipset matching")
|
||||
|
||||
// The rule must actually be present in the ACL chain (not silently dropped).
|
||||
checkRuleSpecs(t, ipv4Client, rule.chain, true, rule.specs...)
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ func newRouter(iptablesClient *iptables.IPTables, wgIface iFaceMapper, mtu uint1
|
||||
wgIface: wgIface,
|
||||
mtu: mtu,
|
||||
v6: iptablesClient.Proto() == iptables.ProtocolIPv6,
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(),
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
|
||||
}
|
||||
|
||||
r.ipsetCounter = refcounter.New(
|
||||
@@ -770,10 +770,6 @@ func (r *router) updateState() {
|
||||
}
|
||||
|
||||
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
if err := r.ipFwdState.RequestForwarding(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
return rule, nil
|
||||
@@ -840,18 +836,34 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
|
||||
for key, ruleInfo := range rules {
|
||||
if err := r.iptablesClient.Append(ruleInfo.table, ruleInfo.chain, ruleInfo.rule...); err != nil {
|
||||
if rollbackErr := r.rollbackRules(rules); rollbackErr != nil {
|
||||
log.Errorf("rollback failed: %v", rollbackErr)
|
||||
}
|
||||
r.cleanupFailedDNATAdd(rules)
|
||||
return nil, fmt.Errorf("add rule %s: %w", key, err)
|
||||
}
|
||||
r.rules[key] = ruleInfo.rule
|
||||
}
|
||||
|
||||
if err := r.ipFwdState.RequestForwarding(r.v6); err != nil {
|
||||
r.cleanupFailedDNATAdd(rules)
|
||||
return nil, fmt.Errorf("enable forwarding: %w", err)
|
||||
}
|
||||
|
||||
r.updateState()
|
||||
return rule, nil
|
||||
}
|
||||
|
||||
// cleanupFailedDNATAdd removes the bookkeeping written by a partially applied
|
||||
// AddDNATRule before rolling back the kernel rules, so no entries remain that
|
||||
// never got a forwarding refcount. rollbackRules re-adds entries it failed to
|
||||
// remove from the kernel.
|
||||
func (r *router) cleanupFailedDNATAdd(rules map[string]ruleInfo) {
|
||||
for key := range rules {
|
||||
delete(r.rules, key)
|
||||
}
|
||||
if err := r.rollbackRules(rules); err != nil {
|
||||
log.Errorf("rollback failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *router) rollbackRules(rules map[string]ruleInfo) error {
|
||||
var merr *multierror.Error
|
||||
for key, ruleInfo := range rules {
|
||||
@@ -868,32 +880,47 @@ func (r *router) rollbackRules(rules map[string]ruleInfo) error {
|
||||
}
|
||||
|
||||
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
if err := r.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
|
||||
_, hadDNAT := r.rules[ruleKey+dnatSuffix]
|
||||
_, hadSNAT := r.rules[ruleKey+snatSuffix]
|
||||
_, hadFWD := r.rules[ruleKey+fwdSuffix]
|
||||
if !hadDNAT && !hadSNAT && !hadFWD {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
if dnatRule, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableNat, chainRTRDR, dnatRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete DNAT rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
}
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
}
|
||||
|
||||
if snatRule, exists := r.rules[ruleKey+snatSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableNat, chainRTNAT, snatRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete SNAT rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
}
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
}
|
||||
|
||||
if fwdRule, exists := r.rules[ruleKey+fwdSuffix]; exists {
|
||||
if err := r.iptablesClient.Delete(tableFilter, chainRTFWDOUT, fwdRule...); err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("delete forward rule: %w", err))
|
||||
} else {
|
||||
delete(r.rules, ruleKey+fwdSuffix)
|
||||
}
|
||||
}
|
||||
|
||||
// Release the refcount only once all rules are gone from the kernel. On
|
||||
// partial failure the failed entries stay in r.rules so a retry can remove
|
||||
// them and release then.
|
||||
if merr == nil {
|
||||
if err := r.ipFwdState.ReleaseForwarding(r.v6); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
delete(r.rules, ruleKey+fwdSuffix)
|
||||
}
|
||||
|
||||
r.updateState()
|
||||
|
||||
249
client/firewall/nftables/dnat_refcount_linux_test.go
Normal file
249
client/firewall/nftables/dnat_refcount_linux_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
//go:build privileged
|
||||
|
||||
package nftables
|
||||
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
fw "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/wgaddr"
|
||||
)
|
||||
|
||||
func nftRefcountIfaceV4() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("100.96.0.1"),
|
||||
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func nftRefcountIfaceDual() *iFaceMock {
|
||||
return &iFaceMock{
|
||||
NameFunc: func() string { return "wt-refcount" },
|
||||
AddressFunc: func() wgaddr.Address {
|
||||
return wgaddr.Address{
|
||||
IP: netip.MustParseAddr("100.96.0.1"),
|
||||
Network: netip.MustParsePrefix("100.96.0.0/16"),
|
||||
IPv6: netip.MustParseAddr("fd00::1"),
|
||||
IPv6Net: netip.MustParsePrefix("fd00::/64"),
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func newNftRefcountManager(t *testing.T, dual bool) *Manager {
|
||||
t.Helper()
|
||||
if check() != NFTABLES {
|
||||
t.Skip("nftables not supported on this system")
|
||||
}
|
||||
var ifMock *iFaceMock
|
||||
if dual {
|
||||
ifMock = nftRefcountIfaceDual()
|
||||
} else {
|
||||
ifMock = nftRefcountIfaceV4()
|
||||
}
|
||||
m, err := Create(ifMock, iface.DefaultMTU)
|
||||
require.NoError(t, err, "create manager")
|
||||
require.NoError(t, m.Init(nil), "init manager")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, m.Close(nil), "close manager")
|
||||
})
|
||||
return m
|
||||
}
|
||||
|
||||
func dnatV4(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("100.96.0.2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
func dnatV6(port uint16) fw.ForwardRule {
|
||||
return fw.ForwardRule{
|
||||
Protocol: fw.ProtocolTCP,
|
||||
DestinationPort: fw.Port{Values: []uint16{port}},
|
||||
TranslatedAddress: netip.MustParseAddr("fd00::2"),
|
||||
TranslatedPort: fw.Port{Values: []uint16{80}},
|
||||
}
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_RefcountBalancedV4 verifies that Add/Delete pairs leave the
|
||||
// v4 refcount at zero.
|
||||
func TestNftablesDNAT_RefcountBalancedV4(t *testing.T) {
|
||||
m := newNftRefcountManager(t, false)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV4(8081))
|
||||
require.NoError(t, err, "add v4 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
r2, err := m.AddDNATRule(dnatV4(8082))
|
||||
require.NoError(t, err, "add v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 2, v4, "v4 refcount after second add")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat 1")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 refcount after first delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2), "delete v4 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount after second delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unchanged")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_RefcountBalancedV6 verifies the v6 path increments v6 only
|
||||
// and decrements back to zero on Delete.
|
||||
func TestNftablesDNAT_RefcountBalancedV6(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
require.NotNil(t, m.router6, "v6 router")
|
||||
require.Same(t, m.router.ipFwdState, m.router6.ipFwdState, "shared state")
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9091))
|
||||
require.NoError(t, err, "add v6 dnat 1")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first add")
|
||||
|
||||
r2, err := m.AddDNATRule(dnatV6(9092))
|
||||
require.NoError(t, err, "add v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 2, v6, "v6 refcount after second add")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat 1")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unchanged")
|
||||
assert.Equal(t, 1, v6, "v6 refcount after first delete")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r2), "delete v6 dnat 2")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount after second delete")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DuplicateAddNoLeak verifies that a duplicate Add (same
|
||||
// ForwardRule) does not double-increment the refcount.
|
||||
func TestNftablesDNAT_DuplicateAddNoLeak(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
rule := dnatV4(8083)
|
||||
r1, err := m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "add v4 dnat")
|
||||
v4, _ := state.Counts()
|
||||
assert.Equal(t, 1, v4)
|
||||
|
||||
// duplicate add: same rule ID, must be a no-op for the refcount.
|
||||
_, err = m.AddDNATRule(rule)
|
||||
require.NoError(t, err, "duplicate add")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "duplicate add must not increment")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v4 dnat")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single delete must drop to zero")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DeleteMissingNoUnderflow verifies deleting a rule that was
|
||||
// never added does not underflow the refcount.
|
||||
func TestNftablesDNAT_DeleteMissingNoUnderflow(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
// Construct a Rule reference for something never added. The router stores
|
||||
// rules by ID(), and DeleteDNATRule looks them up in r.rules; a missing
|
||||
// entry must be a no-op rather than calling Release.
|
||||
phantom := dnatV4(8099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom), "delete missing v4 dnat")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 0, v4, "v4 refcount unaffected by missing delete")
|
||||
assert.Equal(t, 0, v6, "v6 refcount unaffected")
|
||||
|
||||
phantom6 := dnatV6(9099)
|
||||
require.NoError(t, m.DeleteDNATRule(&phantom6), "delete missing v6 dnat")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4)
|
||||
assert.Equal(t, 0, v6, "v6 refcount unaffected by missing delete")
|
||||
|
||||
// And after a phantom delete, a real add still results in count=1.
|
||||
r1, err := m.AddDNATRule(dnatV4(8100))
|
||||
require.NoError(t, err, "add v4 dnat after phantom delete")
|
||||
v4, _ = state.Counts()
|
||||
assert.Equal(t, 1, v4, "real add still increments after phantom delete")
|
||||
require.NoError(t, m.DeleteDNATRule(r1))
|
||||
}
|
||||
|
||||
// TestNftablesRouting_RepeatedEnableSingleReference verifies that EnableRouting
|
||||
// (called on every network-map update) holds at most one reference per family
|
||||
// and a single DisableRouting drops both back to zero.
|
||||
func TestNftablesRouting_RepeatedEnableSingleReference(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
require.NoError(t, m.EnableRouting(), "first enable")
|
||||
require.NoError(t, m.EnableRouting(), "second enable")
|
||||
require.NoError(t, m.EnableRouting(), "third enable")
|
||||
v4, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v4, "repeated enable holds a single v4 reference")
|
||||
assert.Equal(t, 1, v6, "repeated enable holds a single v6 reference")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "disable")
|
||||
v4, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v4, "single disable releases the v4 reference")
|
||||
assert.Equal(t, 0, v6, "single disable releases the v6 reference")
|
||||
}
|
||||
|
||||
// TestNftablesRouting_DisableKeepsDNATReference verifies that an unpaired
|
||||
// DisableRouting does not release references held by active DNAT rules.
|
||||
func TestNftablesRouting_DisableKeepsDNATReference(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9095))
|
||||
require.NoError(t, err, "add v6 dnat")
|
||||
|
||||
require.NoError(t, m.DisableRouting(), "unpaired disable")
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6, "DNAT-held reference survives unpaired DisableRouting")
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "delete v6 dnat")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "delete releases the DNAT reference")
|
||||
}
|
||||
|
||||
// TestNftablesDNAT_DoubleDeleteNoUnderflow verifies that deleting the same rule
|
||||
// twice does not underflow the refcount (the second delete is a no-op).
|
||||
func TestNftablesDNAT_DoubleDeleteNoUnderflow(t *testing.T) {
|
||||
m := newNftRefcountManager(t, true)
|
||||
state := m.router.ipFwdState
|
||||
|
||||
r1, err := m.AddDNATRule(dnatV6(9093))
|
||||
require.NoError(t, err)
|
||||
_, v6 := state.Counts()
|
||||
assert.Equal(t, 1, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "first delete")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6)
|
||||
|
||||
require.NoError(t, m.DeleteDNATRule(r1), "second delete must be no-op")
|
||||
_, v6 = state.Counts()
|
||||
assert.Equal(t, 0, v6, "double delete must not underflow")
|
||||
}
|
||||
@@ -105,8 +105,8 @@ func (m *Manager) createIPv6Components(tableName string, wgIface iFaceMapper, mt
|
||||
return fmt.Errorf("create v6 router: %w", err)
|
||||
}
|
||||
|
||||
// Share the same IP forwarding state with the v4 router, since
|
||||
// EnableIPForwarding controls both v4 and v6 sysctls.
|
||||
// Share the per-family forwarding refcounter with the v4 router so a v4
|
||||
// rule and a v6 rule against the same state machine cooperate cleanly.
|
||||
m.router6.ipFwdState = m.router.ipFwdState
|
||||
|
||||
m.aclManager6, err = newAclManager(workTable6, wgIface, chainNameRoutingFw)
|
||||
@@ -530,17 +530,12 @@ func (m *Manager) SetLogLevel(log.Level) {
|
||||
}
|
||||
|
||||
func (m *Manager) EnableRouting() error {
|
||||
if err := m.router.ipFwdState.RequestForwarding(); err != nil {
|
||||
return fmt.Errorf("enable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
// v6 only when the overlay actually has v6.
|
||||
return m.router.ipFwdState.RequestRouting(m.router6 != nil)
|
||||
}
|
||||
|
||||
func (m *Manager) DisableRouting() error {
|
||||
if err := m.router.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
return fmt.Errorf("disable IP forwarding: %w", err)
|
||||
}
|
||||
return nil
|
||||
return m.router.ipFwdState.ReleaseRouting()
|
||||
}
|
||||
|
||||
// Flush rule/chain/set operations from the buffer
|
||||
|
||||
@@ -93,7 +93,7 @@ func newRouter(workTable *nftables.Table, wgIface iFaceMapper, mtu uint16) (*rou
|
||||
rules: make(map[string]*nftables.Rule),
|
||||
af: familyForAddr(workTable.Family == nftables.TableFamilyIPv4),
|
||||
wgIface: wgIface,
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(),
|
||||
ipFwdState: ipfwdstate.NewIPForwardingState(wgIface.Name()),
|
||||
mtu: mtu,
|
||||
}
|
||||
|
||||
@@ -1553,10 +1553,6 @@ func (r *router) refreshRulesMap() error {
|
||||
}
|
||||
|
||||
func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
if err := r.ipFwdState.RequestForwarding(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
if _, exists := r.rules[ruleKey+dnatSuffix]; exists {
|
||||
return rule, nil
|
||||
@@ -1567,7 +1563,18 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
return nil, fmt.Errorf("convert protocol to number: %w", err)
|
||||
}
|
||||
|
||||
// Request forwarding before queueing rules: addDnatRedirect/addDnatMasq
|
||||
// buffer netlink messages on r.conn that the next caller's Flush would
|
||||
// commit if we returned without flushing them ourselves.
|
||||
v6 := r.af.tableFamily == nftables.TableFamilyIPv6
|
||||
if err := r.ipFwdState.RequestForwarding(v6); err != nil {
|
||||
return nil, fmt.Errorf("enable forwarding: %w", err)
|
||||
}
|
||||
|
||||
if err := r.addDnatRedirect(rule, protoNum, ruleKey); err != nil {
|
||||
if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
|
||||
log.Warnf("rollback forwarding refcount: %v", rerr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -1579,6 +1586,11 @@ func (r *router) AddDNATRule(rule firewall.ForwardRule) (firewall.Rule, error) {
|
||||
// TODO: find chains with drop policies and add rules there
|
||||
|
||||
if err := r.conn.Flush(); err != nil {
|
||||
if rerr := r.ipFwdState.ReleaseForwarding(v6); rerr != nil {
|
||||
log.Warnf("rollback forwarding refcount: %v", rerr)
|
||||
}
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
return nil, fmt.Errorf("flush rules: %w", err)
|
||||
}
|
||||
|
||||
@@ -1781,16 +1793,18 @@ func (r *router) addDnatMasq(rule firewall.ForwardRule, protoNum uint8, ruleKey
|
||||
}
|
||||
|
||||
func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
if err := r.ipFwdState.ReleaseForwarding(); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
|
||||
ruleKey := rule.ID()
|
||||
|
||||
if err := r.refreshRulesMap(); err != nil {
|
||||
return fmt.Errorf(refreshRulesMapError, err)
|
||||
}
|
||||
|
||||
_, hadDNAT := r.rules[ruleKey+dnatSuffix]
|
||||
_, hadSNAT := r.rules[ruleKey+snatSuffix]
|
||||
if !hadDNAT && !hadSNAT {
|
||||
return nil
|
||||
}
|
||||
|
||||
var merr *multierror.Error
|
||||
var needsFlush bool
|
||||
|
||||
@@ -1822,9 +1836,16 @@ func (r *router) DeleteDNATRule(rule firewall.Rule) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Release the refcount only once the rules are gone from the kernel. On
|
||||
// failure (including the refreshRulesMap error above) the rules and their
|
||||
// map entries remain, keeping forwarding on until a retry removes them.
|
||||
if merr == nil {
|
||||
delete(r.rules, ruleKey+dnatSuffix)
|
||||
delete(r.rules, ruleKey+snatSuffix)
|
||||
|
||||
if err := r.ipFwdState.ReleaseForwarding(r.af.tableFamily == nftables.TableFamilyIPv6); err != nil {
|
||||
log.Errorf("%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nberrors.FormatErrorOrNil(merr)
|
||||
|
||||
@@ -22,8 +22,6 @@
|
||||
!define UI_REG_APP_PATH "Software\Microsoft\Windows\CurrentVersion\App Paths\${UI_APP_EXE}"
|
||||
!define UI_UNINSTALL_PATH "Software\Microsoft\Windows\CurrentVersion\Uninstall\${UI_APP_NAME}"
|
||||
|
||||
!define AUTOSTART_REG_KEY "Software\Microsoft\Windows\CurrentVersion\Run"
|
||||
|
||||
!define NETBIRD_DATA_DIR "$COMMONPROGRAMDATA\Netbird"
|
||||
|
||||
Unicode True
|
||||
@@ -228,13 +226,6 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
||||
|
||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||
|
||||
; Autostart is owned by the UI's per-user setting (HKCU\...\Run via Wails),
|
||||
; not the installer. Drop the machine-wide entry older installers wrote so the
|
||||
; toggle is the single source of truth. HKCU is left untouched -- it may hold
|
||||
; the user's own toggle state, which must survive upgrades.
|
||||
DetailPrint "Removing installer-managed autostart registry entry if present..."
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
|
||||
EnVar::SetHKLM
|
||||
EnVar::AddValueEx "path" "$INSTDIR"
|
||||
|
||||
@@ -299,15 +290,6 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
||||
DetailPrint "Terminating Netbird UI process..."
|
||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||
|
||||
; Remove autostart registry entries
|
||||
DetailPrint "Removing autostart registry entries if they exist..."
|
||||
; Legacy machine-wide entry written by older installers.
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
; Per-user entry the UI toggle writes via Wails (value name is the lowercase
|
||||
; app-name slug). Uninstall removes the app, so drop it too.
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "netbird"
|
||||
|
||||
; Handle data deletion based on checkbox
|
||||
DetailPrint "Checking if user requested data deletion..."
|
||||
${If} $DeleteDataEnabled == "1"
|
||||
|
||||
@@ -51,6 +51,7 @@ nftables.txt: Anonymized nftables rules with packet counters across all families
|
||||
sysctls.txt: Forwarding, reverse-path filter, source-validation, and conntrack accounting sysctl values that the NetBird client may read or modify, if --system-info flag was provided (Linux only).
|
||||
resolv.conf: DNS resolver configuration from /etc/resolv.conf (Unix systems only), if --system-info flag was provided.
|
||||
scutil_dns.txt: DNS configuration from scutil --dns (macOS only), if --system-info flag was provided.
|
||||
dns_windows.txt: Anonymized NRPT rules and policy table in effect, DNS client policy, and per-interface and per-adapter DNS configuration (Windows only), if --system-info flag was provided.
|
||||
resolved_domains.txt: Anonymized resolved domain IP addresses from the status recorder.
|
||||
config.txt: Anonymized configuration information of the NetBird client.
|
||||
network_map.json: Anonymized sync response containing peer configurations, routes, DNS settings, and firewall rules.
|
||||
@@ -237,6 +238,13 @@ scutil_dns.txt (macOS only):
|
||||
- Shows DNS configuration for all network interfaces
|
||||
- Includes search domains, nameservers, and DNS resolver settings
|
||||
- All IP addresses and domain names are anonymized
|
||||
|
||||
dns_windows.txt (Windows only):
|
||||
- Lists the NRPT rules of both policy stores, the local one and the group policy one, marking the rules the client created
|
||||
- Follows them with the policy table the resolver has loaded, which differs from the rules while a change has not been picked up yet
|
||||
- Includes the DNS client group policy, the global TCP/IP and Dnscache parameters, and the DNS values of every interface that has any
|
||||
- Ends with the resolver configuration in effect per adapter, from GetAdaptersAddresses
|
||||
- All IP addresses and domain names are anonymized
|
||||
`
|
||||
|
||||
const (
|
||||
|
||||
@@ -844,6 +844,10 @@ func collectSysctls() string {
|
||||
[]string{"net.ipv4.conf.all.src_valid_mark", "net.ipv4.conf.default.src_valid_mark"},
|
||||
listInterfaceSysctls("ipv4", "src_valid_mark")...,
|
||||
))
|
||||
writeSysctlGroup(&builder, "accept_ra", append(
|
||||
[]string{"net.ipv6.conf.all.accept_ra", "net.ipv6.conf.default.accept_ra"},
|
||||
listInterfaceSysctls("ipv6", "accept_ra")...,
|
||||
))
|
||||
writeSysctlGroup(&builder, "conntrack", []string{
|
||||
"net.netfilter.nf_conntrack_acct",
|
||||
"net.netfilter.nf_conntrack_tcp_loose",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//go:build !unix
|
||||
//go:build !unix && !windows
|
||||
|
||||
package debug
|
||||
|
||||
|
||||
443
client/internal/debug/debug_windows.go
Normal file
443
client/internal/debug/debug_windows.go
Normal file
@@ -0,0 +1,443 @@
|
||||
//go:build windows
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
"golang.org/x/sys/windows/registry"
|
||||
|
||||
nbdns "github.com/netbirdio/netbird/client/internal/dns"
|
||||
)
|
||||
|
||||
const dnsInfoFileName = "dns_windows.txt"
|
||||
|
||||
const (
|
||||
gpoDNSClientRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient`
|
||||
tcpipParamsPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters`
|
||||
dnscacheParams = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters`
|
||||
)
|
||||
|
||||
// interfaceDNSValues are the per-interface values that decide how a name is
|
||||
// resolved and registered. Everything the DNS host manager writes is in here,
|
||||
// so a bundle shows both what we set and what it replaced.
|
||||
var interfaceDNSValues = []string{
|
||||
"NameServer",
|
||||
"DhcpNameServer",
|
||||
"Domain",
|
||||
"DhcpDomain",
|
||||
"SearchList",
|
||||
"RegistrationEnabled",
|
||||
"DisableDynamicUpdate",
|
||||
"MaxNumberOfAddressesToRegister",
|
||||
"EnableDHCP",
|
||||
}
|
||||
|
||||
// addDNSInfo collects and adds DNS configuration information to the archive
|
||||
func (g *BundleGenerator) addDNSInfo() error {
|
||||
if err := g.addFileToZip(strings.NewReader(g.collectDNSInfo()), dnsInfoFileName); err != nil {
|
||||
return fmt.Errorf("add DNS info to zip: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// collectDNSInfo renders the report. Everything below it reaches the platform
|
||||
// through COM and through lazily resolved procedures, which panic when a
|
||||
// procedure is missing rather than returning an error, and a debug bundle is not
|
||||
// allowed to take the daemon down. The panic is contained here, and whatever was
|
||||
// collected before it is kept and reported with it.
|
||||
func (g *BundleGenerator) collectDNSInfo() (content string) {
|
||||
var sb strings.Builder
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Errorf("collecting Windows DNS configuration panicked: %v", r)
|
||||
fmt.Fprintf(&sb, "\nerror: collection stopped: %v\n", r)
|
||||
}
|
||||
content = sb.String()
|
||||
}()
|
||||
|
||||
sb.WriteString("Windows DNS configuration\n")
|
||||
sb.WriteString("=========================\n")
|
||||
|
||||
adapters, adaptersErr := adapterAddresses()
|
||||
|
||||
g.writeNRPTRules(&sb, "NRPT rules, local policy store", nbdns.DNSPolicyConfigRoot)
|
||||
g.writeNRPTRules(&sb, "NRPT rules, group policy store", nbdns.GPODNSPolicyConfigRoot)
|
||||
g.writeEffectiveNRPTPolicies(&sb)
|
||||
g.writeRegistryKey(&sb, "DNS client group policy", gpoDNSClientRoot)
|
||||
g.writeRegistryKey(&sb, "Global TCP/IP parameters", tcpipParamsPath)
|
||||
g.writeRegistryKey(&sb, "Dnscache parameters", dnscacheParams)
|
||||
g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv4", nbdns.InterfaceConfigPath, adapterNames(adapters))
|
||||
g.writeInterfaceDNS(&sb, "Per-interface DNS, IPv6", nbdns.InterfaceConfigPathV6, adapterNames(adapters))
|
||||
g.writeAdapterDNS(&sb, adapters, adaptersErr)
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// writeNRPTRules lists every rule in a policy store, ours and any other
|
||||
// product's, since a foreign rule for the same namespace decides resolution
|
||||
// just as ours does. Rules the client wrote are marked.
|
||||
func (g *BundleGenerator) writeNRPTRules(sb *strings.Builder, title, root string) {
|
||||
writeSection(sb, title, root)
|
||||
|
||||
names, err := subKeyNames(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(names) == 0 {
|
||||
sb.WriteString("no rules\n")
|
||||
return
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
owner := ""
|
||||
if strings.HasPrefix(strings.ToLower(name), strings.ToLower(nbdns.NRPTKeyPrefix)) {
|
||||
owner = " (netbird)"
|
||||
}
|
||||
fmt.Fprintf(sb, "%s%s\n", name, owner)
|
||||
g.writeValues(sb, root+`\`+name, nil, " ")
|
||||
}
|
||||
}
|
||||
|
||||
// writeEffectiveNRPTPolicies reports the table the resolver answers from, which
|
||||
// the registry cannot show: a rule is written before it is loaded, and it keeps
|
||||
// being enforced after its key is gone until the resolver reloads its policy.
|
||||
func (g *BundleGenerator) writeEffectiveNRPTPolicies(sb *strings.Builder) {
|
||||
writeSection(sb, "NRPT policy table in effect", nrptPolicyClass+"."+nrptPolicyMethod+" in "+nrptPolicyNamespace)
|
||||
|
||||
entries, err := effectiveNRPTPolicies()
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if len(entries) == 0 {
|
||||
sb.WriteString("no policies\n")
|
||||
return
|
||||
}
|
||||
|
||||
for _, entry := range entries {
|
||||
fmt.Fprintf(sb, "%s\n", g.anonymizeValue("Namespace", entry.namespace))
|
||||
for _, value := range entry.values {
|
||||
fmt.Fprintf(sb, " %s: %s\n", value.name, g.anonymizeValue(value.name, value.value))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeInterfaceDNS reports the DNS values of every interface that has any, so
|
||||
// the netbird interface can be compared against the physical ones. The registry
|
||||
// keys the values by GUID, so each is named from the adapter list; a GUID with
|
||||
// no adapter is a leftover key of an interface that no longer exists.
|
||||
func (g *BundleGenerator) writeInterfaceDNS(sb *strings.Builder, title, root string, names map[string]string) {
|
||||
writeSection(sb, title, root)
|
||||
|
||||
guids, err := subKeyNames(root)
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
var reported int
|
||||
for _, guid := range guids {
|
||||
var iface strings.Builder
|
||||
g.writeValues(&iface, root+`\`+guid, interfaceDNSValues, " ")
|
||||
if iface.Len() == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
name, ok := names[strings.ToLower(guid)]
|
||||
if !ok {
|
||||
name = "no adapter with this GUID"
|
||||
}
|
||||
|
||||
reported++
|
||||
fmt.Fprintf(sb, "%s (%s)\n%s", guid, name, iface.String())
|
||||
}
|
||||
|
||||
if reported == 0 {
|
||||
sb.WriteString("no interface holds DNS values\n")
|
||||
}
|
||||
}
|
||||
|
||||
// writeRegistryKey reports the values of a single key, without its subkeys.
|
||||
func (g *BundleGenerator) writeRegistryKey(sb *strings.Builder, title, path string) {
|
||||
writeSection(sb, title, path)
|
||||
|
||||
var values strings.Builder
|
||||
g.writeValues(&values, path, nil, "")
|
||||
if values.Len() == 0 {
|
||||
sb.WriteString("no values\n")
|
||||
return
|
||||
}
|
||||
|
||||
sb.WriteString(values.String())
|
||||
}
|
||||
|
||||
// writeValues renders the values of a key. A nil names list reports every
|
||||
// value, otherwise only those named and present.
|
||||
func (g *BundleGenerator) writeValues(sb *strings.Builder, path string, names []string, indent string) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, path, registry.QUERY_VALUE)
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist), errors.Is(err, windows.ERROR_PATH_NOT_FOUND):
|
||||
// an absent key is the normal state for the GPO store and for
|
||||
// interfaces without DNS settings
|
||||
log.Debugf("HKEY_LOCAL_MACHINE\\%s does not exist", path)
|
||||
return
|
||||
case err != nil:
|
||||
fmt.Fprintf(sb, "%serror: open HKEY_LOCAL_MACHINE\\%s: %v\n", indent, path, err)
|
||||
return
|
||||
}
|
||||
defer closeKey(k)
|
||||
|
||||
if names == nil {
|
||||
names, err = k.ReadValueNames(-1)
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "%serror: read value names: %v\n", indent, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
value, err := readRegistryValue(k, name)
|
||||
switch {
|
||||
case errors.Is(err, registry.ErrNotExist):
|
||||
// the caller asks for a fixed set of values, most of which a
|
||||
// given interface does not carry
|
||||
continue
|
||||
case err != nil:
|
||||
// report rather than omit: a value that is there but cannot be
|
||||
// read reads as unset otherwise
|
||||
fmt.Fprintf(sb, "%s%s: error: %v\n", indent, name, err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Fprintf(sb, "%s%s: %s\n", indent, name, g.anonymizeValue(name, value))
|
||||
}
|
||||
}
|
||||
|
||||
// anonymizeValue redacts a registry value according to what its name says it
|
||||
// holds. Domains and addresses are handled per entry rather than by the string
|
||||
// pass: the pass only replaces domains something else in the bundle already
|
||||
// seeded, and its address regex would eat the digit labels of a reverse zone.
|
||||
func (g *BundleGenerator) anonymizeValue(name, value string) string {
|
||||
if !g.anonymize || value == "" {
|
||||
return value
|
||||
}
|
||||
|
||||
switch {
|
||||
case holdsDomains(name):
|
||||
return joinValueEntries(splitValueEntries(value), g.anonymizeDomain)
|
||||
case holdsAddresses(name):
|
||||
return joinValueEntries(splitValueEntries(value), g.anonymizer.AnonymizeIPString)
|
||||
default:
|
||||
return g.anonymizer.AnonymizeString(value)
|
||||
}
|
||||
}
|
||||
|
||||
// holdsDomains reports whether a value name holds domains: the domain list of
|
||||
// an NRPT rule (Name) or of the policy table (Namespace), a search list, the
|
||||
// DNS suffix values of the TCP/IP and policy keys, which all end in "Domain"
|
||||
// (Domain, DhcpDomain, NV Domain, ICSDomain), and a proxy host name.
|
||||
func holdsDomains(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
return lower == "name" || lower == "namespace" || lower == "searchlist" ||
|
||||
strings.HasSuffix(lower, "domain") || strings.HasSuffix(lower, "proxyname")
|
||||
}
|
||||
|
||||
// holdsAddresses reports whether a value name holds DNS server addresses
|
||||
// (NameServer, DhcpNameServer, GenericDNSServers, NameServers).
|
||||
func holdsAddresses(name string) bool {
|
||||
lower := strings.ToLower(name)
|
||||
return strings.Contains(lower, "nameserver") || strings.Contains(lower, "dnsserver")
|
||||
}
|
||||
|
||||
// adapterNames maps adapter GUIDs, as the registry keys the interfaces, to the
|
||||
// names an operator sees.
|
||||
func adapterNames(adapters []*windows.IpAdapterAddresses) map[string]string {
|
||||
names := make(map[string]string, len(adapters))
|
||||
for _, adapter := range adapters {
|
||||
guid := windows.BytePtrToString(adapter.AdapterName)
|
||||
names[strings.ToLower(guid)] = windows.UTF16PtrToString(adapter.FriendlyName)
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// writeAdapterDNS reports the resolver configuration in effect per adapter,
|
||||
// which is what the resolver uses for a name no NRPT rule matches.
|
||||
func (g *BundleGenerator) writeAdapterDNS(sb *strings.Builder, adapters []*windows.IpAdapterAddresses, err error) {
|
||||
writeSection(sb, "Adapter DNS configuration", "GetAdaptersAddresses")
|
||||
|
||||
if err != nil {
|
||||
fmt.Fprintf(sb, "error: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
for _, adapter := range adapters {
|
||||
name := windows.UTF16PtrToString(adapter.FriendlyName)
|
||||
suffix := g.anonymizeDomain(windows.UTF16PtrToString(adapter.DnsSuffix))
|
||||
|
||||
fmt.Fprintf(sb, "%s (index %d, oper status %d)\n", name, adapter.IfIndex, adapter.OperStatus)
|
||||
fmt.Fprintf(sb, " DNS suffix: %s\n", suffix)
|
||||
|
||||
var servers []string
|
||||
for server := adapter.FirstDnsServerAddress; server != nil; server = server.Next {
|
||||
addr, ok := netip.AddrFromSlice(server.Address.IP())
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
addr = addr.Unmap()
|
||||
if g.anonymize {
|
||||
addr = g.anonymizer.AnonymizeIP(addr)
|
||||
}
|
||||
servers = append(servers, addr.String())
|
||||
}
|
||||
|
||||
fmt.Fprintf(sb, " DNS servers: %s\n", strings.Join(servers, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// anonymizeDomain anonymizes a single domain, keeping the leading dot an NRPT
|
||||
// match domain carries.
|
||||
func (g *BundleGenerator) anonymizeDomain(entry string) string {
|
||||
if !g.anonymize {
|
||||
return entry
|
||||
}
|
||||
|
||||
domain, dot := strings.CutPrefix(entry, ".")
|
||||
if domain == "" {
|
||||
return entry
|
||||
}
|
||||
|
||||
anonymized := g.anonymizer.AnonymizeDomain(domain)
|
||||
if dot {
|
||||
anonymized = "." + anonymized
|
||||
}
|
||||
return anonymized
|
||||
}
|
||||
|
||||
// splitValueEntries splits a registry value that holds a list. The separator
|
||||
// differs per value: a REG_MULTI_SZ arrives joined with ", ", a SearchList is
|
||||
// comma separated and a NameServer may use commas or spaces.
|
||||
func splitValueEntries(value string) []string {
|
||||
return strings.FieldsFunc(value, func(r rune) bool {
|
||||
return r == ',' || r == ';' || r == ' ' || r == '\t'
|
||||
})
|
||||
}
|
||||
|
||||
func joinValueEntries(entries []string, anonymize func(string) string) string {
|
||||
for i, entry := range entries {
|
||||
entries[i] = anonymize(entry)
|
||||
}
|
||||
return strings.Join(entries, ", ")
|
||||
}
|
||||
|
||||
func writeSection(sb *strings.Builder, title, source string) {
|
||||
fmt.Fprintf(sb, "\n%s\n%s\n%s\n", title, strings.Repeat("-", len(title)), source)
|
||||
}
|
||||
|
||||
func subKeyNames(root string) ([]string, error) {
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, root, registry.ENUMERATE_SUB_KEYS)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", root, err)
|
||||
}
|
||||
defer closeKey(k)
|
||||
|
||||
names, err := k.ReadSubKeyNames(-1)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read subkey names: %w", err)
|
||||
}
|
||||
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// readRegistryValue renders a value as text regardless of its type, so an
|
||||
// unexpected type in a policy key still shows up instead of being dropped.
|
||||
func readRegistryValue(k registry.Key, name string) (string, error) {
|
||||
_, valueType, err := k.GetValue(name, nil)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get value %s: %w", name, err)
|
||||
}
|
||||
|
||||
switch valueType {
|
||||
case registry.SZ, registry.EXPAND_SZ:
|
||||
value, _, err := k.GetStringValue(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get string value %s: %w", name, err)
|
||||
}
|
||||
return value, nil
|
||||
case registry.MULTI_SZ:
|
||||
values, _, err := k.GetStringsValue(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get strings value %s: %w", name, err)
|
||||
}
|
||||
return strings.Join(values, ", "), nil
|
||||
case registry.DWORD, registry.QWORD:
|
||||
value, _, err := k.GetIntegerValue(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get integer value %s: %w", name, err)
|
||||
}
|
||||
return fmt.Sprintf("%d (0x%x)", value, value), nil
|
||||
case registry.BINARY:
|
||||
value, _, err := k.GetBinaryValue(name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get binary value %s: %w", name, err)
|
||||
}
|
||||
return hex.EncodeToString(value), nil
|
||||
default:
|
||||
return fmt.Sprintf("<unhandled registry type %d>", valueType), nil
|
||||
}
|
||||
}
|
||||
|
||||
// adapterAddresses returns the adapter list including DNS servers. The call
|
||||
// reports the size it needs, so grow the buffer and retry until it fits.
|
||||
func adapterAddresses() (adapters []*windows.IpAdapterAddresses, err error) {
|
||||
// GetAdaptersAddresses is resolved on first use and panics when it is
|
||||
// missing, so this reports it as an error and leaves the rest of the
|
||||
// report intact.
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
adapters, err = nil, fmt.Errorf("GetAdaptersAddresses: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
const flags = windows.GAA_FLAG_SKIP_ANYCAST | windows.GAA_FLAG_SKIP_MULTICAST
|
||||
|
||||
size := uint32(15000)
|
||||
for range 3 {
|
||||
buf := make([]byte, size)
|
||||
first := (*windows.IpAdapterAddresses)(unsafe.Pointer(&buf[0]))
|
||||
|
||||
err := windows.GetAdaptersAddresses(windows.AF_UNSPEC, flags, 0, first, &size)
|
||||
if errors.Is(err, windows.ERROR_BUFFER_OVERFLOW) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetAdaptersAddresses: %w", err)
|
||||
}
|
||||
|
||||
for adapter := first; adapter != nil; adapter = adapter.Next {
|
||||
adapters = append(adapters, adapter)
|
||||
}
|
||||
return adapters, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("GetAdaptersAddresses: buffer kept growing")
|
||||
}
|
||||
|
||||
func closeKey(k registry.Key) {
|
||||
if err := k.Close(); err != nil {
|
||||
log.Debugf("close registry key: %v", err)
|
||||
}
|
||||
}
|
||||
146
client/internal/debug/debug_windows_test.go
Normal file
146
client/internal/debug/debug_windows_test.go
Normal file
@@ -0,0 +1,146 @@
|
||||
//go:build windows
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/netbirdio/netbird/client/anonymize"
|
||||
)
|
||||
|
||||
func newDNSValueGenerator(level anonymize.Level) *BundleGenerator {
|
||||
anonymizer := anonymize.NewAnonymizer(anonymize.DefaultAddresses())
|
||||
anonymizer.SetLevel(level)
|
||||
|
||||
return &BundleGenerator{
|
||||
anonymize: true,
|
||||
anonymizeLevel: level,
|
||||
anonymizer: anonymizer,
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnonymizeValueByName covers the value kinds of the DNS registry keys. The
|
||||
// names decide the treatment, because the string pass alone replaces only
|
||||
// domains another part of the bundle already seeded.
|
||||
func TestAnonymizeValueByName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
valueName string
|
||||
value string
|
||||
assert func(t *testing.T, got string)
|
||||
}{
|
||||
{
|
||||
name: "NRPT match domains keep the leading dot",
|
||||
valueName: "Name",
|
||||
value: ".internal.example.com, .corp.example.org",
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
for _, entry := range strings.Split(got, ", ") {
|
||||
assert.True(t, strings.HasPrefix(entry, "."), "entry %q should keep its leading dot", entry)
|
||||
assert.NotContains(t, entry, "example", "entry %q should not keep the original domain", entry)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "any value name ending in Domain is treated as a domain",
|
||||
valueName: "ICSDomain",
|
||||
value: "mshome.net",
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
assert.NotContains(t, got, "mshome", "should anonymize a domain suffix value")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "search list is a comma separated domain list",
|
||||
valueName: "SearchList",
|
||||
value: "corp.example.com,branch.example.com",
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
assert.NotContains(t, got, "example", "should anonymize every search domain")
|
||||
assert.Len(t, strings.Split(got, ", "), 2, "should keep both search domains")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "name servers are anonymized as addresses",
|
||||
valueName: "DhcpNameServer",
|
||||
value: "203.0.113.10 8.8.8.8",
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
assert.NotContains(t, got, "203.0.113.10", "should anonymize a public resolver address")
|
||||
// well-known resolvers stay readable at every level
|
||||
assert.Contains(t, got, "8.8.8.8", "should keep a well-known resolver address")
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "opaque values are left to the string pass",
|
||||
valueName: "DataBasePath",
|
||||
value: `%SystemRoot%\System32\drivers\etc`,
|
||||
assert: func(t *testing.T, got string) {
|
||||
t.Helper()
|
||||
assert.Equal(t, `%SystemRoot%\System32\drivers\etc`, got, "should not alter a path")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
g := newDNSValueGenerator(anonymize.LevelDefault)
|
||||
tc.assert(t, g.anonymizeValue(tc.valueName, tc.value))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseNRPTPolicyTable parses the MOF text of the policy table out
|
||||
// parameters, as the provider on a client with one NRPT rule renders it.
|
||||
func TestParseNRPTPolicyTable(t *testing.T) {
|
||||
const text = `[abstract]
|
||||
class __PARAMETERS
|
||||
{
|
||||
[Out, EmbeddedInstance("DnsClientPolicyConfiguration"): ToSubClass, ID(2): DisableOverride ToInstance] DnsClientPolicyConfiguration cmdletOutput[] = {
|
||||
instance of DnsClientPolicyConfiguration
|
||||
{
|
||||
DirectAccessProxyType = "NoProxy";
|
||||
DirectAccessQueryIPsecRequired = FALSE;
|
||||
NameEncoding = "Utf8WithoutMapping";
|
||||
Namespace = ".0.100.in-addr.arpa";
|
||||
},
|
||||
instance of DnsClientPolicyConfiguration
|
||||
{
|
||||
DirectAccessProxyType = "NoProxy";
|
||||
NameEncoding = "Utf8WithoutMapping";
|
||||
NameServers = {"100.0.255.254", "100.0.255.253"};
|
||||
Namespace = ".nb.internal";
|
||||
}};
|
||||
[in] boolean Effective;
|
||||
[out] uint32 ReturnValue = 0;
|
||||
};
|
||||
`
|
||||
|
||||
entries := parseNRPTPolicyTable(text)
|
||||
require.Len(t, entries, 2, "should parse both embedded instances")
|
||||
|
||||
assert.Equal(t, ".0.100.in-addr.arpa", entries[0].namespace, "should read the namespace of the first instance")
|
||||
assert.Equal(t, ".nb.internal", entries[1].namespace, "should read the namespace of the second instance")
|
||||
|
||||
assert.Equal(t, []registryValue{
|
||||
{name: "DirectAccessProxyType", value: "NoProxy"},
|
||||
{name: "DirectAccessQueryIPsecRequired", value: "FALSE"},
|
||||
{name: "NameEncoding", value: "Utf8WithoutMapping"},
|
||||
}, entries[0].values, "should keep the remaining values in order")
|
||||
|
||||
assert.Contains(t, entries[1].values, registryValue{name: "NameServers", value: "100.0.255.254, 100.0.255.253"},
|
||||
"should flatten a MOF array")
|
||||
|
||||
for _, value := range entries[1].values {
|
||||
assert.NotContains(t, value.name, "ReturnValue", "should not read the class level parameters as values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNRPTPolicyTableEmpty(t *testing.T) {
|
||||
assert.Empty(t, parseNRPTPolicyTable(""), "should parse no entries from empty text")
|
||||
assert.Empty(t, parseNRPTPolicyTable("class __PARAMETERS\n{\n};\n"), "should parse no entries from a table with no instances")
|
||||
}
|
||||
317
client/internal/debug/nrpt_windows.go
Normal file
317
client/internal/debug/nrpt_windows.go
Normal file
@@ -0,0 +1,317 @@
|
||||
//go:build windows
|
||||
|
||||
package debug
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-ole/go-ole"
|
||||
"github.com/go-ole/go-ole/oleutil"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
// The NRPT policy table is reachable through the CIM class that backs
|
||||
// Get-DnsClientNrptPolicy. Unlike the rules in the registry, the table is
|
||||
// what the resolver currently has loaded, which is the only way to tell an
|
||||
// applied rule from one that is merely written, in either direction.
|
||||
nrptPolicyNamespace = `root\Microsoft\Windows\DNS`
|
||||
nrptPolicyClass = "PS_DnsClientNrptPolicy"
|
||||
nrptPolicyMethod = "Get"
|
||||
|
||||
// The class has no instances, so the table comes from the out parameters
|
||||
// of a static method call, rendered as MOF text: the embedded instances
|
||||
// arrive as a safe array of objects, which cannot be read back through the
|
||||
// COM bindings, and the text form carries all of them.
|
||||
nrptPolicyInstanceKeyword = "instance of DnsClientPolicyConfiguration"
|
||||
|
||||
nrptPolicyTimeout = 15 * time.Second
|
||||
)
|
||||
|
||||
// COM initialization results that leave the calling thread usable: S_FALSE for
|
||||
// a thread this process already initialized, RPC_E_CHANGED_MODE for one that
|
||||
// belongs to another apartment.
|
||||
const (
|
||||
sFalse = 0x00000001
|
||||
rpcEChangedMode = 0x80010106
|
||||
)
|
||||
|
||||
// nrptQueryInFlight admits one read of the policy table at a time. A provider
|
||||
// that stops answering keeps its goroutine and the OS thread that goroutine
|
||||
// pinned, so a later bundle reports that instead of pinning another one.
|
||||
var nrptQueryInFlight = make(chan struct{}, 1)
|
||||
|
||||
// nrptPolicyEntry is one namespace of the effective policy table, holding the
|
||||
// values of an embedded DnsClientPolicyConfiguration instance in the order the
|
||||
// provider reported them.
|
||||
type nrptPolicyEntry struct {
|
||||
namespace string
|
||||
values []registryValue
|
||||
}
|
||||
|
||||
// registryValue is a name and its rendered value, shared by the registry and
|
||||
// policy table readers so both anonymize by value name the same way.
|
||||
type registryValue struct {
|
||||
name string
|
||||
value string
|
||||
}
|
||||
|
||||
// effectiveNRPTPolicies reads the effective NRPT table. The call is bounded
|
||||
// because a WMI provider can block indefinitely and a debug bundle must not.
|
||||
func effectiveNRPTPolicies() ([]nrptPolicyEntry, error) {
|
||||
type result struct {
|
||||
text string
|
||||
err error
|
||||
}
|
||||
|
||||
select {
|
||||
case nrptQueryInFlight <- struct{}{}:
|
||||
default:
|
||||
return nil, errors.New("an earlier read of the policy table has not returned")
|
||||
}
|
||||
|
||||
done := make(chan result, 1)
|
||||
go func() {
|
||||
// the slot is released here rather than by the caller, so a read that
|
||||
// outlives the timeout holds it until the provider answers
|
||||
defer func() { <-nrptQueryInFlight }()
|
||||
|
||||
text, err := nrptPolicyTableText()
|
||||
done <- result{text: text, err: err}
|
||||
}()
|
||||
|
||||
select {
|
||||
case res := <-done:
|
||||
if res.err != nil {
|
||||
return nil, res.err
|
||||
}
|
||||
return parseNRPTPolicyTable(res.text), nil
|
||||
case <-time.After(nrptPolicyTimeout):
|
||||
return nil, errors.New("read of the policy table timed out")
|
||||
}
|
||||
}
|
||||
|
||||
// nrptPolicyTableText calls the policy table method and returns the MOF text of
|
||||
// its out parameters.
|
||||
func nrptPolicyTableText() (text string, err error) {
|
||||
// COM is per thread, and the collection is short lived, so the thread is
|
||||
// pinned for the duration rather than initialized for the process.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
|
||||
defer func() {
|
||||
// The COM call chain is dynamically typed, so a provider that answers
|
||||
// with an unexpected shape must not take the daemon down with it.
|
||||
if r := recover(); r != nil {
|
||||
err = fmt.Errorf("read NRPT policy table: %v", r)
|
||||
}
|
||||
}()
|
||||
|
||||
owns, err := coInitialize()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if owns {
|
||||
defer ole.CoUninitialize()
|
||||
}
|
||||
|
||||
locator, err := oleutil.CreateObject("WbemScripting.SWbemLocator")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create WMI locator: %w", err)
|
||||
}
|
||||
defer locator.Release()
|
||||
|
||||
dispatch, err := locator.QueryInterface(ole.IID_IDispatch)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("query WMI locator interface: %w", err)
|
||||
}
|
||||
defer dispatch.Release()
|
||||
|
||||
service, err := dispatchCall(dispatch, "ConnectServer", nil, nrptPolicyNamespace)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("connect to %s: %w", nrptPolicyNamespace, err)
|
||||
}
|
||||
defer service.Release()
|
||||
|
||||
inParams, err := spawnMethodInParams(service)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer inParams.Release()
|
||||
|
||||
// The effective table is the merge of the local and the group policy
|
||||
// store, which is what the resolver answers from.
|
||||
if _, err := oleutil.PutProperty(inParams, "Effective", true); err != nil {
|
||||
return "", fmt.Errorf("set Effective parameter: %w", err)
|
||||
}
|
||||
|
||||
outParams, err := dispatchCall(service, "ExecMethod", nrptPolicyClass, nrptPolicyMethod, inParams)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("call %s.%s: %w", nrptPolicyClass, nrptPolicyMethod, err)
|
||||
}
|
||||
defer outParams.Release()
|
||||
|
||||
textVariant, err := oleutil.CallMethod(outParams, "GetObjectText_")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("render policy table: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
if err := textVariant.Clear(); err != nil {
|
||||
log.Debugf("clear policy table variant: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return textVariant.ToString(), nil
|
||||
}
|
||||
|
||||
// spawnMethodInParams builds the in parameters instance the method needs. The
|
||||
// provider rejects the call without one, even when every parameter is optional.
|
||||
func spawnMethodInParams(service *ole.IDispatch) (*ole.IDispatch, error) {
|
||||
class, err := dispatchCall(service, "Get", nrptPolicyClass)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get class %s: %w", nrptPolicyClass, err)
|
||||
}
|
||||
defer class.Release()
|
||||
|
||||
methods, err := dispatchProperty(class, "Methods_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get class methods: %w", err)
|
||||
}
|
||||
defer methods.Release()
|
||||
|
||||
method, err := dispatchCall(methods, "Item", nrptPolicyMethod)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get method %s: %w", nrptPolicyMethod, err)
|
||||
}
|
||||
defer method.Release()
|
||||
|
||||
params, err := dispatchProperty(method, "InParameters")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get method parameters: %w", err)
|
||||
}
|
||||
defer params.Release()
|
||||
|
||||
inParams, err := dispatchCall(params, "SpawnInstance_")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("spawn parameter instance: %w", err)
|
||||
}
|
||||
|
||||
return inParams, nil
|
||||
}
|
||||
|
||||
// parseNRPTPolicyTable pulls the embedded instances out of the MOF text. Each
|
||||
// instance is a namespace of the table, with one name and value per line.
|
||||
func parseNRPTPolicyTable(text string) []nrptPolicyEntry {
|
||||
var entries []nrptPolicyEntry
|
||||
var current *nrptPolicyEntry
|
||||
|
||||
for _, line := range strings.Split(text, "\n") {
|
||||
line = strings.TrimSpace(strings.TrimSuffix(strings.TrimSpace(line), ";"))
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(line, nrptPolicyInstanceKeyword):
|
||||
entries = append(entries, nrptPolicyEntry{})
|
||||
current = &entries[len(entries)-1]
|
||||
continue
|
||||
case strings.HasPrefix(line, "}"):
|
||||
// closes an instance, and the array with the last one, so the
|
||||
// class level parameters that follow are not read as values
|
||||
current = nil
|
||||
continue
|
||||
case current == nil, line == "{":
|
||||
continue
|
||||
}
|
||||
|
||||
name, value, ok := strings.Cut(line, " = ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
value = unquoteMOFValue(value)
|
||||
if name == "Namespace" {
|
||||
current.namespace = value
|
||||
continue
|
||||
}
|
||||
|
||||
current.values = append(current.values, registryValue{name: name, value: value})
|
||||
}
|
||||
|
||||
return entries
|
||||
}
|
||||
|
||||
// unquoteMOFValue renders a MOF scalar or array as plain text: "a" becomes a,
|
||||
// and {"a", "b"} becomes a, b.
|
||||
func unquoteMOFValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
if inner, ok := strings.CutPrefix(value, "{"); ok {
|
||||
value = strings.TrimSuffix(inner, "}")
|
||||
|
||||
entries := strings.Split(value, ",")
|
||||
for i, entry := range entries {
|
||||
entries[i] = strings.Trim(strings.TrimSpace(entry), `"`)
|
||||
}
|
||||
return strings.Join(entries, ", ")
|
||||
}
|
||||
|
||||
return strings.Trim(value, `"`)
|
||||
}
|
||||
|
||||
// coInitialize prepares the calling thread for COM and reports whether this
|
||||
// call owns the initialization, which decides whether it may be balanced with
|
||||
// CoUninitialize. S_FALSE took a reference on a thread this process had already
|
||||
// initialized and so has to be released, while RPC_E_CHANGED_MODE took none:
|
||||
// the thread belongs to another apartment, which is usable but is not ours to
|
||||
// uninitialize.
|
||||
func coInitialize() (bool, error) {
|
||||
err := ole.CoInitializeEx(0, ole.COINIT_MULTITHREADED)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
var oleErr *ole.OleError
|
||||
if errors.As(err, &oleErr) {
|
||||
switch oleErr.Code() {
|
||||
case sFalse:
|
||||
return true, nil
|
||||
case rpcEChangedMode:
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("initialize COM: %w", err)
|
||||
}
|
||||
|
||||
// dispatchCall calls a COM method that returns an object.
|
||||
func dispatchCall(dispatch *ole.IDispatch, method string, params ...any) (*ole.IDispatch, error) {
|
||||
variant, err := oleutil.CallMethod(dispatch, method, params...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
object := variant.ToIDispatch()
|
||||
if object == nil {
|
||||
return nil, fmt.Errorf("%s returned no object", method)
|
||||
}
|
||||
|
||||
return object, nil
|
||||
}
|
||||
|
||||
// dispatchProperty reads a COM property that holds an object.
|
||||
func dispatchProperty(dispatch *ole.IDispatch, property string) (*ole.IDispatch, error) {
|
||||
variant, err := oleutil.GetProperty(dispatch, property)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
object := variant.ToIDispatch()
|
||||
if object == nil {
|
||||
return nil, fmt.Errorf("property %s holds no object", property)
|
||||
}
|
||||
|
||||
return object, nil
|
||||
}
|
||||
@@ -267,18 +267,38 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) {
|
||||
return SystemDNSSettings{}, fmt.Errorf("sending the command: %w", err)
|
||||
}
|
||||
|
||||
var dnsSettings SystemDNSSettings
|
||||
dnsSettings, serverAddresses, err := parseSystemDNSSettings(b)
|
||||
if err != nil {
|
||||
return dnsSettings, err
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.origNameservers = serverAddresses
|
||||
s.mu.Unlock()
|
||||
|
||||
return dnsSettings, nil
|
||||
}
|
||||
|
||||
// parseSystemDNSSettings parses the output of `scutil show State:/Network/Service/<id>/DNS`.
|
||||
// Lines that don't match the expected "index : value" shape are skipped: hosts with unusual
|
||||
// network services (e.g. orphaned hardware ports) can produce entries without a value.
|
||||
func parseSystemDNSSettings(out []byte) (SystemDNSSettings, []netip.Addr, error) {
|
||||
// port is not exposed by scutil, default to 53
|
||||
dnsSettings := SystemDNSSettings{ServerPort: DefaultPort}
|
||||
var serverAddresses []netip.Addr
|
||||
inSearchDomainsArray := false
|
||||
inServerAddressesArray := false
|
||||
|
||||
scanner := bufio.NewScanner(bytes.NewReader(b))
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
switch {
|
||||
case strings.HasPrefix(line, "DomainName :"):
|
||||
domainName := strings.TrimSpace(strings.Split(line, ":")[1])
|
||||
dnsSettings.Domains = append(dnsSettings.Domains, domainName)
|
||||
domainName := strings.TrimSpace(strings.TrimPrefix(line, "DomainName :"))
|
||||
if domainName != "" {
|
||||
dnsSettings.Domains = append(dnsSettings.Domains, domainName)
|
||||
}
|
||||
continue
|
||||
case line == "SearchDomains : <array> {":
|
||||
inSearchDomainsArray = true
|
||||
continue
|
||||
@@ -288,36 +308,45 @@ func (s *systemConfigurator) getSystemDNSSettings() (SystemDNSSettings, error) {
|
||||
case line == "}":
|
||||
inSearchDomainsArray = false
|
||||
inServerAddressesArray = false
|
||||
continue
|
||||
}
|
||||
|
||||
if !inSearchDomainsArray && !inServerAddressesArray {
|
||||
continue
|
||||
}
|
||||
|
||||
parts := strings.SplitN(line, " : ", 2)
|
||||
if len(parts) != 2 {
|
||||
log.Debugf("skipping unexpected scutil DNS line %q", line)
|
||||
continue
|
||||
}
|
||||
value := strings.TrimSpace(parts[1])
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if inSearchDomainsArray {
|
||||
searchDomain := strings.Split(line, " : ")[1]
|
||||
dnsSettings.Domains = append(dnsSettings.Domains, searchDomain)
|
||||
} else if inServerAddressesArray {
|
||||
address := strings.Split(line, " : ")[1]
|
||||
if ip, err := netip.ParseAddr(address); err == nil && !ip.IsUnspecified() {
|
||||
ip = ip.Unmap()
|
||||
serverAddresses = append(serverAddresses, ip)
|
||||
// Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4.
|
||||
if !dnsSettings.ServerIP.IsValid() && ip.Is4() {
|
||||
dnsSettings.ServerIP = ip
|
||||
}
|
||||
}
|
||||
dnsSettings.Domains = append(dnsSettings.Domains, value)
|
||||
continue
|
||||
}
|
||||
|
||||
ip, err := netip.ParseAddr(value)
|
||||
if err != nil || ip.IsUnspecified() {
|
||||
continue
|
||||
}
|
||||
ip = ip.Unmap()
|
||||
serverAddresses = append(serverAddresses, ip)
|
||||
// Prefer the first IPv4 server as ServerIP since our DNS listener is IPv4.
|
||||
if !dnsSettings.ServerIP.IsValid() && ip.Is4() {
|
||||
dnsSettings.ServerIP = ip
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return dnsSettings, err
|
||||
return dnsSettings, serverAddresses, err
|
||||
}
|
||||
|
||||
// default to 53 port
|
||||
dnsSettings.ServerPort = DefaultPort
|
||||
|
||||
s.mu.Lock()
|
||||
s.origNameservers = serverAddresses
|
||||
s.mu.Unlock()
|
||||
|
||||
return dnsSettings, nil
|
||||
return dnsSettings, serverAddresses, nil
|
||||
}
|
||||
|
||||
func (s *systemConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
@@ -435,11 +464,15 @@ func (s *systemConfigurator) getPrimaryService() (string, string, error) {
|
||||
router := ""
|
||||
for scanner.Scan() {
|
||||
text := scanner.Text()
|
||||
parts := strings.SplitN(text, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
continue
|
||||
}
|
||||
if strings.Contains(text, "PrimaryService") {
|
||||
primaryService = strings.TrimSpace(strings.Split(text, ":")[1])
|
||||
primaryService = strings.TrimSpace(parts[1])
|
||||
}
|
||||
if strings.Contains(text, "Router") {
|
||||
router = strings.TrimSpace(strings.Split(text, ":")[1])
|
||||
router = strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil && err != io.EOF {
|
||||
|
||||
@@ -328,6 +328,120 @@ func removeTestDNSKey(key string) error {
|
||||
return err
|
||||
}
|
||||
|
||||
func TestParseSystemDNSSettings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
expectedDomains []string
|
||||
expectedServers []netip.Addr
|
||||
expectedIP netip.Addr
|
||||
}{
|
||||
{
|
||||
name: "well_formed",
|
||||
output: `<dictionary> {
|
||||
DomainName : example.com
|
||||
SearchDomains : <array> {
|
||||
0 : example.com
|
||||
1 : corp.example.com
|
||||
}
|
||||
ServerAddresses : <array> {
|
||||
0 : 192.168.1.1
|
||||
1 : fd00::53
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedDomains: []string{"example.com", "example.com", "corp.example.com"},
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1"), netip.MustParseAddr("fd00::53")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
// entries without a value after the separator used to panic with
|
||||
// "index out of range [1] with length 1"
|
||||
name: "malformed_array_entries_skipped",
|
||||
output: `<dictionary> {
|
||||
SearchDomains : <array> {
|
||||
0 :
|
||||
(null)
|
||||
|
||||
1 : corp.example.com
|
||||
}
|
||||
ServerAddresses : <array> {
|
||||
0 :
|
||||
1 : 192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedDomains: []string{"corp.example.com"},
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "domain_name_without_value_skipped",
|
||||
output: `<dictionary> {
|
||||
DomainName :
|
||||
ServerAddresses : <array> {
|
||||
0 : 192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "ipv6_first_prefers_ipv4_server_ip",
|
||||
output: `<dictionary> {
|
||||
ServerAddresses : <array> {
|
||||
0 : fd00::53
|
||||
1 : 192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("fd00::53"), netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "invalid_and_unspecified_addresses_skipped",
|
||||
output: `<dictionary> {
|
||||
ServerAddresses : <array> {
|
||||
0 : (null)
|
||||
1 : 0.0.0.0
|
||||
2 : 192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "v4_mapped_address_unmapped",
|
||||
output: `<dictionary> {
|
||||
ServerAddresses : <array> {
|
||||
0 : ::ffff:192.168.1.1
|
||||
}
|
||||
}
|
||||
`,
|
||||
expectedServers: []netip.Addr{netip.MustParseAddr("192.168.1.1")},
|
||||
expectedIP: netip.MustParseAddr("192.168.1.1"),
|
||||
},
|
||||
{
|
||||
name: "empty_output",
|
||||
output: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
settings, servers, err := parseSystemDNSSettings([]byte(tc.output))
|
||||
require.NoError(t, err, "parsing should not fail")
|
||||
|
||||
assert.Equal(t, tc.expectedDomains, settings.Domains, "domains should match")
|
||||
assert.Equal(t, tc.expectedServers, servers, "server addresses should match")
|
||||
assert.Equal(t, tc.expectedIP, settings.ServerIP, "server IP should match")
|
||||
assert.Equal(t, DefaultPort, settings.ServerPort, "server port should default to 53")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetOriginalNameservers(t *testing.T) {
|
||||
configurator := &systemConfigurator{
|
||||
createdKeys: make(map[string]struct{}),
|
||||
|
||||
@@ -31,10 +31,28 @@ var (
|
||||
dnsFlushResolverCacheFn = dnsapi.NewProc("DnsFlushResolverCache")
|
||||
)
|
||||
|
||||
// Registry locations of the host DNS configuration this package programs,
|
||||
// exported so a diagnostic reader reports the same locations that are written.
|
||||
const (
|
||||
dnsPolicyConfigMatchPath = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig\NetBird-Match`
|
||||
gpoDnsPolicyRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
|
||||
gpoDnsPolicyConfigMatchPath = gpoDnsPolicyRoot + `\NetBird-Match`
|
||||
// NRPTKeyPrefix starts the name of every NRPT rule key this client creates.
|
||||
NRPTKeyPrefix = "NetBird-Match"
|
||||
|
||||
// DNSPolicyConfigRoot holds the NRPT rules of the local policy store.
|
||||
DNSPolicyConfigRoot = `SYSTEM\CurrentControlSet\Services\Dnscache\Parameters\DnsPolicyConfig`
|
||||
|
||||
// GPODNSPolicyConfigRoot holds the NRPT rules of the group policy store,
|
||||
// which takes precedence over the local one when it is present.
|
||||
GPODNSPolicyConfigRoot = `SOFTWARE\Policies\Microsoft\Windows NT\DNSClient\DnsPolicyConfig`
|
||||
|
||||
// InterfaceConfigPath and InterfaceConfigPathV6 hold the per-interface DNS
|
||||
// settings, keyed by interface GUID, in separate hives per address family.
|
||||
InterfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
|
||||
InterfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
|
||||
)
|
||||
|
||||
const (
|
||||
dnsPolicyConfigMatchPath = DNSPolicyConfigRoot + `\` + NRPTKeyPrefix
|
||||
gpoDnsPolicyConfigMatchPath = GPODNSPolicyConfigRoot + `\` + NRPTKeyPrefix
|
||||
|
||||
dnsPolicyConfigVersionKey = "Version"
|
||||
dnsPolicyConfigVersionValue = 2
|
||||
@@ -45,8 +63,6 @@ const (
|
||||
|
||||
nrptMaxDomainsPerRule = 50
|
||||
|
||||
interfaceConfigPath = `SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\Interfaces`
|
||||
interfaceConfigPathV6 = `SYSTEM\CurrentControlSet\Services\Tcpip6\Parameters\Interfaces`
|
||||
interfaceConfigNameServerKey = "NameServer"
|
||||
interfaceConfigDhcpNameSrvKey = "DhcpNameServer"
|
||||
interfaceConfigSearchListKey = "SearchList"
|
||||
@@ -84,7 +100,7 @@ func newHostManager(wgInterface WGIface) (*registryConfigurator, error) {
|
||||
}
|
||||
|
||||
var useGPO bool
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, gpoDnsPolicyRoot, registry.QUERY_VALUE)
|
||||
k, err := registry.OpenKey(registry.LOCAL_MACHINE, GPODNSPolicyConfigRoot, registry.QUERY_VALUE)
|
||||
if err != nil {
|
||||
log.Debugf("failed to open GPO DNS policy root: %v", err)
|
||||
} else {
|
||||
@@ -123,7 +139,7 @@ func (r *registryConfigurator) captureOriginalNameservers() ([]netip.Addr, error
|
||||
seen := make(map[netip.Addr]struct{})
|
||||
var out []netip.Addr
|
||||
var merr *multierror.Error
|
||||
for _, root := range []string{interfaceConfigPath, interfaceConfigPathV6} {
|
||||
for _, root := range []string{InterfaceConfigPath, InterfaceConfigPathV6} {
|
||||
addrs, err := r.captureFromTcpipRoot(root)
|
||||
if err != nil {
|
||||
merr = multierror.Append(merr, fmt.Errorf("%s: %w", root, err))
|
||||
@@ -496,7 +512,7 @@ func (r *registryConfigurator) deleteInterfaceRegistryKeyProperty(propertyKey st
|
||||
}
|
||||
|
||||
func (r *registryConfigurator) getInterfaceRegistryKey() (registry.Key, error) {
|
||||
regKeyPath := interfaceConfigPath + "\\" + r.guid
|
||||
regKeyPath := InterfaceConfigPath + "\\" + r.guid
|
||||
regKey, err := registry.OpenKey(registry.LOCAL_MACHINE, regKeyPath, registry.SET_VALUE)
|
||||
if err != nil {
|
||||
return regKey, fmt.Errorf("open HKEY_LOCAL_MACHINE\\%s: %w", regKeyPath, err)
|
||||
|
||||
@@ -87,9 +87,10 @@ func (pm *ProfileManager) SetActiveProfileState(state *ProfileState) error {
|
||||
|
||||
// RemoveProfileState deletes the per-profile state file (which holds the
|
||||
// account email used for the SSO login hint and the UI display). Called after
|
||||
// a successful logout so a logged-out profile no longer shows a stale account
|
||||
// email. The state file only stores the email, so deleting it is equivalent to
|
||||
// clearing it; the next SSO login recreates it. A missing file is not an error.
|
||||
// profile removal; logout keeps the file so the next login can pass the email
|
||||
// as the login_hint. The state file only stores the email, so deleting it is
|
||||
// equivalent to clearing it; the next SSO login recreates it. A missing file
|
||||
// is not an error.
|
||||
func (pm *ProfileManager) RemoveProfileState(profileName string) error {
|
||||
configDir, err := getConfigDir()
|
||||
if err != nil {
|
||||
|
||||
@@ -2,54 +2,183 @@ package ipfwdstate
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/systemops"
|
||||
)
|
||||
|
||||
// IPForwardingState is a struct that keeps track of the IP forwarding state.
|
||||
// todo: read initial state of the IP forwarding from the system and reset the state based on it.
|
||||
// todo: separate v4/v6 forwarding state, since the sysctls are independent
|
||||
// (net.ipv4.ip_forward vs net.ipv6.conf.all.forwarding). Currently the nftables
|
||||
// manager shares one instance between both routers, which works only because
|
||||
// EnableIPForwarding enables both sysctls in a single call.
|
||||
// IPForwardingState tracks v4 and v6 IP-forwarding sysctl enables with
|
||||
// independent refcounts so a v4-only routing setup doesn't flip v6 sysctls.
|
||||
type IPForwardingState struct {
|
||||
enabledCounter int
|
||||
mu sync.Mutex
|
||||
|
||||
v4Count int
|
||||
v6Count int
|
||||
|
||||
// routingV4/routingV6 track whether the routing path currently holds a
|
||||
// reference, so repeated EnableRouting calls (one per network-map update)
|
||||
// hold at most one reference per family and an unpaired DisableRouting
|
||||
// can't release references held by DNAT rules.
|
||||
routingV4 bool
|
||||
routingV6 bool
|
||||
|
||||
wgIfaceName string
|
||||
v6Saved map[string]int
|
||||
}
|
||||
|
||||
func NewIPForwardingState() *IPForwardingState {
|
||||
return &IPForwardingState{}
|
||||
// NewIPForwardingState returns a state tracker for the IP-forwarding sysctls.
|
||||
// wgIfaceName is excluded from the per-interface accept_ra handling.
|
||||
func NewIPForwardingState(wgIfaceName string) *IPForwardingState {
|
||||
return &IPForwardingState{wgIfaceName: wgIfaceName}
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) RequestForwarding() error {
|
||||
if f.enabledCounter != 0 {
|
||||
f.enabledCounter++
|
||||
// Counts returns the current v4 and v6 refcounts. Intended for diagnostics
|
||||
// and tests.
|
||||
func (f *IPForwardingState) Counts() (v4, v6 int) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.v4Count, f.v6Count
|
||||
}
|
||||
|
||||
// RequestRouting takes the forwarding references for the routing path. It is
|
||||
// idempotent: while routing already holds a reference, further calls don't
|
||||
// increment the refcounts, and a v4-only request releases a previously held v6
|
||||
// reference. A v6 sysctl failure is logged and not returned so it can't take
|
||||
// down v4 routing (the sysctl may be unwritable, e.g. read-only /proc/sys or
|
||||
// IPv6 disabled on the kernel command line); v6 is retried on the next call.
|
||||
func (f *IPForwardingState) RequestRouting(v6 bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if !f.routingV4 {
|
||||
if err := f.requestV4(); err != nil {
|
||||
return err
|
||||
}
|
||||
f.routingV4 = true
|
||||
}
|
||||
|
||||
if !v6 {
|
||||
if !f.routingV6 {
|
||||
return nil
|
||||
}
|
||||
f.routingV6 = false
|
||||
return f.releaseV6()
|
||||
}
|
||||
|
||||
if f.routingV6 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := systemops.EnableIPForwarding(); err != nil {
|
||||
return fmt.Errorf("failed to enable IP forwarding with sysctl: %w", err)
|
||||
if err := f.requestV6(); err != nil {
|
||||
log.Warnf("enable IPv6 forwarding for routing: %v", err)
|
||||
return nil
|
||||
}
|
||||
f.enabledCounter = 1
|
||||
log.Info("IP forwarding enabled")
|
||||
|
||||
f.routingV6 = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) ReleaseForwarding() error {
|
||||
if f.enabledCounter == 0 {
|
||||
return nil
|
||||
// ReleaseRouting releases the references RequestRouting holds. Calls without a
|
||||
// held reference are no-ops.
|
||||
func (f *IPForwardingState) ReleaseRouting() error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if f.routingV4 {
|
||||
f.routingV4 = false
|
||||
f.releaseV4()
|
||||
}
|
||||
|
||||
if f.enabledCounter > 1 {
|
||||
f.enabledCounter--
|
||||
return nil
|
||||
if f.routingV6 {
|
||||
f.routingV6 = false
|
||||
return f.releaseV6()
|
||||
}
|
||||
|
||||
// if failed to disable IP forwarding we anyway decrement the counter
|
||||
f.enabledCounter = 0
|
||||
|
||||
// todo call systemops.DisableIPForwarding()
|
||||
return nil
|
||||
}
|
||||
|
||||
// RequestForwarding enables the family's forwarding sysctl on first request.
|
||||
func (f *IPForwardingState) RequestForwarding(v6 bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if v6 {
|
||||
return f.requestV6()
|
||||
}
|
||||
return f.requestV4()
|
||||
}
|
||||
|
||||
// ReleaseForwarding decrements the family counter. The last v6 release restores
|
||||
// what enable captured. v4 stays on: net.ipv4.ip_forward is co-owned by other
|
||||
// tooling (docker, k8s, libvirt).
|
||||
func (f *IPForwardingState) ReleaseForwarding(v6 bool) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
|
||||
if v6 {
|
||||
return f.releaseV6()
|
||||
}
|
||||
f.releaseV4()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) requestV4() error {
|
||||
if f.v4Count == 0 {
|
||||
if err := systemops.EnableV4IPForwarding(); err != nil {
|
||||
return fmt.Errorf("enable IPv4 forwarding: %w", err)
|
||||
}
|
||||
log.Info("IPv4 forwarding enabled")
|
||||
}
|
||||
f.v4Count++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) releaseV4() {
|
||||
if f.v4Count > 0 {
|
||||
f.v4Count--
|
||||
}
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) requestV6() error {
|
||||
if f.v6Count == 0 {
|
||||
saved, err := systemops.EnableV6IPForwarding(f.wgIfaceName)
|
||||
if err != nil {
|
||||
if rerr := systemops.DisableV6IPForwarding(saved); rerr != nil {
|
||||
log.Warnf("rollback partial v6 sysctls: %v", rerr)
|
||||
}
|
||||
return fmt.Errorf("enable IPv6 forwarding: %w", err)
|
||||
}
|
||||
// A failed restore on a previous release keeps its saved values; those
|
||||
// are the true originals, so keep them over what this enable captured.
|
||||
if f.v6Saved == nil {
|
||||
f.v6Saved = saved
|
||||
} else {
|
||||
for k, v := range saved {
|
||||
if _, ok := f.v6Saved[k]; !ok {
|
||||
f.v6Saved[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
log.Info("IPv6 forwarding enabled")
|
||||
}
|
||||
f.v6Count++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *IPForwardingState) releaseV6() error {
|
||||
if f.v6Count == 0 {
|
||||
return nil
|
||||
}
|
||||
f.v6Count--
|
||||
if f.v6Count > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Keep the saved values on failure so a later release or enable/release
|
||||
// cycle can still restore them; re-restoring an already-restored key is a
|
||||
// no-op since the sysctl already holds the desired value.
|
||||
if err := systemops.DisableV6IPForwarding(f.v6Saved); err != nil {
|
||||
return fmt.Errorf("disable IPv6 forwarding: %w", err)
|
||||
}
|
||||
f.v6Saved = nil
|
||||
log.Info("IPv6 forwarding disabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
//go:build privileged
|
||||
|
||||
package ipfwdstate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRequestRoutingV6ToV4Transition verifies that a v4-only routing request
|
||||
// releases a previously held routing-owned v6 reference without touching
|
||||
// references held by DNAT rules.
|
||||
func TestRequestRoutingV6ToV4Transition(t *testing.T) {
|
||||
f := NewIPForwardingState("wt-fwd-test")
|
||||
|
||||
require.NoError(t, f.RequestRouting(true), "request routing with v6")
|
||||
v4, v6 := f.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 reference held")
|
||||
assert.Equal(t, 1, v6, "v6 reference held")
|
||||
|
||||
require.NoError(t, f.RequestRouting(false), "request routing v4-only")
|
||||
v4, v6 = f.Counts()
|
||||
assert.Equal(t, 1, v4, "v4 reference kept")
|
||||
assert.Equal(t, 0, v6, "routing-owned v6 reference released")
|
||||
|
||||
// A DNAT-held reference survives a v4-only routing request.
|
||||
require.NoError(t, f.RequestForwarding(true), "dnat v6 reference")
|
||||
require.NoError(t, f.RequestRouting(false), "repeat v4-only request")
|
||||
_, v6 = f.Counts()
|
||||
assert.Equal(t, 1, v6, "dnat-held v6 reference survives")
|
||||
require.NoError(t, f.ReleaseForwarding(true), "release dnat v6 reference")
|
||||
|
||||
require.NoError(t, f.ReleaseRouting(), "release routing")
|
||||
v4, v6 = f.Counts()
|
||||
assert.Equal(t, 0, v4, "all v4 references released")
|
||||
assert.Equal(t, 0, v6, "all v6 references released")
|
||||
}
|
||||
@@ -58,11 +58,7 @@ func Setup(wgIface iface) (map[string]int, error) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Escape '%' and '.' so they survive the dot-to-slash conversion in Set()
|
||||
safeName := strings.ReplaceAll(intf.Name, "%", percentEscape)
|
||||
safeName = strings.ReplaceAll(safeName, ".", dotEscape)
|
||||
|
||||
i := fmt.Sprintf(rpFilterInterfacePath, safeName)
|
||||
i := fmt.Sprintf(rpFilterInterfacePath, EscapeInterfaceName(intf.Name))
|
||||
oldVal, err := Set(i, 2, true)
|
||||
if err != nil {
|
||||
result = multierror.Append(result, err)
|
||||
@@ -74,6 +70,13 @@ func Setup(wgIface iface) (map[string]int, error) {
|
||||
return keys, nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
// EscapeInterfaceName escapes '%' and '.' in an interface name (e.g. VLANs
|
||||
// like eth0.100) so the name survives the dot-to-slash conversion in Set.
|
||||
func EscapeInterfaceName(name string) string {
|
||||
safe := strings.ReplaceAll(name, "%", percentEscape)
|
||||
return strings.ReplaceAll(safe, ".", dotEscape)
|
||||
}
|
||||
|
||||
// Set sets a sysctl configuration, if onlyIfOne is true it will only set the new value if it's set to 1
|
||||
func Set(key string, desiredValue int, onlyIfOne bool) (int, error) {
|
||||
path := strings.ReplaceAll(key, ".", "/")
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
//go:build windows
|
||||
|
||||
package systemops
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestSortRouteCandidates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
candidates []candidateRoute
|
||||
wantOrder []uint32
|
||||
}{
|
||||
{
|
||||
name: "longest prefix wins over metrics",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 1, prefixLength: 0, routeMetric: 0, interfaceMetric: 5},
|
||||
{interfaceIndex: 2, prefixLength: 24, routeMetric: 100, interfaceMetric: 50},
|
||||
},
|
||||
wantOrder: []uint32{2, 1},
|
||||
},
|
||||
{
|
||||
// Windows ranks equal-length prefixes by route metric + interface metric,
|
||||
// so a higher route metric on a low metric interface can still win.
|
||||
name: "combined metric beats route metric alone",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100},
|
||||
{interfaceIndex: 5, prefixLength: 0, routeMetric: 10, interfaceMetric: 5},
|
||||
},
|
||||
wantOrder: []uint32{5, 8},
|
||||
},
|
||||
{
|
||||
name: "lower combined metric wins",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 5, prefixLength: 0, routeMetric: 300, interfaceMetric: 5},
|
||||
{interfaceIndex: 8, prefixLength: 0, routeMetric: 0, interfaceMetric: 100},
|
||||
},
|
||||
wantOrder: []uint32{8, 5},
|
||||
},
|
||||
{
|
||||
name: "equal combined metric falls back to route metric",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 1, prefixLength: 0, routeMetric: 20, interfaceMetric: 10},
|
||||
{interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 25},
|
||||
},
|
||||
wantOrder: []uint32{2, 1},
|
||||
},
|
||||
{
|
||||
// The metrics are uint32 on the Windows side, so the sum must not wrap.
|
||||
name: "combined metric beyond the uint32 range",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 1, prefixLength: 0, routeMetric: math.MaxUint32, interfaceMetric: 5},
|
||||
{interfaceIndex: 2, prefixLength: 0, routeMetric: math.MaxUint32 - 10, interfaceMetric: 5},
|
||||
},
|
||||
wantOrder: []uint32{2, 1},
|
||||
},
|
||||
{
|
||||
name: "unknown interface metric ranks on route metric only",
|
||||
candidates: []candidateRoute{
|
||||
{interfaceIndex: 1, prefixLength: 0, routeMetric: 30, interfaceMetric: -1},
|
||||
{interfaceIndex: 2, prefixLength: 0, routeMetric: 5, interfaceMetric: 10},
|
||||
},
|
||||
wantOrder: []uint32{2, 1},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sortRouteCandidates(tt.candidates)
|
||||
|
||||
got := make([]uint32, 0, len(tt.candidates))
|
||||
for _, c := range tt.candidates {
|
||||
got = append(got, c.interfaceIndex)
|
||||
}
|
||||
assert.Equal(t, tt.wantOrder, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableIPForwarding() error {
|
||||
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
|
||||
func EnableV4IPForwarding() error {
|
||||
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableV6IPForwarding(string) (map[string]int, error) {
|
||||
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
func DisableV6IPForwarding(map[string]int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -58,8 +58,17 @@ func (r *SysOps) removeFromRouteTable(netip.Prefix, Nexthop) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableIPForwarding() error {
|
||||
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
|
||||
func EnableV4IPForwarding() error {
|
||||
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableV6IPForwarding(string) (map[string]int, error) {
|
||||
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
func DisableV6IPForwarding(map[string]int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -763,13 +763,10 @@ func flushRoutes(tableID, family int) error {
|
||||
return nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
func EnableIPForwarding() error {
|
||||
func EnableV4IPForwarding() error {
|
||||
if _, err := sysctl.Set(ipv4ForwardingPath, 1, false); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := sysctl.Set(ipv6ForwardingPath, 1, false); err != nil {
|
||||
log.Warnf("failed to enable IPv6 forwarding: %v", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,17 @@ func (r *SysOps) RemoveVPNRoute(prefix netip.Prefix, intf *net.Interface) error
|
||||
return r.genericRemoveVPNRoute(prefix, intf)
|
||||
}
|
||||
|
||||
func EnableIPForwarding() error {
|
||||
log.Infof("Enable IP forwarding is not implemented on %s", runtime.GOOS)
|
||||
func EnableV4IPForwarding() error {
|
||||
log.Infof("Enable IPv4 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return nil
|
||||
}
|
||||
|
||||
func EnableV6IPForwarding(string) (map[string]int, error) {
|
||||
log.Infof("Enable IPv6 forwarding is not implemented on %s", runtime.GOOS)
|
||||
return map[string]int{}, nil
|
||||
}
|
||||
|
||||
func DisableV6IPForwarding(map[string]int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -882,26 +882,40 @@ func getInterfaceMetric(interfaceIndex uint32, family int16) int {
|
||||
return int(ipInterfaceRow.Metric)
|
||||
}
|
||||
|
||||
// sortRouteCandidates sorts route candidates by priority: prefix length -> route metric -> interface metric
|
||||
// sortRouteCandidates sorts route candidates by priority: prefix length -> combined metric -> route metric.
|
||||
// Windows prefers the longest matching prefix and, among prefixes of the same length, the lowest metric, see
|
||||
// https://learn.microsoft.com/en-us/windows-hardware/customize/desktop/unattend/microsoft-windows-tcpip-interfaces-interface-routes-route-metric
|
||||
func sortRouteCandidates(candidates []candidateRoute) {
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].prefixLength != candidates[j].prefixLength {
|
||||
return candidates[i].prefixLength > candidates[j].prefixLength
|
||||
}
|
||||
if candidates[i].routeMetric != candidates[j].routeMetric {
|
||||
return candidates[i].routeMetric < candidates[j].routeMetric
|
||||
mi, mj := combinedMetric(candidates[i]), combinedMetric(candidates[j])
|
||||
if mi != mj {
|
||||
return mi < mj
|
||||
}
|
||||
return candidates[i].interfaceMetric < candidates[j].interfaceMetric
|
||||
return candidates[i].routeMetric < candidates[j].routeMetric
|
||||
})
|
||||
}
|
||||
|
||||
// combinedMetric returns the effective metric Windows uses to rank routes with an equal prefix length:
|
||||
// the sum of the route metric and the metric of the interface the route is on, see
|
||||
// https://learn.microsoft.com/en-us/windows-server/networking/technologies/network-subsystem/net-sub-interface-metric
|
||||
// An unknown interface metric contributes nothing.
|
||||
func combinedMetric(candidate candidateRoute) uint64 {
|
||||
if candidate.interfaceMetric < 0 {
|
||||
return uint64(candidate.routeMetric)
|
||||
}
|
||||
return uint64(candidate.routeMetric) + uint64(candidate.interfaceMetric)
|
||||
}
|
||||
|
||||
// GetBestInterface finds the best interface for reaching a destination,
|
||||
// excluding the VPN interface to avoid routing loops.
|
||||
//
|
||||
// Route selection priority:
|
||||
// 1. Longest prefix match (most specific route)
|
||||
// 2. Lowest route metric
|
||||
// 3. Lowest interface metric
|
||||
// 2. Lowest combined metric (route metric + interface metric)
|
||||
// 3. Lowest route metric.
|
||||
func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) {
|
||||
var skipInterfaceIndex int
|
||||
if vpnIntf != "" {
|
||||
@@ -925,7 +939,6 @@ func GetBestInterface(dest netip.Addr, vpnIntf string) (*net.Interface, error) {
|
||||
return nil, fmt.Errorf("no route to %s", dest)
|
||||
}
|
||||
|
||||
// Sort routes: prefix length -> route metric -> interface metric
|
||||
sortRouteCandidates(candidates)
|
||||
|
||||
for _, candidate := range candidates {
|
||||
|
||||
92
client/internal/routemanager/systemops/v6forwarding_linux.go
Normal file
92
client/internal/routemanager/systemops/v6forwarding_linux.go
Normal file
@@ -0,0 +1,92 @@
|
||||
//go:build !android
|
||||
|
||||
package systemops
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
"github.com/netbirdio/netbird/client/internal/routemanager/sysctl"
|
||||
)
|
||||
|
||||
const (
|
||||
// 1 (default) accepts RAs only while forwarding is off; 2 keeps RA
|
||||
// acceptance on regardless, so RA-installed host defaults survive our
|
||||
// v6 forwarding flip.
|
||||
acceptRAInterfacePath = "net.ipv6.conf.%s.accept_ra"
|
||||
acceptRADefaultPath = "net.ipv6.conf.default.accept_ra"
|
||||
acceptRAProcPathFormat = "/proc/sys/net/ipv6/conf/%s/accept_ra"
|
||||
)
|
||||
|
||||
// EnableV6IPForwarding bumps accept_ra=2 on host v6 interfaces before flipping
|
||||
// forwarding=1, so RA-installed host defaults survive. Returns the prior values
|
||||
// of sysctls we actually changed; entries already at the target are omitted.
|
||||
func EnableV6IPForwarding(wgIfaceName string) (map[string]int, error) {
|
||||
saved := map[string]int{}
|
||||
bumpAcceptRA(saved, wgIfaceName)
|
||||
|
||||
oldVal, err := sysctl.Set(ipv6ForwardingPath, 1, false)
|
||||
if err != nil {
|
||||
return saved, err
|
||||
}
|
||||
if oldVal != 1 {
|
||||
saved[ipv6ForwardingPath] = oldVal
|
||||
}
|
||||
return saved, nil
|
||||
}
|
||||
|
||||
// DisableV6IPForwarding restores what EnableV6IPForwarding captured.
|
||||
func DisableV6IPForwarding(saved map[string]int) error {
|
||||
var result *multierror.Error
|
||||
for key, value := range saved {
|
||||
if _, err := sysctl.Set(key, value, false); err != nil {
|
||||
result = multierror.Append(result, fmt.Errorf("restore %s: %w", key, err))
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(result)
|
||||
}
|
||||
|
||||
func bumpAcceptRA(saved map[string]int, wgIfaceName string) {
|
||||
// Also bump conf.default so interfaces created while forwarding is on
|
||||
// (hotplug, new Wi-Fi/dock) inherit accept_ra=2 and keep accepting RAs.
|
||||
bumpAcceptRAKey(saved, acceptRADefaultPath)
|
||||
|
||||
interfaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
log.Warnf("list interfaces for accept_ra: %v", err)
|
||||
return
|
||||
}
|
||||
for _, intf := range interfaces {
|
||||
if intf.Name == "lo" || intf.Name == wgIfaceName {
|
||||
continue
|
||||
}
|
||||
bumpAcceptRAForInterface(saved, intf.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func bumpAcceptRAForInterface(saved map[string]int, name string) {
|
||||
// Build procfs path from name, not the dotted key: VLAN names like eth0.100.
|
||||
if _, err := os.Stat(fmt.Sprintf(acceptRAProcPathFormat, name)); err != nil {
|
||||
return
|
||||
}
|
||||
bumpAcceptRAKey(saved, fmt.Sprintf(acceptRAInterfacePath, sysctl.EscapeInterfaceName(name)))
|
||||
}
|
||||
|
||||
func bumpAcceptRAKey(saved map[string]int, key string) {
|
||||
// onlyIfOne=true: leave admin overrides (0, 2) alone.
|
||||
oldVal, err := sysctl.Set(key, 2, true)
|
||||
if err != nil {
|
||||
log.Warnf("bump %s: %v", key, err)
|
||||
return
|
||||
}
|
||||
// With onlyIfOne, a write only happened when the old value was 1; values
|
||||
// left untouched (0, 2) must not be recorded for restore.
|
||||
if oldVal == 1 {
|
||||
saved[key] = oldVal
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ package systemops
|
||||
import (
|
||||
"errors"
|
||||
"net"
|
||||
"net/netip"
|
||||
"syscall"
|
||||
"testing"
|
||||
|
||||
@@ -29,6 +30,7 @@ func ensureIPv6DefaultRoute(t *testing.T) {
|
||||
}
|
||||
if err := netlink.RouteAdd(route); err != nil {
|
||||
if errors.Is(err, syscall.EEXIST) {
|
||||
requireUsableIPv6Nexthop(t)
|
||||
return
|
||||
}
|
||||
t.Skipf("install IPv6 fallback default route: %v", err)
|
||||
@@ -38,4 +40,36 @@ func ensureIPv6DefaultRoute(t *testing.T) {
|
||||
t.Logf("delete IPv6 fallback default route: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
requireUsableIPv6Nexthop(t)
|
||||
}
|
||||
|
||||
// requireUsableIPv6Nexthop skips the test unless the resolved IPv6 default
|
||||
// nexthop can actually carry a route. Installing the default route succeeding
|
||||
// does not imply the kernel accepts it as a nexthop for a concrete prefix.
|
||||
func requireUsableIPv6Nexthop(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
nexthop, err := GetNextHop(netip.IPv6Unspecified())
|
||||
if err != nil {
|
||||
t.Skipf("resolve IPv6 default nexthop: %v", err)
|
||||
}
|
||||
|
||||
probe := &netlink.Route{
|
||||
Scope: netlink.SCOPE_UNIVERSE,
|
||||
Table: syscall.RT_TABLE_MAIN,
|
||||
Family: netlink.FAMILY_V6,
|
||||
Dst: &net.IPNet{IP: net.ParseIP("100::64"), Mask: net.CIDRMask(128, 128)},
|
||||
}
|
||||
require.NoError(t, addNextHop(nexthop, probe), "build IPv6 probe route")
|
||||
|
||||
switch err := netlink.RouteAdd(probe); {
|
||||
case err == nil:
|
||||
if err := netlink.RouteDel(probe); err != nil && !errors.Is(err, syscall.ESRCH) {
|
||||
t.Logf("delete IPv6 probe route: %v", err)
|
||||
}
|
||||
case errors.Is(err, syscall.EEXIST):
|
||||
default:
|
||||
t.Skipf("IPv6 nexthop %s unusable for route installation: %v", nexthop, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5628,9 +5628,13 @@ func (x *GetPeerSSHHostKeyResponse) GetFound() bool {
|
||||
type RequestJWTAuthRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// hint for OIDC login_hint parameter (typically email address)
|
||||
Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"`
|
||||
// hasGraphicalSession tells the daemon that the caller has a graphical session,
|
||||
// which decides whether PKCE or the device code flow is preferred. The daemon
|
||||
// cannot detect this itself: it does not inherit the session environment.
|
||||
HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RequestJWTAuthRequest) Reset() {
|
||||
@@ -5670,6 +5674,13 @@ func (x *RequestJWTAuthRequest) GetHint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RequestJWTAuthRequest) GetHasGraphicalSession() bool {
|
||||
if x != nil {
|
||||
return x.HasGraphicalSession
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RequestJWTAuthResponse contains authentication flow information
|
||||
type RequestJWTAuthResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
@@ -5894,9 +5905,13 @@ type RequestExtendAuthSessionRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
// Optional OIDC login_hint (typically the user's email) to pre-fill the
|
||||
// IdP login form.
|
||||
Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
Hint *string `protobuf:"bytes,1,opt,name=hint,proto3,oneof" json:"hint,omitempty"`
|
||||
// hasGraphicalSession tells the daemon that the caller has a graphical session,
|
||||
// which decides whether PKCE or the device code flow is preferred. The daemon
|
||||
// cannot detect this itself: it does not inherit the session environment.
|
||||
HasGraphicalSession bool `protobuf:"varint,2,opt,name=hasGraphicalSession,proto3" json:"hasGraphicalSession,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
|
||||
func (x *RequestExtendAuthSessionRequest) Reset() {
|
||||
@@ -5936,6 +5951,13 @@ func (x *RequestExtendAuthSessionRequest) GetHint() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *RequestExtendAuthSessionRequest) GetHasGraphicalSession() bool {
|
||||
if x != nil {
|
||||
return x.HasGraphicalSession
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RequestExtendAuthSessionResponse carries the verification URI the UI
|
||||
// should open in a browser. The daemon retains the flow state and resolves
|
||||
// it via WaitExtendAuthSession.
|
||||
@@ -7503,9 +7525,10 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"sshHostKey\x12\x16\n" +
|
||||
"\x06peerIP\x18\x02 \x01(\tR\x06peerIP\x12\x1a\n" +
|
||||
"\bpeerFQDN\x18\x03 \x01(\tR\bpeerFQDN\x12\x14\n" +
|
||||
"\x05found\x18\x04 \x01(\bR\x05found\"9\n" +
|
||||
"\x05found\x18\x04 \x01(\bR\x05found\"k\n" +
|
||||
"\x15RequestJWTAuthRequest\x12\x17\n" +
|
||||
"\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" +
|
||||
"\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" +
|
||||
"\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" +
|
||||
"\x05_hint\"\x9a\x02\n" +
|
||||
"\x16RequestJWTAuthResponse\x12(\n" +
|
||||
"\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" +
|
||||
@@ -7525,9 +7548,10 @@ const file_daemon_proto_rawDesc = "" +
|
||||
"\x14WaitJWTTokenResponse\x12\x14\n" +
|
||||
"\x05token\x18\x01 \x01(\tR\x05token\x12\x1c\n" +
|
||||
"\ttokenType\x18\x02 \x01(\tR\ttokenType\x12\x1c\n" +
|
||||
"\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"C\n" +
|
||||
"\texpiresIn\x18\x03 \x01(\x03R\texpiresIn\"u\n" +
|
||||
"\x1fRequestExtendAuthSessionRequest\x12\x17\n" +
|
||||
"\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01B\a\n" +
|
||||
"\x04hint\x18\x01 \x01(\tH\x00R\x04hint\x88\x01\x01\x120\n" +
|
||||
"\x13hasGraphicalSession\x18\x02 \x01(\bR\x13hasGraphicalSessionB\a\n" +
|
||||
"\x05_hint\"\xe0\x01\n" +
|
||||
" RequestExtendAuthSessionResponse\x12(\n" +
|
||||
"\x0fverificationURI\x18\x01 \x01(\tR\x0fverificationURI\x128\n" +
|
||||
|
||||
@@ -894,6 +894,10 @@ message GetPeerSSHHostKeyResponse {
|
||||
message RequestJWTAuthRequest {
|
||||
// hint for OIDC login_hint parameter (typically email address)
|
||||
optional string hint = 1;
|
||||
// hasGraphicalSession tells the daemon that the caller has a graphical session,
|
||||
// which decides whether PKCE or the device code flow is preferred. The daemon
|
||||
// cannot detect this itself: it does not inherit the session environment.
|
||||
bool hasGraphicalSession = 2;
|
||||
}
|
||||
|
||||
// RequestJWTAuthResponse contains authentication flow information
|
||||
@@ -937,6 +941,10 @@ message RequestExtendAuthSessionRequest {
|
||||
// Optional OIDC login_hint (typically the user's email) to pre-fill the
|
||||
// IdP login form.
|
||||
optional string hint = 1;
|
||||
// hasGraphicalSession tells the daemon that the caller has a graphical session,
|
||||
// which decides whether PKCE or the device code flow is preferred. The daemon
|
||||
// cannot detect this itself: it does not inherit the session environment.
|
||||
bool hasGraphicalSession = 2;
|
||||
}
|
||||
|
||||
// RequestExtendAuthSessionResponse carries the verification URI the UI
|
||||
|
||||
@@ -1723,8 +1723,8 @@ func (s *Server) RequestJWTAuth(
|
||||
hint = profilemanager.GetLoginHint()
|
||||
}
|
||||
|
||||
isDesktop := isUnixRunningDesktop()
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint)
|
||||
// the daemon has no graphical session of its own, only the caller can answer this
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
|
||||
if err != nil {
|
||||
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
|
||||
}
|
||||
@@ -1827,8 +1827,8 @@ func (s *Server) RequestExtendAuthSession(
|
||||
hint = profilemanager.GetLoginHint()
|
||||
}
|
||||
|
||||
isDesktop := isUnixRunningDesktop()
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, isDesktop, false, hint)
|
||||
// the daemon has no graphical session of its own, only the caller can answer this
|
||||
oAuthFlow, err := auth.NewOAuthFlow(ctx, config, msg.GetHasGraphicalSession(), false, hint)
|
||||
if err != nil {
|
||||
return nil, gstatus.Errorf(codes.Internal, "failed to create OAuth flow: %v", err)
|
||||
}
|
||||
@@ -2000,13 +2000,6 @@ func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.Daemon
|
||||
return nil
|
||||
}
|
||||
|
||||
func isUnixRunningDesktop() bool {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
|
||||
return false
|
||||
}
|
||||
return os.Getenv("DESKTOP_SESSION") != "" || os.Getenv("XDG_CURRENT_DESKTOP") != ""
|
||||
}
|
||||
|
||||
func (s *Server) runProbes(ctx context.Context, waitForProbeResult bool) {
|
||||
if s.connectClient == nil {
|
||||
return
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"golang.org/x/crypto/ssh"
|
||||
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
"github.com/netbirdio/netbird/util"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -92,7 +93,8 @@ func printAuthInstructions(stderr io.Writer, authResponse *proto.RequestJWTAuthR
|
||||
|
||||
// RequestJWTToken requests or retrieves a JWT token for SSH authentication
|
||||
func RequestJWTToken(ctx context.Context, client proto.DaemonServiceClient, stdout, stderr io.Writer, useCache bool, hint string, openBrowser func(string) error) (string, error) {
|
||||
req := &proto.RequestJWTAuthRequest{}
|
||||
// the ssh client runs in the user's session, the daemon does not: tell it what we can see
|
||||
req := &proto.RequestJWTAuthRequest{HasGraphicalSession: util.HasGraphicalSession()}
|
||||
if hint != "" {
|
||||
req.Hint = &hint
|
||||
}
|
||||
@@ -193,4 +195,3 @@ func buildAddressList(hostname string, remote net.Addr) []string {
|
||||
}
|
||||
return addresses
|
||||
}
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ func (s *Server) setUserEnvironmentVariables(envMap map[string]string, userProfi
|
||||
|
||||
// prepareCommandEnv prepares environment variables for command execution on Windows
|
||||
func (s *Server) prepareCommandEnv(logger *log.Entry, localUser *user.User, session ssh.Session) []string {
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
username, domain := parseUsername(localUser.Username)
|
||||
userEnv, err := s.getUserEnvironment(logger, username, domain)
|
||||
if err != nil {
|
||||
log.Debugf("failed to get user environment for %s\\%s, using fallback: %v", domain, username, err)
|
||||
@@ -383,7 +383,7 @@ func (s *Server) executeCommandWithPty(logger *log.Entry, session ssh.Session, _
|
||||
return false
|
||||
}
|
||||
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
username, domain := parseUsername(localUser.Username)
|
||||
shell := getUserShell(localUser.Uid)
|
||||
|
||||
req := PtyExecutionRequest{
|
||||
|
||||
@@ -133,7 +133,12 @@ func (s *Server) checkPrivilegedPortAccess(forwardType string, port uint32, resu
|
||||
return nil
|
||||
}
|
||||
|
||||
if result.User != nil && isPrivilegedUsername(result.User.Username) {
|
||||
// Only uid 0 may bind below the threshold, which is the kernel's own rule and
|
||||
// is asked directly rather than through isPrivilegedOrUnknown: that helper
|
||||
// reports an account it cannot evaluate as privileged, which is safe for a
|
||||
// refusal and unsafe for a grant such as this one. Windows has returned
|
||||
// above, so Uid here is a Unix uid and never a SID.
|
||||
if result.User != nil && result.User.Uid == "0" {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
16
client/ssh/server/privileges_other.go
Normal file
16
client/ssh/server/privileges_other.go
Normal file
@@ -0,0 +1,16 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
// isProcessElevated is only meaningful on Windows; other platforms use the
|
||||
// effective UID check in isCurrentProcessPrivileged.
|
||||
func isProcessElevated() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// isWindowsAccountPrivilegedOrUnknown is only reachable on Windows. Report
|
||||
// privileged on other platforms so a caller refusing privileged accounts fails
|
||||
// closed.
|
||||
func isWindowsAccountPrivilegedOrUnknown(string) bool {
|
||||
return true
|
||||
}
|
||||
228
client/ssh/server/privileges_windows.go
Normal file
228
client/ssh/server/privileges_windows.go
Normal file
@@ -0,0 +1,228 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
netapi32 = windows.NewLazySystemDLL("netapi32.dll")
|
||||
procNetUserGetLocalGroups = netapi32.NewProc("NetUserGetLocalGroups")
|
||||
)
|
||||
|
||||
const (
|
||||
// lgIncludeIndirect makes NetUserGetLocalGroups also return local groups
|
||||
// the user belongs to through a global group.
|
||||
lgIncludeIndirect = 0x1
|
||||
maxPreferredLength = 0xFFFFFFFF
|
||||
)
|
||||
|
||||
// localGroupUsersInfo0 mirrors LOCALGROUP_USERS_INFO_0.
|
||||
type localGroupUsersInfo0 struct {
|
||||
name *uint16
|
||||
}
|
||||
|
||||
// isProcessElevated reports whether the current process token is elevated
|
||||
// (TokenElevation): true for elevated administrators, the built-in
|
||||
// Administrator, administrators with UAC disabled, and SYSTEM; false for
|
||||
// standard users and administrators running with a UAC-filtered token.
|
||||
func isProcessElevated() bool {
|
||||
return windows.GetCurrentProcessToken().IsElevated()
|
||||
}
|
||||
|
||||
// isWindowsAccountPrivilegedOrUnknown reports whether the account is privileged
|
||||
// on this machine: a well-known service account, a built-in Administrator
|
||||
// (RID 500), or a member of the local Administrators group, directly or through
|
||||
// nested groups.
|
||||
//
|
||||
// An account whose privilege cannot be determined counts as privileged, which
|
||||
// is why the name says "or unknown". That is fail-closed for a caller that
|
||||
// refuses privileged accounts, and fail-open for a caller that grants something
|
||||
// to them, so only the former may use this.
|
||||
func isWindowsAccountPrivilegedOrUnknown(username string) bool {
|
||||
sid, _, _, err := windows.LookupSID("", username)
|
||||
if err != nil {
|
||||
log.Warnf("privilege check: SID lookup for %q failed, treating as privileged: %v", username, err)
|
||||
return true
|
||||
}
|
||||
|
||||
if isPrivilegedUserSID(sid) {
|
||||
return true
|
||||
}
|
||||
|
||||
member, err := isLocalAdminsMember(username)
|
||||
if err != nil {
|
||||
log.Warnf("privilege check: cannot determine Administrators membership for %q, treating as privileged: %v", username, err)
|
||||
return true
|
||||
}
|
||||
return member
|
||||
}
|
||||
|
||||
// isPrivilegedUserSID reports whether the SID itself identifies a privileged
|
||||
// principal, without consulting group membership.
|
||||
func isPrivilegedUserSID(sid *windows.SID) bool {
|
||||
wellKnown := []windows.WELL_KNOWN_SID_TYPE{
|
||||
windows.WinLocalSystemSid,
|
||||
windows.WinLocalServiceSid,
|
||||
windows.WinNetworkServiceSid,
|
||||
windows.WinBuiltinAdministratorsSid,
|
||||
}
|
||||
for _, sidType := range wellKnown {
|
||||
if sid.IsWellKnown(sidType) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return isBuiltinAdministratorSID(sid)
|
||||
}
|
||||
|
||||
// isBuiltinAdministratorSID reports whether the SID is a machine or domain
|
||||
// built-in Administrator account (S-1-5-21-...-500). RID 500 is reserved for
|
||||
// that account; it can be renamed but cannot be removed from the
|
||||
// Administrators group.
|
||||
func isBuiltinAdministratorSID(sid *windows.SID) bool {
|
||||
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
|
||||
return false
|
||||
}
|
||||
count := sid.SubAuthorityCount()
|
||||
if count < 2 || sid.SubAuthority(0) != 21 {
|
||||
return false
|
||||
}
|
||||
return sid.SubAuthority(uint32(count-1)) == 500
|
||||
}
|
||||
|
||||
// isLocalAdminsMember reports whether the account is a member of the local
|
||||
// Administrators group.
|
||||
//
|
||||
// Local accounts are checked against the local SAM, which is authoritative for
|
||||
// them and, unlike a token, cannot under-report: UAC filters the tokens of
|
||||
// local administrators, and a filtered token carries Administrators as
|
||||
// deny-only, which a membership check on the token would read as "not a
|
||||
// member". Domain accounts are exempt from that filtering, so for them an S4U
|
||||
// token is preferred because its group list is LSA's transitive expansion and
|
||||
// therefore covers nested and universal groups plus the machine's own local
|
||||
// groups. NetUserGetLocalGroups expands only one global-group hop but needs no
|
||||
// logon, so it serves as the fallback when no token can be obtained.
|
||||
func isLocalAdminsMember(username string) (bool, error) {
|
||||
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("create Administrators SID: %w", err)
|
||||
}
|
||||
|
||||
account, domain := parseUsername(username)
|
||||
if NewPrivilegeDropper().isLocalUser(domain) {
|
||||
return localGroupsContainSID(account, adminSid)
|
||||
}
|
||||
|
||||
member, s4uErr := s4uTokenIsMember(account, domain, adminSid)
|
||||
if s4uErr == nil {
|
||||
return member, nil
|
||||
}
|
||||
log.Debugf("privilege check: S4U membership check for %q failed, falling back to local group enumeration: %v", username, s4uErr)
|
||||
|
||||
member, err = localGroupsContainSID(buildUserCpn(account, domain), adminSid)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("S4U check: %w; local group enumeration: %w", s4uErr, err)
|
||||
}
|
||||
return member, nil
|
||||
}
|
||||
|
||||
// s4uTokenIsMember obtains an S4U token for the account and checks whether the
|
||||
// given SID is enabled in it.
|
||||
func s4uTokenIsMember(account, domain string, sid *windows.SID) (bool, error) {
|
||||
token, err := generateS4UUserToken(log.NewEntry(log.StandardLogger()), account, domain)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.CloseHandle(token); err != nil {
|
||||
log.Debugf("close S4U token: %v", err)
|
||||
}
|
||||
}()
|
||||
return windows.Token(token).IsMember(sid)
|
||||
}
|
||||
|
||||
// localGroupsContainSID reports whether the wanted group is among the local
|
||||
// groups the account belongs to, directly or through a global group.
|
||||
//
|
||||
// The wanted SID is resolved to its group name once and compared against the
|
||||
// enumerated names. Well-known SIDs resolve from a static table, so that lookup
|
||||
// needs no domain controller, and it keeps the comparison correct for a renamed
|
||||
// or localized group because both sides then carry the new name. Resolving each
|
||||
// enumerated name back to a SID instead would add a lookup per group that can
|
||||
// block until it times out while a domain controller is unreachable, and cannot
|
||||
// change the outcome: the names enumerated here are local groups of this
|
||||
// machine, whose names are unique, so a name match identifies the group.
|
||||
//
|
||||
// A failure to resolve the wanted SID is returned rather than reported as
|
||||
// "not a member", so a privilege check built on this fails closed.
|
||||
func localGroupsContainSID(username string, want *windows.SID) (bool, error) {
|
||||
wantName, _, _, err := want.LookupAccount("")
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("resolve group SID %s to a name: %w", want, err)
|
||||
}
|
||||
|
||||
groups, err := netUserGetLocalGroups(username)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
for _, group := range groups {
|
||||
if strings.EqualFold(group, wantName) {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// netUserGetLocalGroups returns the names of the local groups the account is a
|
||||
// member of, including indirect membership through global groups.
|
||||
func netUserGetLocalGroups(username string) ([]string, error) {
|
||||
name16, err := windows.UTF16PtrFromString(username)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("convert username: %w", err)
|
||||
}
|
||||
|
||||
var buf *byte
|
||||
var entriesRead, totalEntries uint32
|
||||
status, _, _ := procNetUserGetLocalGroups.Call(
|
||||
0, // local server
|
||||
uintptr(unsafe.Pointer(name16)),
|
||||
0, // level 0: LOCALGROUP_USERS_INFO_0
|
||||
lgIncludeIndirect,
|
||||
uintptr(unsafe.Pointer(&buf)),
|
||||
maxPreferredLength,
|
||||
uintptr(unsafe.Pointer(&entriesRead)),
|
||||
uintptr(unsafe.Pointer(&totalEntries)),
|
||||
)
|
||||
if status != 0 {
|
||||
return nil, fmt.Errorf("NetUserGetLocalGroups for %q: status %d", username, status)
|
||||
}
|
||||
if buf == nil {
|
||||
return nil, nil
|
||||
}
|
||||
defer func() {
|
||||
if err := windows.NetApiBufferFree(buf); err != nil {
|
||||
log.Debugf("free NetApi buffer: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// MAX_PREFERRED_LENGTH makes the API allocate as much as it needs, so a
|
||||
// short read is not expected. Report it rather than silently returning a
|
||||
// subset of the account's groups.
|
||||
if entriesRead != totalEntries {
|
||||
return nil, fmt.Errorf("NetUserGetLocalGroups for %q returned %d of %d groups", username, entriesRead, totalEntries)
|
||||
}
|
||||
|
||||
entries := unsafe.Slice((*localGroupUsersInfo0)(unsafe.Pointer(buf)), entriesRead)
|
||||
groups := make([]string, 0, entriesRead)
|
||||
for _, entry := range entries {
|
||||
groups = append(groups, windows.UTF16PtrToString(entry.name))
|
||||
}
|
||||
return groups, nil
|
||||
}
|
||||
293
client/ssh/server/privileges_windows_test.go
Normal file
293
client/ssh/server/privileges_windows_test.go
Normal file
@@ -0,0 +1,293 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os/user"
|
||||
"testing"
|
||||
"unsafe"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// filterNormalAccount limits NetUserEnum to normal user accounts.
|
||||
const filterNormalAccount = 0x2
|
||||
|
||||
// TOKEN_ELEVATION_TYPE values.
|
||||
const (
|
||||
tokenElevationTypeDefault = 1
|
||||
tokenElevationTypeFull = 2
|
||||
tokenElevationTypeLimited = 3
|
||||
)
|
||||
|
||||
// tokenElevationType reads TokenElevationType from a token.
|
||||
func tokenElevationType(token windows.Token) (uint32, error) {
|
||||
var elevationType, returnedLen uint32
|
||||
err := windows.GetTokenInformation(token, windows.TokenElevationType,
|
||||
(*byte)(unsafe.Pointer(&elevationType)), uint32(unsafe.Sizeof(elevationType)), &returnedLen)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return elevationType, nil
|
||||
}
|
||||
|
||||
// userInfo0 mirrors USER_INFO_0.
|
||||
type userInfo0 struct {
|
||||
name *uint16
|
||||
}
|
||||
|
||||
func mustParseSID(t *testing.T, s string) *windows.SID {
|
||||
t.Helper()
|
||||
sid, err := windows.StringToSid(s)
|
||||
require.NoError(t, err, "parse SID %s", s)
|
||||
return sid
|
||||
}
|
||||
|
||||
// localAccountNames returns the names of the local user accounts.
|
||||
func localAccountNames(t *testing.T) []string {
|
||||
t.Helper()
|
||||
|
||||
var buf *byte
|
||||
var entriesRead, totalEntries, resume uint32
|
||||
err := windows.NetUserEnum(nil, 0, filterNormalAccount, &buf, maxPreferredLength,
|
||||
&entriesRead, &totalEntries, &resume)
|
||||
require.NoError(t, err, "enumerate local users")
|
||||
t.Cleanup(func() {
|
||||
require.NoError(t, windows.NetApiBufferFree(buf), "free NetApi buffer")
|
||||
})
|
||||
|
||||
entries := unsafe.Slice((*userInfo0)(unsafe.Pointer(buf)), entriesRead)
|
||||
names := make([]string, 0, entriesRead)
|
||||
for _, entry := range entries {
|
||||
names = append(names, windows.UTF16PtrToString(entry.name))
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// localAccountNameByRID returns the name of the local account carrying the
|
||||
// given RID. Accounts such as Administrator and Guest can be renamed and are
|
||||
// localized, so tests must not name them literally.
|
||||
func localAccountNameByRID(t *testing.T, rid uint32) string {
|
||||
t.Helper()
|
||||
|
||||
for _, name := range localAccountNames(t) {
|
||||
sid, _, _, err := windows.LookupSID("", name)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if sid.IdentifierAuthority() != windows.SECURITY_NT_AUTHORITY {
|
||||
continue
|
||||
}
|
||||
count := sid.SubAuthorityCount()
|
||||
if count < 2 || sid.SubAuthority(0) != 21 {
|
||||
continue
|
||||
}
|
||||
if sid.SubAuthority(uint32(count-1)) == rid {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
t.Fatalf("no local account with RID %d", rid)
|
||||
return ""
|
||||
}
|
||||
|
||||
// wellKnownAccountName resolves a well-known SID to the qualified account name
|
||||
// the local system uses for it, which is localized.
|
||||
func wellKnownAccountName(t *testing.T, sidType windows.WELL_KNOWN_SID_TYPE) string {
|
||||
t.Helper()
|
||||
|
||||
sid, err := windows.CreateWellKnownSid(sidType)
|
||||
require.NoError(t, err, "create well-known SID")
|
||||
name, domain, _, err := sid.LookupAccount("")
|
||||
require.NoError(t, err, "resolve %s to an account name", sid)
|
||||
if domain == "" {
|
||||
return name
|
||||
}
|
||||
return domain + `\` + name
|
||||
}
|
||||
|
||||
func TestIsBuiltinAdministratorSID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sid string
|
||||
want bool
|
||||
}{
|
||||
{"machine_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
|
||||
{"domain_administrator", "S-1-5-21-3390233681-4087452608-412898826-500", true},
|
||||
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
|
||||
{"guest_account", "S-1-5-21-1111111111-2222222222-3333333333-501", false},
|
||||
{"domain_admins_group", "S-1-5-21-1111111111-2222222222-3333333333-512", false},
|
||||
{"system", "S-1-5-18", false},
|
||||
{"administrators_group", "S-1-5-32-544", false},
|
||||
{"non_nt_authority", "S-1-1-0", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isBuiltinAdministratorSID(mustParseSID(t, tt.sid))
|
||||
assert.Equal(t, tt.want, result, "RID 500 detection for %s", tt.sid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivilegedUserSID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sid string
|
||||
want bool
|
||||
}{
|
||||
{"local_system", "S-1-5-18", true},
|
||||
{"local_service", "S-1-5-19", true},
|
||||
{"network_service", "S-1-5-20", true},
|
||||
{"administrators_group", "S-1-5-32-544", true},
|
||||
{"builtin_administrator", "S-1-5-21-1111111111-2222222222-3333333333-500", true},
|
||||
{"regular_user", "S-1-5-21-1111111111-2222222222-3333333333-1001", false},
|
||||
{"users_group", "S-1-5-32-545", false},
|
||||
{"everyone", "S-1-1-0", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isPrivilegedUserSID(mustParseSID(t, tt.sid))
|
||||
assert.Equal(t, tt.want, result, "SID privilege classification for %s", tt.sid)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsWindowsAccountPrivilegedOrUnknown(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
want bool
|
||||
}{
|
||||
{"system", wellKnownAccountName(t, windows.WinLocalSystemSid), true},
|
||||
{"local_service", wellKnownAccountName(t, windows.WinLocalServiceSid), true},
|
||||
{"network_service", wellKnownAccountName(t, windows.WinNetworkServiceSid), true},
|
||||
{"administrators_group", wellKnownAccountName(t, windows.WinBuiltinAdministratorsSid), true},
|
||||
// The built-in Administrator (RID 500) and Guest (RID 501) accounts
|
||||
// exist on every Windows installation, though they may be disabled.
|
||||
{"builtin_administrator", localAccountNameByRID(t, 500), true},
|
||||
{"guest", localAccountNameByRID(t, 501), false},
|
||||
// Unresolvable accounts fail closed.
|
||||
{"nonexistent_user", "netbird-no-such-user", true},
|
||||
{"empty_username", "", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isWindowsAccountPrivilegedOrUnknown(tt.username)
|
||||
assert.Equal(t, tt.want, result, "account privilege classification for %q", tt.username)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProcessElevated(t *testing.T) {
|
||||
elevated := isProcessElevated()
|
||||
|
||||
// TokenElevationType is a second, independent view of the same token:
|
||||
// Full means elevated and Limited means a filtered administrator, while
|
||||
// Default covers both a standard user and an administrator with no linked
|
||||
// token (UAC off, the built-in Administrator, SYSTEM), so it implies nothing.
|
||||
elevationType, err := tokenElevationType(windows.GetCurrentProcessToken())
|
||||
require.NoError(t, err, "read token elevation type")
|
||||
|
||||
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
require.NoError(t, err, "create Administrators SID")
|
||||
|
||||
// Token(0) makes CheckTokenMembership evaluate the caller's own token. It
|
||||
// counts only enabled SIDs, so a filtered administrator reports false here.
|
||||
member, err := windows.Token(0).IsMember(adminSid)
|
||||
require.NoError(t, err, "check own Administrators membership")
|
||||
|
||||
t.Logf("elevated=%v elevationType=%d memberOfAdministrators=%v", elevated, elevationType, member)
|
||||
|
||||
switch elevationType {
|
||||
case tokenElevationTypeFull:
|
||||
assert.True(t, elevated, "a token of elevation type Full must report elevated")
|
||||
case tokenElevationTypeLimited:
|
||||
assert.False(t, elevated, "a filtered administrator token must not report elevated")
|
||||
}
|
||||
|
||||
// Administrators enabled in the token means the token wields administrative
|
||||
// rights, which is what elevation reports.
|
||||
if member {
|
||||
assert.True(t, elevated, "token with enabled Administrators membership must report elevated")
|
||||
}
|
||||
}
|
||||
|
||||
// TestS4UMembershipAgreesWithLocalGroups exercises the S4U token path used
|
||||
// for domain accounts. S4U logons need the TCB privilege, so the test runs
|
||||
// only as SYSTEM (which is how CI executes the suite). For local accounts the
|
||||
// token's Administrators membership must agree with the SAM enumeration.
|
||||
func TestS4UMembershipAgreesWithLocalGroups(t *testing.T) {
|
||||
system, err := windows.CreateWellKnownSid(windows.WinLocalSystemSid)
|
||||
require.NoError(t, err, "create SYSTEM SID")
|
||||
current, err := user.Current()
|
||||
require.NoError(t, err, "get current user")
|
||||
if current.Uid != system.String() {
|
||||
t.Skipf("S4U logon requires SYSTEM (running as %s)", current.Username)
|
||||
}
|
||||
|
||||
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
require.NoError(t, err, "create Administrators SID")
|
||||
|
||||
checked := 0
|
||||
for _, name := range localAccountNames(t) {
|
||||
viaToken, err := s4uTokenIsMember(name, ".", adminSid)
|
||||
if err != nil {
|
||||
// Disabled or logon-restricted accounts cannot get an S4U logon.
|
||||
t.Logf("skipping %s: %v", name, err)
|
||||
continue
|
||||
}
|
||||
viaSAM, err := localGroupsContainSID(name, adminSid)
|
||||
require.NoError(t, err, "enumerate local groups for %s", name)
|
||||
|
||||
assert.Equal(t, viaSAM, viaToken, "S4U token and SAM enumeration must agree on Administrators membership for %s", name)
|
||||
checked++
|
||||
}
|
||||
// Ineligible accounts are skipped, so without this the test could report
|
||||
// success while comparing nothing at all.
|
||||
require.Positive(t, checked, "no local account completed an S4U logon, so nothing was compared")
|
||||
t.Logf("checked %d local accounts via S4U", checked)
|
||||
}
|
||||
|
||||
// TestLocalGroupsContainSID_Administrator checks the positive case against the
|
||||
// built-in Administrator, a member of Administrators on every installation.
|
||||
func TestLocalGroupsContainSID_Administrator(t *testing.T) {
|
||||
adminSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
require.NoError(t, err, "create Administrators SID")
|
||||
|
||||
administrator := localAccountNameByRID(t, 500)
|
||||
member, err := localGroupsContainSID(administrator, adminSid)
|
||||
require.NoError(t, err, "enumerate local groups for %s", administrator)
|
||||
assert.True(t, member, "%s is a member of the Administrators group", administrator)
|
||||
}
|
||||
|
||||
// TestLocalGroupsContainSID_UnresolvableGroupFailsClosed covers a wanted SID
|
||||
// that resolves to no group: the error must surface rather than being reported
|
||||
// as "not a member", so the privilege check treats the account as privileged.
|
||||
func TestLocalGroupsContainSID_UnresolvableGroupFailsClosed(t *testing.T) {
|
||||
unknown := mustParseSID(t, "S-1-5-21-1111111111-2222222222-3333333333-4444")
|
||||
|
||||
_, err := localGroupsContainSID(localAccountNameByRID(t, 500), unknown)
|
||||
require.Error(t, err, "must report an error when the wanted group cannot be identified")
|
||||
}
|
||||
|
||||
func TestLocalGroupsContainSID_Guest(t *testing.T) {
|
||||
guestsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinGuestsSid)
|
||||
require.NoError(t, err, "create Guests SID")
|
||||
adminsSid, err := windows.CreateWellKnownSid(windows.WinBuiltinAdministratorsSid)
|
||||
require.NoError(t, err, "create Administrators SID")
|
||||
|
||||
guest := localAccountNameByRID(t, 501)
|
||||
|
||||
inGuests, err := localGroupsContainSID(guest, guestsSid)
|
||||
require.NoError(t, err, "enumerate local groups for %s", guest)
|
||||
assert.True(t, inGuests, "%s is a member of the Guests group", guest)
|
||||
|
||||
inAdmins, err := localGroupsContainSID(guest, adminsSid)
|
||||
require.NoError(t, err, "enumerate local groups for %s", guest)
|
||||
assert.False(t, inAdmins, "%s is not a member of the Administrators group", guest)
|
||||
}
|
||||
@@ -239,6 +239,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType string
|
||||
port uint32
|
||||
username string
|
||||
uid string
|
||||
expectError bool
|
||||
errorMsg string
|
||||
skipOnWindows bool
|
||||
@@ -248,6 +249,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 80,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: true,
|
||||
errorMsg: "cannot bind to privileged port",
|
||||
skipOnWindows: true,
|
||||
@@ -257,6 +259,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "tcpip-forward",
|
||||
port: 443,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: true,
|
||||
errorMsg: "cannot bind to privileged port",
|
||||
skipOnWindows: true,
|
||||
@@ -266,6 +269,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 8080,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
@@ -273,6 +277,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 0,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
@@ -280,13 +285,35 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
forwardType: "remote",
|
||||
port: 22,
|
||||
username: "root",
|
||||
uid: "0",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
// Only uid 0 is privileged, whatever the account is called.
|
||||
name: "uid 0 under another name may bind a privileged port",
|
||||
forwardType: "remote",
|
||||
port: 22,
|
||||
username: "toor",
|
||||
uid: "0",
|
||||
expectError: false,
|
||||
skipOnWindows: true,
|
||||
},
|
||||
{
|
||||
name: "account named root without uid 0 may not",
|
||||
forwardType: "remote",
|
||||
port: 22,
|
||||
username: "root",
|
||||
uid: "1000",
|
||||
expectError: true,
|
||||
errorMsg: "cannot bind to privileged port",
|
||||
skipOnWindows: true,
|
||||
},
|
||||
{
|
||||
name: "local forward privileged port allowed for non-root",
|
||||
forwardType: "local",
|
||||
port: 80,
|
||||
username: "testuser",
|
||||
uid: "1000",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
@@ -299,7 +326,7 @@ func TestServer_PrivilegedPortAccess(t *testing.T) {
|
||||
|
||||
result := PrivilegeCheckResult{
|
||||
Allowed: true,
|
||||
User: &user.User{Username: tt.username},
|
||||
User: &user.User{Username: tt.username, Uid: tt.uid},
|
||||
}
|
||||
|
||||
err := server.checkPrivilegedPortAccess(tt.forwardType, tt.port, result)
|
||||
@@ -420,6 +447,13 @@ func TestServer_PortConflictHandling(t *testing.T) {
|
||||
|
||||
func TestServer_IsPrivilegedUser(t *testing.T) {
|
||||
|
||||
// Windows classification depends on account SIDs and group membership, and
|
||||
// the accounts involved carry localized, renameable names. It is covered by
|
||||
// TestIsWindowsAccountPrivileged, which resolves them from well-known SIDs.
|
||||
if runtime.GOOS == "windows" {
|
||||
t.Skip("covered by TestIsWindowsAccountPrivileged")
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
username string
|
||||
expected bool
|
||||
@@ -440,44 +474,16 @@ func TestServer_IsPrivilegedUser(t *testing.T) {
|
||||
expected: false,
|
||||
description: "empty username should not be privileged",
|
||||
},
|
||||
}
|
||||
|
||||
// Add Windows-specific tests
|
||||
if runtime.GOOS == "windows" {
|
||||
tests = append(tests, []struct {
|
||||
username string
|
||||
expected bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
username: "Administrator",
|
||||
expected: true,
|
||||
description: "Administrator should be considered privileged on Windows",
|
||||
},
|
||||
{
|
||||
username: "administrator",
|
||||
expected: true,
|
||||
description: "administrator should be considered privileged on Windows (case insensitive)",
|
||||
},
|
||||
}...)
|
||||
} else {
|
||||
// On non-Windows systems, Administrator should not be privileged
|
||||
tests = append(tests, []struct {
|
||||
username string
|
||||
expected bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
username: "Administrator",
|
||||
expected: false,
|
||||
description: "Administrator should not be privileged on non-Windows systems",
|
||||
},
|
||||
}...)
|
||||
{
|
||||
username: "Administrator",
|
||||
expected: false,
|
||||
description: "Administrator should not be privileged on non-Windows systems",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.description, func(t *testing.T) {
|
||||
result := isPrivilegedUsername(tt.username)
|
||||
result := isPrivilegedOrUnknown(tt.username)
|
||||
assert.Equal(t, tt.expected, result, tt.description)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// createSftpCommand creates a Windows SFTP command with user switching.
|
||||
// The caller must close the returned token handle after starting the process.
|
||||
func (s *Server) createSftpCommand(targetUser *user.User, sess ssh.Session) (*exec.Cmd, windows.Token, error) {
|
||||
username, domain := s.parseUsername(targetUser.Username)
|
||||
username, domain := parseUsername(targetUser.Username)
|
||||
|
||||
netbirdPath, err := os.Executable()
|
||||
if err != nil {
|
||||
|
||||
@@ -16,11 +16,6 @@ var (
|
||||
ErrPrivilegedUserSwitch = errors.New("cannot switch to privileged user - current user lacks required privileges")
|
||||
)
|
||||
|
||||
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
|
||||
func isPlatformUnix() bool {
|
||||
return getCurrentOS() != "windows"
|
||||
}
|
||||
|
||||
// Dependency injection variables for testing - allows mocking dynamic runtime checks
|
||||
var (
|
||||
getCurrentUser = currentUserWithGetent
|
||||
@@ -29,6 +24,9 @@ var (
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
getEuid = os.Geteuid
|
||||
|
||||
getProcessElevated = isProcessElevated
|
||||
getWindowsAccountPrivilegedOrUnknown = isWindowsAccountPrivilegedOrUnknown
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -65,6 +63,13 @@ type PrivilegeCheckResult struct {
|
||||
RequiresUserSwitching bool
|
||||
}
|
||||
|
||||
// privilegeCheckContext holds all context needed for privilege checking
|
||||
type privilegeCheckContext struct {
|
||||
currentUser *user.User
|
||||
currentUserPrivileged bool
|
||||
allowRoot bool
|
||||
}
|
||||
|
||||
// CheckPrivileges performs comprehensive privilege checking for all SSH features.
|
||||
// This is the single source of truth for privilege decisions across the SSH server.
|
||||
func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult {
|
||||
@@ -75,7 +80,7 @@ func (s *Server) CheckPrivileges(req PrivilegeCheckRequest) PrivilegeCheckResult
|
||||
|
||||
// Handle empty username case - but still check root access controls
|
||||
if req.RequestedUsername == "" {
|
||||
if isPrivilegedUsername(context.currentUser.Username) && !context.allowRoot {
|
||||
if isPrivilegedOrUnknown(context.currentUser.Username) && !context.allowRoot {
|
||||
return PrivilegeCheckResult{
|
||||
Allowed: false,
|
||||
Error: &PrivilegedUserError{Username: context.currentUser.Username},
|
||||
@@ -135,7 +140,7 @@ func (s *Server) checkUserRequest(ctx *privilegeCheckContext, req PrivilegeCheck
|
||||
|
||||
needsUserSwitching := !isSameResolvedUser(resolvedUser, ctx.currentUser)
|
||||
|
||||
if isPrivilegedUsername(resolvedUser.Username) && !ctx.allowRoot {
|
||||
if isPrivilegedOrUnknown(resolvedUser.Username) && !ctx.allowRoot {
|
||||
return PrivilegeCheckResult{
|
||||
Allowed: false,
|
||||
Error: &PrivilegedUserError{Username: resolvedUser.Username},
|
||||
@@ -175,6 +180,42 @@ func (s *Server) resolveRequestedUser(requestedUsername string) (*user.User, err
|
||||
return u, nil
|
||||
}
|
||||
|
||||
// SetAllowRootLogin configures root login access
|
||||
func (s *Server) SetAllowRootLogin(allow bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.allowRootLogin = allow
|
||||
}
|
||||
|
||||
// userNameLookup performs user lookup with root login permission check
|
||||
func (s *Server) userNameLookup(username string) (*user.User, error) {
|
||||
result, err := s.userPrivilegeCheck(username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.User, nil
|
||||
}
|
||||
|
||||
// userPrivilegeCheck performs user lookup with full privilege check result
|
||||
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
|
||||
result := s.CheckPrivileges(PrivilegeCheckRequest{
|
||||
RequestedUsername: username,
|
||||
FeatureSupportsUserSwitch: true,
|
||||
FeatureName: FeatureSSHLogin,
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isPlatformUnix returns true for Unix-like platforms (Linux, macOS, etc.)
|
||||
func isPlatformUnix() bool {
|
||||
return getCurrentOS() != "windows"
|
||||
}
|
||||
|
||||
// isSameResolvedUser compares two resolved user identities
|
||||
func isSameResolvedUser(user1, user2 *user.User) bool {
|
||||
if user1 == nil || user2 == nil {
|
||||
@@ -183,13 +224,6 @@ func isSameResolvedUser(user1, user2 *user.User) bool {
|
||||
return user1.Uid == user2.Uid
|
||||
}
|
||||
|
||||
// privilegeCheckContext holds all context needed for privilege checking
|
||||
type privilegeCheckContext struct {
|
||||
currentUser *user.User
|
||||
currentUserPrivileged bool
|
||||
allowRoot bool
|
||||
}
|
||||
|
||||
// isSameUser checks if two usernames refer to the same user
|
||||
// SECURITY: This function must be conservative - it should only return true
|
||||
// when we're certain both usernames refer to the exact same user identity
|
||||
@@ -253,159 +287,30 @@ func isWindowsSameUser(requestedUsername, currentUsername string) bool {
|
||||
return strings.EqualFold(reqDomain, curDomain)
|
||||
}
|
||||
|
||||
// SetAllowRootLogin configures root login access
|
||||
func (s *Server) SetAllowRootLogin(allow bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.allowRootLogin = allow
|
||||
}
|
||||
|
||||
// userNameLookup performs user lookup with root login permission check
|
||||
func (s *Server) userNameLookup(username string) (*user.User, error) {
|
||||
result := s.CheckPrivileges(PrivilegeCheckRequest{
|
||||
RequestedUsername: username,
|
||||
FeatureSupportsUserSwitch: true,
|
||||
FeatureName: FeatureSSHLogin,
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
return nil, result.Error
|
||||
}
|
||||
|
||||
return result.User, nil
|
||||
}
|
||||
|
||||
// userPrivilegeCheck performs user lookup with full privilege check result
|
||||
func (s *Server) userPrivilegeCheck(username string) (PrivilegeCheckResult, error) {
|
||||
result := s.CheckPrivileges(PrivilegeCheckRequest{
|
||||
RequestedUsername: username,
|
||||
FeatureSupportsUserSwitch: true,
|
||||
FeatureName: FeatureSSHLogin,
|
||||
})
|
||||
|
||||
if !result.Allowed {
|
||||
return result, result.Error
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// isPrivilegedUsername checks if the given username represents a privileged user across platforms.
|
||||
// On Unix: root
|
||||
// On Windows: Administrator, SYSTEM (case-insensitive)
|
||||
// Handles domain-qualified usernames like "DOMAIN\Administrator" or "user@domain.com"
|
||||
func isPrivilegedUsername(username string) bool {
|
||||
// isPrivilegedOrUnknown reports whether the given username represents a
|
||||
// privileged user, or on Windows an account whose privilege could not be
|
||||
// determined.
|
||||
// On Unix: root.
|
||||
// On Windows: well-known service accounts, built-in Administrator accounts,
|
||||
// and members of the local Administrators group; handles domain-qualified
|
||||
// usernames like "DOMAIN\user" or "user@domain.com". An account that cannot be
|
||||
// resolved or evaluated is reported as privileged.
|
||||
//
|
||||
// Use this to refuse privileged accounts, never to grant them anything: the
|
||||
// undetermined case is safe for a refusal and unsafe for a grant.
|
||||
func isPrivilegedOrUnknown(username string) bool {
|
||||
if getCurrentOS() != "windows" {
|
||||
return username == "root"
|
||||
}
|
||||
|
||||
bareUsername := username
|
||||
// Handle Windows domain format: DOMAIN\username
|
||||
if idx := strings.LastIndex(username, `\`); idx != -1 {
|
||||
bareUsername = username[idx+1:]
|
||||
}
|
||||
// Handle email-style format: username@domain.com
|
||||
if idx := strings.Index(bareUsername, "@"); idx != -1 {
|
||||
bareUsername = bareUsername[:idx]
|
||||
}
|
||||
|
||||
return isWindowsPrivilegedUser(bareUsername)
|
||||
}
|
||||
|
||||
// isWindowsPrivilegedUser checks if a bare username (domain already stripped) represents a Windows privileged account
|
||||
func isWindowsPrivilegedUser(bareUsername string) bool {
|
||||
// common privileged usernames (case insensitive)
|
||||
privilegedNames := []string{
|
||||
"administrator",
|
||||
"admin",
|
||||
"root",
|
||||
"system",
|
||||
"localsystem",
|
||||
"networkservice",
|
||||
"localservice",
|
||||
}
|
||||
|
||||
usernameLower := strings.ToLower(bareUsername)
|
||||
for _, privilegedName := range privilegedNames {
|
||||
if usernameLower == privilegedName {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// computer accounts (ending with $) are not privileged by themselves
|
||||
// They only gain privileges through group membership or specific SIDs
|
||||
|
||||
if targetUser, err := lookupUser(bareUsername); err == nil {
|
||||
return isWindowsPrivilegedSID(targetUser.Uid)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// isWindowsPrivilegedSID checks if a Windows SID represents a privileged account
|
||||
func isWindowsPrivilegedSID(sid string) bool {
|
||||
privilegedSIDs := []string{
|
||||
"S-1-5-18", // Local System (SYSTEM)
|
||||
"S-1-5-19", // Local Service (NT AUTHORITY\LOCAL SERVICE)
|
||||
"S-1-5-20", // Network Service (NT AUTHORITY\NETWORK SERVICE)
|
||||
"S-1-5-32-544", // Administrators group (BUILTIN\Administrators)
|
||||
"S-1-5-500", // Built-in Administrator account (local machine RID 500)
|
||||
}
|
||||
|
||||
for _, privilegedSID := range privilegedSIDs {
|
||||
if sid == privilegedSID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Check for domain administrator accounts (RID 500 in any domain)
|
||||
// Format: S-1-5-21-domain-domain-domain-500
|
||||
// This is reliable as RID 500 is reserved for the domain Administrator account
|
||||
if strings.HasPrefix(sid, "S-1-5-21-") && strings.HasSuffix(sid, "-500") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for other well-known privileged RIDs in domain contexts
|
||||
// RID 512 = Domain Admins group, RID 516 = Domain Controllers group
|
||||
if strings.HasPrefix(sid, "S-1-5-21-") {
|
||||
if strings.HasSuffix(sid, "-512") || // Domain Admins group
|
||||
strings.HasSuffix(sid, "-516") || // Domain Controllers group
|
||||
strings.HasSuffix(sid, "-519") { // Enterprise Admins group
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
return getWindowsAccountPrivilegedOrUnknown(username)
|
||||
}
|
||||
|
||||
// isCurrentProcessPrivileged checks if the current process is running with elevated privileges.
|
||||
// On Unix systems, this means running as root (UID 0).
|
||||
// On Windows, this means running as Administrator or SYSTEM.
|
||||
// On Windows, this means the process token is elevated (administrators, SYSTEM).
|
||||
func isCurrentProcessPrivileged() bool {
|
||||
if getCurrentOS() == "windows" {
|
||||
return isWindowsElevated()
|
||||
return getProcessElevated()
|
||||
}
|
||||
return getEuid() == 0
|
||||
}
|
||||
|
||||
// isWindowsElevated checks if the current process is running with elevated privileges on Windows
|
||||
func isWindowsElevated() bool {
|
||||
currentUser, err := getCurrentUser()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get current user for privilege check, assuming non-privileged: %v", err)
|
||||
return false
|
||||
}
|
||||
|
||||
if isWindowsPrivilegedSID(currentUser.Uid) {
|
||||
log.Debugf("Windows user switching supported: running as privileged SID %s", currentUser.Uid)
|
||||
return true
|
||||
}
|
||||
|
||||
if isPrivilegedUsername(currentUser.Username) {
|
||||
log.Debugf("Windows user switching supported: running as privileged username %s", currentUser.Username)
|
||||
return true
|
||||
}
|
||||
|
||||
log.Debugf("Windows user switching not supported: not running as privileged user (current: %s)", currentUser.Uid)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"os/user"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -27,8 +28,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
originalLookupUser := lookupUser
|
||||
originalGetCurrentOS := getCurrentOS
|
||||
originalGetEuid := getEuid
|
||||
|
||||
// Reset caches to ensure clean test state
|
||||
originalGetProcessElevated := getProcessElevated
|
||||
originalGetWindowsAccountPrivilegedOrUnknown := getWindowsAccountPrivilegedOrUnknown
|
||||
|
||||
// Set test values - inject platform dependencies
|
||||
getCurrentUser = func() (*user.User, error) {
|
||||
@@ -53,16 +54,31 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
return euid
|
||||
}
|
||||
|
||||
// Mock privilege detection based on the test user
|
||||
getIsProcessPrivileged = func() bool {
|
||||
// Simulate the Windows token elevation check based on the fixture user:
|
||||
// the built-in Administrator (RID 500) and SYSTEM run elevated.
|
||||
getProcessElevated = func() bool {
|
||||
if currentUser == nil {
|
||||
return false
|
||||
}
|
||||
// Check both username and SID for Windows systems
|
||||
if os == "windows" && isWindowsPrivilegedSID(currentUser.Uid) {
|
||||
return currentUser.Uid == "S-1-5-18" || strings.HasSuffix(currentUser.Uid, "-500")
|
||||
}
|
||||
|
||||
// Simulate the Windows account classifier for the fixture accounts.
|
||||
// "root" does not exist on Windows; the real classifier fails closed on
|
||||
// unresolvable accounts, so it counts as privileged here too.
|
||||
getWindowsAccountPrivilegedOrUnknown = func(username string) bool {
|
||||
bare := username
|
||||
if idx := strings.LastIndex(bare, `\`); idx != -1 {
|
||||
bare = bare[idx+1:]
|
||||
}
|
||||
if idx := strings.Index(bare, "@"); idx != -1 {
|
||||
bare = bare[:idx]
|
||||
}
|
||||
switch strings.ToLower(bare) {
|
||||
case "administrator", "system", "root":
|
||||
return true
|
||||
}
|
||||
return isPrivilegedUsername(currentUser.Username)
|
||||
return false
|
||||
}
|
||||
|
||||
// Return cleanup function
|
||||
@@ -71,10 +87,8 @@ func setupTestDependencies(currentUser *user.User, currentUserErr error, os stri
|
||||
lookupUser = originalLookupUser
|
||||
getCurrentOS = originalGetCurrentOS
|
||||
getEuid = originalGetEuid
|
||||
|
||||
getIsProcessPrivileged = isCurrentProcessPrivileged
|
||||
|
||||
// Reset caches after test
|
||||
getProcessElevated = originalGetProcessElevated
|
||||
getWindowsAccountPrivilegedOrUnknown = originalGetWindowsAccountPrivilegedOrUnknown
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,6 +435,9 @@ func TestUsedFallback_MeansNoPrivilegeDropping(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestPrivilegedUsernameDetection(t *testing.T) {
|
||||
// Windows classification is syscall-backed (SID resolution, group
|
||||
// membership) and is covered by privileges_windows_test.go; here only the
|
||||
// Unix logic and the platform dispatch are exercised.
|
||||
tests := []struct {
|
||||
name string
|
||||
username string
|
||||
@@ -432,25 +449,9 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
|
||||
{"unix_regular_user", "alice", "linux", false},
|
||||
{"unix_root_capital", "Root", "linux", false}, // Case-sensitive
|
||||
|
||||
// Windows tests
|
||||
// Windows dispatch to the (mocked) account classifier
|
||||
{"windows_administrator", "Administrator", "windows", true},
|
||||
{"windows_system", "SYSTEM", "windows", true},
|
||||
{"windows_admin", "admin", "windows", true},
|
||||
{"windows_admin_lowercase", "administrator", "windows", true}, // Case-insensitive
|
||||
{"windows_domain_admin", "DOMAIN\\Administrator", "windows", true},
|
||||
{"windows_email_admin", "admin@domain.com", "windows", true},
|
||||
{"windows_regular_user", "alice", "windows", false},
|
||||
{"windows_domain_user", "DOMAIN\\alice", "windows", false},
|
||||
{"windows_localsystem", "localsystem", "windows", true},
|
||||
{"windows_networkservice", "networkservice", "windows", true},
|
||||
{"windows_localservice", "localservice", "windows", true},
|
||||
|
||||
// Computer accounts (these depend on current user context in real implementation)
|
||||
{"windows_computer_account", "WIN2K19-C2$", "windows", false}, // Computer account by itself not privileged
|
||||
{"windows_domain_computer", "DOMAIN\\COMPUTER$", "windows", false}, // Domain computer account
|
||||
|
||||
// Cross-platform
|
||||
{"root_on_windows", "root", "windows", true}, // Root should be privileged everywhere
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -459,50 +460,8 @@ func TestPrivilegedUsernameDetection(t *testing.T) {
|
||||
cleanup := setupTestDependencies(nil, nil, tt.platform, 1000, nil, nil)
|
||||
defer cleanup()
|
||||
|
||||
result := isPrivilegedUsername(tt.username)
|
||||
assert.Equal(t, tt.privileged, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWindowsPrivilegedSIDDetection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
sid string
|
||||
privileged bool
|
||||
description string
|
||||
}{
|
||||
// Well-known system accounts
|
||||
{"system_account", "S-1-5-18", true, "Local System (SYSTEM)"},
|
||||
{"local_service", "S-1-5-19", true, "Local Service"},
|
||||
{"network_service", "S-1-5-20", true, "Network Service"},
|
||||
{"administrators_group", "S-1-5-32-544", true, "Administrators group"},
|
||||
{"builtin_administrator", "S-1-5-500", true, "Built-in Administrator"},
|
||||
|
||||
// Domain accounts
|
||||
{"domain_administrator", "S-1-5-21-1234567890-1234567890-1234567890-500", true, "Domain Administrator (RID 500)"},
|
||||
{"domain_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-512", true, "Domain Admins group"},
|
||||
{"domain_controllers_group", "S-1-5-21-1234567890-1234567890-1234567890-516", true, "Domain Controllers group"},
|
||||
{"enterprise_admins_group", "S-1-5-21-1234567890-1234567890-1234567890-519", true, "Enterprise Admins group"},
|
||||
|
||||
// Regular users
|
||||
{"regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1001", false, "Regular domain user"},
|
||||
{"another_regular_user", "S-1-5-21-1234567890-1234567890-1234567890-1234", false, "Another regular user"},
|
||||
{"local_user", "S-1-5-21-1234567890-1234567890-1234567890-1000", false, "Local regular user"},
|
||||
|
||||
// Groups that are not privileged
|
||||
{"domain_users", "S-1-5-21-1234567890-1234567890-1234567890-513", false, "Domain Users group"},
|
||||
{"power_users", "S-1-5-32-547", false, "Power Users group"},
|
||||
|
||||
// Invalid SIDs
|
||||
{"malformed_sid", "S-1-5-invalid", false, "Malformed SID"},
|
||||
{"empty_sid", "", false, "Empty SID"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := isWindowsPrivilegedSID(tt.sid)
|
||||
assert.Equal(t, tt.privileged, result, "Failed for %s: %s", tt.description, tt.sid)
|
||||
result := isPrivilegedOrUnknown(tt.username)
|
||||
assert.Equal(t, tt.privileged, result, "privilege classification for %s on %s", tt.username, tt.platform)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,7 +91,7 @@ func validateUsernameFormat(username string) error {
|
||||
func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, localUser *user.User, hasPty bool) (*exec.Cmd, func(), error) {
|
||||
logger.Debugf("creating Windows executor command for user %s (Pty: %v)", localUser.Username, hasPty)
|
||||
|
||||
username, _ := s.parseUsername(localUser.Username)
|
||||
username, _ := parseUsername(localUser.Username)
|
||||
if err := validateUsername(username); err != nil {
|
||||
return nil, nil, fmt.Errorf("invalid username %q: %w", username, err)
|
||||
}
|
||||
@@ -102,7 +102,7 @@ func (s *Server) createExecutorCommand(logger *log.Entry, session ssh.Session, l
|
||||
// createUserSwitchCommand creates a command with Windows user switching.
|
||||
// Returns the command and a cleanup function that must be called after starting the process.
|
||||
func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session, localUser *user.User) (*exec.Cmd, func(), error) {
|
||||
username, domain := s.parseUsername(localUser.Username)
|
||||
username, domain := parseUsername(localUser.Username)
|
||||
|
||||
shell := getUserShell(localUser.Uid)
|
||||
|
||||
@@ -138,7 +138,7 @@ func (s *Server) createUserSwitchCommand(logger *log.Entry, session ssh.Session,
|
||||
}
|
||||
|
||||
// parseUsername extracts username and domain from a Windows username
|
||||
func (s *Server) parseUsername(fullUsername string) (username, domain string) {
|
||||
func parseUsername(fullUsername string) (username, domain string) {
|
||||
// Handle DOMAIN\username format
|
||||
if idx := strings.LastIndex(fullUsername, `\`); idx != -1 {
|
||||
domain = fullUsername[:idx]
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"google.golang.org/grpc/codes"
|
||||
gstatus "google.golang.org/grpc/status"
|
||||
|
||||
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
||||
"github.com/netbirdio/netbird/client/proto"
|
||||
)
|
||||
|
||||
@@ -58,10 +60,21 @@ func (s *Session) RequestExtend(ctx context.Context, p ExtendStartParams) (Exten
|
||||
return ExtendStartResult{}, err
|
||||
}
|
||||
|
||||
req := &proto.RequestExtendAuthSessionRequest{}
|
||||
if p.Hint != "" {
|
||||
h := p.Hint
|
||||
req.Hint = &h
|
||||
// a request from the UI implies a graphical session, which the daemon cannot detect itself
|
||||
req := &proto.RequestExtendAuthSessionRequest{HasGraphicalSession: true}
|
||||
hint := p.Hint
|
||||
if hint == "" {
|
||||
pm := profilemanager.NewProfileManager()
|
||||
if active, perr := pm.GetActiveProfile(); perr != nil {
|
||||
log.Debugf("failed to get active profile for login hint: %v", perr)
|
||||
} else if state, serr := pm.GetProfileState(active.ID); serr != nil {
|
||||
log.Debugf("failed to get profile state for login hint: %v", serr)
|
||||
} else {
|
||||
hint = state.Email
|
||||
}
|
||||
}
|
||||
if hint != "" {
|
||||
req.Hint = &hint
|
||||
}
|
||||
|
||||
resp, err := cli.RequestExtendAuthSession(ctx, req)
|
||||
|
||||
@@ -15,7 +15,8 @@
|
||||
"lint": "eslint \"src/**/*.{ts,tsx}\"",
|
||||
"lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix",
|
||||
"check": "pnpm lint && pnpm typecheck && pnpm format:check",
|
||||
"check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck"
|
||||
"check:fix": "pnpm lint:fix && pnpm format && pnpm typecheck",
|
||||
"i18n:check": "node ../i18n/check-translations.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
|
||||
@@ -2,9 +2,24 @@
|
||||
|
||||
A short brief for translating the desktop UI — for any translator, human or AI agent (*"you"* = whoever's translating).
|
||||
|
||||
**Drive an agent with:** *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* — or *"…and review the existing German translation."*
|
||||
**Translations are managed on Crowdin: <https://crowdin.com/project/netbird>.** Join the project, pick your language, and translate in the editor. Each string carries a context note (the `description` from the source file) telling you what it is and where it shows up, and the project's glossary, style guide, and QA checks mirror this document.
|
||||
|
||||
> 💡 **The one habit that matters most:** read each key's `description` before translating it. Labels are terse and ambiguous on their own; the `description` tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
|
||||
> 💡 **The one habit that matters most:** read each string's context before translating it. Labels are terse and ambiguous on their own; the context tells you what the string is, where it shows up, what to keep verbatim, and what it actually means.
|
||||
|
||||
---
|
||||
|
||||
## How contributions flow
|
||||
|
||||
```text
|
||||
i18n/locales/en/common.json ──sync──▶ Crowdin ──service PR──▶ i18n/locales/<code>/common.json
|
||||
```
|
||||
|
||||
- `i18n/locales/en/common.json` is the source of truth. New and changed strings sync to Crowdin automatically (see `crowdin.yml` in the repository root).
|
||||
- Crowdin opens and updates a service pull request with the translated bundles, keeping the source's file shape and key order. Keys nobody has translated yet are left out of the export; the app falls back to English for them at runtime. Maintainers review and merge that PR.
|
||||
- Don't hand-edit `i18n/locales/<code>/common.json` in your own PRs: the next sync would conflict with or overwrite your changes. Translate on Crowdin instead.
|
||||
- Missing your language? Request it on the Crowdin project page or in a [GitHub discussion](https://github.com/netbirdio/netbird/discussions). When a language first ships, a maintainer adds its row to `i18n/locales/_index.json` with `code`, `displayName` (the native name), and `englishName`, which puts it in the app's language picker.
|
||||
|
||||
**Prefer translating with an AI agent?** That still works: drive it with *"Read `i18n/TRANSLATING.md` and translate the UI to Russian"* as before, but deliver the result to Crowdin instead of a pull request. Download your language's file from the Crowdin editor, let the agent translate it, and upload it back (the editor's offline translation flow). Crowdin runs its QA checks on upload, and the next service PR carries the strings into the repo.
|
||||
|
||||
---
|
||||
|
||||
@@ -30,25 +45,6 @@ A **business zero-trust VPN** — an encrypted **overlay mesh** between a compan
|
||||
|
||||
---
|
||||
|
||||
## The files
|
||||
|
||||
```
|
||||
i18n/locales/_index.json shipped-language list
|
||||
i18n/locales/en/common.json source of truth — message + description
|
||||
i18n/locales/<code>/common.json a target — message only
|
||||
```
|
||||
|
||||
Chrome-extension JSON, each key → `{ "message", "description" }`. You translate the **`message`**.
|
||||
|
||||
| ✅ Do | ❌ Don't |
|
||||
|---|---|
|
||||
| Keep **every key** from `en`, in the same order | Translate, rename, reorder, drop, or add keys (they're identifiers; the set grows over time) |
|
||||
| Put **only `message`** in target bundles | Copy `description` into a target bundle |
|
||||
| Give every key a non-empty `message` | Leave keys missing or empty |
|
||||
| Save valid UTF-8 JSON, no BOM | Add trailing commas or break the JSON |
|
||||
|
||||
---
|
||||
|
||||
## Hard rules — get these exactly right
|
||||
|
||||
These are the usual ways a translation *breaks the app*, not just reads oddly.
|
||||
@@ -58,7 +54,7 @@ These are the usual ways a translation *breaks the app*, not just reads oddly.
|
||||
| Copy `{placeholders}` verbatim — `{version}`, `{count}`, `{name}`… | Translate the word inside the braces (`{verbleibend}` breaks it) |
|
||||
| Reposition a placeholder so the sentence flows | Drop or duplicate a placeholder |
|
||||
| Preserve every `\n`, leading/trailing space, and trailing `...` | Trim "invisible" spaces or the `...` (they're load-bearing) |
|
||||
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the description flags |
|
||||
| Keep `®` in WireGuard® and quotes around `{name}` | Strip punctuation the context flags |
|
||||
|
||||
**Plurals:** the app has only a *one / other* split — the singular key fires only when `count == 1`; the `{count}` key covers everything else (0, 2, 5, 100…). Languages with more than two forms (ru, pl, uk) can't be fully correct here — use the form that fits the widest range (Russian genitive plural: `минут` / `часов` / `дней`). Don't invent extra keys or cram multiple forms into one string. When no single form fits every value — a unit label after a number field, say — reach for a number-agnostic form (an abbreviation, or wording that reads the same for 1 and 100) instead of forcing a plural the *one / other* split can't supply.
|
||||
|
||||
@@ -78,13 +74,15 @@ When a brand sits beside a common noun, keep its exact spelling but join them th
|
||||
|
||||
> **Use the word that language's IT users actually say.** Translate when a natural, common term exists; keep the English term *only* when the literal translation would be awkward or no one in that field really uses it.
|
||||
|
||||
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing bundles:** match how a term was already rendered for your language rather than re-deciding it.
|
||||
Apply each term **consistently** — same English term → same translation everywhere — and keep a term once you've settled it. Whether a term stays English or takes a native word is **language-dependent**: a technical loanword (e.g. *Daemon*, *Handshake*) often stays, an everyday word (e.g. *Latency*, *Public key*) usually localizes, and some (*Exit Node*, *Peer*) go either way depending on the language. Decide per term with the rule above — a foreign origin alone is no reason to keep English. **Your main reference is the existing translation:** match how a term was already rendered for your language rather than re-deciding it.
|
||||
|
||||
Two checks before you commit a term:
|
||||
|
||||
- **Prefer established localized wording.** If a widely used tool in this space (for example WireGuard) ships your language, its wording for a shared term such as *handshake* is what users already expect — look at the translated app, not just English docs. For generic UI verbs and formal address, follow your OS vendor's style guide (Microsoft / Apple / Google).
|
||||
- **Watch for false friends.** A literal translation can collide with a *different* established term in your field — confirm your word doesn't already mean something else in this domain before using it.
|
||||
|
||||
These tiers are mirrored in the Crowdin project glossary, so the editor highlights them inline. When you settle a new Tier C term for your language, add its translation to the glossary entry so it sticks for everyone who comes after you.
|
||||
|
||||
---
|
||||
|
||||
## Style
|
||||
@@ -98,7 +96,7 @@ Two checks before you commit a term:
|
||||
|
||||
Where it reads naturally, aim to keep each string **roughly the same length** as the English — the UI is tight and over-long strings can wrap or truncate. It's a soft preference, not a rule: if your language simply needs more words, use them.
|
||||
|
||||
A few habits that keep a bundle reading like one product rather than a word-for-word port:
|
||||
A few habits that keep a translation reading like one product rather than a word-for-word port:
|
||||
|
||||
- **Translate meaning, not words.** Render what a string *does*. An idiom or an awkward source phrase should become natural in your language, not a literal calque.
|
||||
- **Keep one voice within a family.** Sibling strings — the connection states, every settings *help* caption, every "… Failed" title — should share a grammatical form. If one member sounds wrong in that form, re-voice the whole family rather than leave one odd sibling.
|
||||
@@ -107,27 +105,26 @@ A few habits that keep a bundle reading like one product rather than a word-for-
|
||||
|
||||
---
|
||||
|
||||
## Procedure
|
||||
## Reviewing a language
|
||||
|
||||
**New language** — read `en/common.json` *with* descriptions → settle your Tier C terms → write `i18n/locales/<code>/common.json` (same keys and order as `en`, `message` only, placeholders & brands preserved) → add a row to `_index.json` (`{"code","displayName"` = native name`,"englishName"}`) → run the QA list. Use the locale-code style the existing entries use (e.g. `fr`, `pt`, `zh-CN`).
|
||||
**On Crowdin:** proofread in the editor — context, glossary highlights, and QA flags sit inline next to each string.
|
||||
|
||||
**Review (de / hu / …)** — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Fix in place, then report what you changed (especially term standardizations) so a native speaker can sanity-check.
|
||||
**In the repo** — e.g. driving an AI agent with *"Read `i18n/TRANSLATING.md` and review the existing German translation"* — read source and target side by side; for each key check glossary conformance (e.g. de `Exit-Node` → `Exit Node`, hu `Kilépő csomópont` → `Exit Node`), placeholder/`\n` integrity, consistency, tone, and that the meaning matches the English `description`. Report what you found, and apply the fixes **on Crowdin** — direct edits to the locale files are overwritten by the next sync.
|
||||
|
||||
---
|
||||
|
||||
## QA before you finish
|
||||
|
||||
- [ ] Valid JSON · **every `en` key** present, same order · **no `description`** fields
|
||||
- [ ] Every `{placeholder}`, `\n`, and intentional space preserved · `...` / `… Failed` / `{name}` quotes kept
|
||||
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing bundle for your language)
|
||||
- [ ] Tier A/B left intact · Tier C applied consistently (and matching the existing translation for your language)
|
||||
- [ ] Buttons & tray short · locale punctuation and capitalization applied
|
||||
- [ ] New language added to `_index.json`
|
||||
- [ ] Crowdin QA flags resolved (variables, glossary terms, punctuation)
|
||||
- [ ] **Tested in the running app** ↓
|
||||
|
||||
---
|
||||
|
||||
## Test it in the app
|
||||
|
||||
A bundle can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
|
||||
A translation can pass every check above and still read wrong on screen. **Run the app, switch to your language, and click through the real surfaces** — tray menu, main window, every Settings tab, the dialogs. Watch for text overflow or truncation, labels that are technically right but wrong *for what the control does*, leaked placeholders, and terms that drift between screens.
|
||||
|
||||
How to run the app and switch language: see the project README. Can't run it (e.g. a headless agent)? Say so in your summary — don't silently skip this step.
|
||||
|
||||
104
client/ui/i18n/check-translations.mjs
Normal file
104
client/ui/i18n/check-translations.mjs
Normal file
@@ -0,0 +1,104 @@
|
||||
#!/usr/bin/env node
|
||||
// Validates that every shipped translation bundle carries exactly the same set
|
||||
// of keys as the English source of truth. English (en) defines the keys; every
|
||||
// other locale declared in _index.json must match it 1:1:
|
||||
//
|
||||
// - no missing keys — a missing key silently falls back to English at runtime
|
||||
// (see i18n bundle fallback), so the gap never surfaces to users or CI
|
||||
// without this check;
|
||||
// - no orphaned keys — keys left behind after an English key is renamed or
|
||||
// removed are dead weight and a sign the locale is drifting.
|
||||
//
|
||||
// Pure Node, no dependencies, so it runs without installing the frontend
|
||||
// toolchain.
|
||||
//
|
||||
// Local: node client/ui/i18n/check-translations.mjs (or: pnpm i18n:check)
|
||||
// CI: .github/workflows/ui-translations.yml
|
||||
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const SOURCE = "en";
|
||||
const localesDir = join(dirname(fileURLToPath(import.meta.url)), "locales");
|
||||
const isCI = Boolean(process.env.GITHUB_ACTIONS);
|
||||
|
||||
function readJSON(path) {
|
||||
return JSON.parse(readFileSync(path, "utf8"));
|
||||
}
|
||||
|
||||
function keysOf(langCode) {
|
||||
return Object.keys(readJSON(join(localesDir, langCode, "common.json")));
|
||||
}
|
||||
|
||||
// Emit a GitHub Actions annotation so failures render inline on the PR diff.
|
||||
function annotate(file, message) {
|
||||
if (isCI) console.log(`::error file=${file}::${message}`);
|
||||
}
|
||||
|
||||
const index = readJSON(join(localesDir, "_index.json"));
|
||||
const declared = index.languages.map((l) => l.code);
|
||||
|
||||
if (!declared.includes(SOURCE)) {
|
||||
console.error(`FATAL: source language "${SOURCE}" is not declared in _index.json`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sourceKeys = keysOf(SOURCE);
|
||||
const sourceSet = new Set(sourceKeys);
|
||||
console.log(`Source of truth: ${SOURCE}/common.json — ${sourceKeys.length} keys\n`);
|
||||
|
||||
let failed = false;
|
||||
|
||||
for (const code of declared) {
|
||||
if (code === SOURCE) continue;
|
||||
const file = `client/ui/i18n/locales/${code}/common.json`;
|
||||
|
||||
let keys;
|
||||
try {
|
||||
keys = keysOf(code);
|
||||
} catch (e) {
|
||||
failed = true;
|
||||
const msg = `bundle is declared in _index.json but common.json is missing or invalid (${e.message})`;
|
||||
console.error(`✗ ${code}: ${msg}`);
|
||||
annotate("client/ui/i18n/locales/_index.json", `${code}: ${msg}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const set = new Set(keys);
|
||||
const missing = sourceKeys.filter((k) => !set.has(k));
|
||||
const extra = keys.filter((k) => !sourceSet.has(k));
|
||||
|
||||
if (missing.length === 0 && extra.length === 0) {
|
||||
console.log(`✓ ${code}: ${keys.length} keys`);
|
||||
continue;
|
||||
}
|
||||
|
||||
failed = true;
|
||||
console.error(`✗ ${code}: ${keys.length} keys (expected ${sourceKeys.length})`);
|
||||
if (missing.length) {
|
||||
console.error(` missing ${missing.length}: ${missing.join(", ")}`);
|
||||
annotate(file, `Missing ${missing.length} key(s) present in ${SOURCE}: ${missing.join(", ")}`);
|
||||
}
|
||||
if (extra.length) {
|
||||
console.error(` extra ${extra.length}: ${extra.join(", ")}`);
|
||||
annotate(file, `Has ${extra.length} key(s) not present in ${SOURCE}: ${extra.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Locale directories present on disk but not declared in _index.json are never
|
||||
// loaded by the app — surface them so dead translation files don't rot silently.
|
||||
const onDisk = readdirSync(localesDir, { withFileTypes: true })
|
||||
.filter((e) => e.isDirectory())
|
||||
.map((e) => e.name);
|
||||
const undeclared = onDisk.filter((d) => !declared.includes(d));
|
||||
if (undeclared.length) {
|
||||
console.warn(`\n⚠ locale directories not declared in _index.json (not shipped): ${undeclared.join(", ")}`);
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (failed) {
|
||||
console.error("Translation check FAILED — every locale must match the English key set.");
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("Translation check passed — all locales match the English key set.");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -108,10 +108,11 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
}
|
||||
|
||||
req := &proto.LoginRequest{
|
||||
ManagementUrl: p.ManagementURL,
|
||||
SetupKey: p.SetupKey,
|
||||
Hostname: p.Hostname,
|
||||
IsUnixDesktopClient: runtime.GOOS == "linux",
|
||||
ManagementUrl: p.ManagementURL,
|
||||
SetupKey: p.SetupKey,
|
||||
Hostname: p.Hostname,
|
||||
// a login driven by the UI always has a graphical session available
|
||||
IsUnixDesktopClient: true,
|
||||
}
|
||||
if profileName != "" {
|
||||
req.ProfileName = ptrStr(profileName)
|
||||
@@ -122,8 +123,16 @@ func (s *Connection) Login(ctx context.Context, p LoginParams) (LoginResult, err
|
||||
if p.PreSharedKey != "" {
|
||||
req.OptionalPreSharedKey = ptrStr(p.PreSharedKey)
|
||||
}
|
||||
if p.Hint != "" {
|
||||
req.Hint = ptrStr(p.Hint)
|
||||
hint := p.Hint
|
||||
if hint == "" && profileID != "" {
|
||||
if state, serr := profilemanager.NewProfileManager().GetProfileState(profilemanager.ID(profileID)); serr == nil {
|
||||
hint = state.Email
|
||||
} else {
|
||||
log.Debugf("failed to get profile state for login hint: %v", serr)
|
||||
}
|
||||
}
|
||||
if hint != "" {
|
||||
req.Hint = ptrStr(hint)
|
||||
}
|
||||
|
||||
resp, err := cli.Login(ctx, req)
|
||||
@@ -227,16 +236,6 @@ func (s *Connection) Logout(ctx context.Context, p LogoutParams) error {
|
||||
return s.classifyDaemonError(err)
|
||||
}
|
||||
|
||||
// The daemon runs as root and can't reach the user-owned per-profile state
|
||||
// file holding the account email (see Profiles.List), so clear the stale
|
||||
// email here; the next SSO login recreates it.
|
||||
if p.ProfileName != "" {
|
||||
if err := profilemanager.NewProfileManager().RemoveProfileState(p.ProfileName); err != nil {
|
||||
// Non-fatal: the logout itself succeeded.
|
||||
log.Warnf("failed to remove profile state for %s: %v", p.ProfileName, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -260,7 +259,7 @@ func (s *Connection) waitSSOLogin(ctx context.Context, p WaitSSOParams) (string,
|
||||
|
||||
// Persist the account email the same way the CLI does after its own
|
||||
// WaitSSOLogin: the daemon returns it but cannot store it, since it runs as
|
||||
// root and the per-profile state file is user-owned (see Logout below).
|
||||
// root and the per-profile state file is user-owned (see Profiles.List).
|
||||
// Without this the profile has no email, so Profiles.List shows no account
|
||||
// and later logins and session extends go out without a login_hint —
|
||||
// leaving the IdP to guess which account was meant.
|
||||
|
||||
@@ -162,8 +162,9 @@ func (s *Profiles) Remove(ctx context.Context, p ProfileRef) error {
|
||||
}
|
||||
|
||||
// The daemon deletes what it owns but runs as root, so it leaves the
|
||||
// user-owned state file holding the account email behind (same split as
|
||||
// Connection.Logout). Legacy profiles are keyed by name rather than by a
|
||||
// user-owned state file holding the account email behind. Logout keeps the
|
||||
// email on purpose so later logins can pass it as the login_hint; profile
|
||||
// removal is what deletes it. Legacy profiles are keyed by name rather than by a
|
||||
// generated ID, so a recreated profile of the same name would inherit the
|
||||
// deleted one's email and offer it as the login_hint.
|
||||
//
|
||||
|
||||
11
crowdin.yml
11
crowdin.yml
@@ -1,8 +1,11 @@
|
||||
skip_untranslated_strings: true
|
||||
skip_untranslated_files: true
|
||||
import_eq_suggestions: true
|
||||
|
||||
files:
|
||||
- source: /client/ui/i18n/locales/en/common.json
|
||||
translation: /client/ui/i18n/locales/%locale%/common.json
|
||||
translation: /client/ui/i18n/locales/%two_letters_code%/common.json
|
||||
type: chrome
|
||||
languages_mapping:
|
||||
locale:
|
||||
es-ES: es
|
||||
pt-BR: pt
|
||||
two_letters_code:
|
||||
zh-CN: zh-CN
|
||||
|
||||
@@ -23,9 +23,10 @@ import (
|
||||
// model the client asks for. The proxy prices off the REQUEST model, not the
|
||||
// upstream response model, so a made-up model id billed at operator rates lets
|
||||
// these tests assert exact costs without a real vendor key.
|
||||
// Sourced from the harness so the counts can't drift from the mock's config.
|
||||
const (
|
||||
vllmPromptTokens = 11
|
||||
vllmCompletionTokens = 2
|
||||
vllmPromptTokens = harness.VLLMChatInputTokens
|
||||
vllmCompletionTokens = harness.VLLMChatOutputTokens
|
||||
)
|
||||
|
||||
// pricedEnv is a connected single-provider agent-network deployment pointed at
|
||||
@@ -169,23 +170,43 @@ func chatOnce(t *testing.T, ctx context.Context, env pricedEnv, model, sessionID
|
||||
return body
|
||||
}
|
||||
|
||||
// findAccessLogBySession polls the access-log page for the row carrying sessionID.
|
||||
func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog {
|
||||
t.Helper()
|
||||
var row api.AgentNetworkAccessLog
|
||||
require.Eventually(t, func() bool {
|
||||
logs, lerr := srv.ListAccessLogs(ctx)
|
||||
if lerr != nil {
|
||||
return false
|
||||
}
|
||||
for _, r := range logs.Data {
|
||||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||||
row = r
|
||||
return true
|
||||
// accessLogIngestWindow is how long a single request's access-log row is given
|
||||
// to appear before the caller gives up on it.
|
||||
const accessLogIngestWindow = 30 * time.Second
|
||||
|
||||
// lookupAccessLogBySession polls the access-log page for the row carrying
|
||||
// sessionID and reports whether it arrived within the window. It never fails
|
||||
// the test: callers that can recover — by firing a fresh request under a new
|
||||
// session — need to see the miss rather than die on it.
|
||||
func lookupAccessLogBySession(ctx context.Context, sessionID string, within time.Duration) (api.AgentNetworkAccessLog, bool) {
|
||||
deadline := time.Now().Add(within)
|
||||
for {
|
||||
if logs, lerr := srv.ListAccessLogs(ctx); lerr == nil {
|
||||
for _, r := range logs.Data {
|
||||
if r.SessionId != nil && *r.SessionId == sessionID {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 30*time.Second, 2*time.Second, "session id %q must be recorded in an access-log row", sessionID)
|
||||
if time.Now().After(deadline) {
|
||||
return api.AgentNetworkAccessLog{}, false
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return api.AgentNetworkAccessLog{}, false
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// findAccessLogBySession polls the access-log page for the row carrying
|
||||
// sessionID, failing the test if it never lands. Use it for a request whose row
|
||||
// must exist; where a missing row is a recoverable race, use
|
||||
// lookupAccessLogBySession and retry.
|
||||
func findAccessLogBySession(t *testing.T, ctx context.Context, sessionID string) api.AgentNetworkAccessLog {
|
||||
t.Helper()
|
||||
row, ok := lookupAccessLogBySession(ctx, sessionID, accessLogIngestWindow)
|
||||
require.True(t, ok, "session id %q must be recorded in an access-log row", sessionID)
|
||||
return row
|
||||
}
|
||||
|
||||
@@ -319,6 +340,11 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
outRateA = 0.020
|
||||
inRateB = 0.050 // 5x / 4x the original, so a repriced row is unmistakable
|
||||
outRateB = 0.080
|
||||
// Per-attempt ingest wait, shorter than the default so a request that
|
||||
// produces no row costs one retry rather than most of the budget, and an
|
||||
// overall deadline long enough to hold several attempts.
|
||||
repriceIngestWindow = 20 * time.Second
|
||||
repriceDeadline = 180 * time.Second
|
||||
)
|
||||
|
||||
env := provisionPricedProvider(t, ctx, "reprice", []api.AgentNetworkProviderModel{
|
||||
@@ -353,10 +379,15 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
// reading its cost, so an un-ingested row is never mistaken for "still rate A".
|
||||
// The expected new input cost is unmistakably higher than rate A, so a
|
||||
// lingering old-rate row can't satisfy the check.
|
||||
//
|
||||
// Every way an iteration can come up short — the request failing, its row not
|
||||
// landing, or the row still carrying rate A — is a symptom of the same
|
||||
// in-flight rebuild, so each one retries under a fresh session rather than
|
||||
// ending the test. Only the outer deadline is fatal.
|
||||
wantInputB := float64(vllmPromptTokens) / 1000 * inRateB
|
||||
var repriced api.AgentNetworkAccessLog
|
||||
var lastSession string
|
||||
deadline := time.Now().Add(90 * time.Second)
|
||||
deadline := time.Now().Add(repriceDeadline)
|
||||
for time.Now().Before(deadline) {
|
||||
lastSession = fmt.Sprintf("e2e-session-reprice-b-%d", time.Now().UnixNano())
|
||||
code, _, cerr := env.client.Chat(ctx, env.endpoint, env.proxyIP, harness.WireChat, customModel, "Reply with exactly: pong", lastSession)
|
||||
@@ -364,7 +395,15 @@ func TestPriceChangeUpdatesRecordedCost(t *testing.T) {
|
||||
time.Sleep(5 * time.Second)
|
||||
continue
|
||||
}
|
||||
row := findAccessLogBySession(t, ctx, lastSession)
|
||||
row, ok := lookupAccessLogBySession(ctx, lastSession, repriceIngestWindow)
|
||||
if !ok {
|
||||
// No row for this request. The provider update rebuilds the proxy's
|
||||
// middleware chain, and a request served mid-rebuild can complete
|
||||
// without a resolved provider — 200 to the caller, nothing to
|
||||
// attribute, so no row is ever written for it. Fire another one.
|
||||
t.Logf("no access-log row for session %q within %s; retrying under a fresh session", lastSession, repriceIngestWindow)
|
||||
continue
|
||||
}
|
||||
if inDelta(row.InputCostUsd, wantInputB, 1e-6) {
|
||||
repriced = row
|
||||
break
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user