Compare commits
80 Commits
reduce-emb
...
ui-refacto
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
842ef0d657 | ||
|
|
439f44c6b4 | ||
|
|
b5a970155b | ||
|
|
686e0d97f2 | ||
|
|
0c287b6f4d | ||
|
|
f7f5946910 | ||
|
|
7a9f5a734f | ||
|
|
1aae067aaa | ||
|
|
28a7eba756 | ||
|
|
8841b950a2 | ||
|
|
0c2702c0d7 | ||
|
|
b43a09a1c7 | ||
|
|
595dfbb6f1 | ||
|
|
7f560df9be | ||
|
|
09052949a2 | ||
|
|
9aef31ff53 | ||
|
|
08f52f4517 | ||
|
|
a4114a5e45 | ||
|
|
6b08e89c7b | ||
|
|
a852b3bd34 | ||
|
|
afb83b3049 | ||
|
|
18e3b5dd32 | ||
|
|
f3f9704c6f | ||
|
|
4c3d4effbd | ||
|
|
e89aad09f5 | ||
|
|
3953fee5a4 | ||
|
|
adeaa49cda | ||
|
|
2c5d52a1bf | ||
|
|
70a755fbae | ||
|
|
7da94a4956 | ||
|
|
39eac377e4 | ||
|
|
559da5d5b9 | ||
|
|
614ee11ac7 | ||
|
|
85080afa59 | ||
|
|
a5cc8da054 | ||
|
|
a4fd5a78b4 | ||
|
|
062a183e4e | ||
|
|
a2be41caf8 | ||
|
|
5b70989e3e | ||
|
|
d324a5ff48 | ||
|
|
debb558aa3 | ||
|
|
cce80f8276 | ||
|
|
05ee4e52b8 | ||
|
|
bb2bf673a0 | ||
|
|
91c745e5e8 | ||
|
|
68c38247f1 | ||
|
|
8b8f38de1b | ||
|
|
2b272e74c8 | ||
|
|
e6cbf30415 | ||
|
|
490b60ad0e | ||
|
|
553be144b4 | ||
|
|
c3f9514182 | ||
|
|
a8812d5fb1 | ||
|
|
6f93cf6ac3 | ||
|
|
18909390c2 | ||
|
|
b3eb5f2453 | ||
|
|
dc02542a9e | ||
|
|
0c136fffb9 | ||
|
|
fffb9dd219 | ||
|
|
93275f9052 | ||
|
|
dd9c15072f | ||
|
|
4c743bc03d | ||
|
|
2e61b42e92 | ||
|
|
3f8de2a149 | ||
|
|
bc609c3ae7 | ||
|
|
e3994d0c99 | ||
|
|
ba6e10cef3 | ||
|
|
ce53981b55 | ||
|
|
a69037630b | ||
|
|
df58935cc0 | ||
|
|
a1743dbf9b | ||
|
|
f9771de3f5 | ||
|
|
bfe19fa542 | ||
|
|
d07f25fc49 | ||
|
|
670b0f66ac | ||
|
|
15d73a2edd | ||
|
|
88a2bf582d | ||
|
|
0148d926d5 | ||
|
|
8f16a19b8f | ||
|
|
504dceedf3 |
10
.github/workflows/golang-test-darwin.yml
vendored
@@ -43,5 +43,13 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||||
|
# which fails to compile until the frontend has been built. The Wails UI
|
||||||
|
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||||
|
# before goreleaser.
|
||||||
|
# `go list -e` lets the listing succeed even though the embed fails to
|
||||||
|
# resolve; the grep then drops the broken package by path. Without -e,
|
||||||
|
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||||
|
# root, which has no Go files.
|
||||||
|
run: NETBIRD_STORE_ENGINE=${{ matrix.store }} CI=true go test -tags=devcert -exec 'sudo --preserve-env=CI,NETBIRD_STORE_ENGINE' -timeout 5m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
|
||||||
|
|
||||||
|
|||||||
12
.github/workflows/golang-test-linux.yml
vendored
@@ -154,7 +154,15 @@ jobs:
|
|||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
- name: Test
|
- name: Test
|
||||||
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined)
|
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||||
|
# which fails to compile until the frontend has been built. The Wails UI
|
||||||
|
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||||
|
# before goreleaser.
|
||||||
|
# `go list -e` lets the listing succeed even though the embed fails to
|
||||||
|
# resolve; the grep then drops the broken package by path. Without -e,
|
||||||
|
# go list aborts with empty stdout and `go test` falls back to the repo
|
||||||
|
# root, which has no Go files.
|
||||||
|
run: CGO_ENABLED=1 GOARCH=${{ matrix.arch }} CI=true go test -tags devcert -exec 'sudo' -timeout 10m -p 1 $(go list -e ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui)
|
||||||
|
|
||||||
test_client_on_docker:
|
test_client_on_docker:
|
||||||
name: "Client (Docker) / Unit"
|
name: "Client (Docker) / Unit"
|
||||||
@@ -214,7 +222,7 @@ jobs:
|
|||||||
sh -c ' \
|
sh -c ' \
|
||||||
apk update; apk add --no-cache \
|
apk update; apk add --no-cache \
|
||||||
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
ca-certificates iptables ip6tables dbus dbus-dev libpcap-dev build-base; \
|
||||||
go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server)
|
go test -buildvcs=false -tags devcert -v -timeout 10m -p 1 $(go list -e -buildvcs=false ./... | grep -v -e /management -e /signal -e /relay -e /proxy -e /combined -e /client/ui -e /upload-server)
|
||||||
'
|
'
|
||||||
|
|
||||||
test_relay:
|
test_relay:
|
||||||
|
|||||||
9
.github/workflows/golang-test-windows.yml
vendored
@@ -64,8 +64,15 @@ jobs:
|
|||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe env -w GOCACHE=${{ env.modcache }}
|
||||||
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
- run: PsExec64 -s -w ${{ github.workspace }} C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe mod tidy
|
||||||
- name: Generate test script
|
- name: Generate test script
|
||||||
|
# Exclude client/ui: its main.go uses //go:embed all:frontend/dist,
|
||||||
|
# which fails to compile until the frontend has been built. The Wails UI
|
||||||
|
# has no Go-side unit tests, and its release pipeline runs `pnpm build`
|
||||||
|
# before goreleaser.
|
||||||
|
# `go list -e` lets the listing succeed even though the embed fails to
|
||||||
|
# resolve; the Where-Object pipeline then drops the broken package by
|
||||||
|
# path. Without -e, go list aborts with empty stdout.
|
||||||
run: |
|
run: |
|
||||||
$packages = go list ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' }
|
$packages = go list -e ./... | Where-Object { $_ -notmatch '/management' } | Where-Object { $_ -notmatch '/relay' } | Where-Object { $_ -notmatch '/signal' } | Where-Object { $_ -notmatch '/proxy' } | Where-Object { $_ -notmatch '/combined' } | Where-Object { $_ -notmatch '/client/ui' }
|
||||||
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
$goExe = "C:\hostedtoolcache\windows\go\${{ steps.go.outputs.go-version }}\x64\bin\go.exe"
|
||||||
$cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
$cmd = "$goExe test -tags=devcert -timeout 10m -p 1 $($packages -join ' ') > test-out.txt 2>&1"
|
||||||
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
Set-Content -Path "${{ github.workspace }}\run-tests.cmd" -Value $cmd
|
||||||
|
|||||||
13
.github/workflows/golangci-lint.yml
vendored
@@ -19,8 +19,8 @@ jobs:
|
|||||||
- name: codespell
|
- name: codespell
|
||||||
uses: codespell-project/actions-codespell@v2
|
uses: codespell-project/actions-codespell@v2
|
||||||
with:
|
with:
|
||||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA
|
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||||
skip: go.mod,go.sum,**/proxy/web/**
|
skip: go.mod,go.sum,**/proxy/web/**,**/pnpm-lock.yaml,**/package-lock.json
|
||||||
golangci:
|
golangci:
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
@@ -51,6 +51,15 @@ jobs:
|
|||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
if: matrix.os == 'ubuntu-latest'
|
if: matrix.os == 'ubuntu-latest'
|
||||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||||
|
- name: Stub Wails frontend bundle
|
||||||
|
# client/ui/main.go has //go:embed all:frontend/dist. The
|
||||||
|
# directory is produced by `pnpm run build` and is gitignored, so
|
||||||
|
# lint-only runs (no frontend toolchain) need a placeholder file
|
||||||
|
# for the embed pattern to match.
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
mkdir -p client/ui/frontend/dist
|
||||||
|
touch client/ui/frontend/dist/.embed-placeholder
|
||||||
- name: golangci-lint
|
- name: golangci-lint
|
||||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
28
.github/workflows/release.yml
vendored
@@ -186,9 +186,9 @@ jobs:
|
|||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
run: goversioninfo -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
run: goversioninfo -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/manifest.xml -product-name ${{ env.PRODUCT_NAME }} -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/resources_windows_arm64.syso
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@v4
|
||||||
@@ -349,8 +349,18 @@ jobs:
|
|||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: sudo apt update && sudo apt install -y -q libappindicator3-dev gir1.2-appindicator3-0.1 libxxf86vm-dev gcc-mingw-w64-x86-64
|
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libwebkit2gtk-4.1-dev libsoup-3.0-dev libayatana-appindicator3-dev gcc-mingw-w64-x86-64
|
||||||
|
|
||||||
- name: Decode GPG signing key
|
- name: Decode GPG signing key
|
||||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||||
@@ -370,9 +380,9 @@ jobs:
|
|||||||
- name: Install goversioninfo
|
- name: Install goversioninfo
|
||||||
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
run: go install github.com/josephspurrier/goversioninfo/cmd/goversioninfo@233067e
|
||||||
- name: Generate windows syso amd64
|
- name: Generate windows syso amd64
|
||||||
run: goversioninfo -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
run: goversioninfo -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_amd64.syso
|
||||||
- name: Generate windows syso arm64
|
- name: Generate windows syso arm64
|
||||||
run: goversioninfo -arm -64 -icon client/ui/assets/netbird.ico -manifest client/ui/manifest.xml -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
run: goversioninfo -arm -64 -icon client/ui/build/windows/icon.ico -manifest client/ui/build/windows/wails.exe.manifest -product-name ${{ env.PRODUCT_NAME }}-"UI" -copyright "${{ env.COPYRIGHT }}" -ver-major ${{ steps.semver_parser.outputs.major }} -ver-minor ${{ steps.semver_parser.outputs.minor }} -ver-patch ${{ steps.semver_parser.outputs.patch }} -ver-build 0 -file-version ${{ steps.semver_parser.outputs.fullversion }}.0 -product-version ${{ steps.semver_parser.outputs.fullversion }}.0 -o client/ui/resources_windows_arm64.syso
|
||||||
|
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@v4
|
||||||
@@ -439,6 +449,14 @@ jobs:
|
|||||||
run: go mod tidy
|
run: go mod tidy
|
||||||
- name: check git status
|
- name: check git status
|
||||||
run: git --no-pager diff --exit-code
|
run: git --no-pager diff --exit-code
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
- name: Set up pnpm
|
||||||
|
uses: pnpm/action-setup@v3
|
||||||
|
with:
|
||||||
|
version: 9
|
||||||
- name: Run GoReleaser
|
- name: Run GoReleaser
|
||||||
id: goreleaser
|
id: goreleaser
|
||||||
uses: goreleaser/goreleaser-action@v4
|
uses: goreleaser/goreleaser-action@v4
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ linters:
|
|||||||
- linters:
|
- linters:
|
||||||
- staticcheck
|
- staticcheck
|
||||||
text: "QF1012"
|
text: "QF1012"
|
||||||
|
# client/ui/main.go uses //go:embed all:frontend/dist; the
|
||||||
|
# directory is populated by `pnpm build` in the release pipeline
|
||||||
|
# and missing at lint time, so the embed parses to "no matching
|
||||||
|
# files found" — surfaced by golangci-lint's typecheck pre-pass.
|
||||||
|
# Suppress just that one diagnostic; the rest of the package
|
||||||
|
# (services/, tray.go, grpc.go, ...) still gets linted normally.
|
||||||
|
- linters:
|
||||||
|
- typecheck
|
||||||
|
path: client/ui/main\.go
|
||||||
|
text: "pattern all:frontend/dist"
|
||||||
paths:
|
paths:
|
||||||
- third_party$
|
- third_party$
|
||||||
- builtin$
|
- builtin$
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui
|
- id: netbird-ui
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -70,12 +75,15 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/netbird.desktop
|
- src: client/ui/build/linux/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/assets/netbird.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
- libgtk-3-0
|
||||||
|
- libwebkit2gtk-4.1-0
|
||||||
|
- libayatana-appindicator3-1
|
||||||
|
|
||||||
- maintainer: Netbird <dev@netbird.io>
|
- maintainer: Netbird <dev@netbird.io>
|
||||||
description: Netbird client UI.
|
description: Netbird client UI.
|
||||||
@@ -89,12 +97,15 @@ nfpms:
|
|||||||
scripts:
|
scripts:
|
||||||
postinstall: "release_files/ui-post-install.sh"
|
postinstall: "release_files/ui-post-install.sh"
|
||||||
contents:
|
contents:
|
||||||
- src: client/ui/build/netbird.desktop
|
- src: client/ui/build/linux/netbird.desktop
|
||||||
dst: /usr/share/applications/netbird.desktop
|
dst: /usr/share/applications/netbird.desktop
|
||||||
- src: client/ui/assets/netbird.png
|
- src: client/ui/build/appicon.png
|
||||||
dst: /usr/share/pixmaps/netbird.png
|
dst: /usr/share/pixmaps/netbird.png
|
||||||
dependencies:
|
dependencies:
|
||||||
- netbird
|
- netbird
|
||||||
|
- gtk3
|
||||||
|
- webkit2gtk4.1
|
||||||
|
- libayatana-appindicator-gtk3
|
||||||
rpm:
|
rpm:
|
||||||
signature:
|
signature:
|
||||||
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
key_file: '{{ if index .Env "GPG_RPM_KEY_FILE" }}{{ .Env.GPG_RPM_KEY_FILE }}{{ end }}'
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
version: 2
|
version: 2
|
||||||
|
|
||||||
project_name: netbird-ui
|
project_name: netbird-ui
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- sh -c 'cd client/ui/frontend && pnpm install --frozen-lockfile && pnpm build'
|
||||||
|
|
||||||
builds:
|
builds:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
dir: client/ui
|
dir: client/ui
|
||||||
@@ -20,8 +25,6 @@ builds:
|
|||||||
ldflags:
|
ldflags:
|
||||||
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
- -s -w -X github.com/netbirdio/netbird/version.version={{.Version}} -X main.commit={{.Commit}} -X main.date={{.CommitDate}} -X main.builtBy=goreleaser
|
||||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||||
tags:
|
|
||||||
- load_wgnt_from_rsrc
|
|
||||||
|
|
||||||
universal_binaries:
|
universal_binaries:
|
||||||
- id: netbird-ui-darwin
|
- id: netbird-ui-darwin
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ There are many ways that you can contribute:
|
|||||||
- Sharing use cases in slack or Reddit
|
- Sharing use cases in slack or Reddit
|
||||||
- Bug fix or feature enhancement
|
- Bug fix or feature enhancement
|
||||||
|
|
||||||
If you haven't already, join our slack workspace [here](https://join.slack.com/t/netbirdio/shared_invite/zt-vrahf41g-ik1v7fV8du6t0RwxSrJ96A), we would love to discuss topics that need community contribution and enhancements to existing features.
|
If you haven't already, join our slack workspace [here](https://docs.netbird.io/slack-url), we would love to discuss topics that need community contribution and enhancements to existing features.
|
||||||
|
|
||||||
## Contents
|
## Contents
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
|
|
||||||
"github.com/sirupsen/logrus"
|
"github.com/sirupsen/logrus"
|
||||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
|
||||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||||
|
|
||||||
"github.com/netbirdio/netbird/client/iface"
|
"github.com/netbirdio/netbird/client/iface"
|
||||||
@@ -474,55 +473,6 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
|||||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WGTuning bundles runtime-adjustable WireGuard knobs exposed by the embed
|
|
||||||
// client. Nil fields are left unchanged; set a non-nil pointer to apply.
|
|
||||||
type WGTuning struct {
|
|
||||||
// PreallocatedBuffersPerPool caps each per-Device WaitPool.
|
|
||||||
// Zero means "unbounded" (no cap). Live-tunable only if the underlying
|
|
||||||
// Device was originally created with a nonzero cap.
|
|
||||||
PreallocatedBuffersPerPool *uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWGTuning applies the given tuning to this client's live Device.
|
|
||||||
// Startup-only knobs (batch size) must be set via the package-level
|
|
||||||
// setters before Start.
|
|
||||||
func (c *Client) SetWGTuning(t WGTuning) error {
|
|
||||||
engine, err := c.getEngine()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
return engine.SetWGTuning(internal.WGTuning{
|
|
||||||
PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWGDefaultPreallocatedBuffersPerPool sets the default WaitPool cap
|
|
||||||
// applied to Devices created after this call. Zero disables the cap.
|
|
||||||
// Existing Devices are unaffected; use Client.SetWGTuning for that.
|
|
||||||
func SetWGDefaultPreallocatedBuffersPerPool(n uint32) {
|
|
||||||
wgdevice.SetPreallocatedBuffersPerPool(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WGDefaultPreallocatedBuffersPerPool returns the current default WaitPool
|
|
||||||
// cap applied to newly-created Devices.
|
|
||||||
func WGDefaultPreallocatedBuffersPerPool() uint32 {
|
|
||||||
return wgdevice.PreallocatedBuffersPerPool
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWGDefaultMaxBatchSize sets the default per-Device batch size applied
|
|
||||||
// to Devices created after this call. Zero means "use the bind+tun default"
|
|
||||||
// (NOT unlimited). Must be called before Start to take effect for a new
|
|
||||||
// Client.
|
|
||||||
func SetWGDefaultMaxBatchSize(n uint32) {
|
|
||||||
wgdevice.SetMaxBatchSizeOverride(n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WGDefaultMaxBatchSize returns the current default batch-size override.
|
|
||||||
// Zero means "no override".
|
|
||||||
func WGDefaultMaxBatchSize() uint32 {
|
|
||||||
return wgdevice.MaxBatchSizeOverride
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCapture begins capturing packets on this client's tunnel device.
|
// StartCapture begins capturing packets on this client's tunnel device.
|
||||||
// Only one capture can be active at a time; starting a new one stops the previous.
|
// Only one capture can be active at a time; starting a new one stops the previous.
|
||||||
// Call StopCapture (or CaptureSession.Stop) to end it.
|
// Call StopCapture (or CaptureSession.Stop) to end it.
|
||||||
|
|||||||
125
client/firewall/uspfilter/conntrack/cap_test.go
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
package conntrack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/gopacket/layers"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTCPCapEvicts(t *testing.T) {
|
||||||
|
t.Setenv(EnvTCPMaxEntries, "4")
|
||||||
|
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
require.Equal(t, 4, tracker.maxEntries)
|
||||||
|
|
||||||
|
src := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dst := netip.MustParseAddr("100.64.0.2")
|
||||||
|
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
tracker.TrackOutbound(src, dst, uint16(10000+i), 80, TCPSyn, 0)
|
||||||
|
}
|
||||||
|
require.LessOrEqual(t, len(tracker.connections), 4,
|
||||||
|
"TCP table must not exceed the configured cap")
|
||||||
|
require.Greater(t, len(tracker.connections), 0,
|
||||||
|
"some entries must remain after eviction")
|
||||||
|
|
||||||
|
// The most recently admitted flow must be present: eviction must make
|
||||||
|
// room for new entries, not silently drop them.
|
||||||
|
require.Contains(t, tracker.connections,
|
||||||
|
ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(10009), DstPort: 80},
|
||||||
|
"newest TCP flow must be admitted after eviction")
|
||||||
|
// A pre-cap flow must have been evicted to fit the last one.
|
||||||
|
require.NotContains(t, tracker.connections,
|
||||||
|
ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(10000), DstPort: 80},
|
||||||
|
"oldest TCP flow should have been evicted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPCapPrefersTombstonedForEviction(t *testing.T) {
|
||||||
|
t.Setenv(EnvTCPMaxEntries, "3")
|
||||||
|
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
src := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dst := netip.MustParseAddr("100.64.0.2")
|
||||||
|
|
||||||
|
// Fill to cap with 3 live connections.
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
tracker.TrackOutbound(src, dst, uint16(20000+i), 80, TCPSyn, 0)
|
||||||
|
}
|
||||||
|
require.Len(t, tracker.connections, 3)
|
||||||
|
|
||||||
|
// Tombstone one by sending RST through IsValidInbound.
|
||||||
|
tombstonedKey := ConnKey{SrcIP: src, DstIP: dst, SrcPort: 20001, DstPort: 80}
|
||||||
|
require.True(t, tracker.IsValidInbound(dst, src, 80, 20001, TCPRst|TCPAck, 0))
|
||||||
|
require.True(t, tracker.connections[tombstonedKey].IsTombstone())
|
||||||
|
|
||||||
|
// Another live connection forces eviction. The tombstone must go first.
|
||||||
|
tracker.TrackOutbound(src, dst, uint16(29999), 80, TCPSyn, 0)
|
||||||
|
|
||||||
|
_, tombstonedStillPresent := tracker.connections[tombstonedKey]
|
||||||
|
require.False(t, tombstonedStillPresent,
|
||||||
|
"tombstoned entry should be evicted before live entries")
|
||||||
|
require.LessOrEqual(t, len(tracker.connections), 3)
|
||||||
|
|
||||||
|
// Both live pre-cap entries must survive: eviction must prefer the
|
||||||
|
// tombstone, not just satisfy the size bound by dropping any entry.
|
||||||
|
require.Contains(t, tracker.connections,
|
||||||
|
ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(20000), DstPort: 80},
|
||||||
|
"live entries must not be evicted while a tombstone exists")
|
||||||
|
require.Contains(t, tracker.connections,
|
||||||
|
ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(20002), DstPort: 80},
|
||||||
|
"live entries must not be evicted while a tombstone exists")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUDPCapEvicts(t *testing.T) {
|
||||||
|
t.Setenv(EnvUDPMaxEntries, "5")
|
||||||
|
|
||||||
|
tracker := NewUDPTracker(DefaultUDPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
require.Equal(t, 5, tracker.maxEntries)
|
||||||
|
|
||||||
|
src := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dst := netip.MustParseAddr("100.64.0.2")
|
||||||
|
|
||||||
|
for i := 0; i < 12; i++ {
|
||||||
|
tracker.TrackOutbound(src, dst, uint16(30000+i), 53, 0)
|
||||||
|
}
|
||||||
|
require.LessOrEqual(t, len(tracker.connections), 5)
|
||||||
|
require.Greater(t, len(tracker.connections), 0)
|
||||||
|
|
||||||
|
require.Contains(t, tracker.connections,
|
||||||
|
ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(30011), DstPort: 53},
|
||||||
|
"newest UDP flow must be admitted after eviction")
|
||||||
|
require.NotContains(t, tracker.connections,
|
||||||
|
ConnKey{SrcIP: src, DstIP: dst, SrcPort: uint16(30000), DstPort: 53},
|
||||||
|
"oldest UDP flow should have been evicted")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestICMPCapEvicts(t *testing.T) {
|
||||||
|
t.Setenv(EnvICMPMaxEntries, "3")
|
||||||
|
|
||||||
|
tracker := NewICMPTracker(DefaultICMPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
require.Equal(t, 3, tracker.maxEntries)
|
||||||
|
|
||||||
|
src := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dst := netip.MustParseAddr("100.64.0.2")
|
||||||
|
|
||||||
|
echoReq := layers.CreateICMPv4TypeCode(uint8(layers.ICMPv4TypeEchoRequest), 0)
|
||||||
|
for i := 0; i < 8; i++ {
|
||||||
|
tracker.TrackOutbound(src, dst, uint16(i), echoReq, nil, 64)
|
||||||
|
}
|
||||||
|
require.LessOrEqual(t, len(tracker.connections), 3)
|
||||||
|
require.Greater(t, len(tracker.connections), 0)
|
||||||
|
|
||||||
|
require.Contains(t, tracker.connections,
|
||||||
|
ICMPConnKey{SrcIP: src, DstIP: dst, ID: uint16(7)},
|
||||||
|
"newest ICMP flow must be admitted after eviction")
|
||||||
|
require.NotContains(t, tracker.connections,
|
||||||
|
ICMPConnKey{SrcIP: src, DstIP: dst, ID: uint16(0)},
|
||||||
|
"oldest ICMP flow should have been evicted")
|
||||||
|
}
|
||||||
@@ -3,15 +3,61 @@ package conntrack
|
|||||||
import (
|
import (
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// evictSampleSize bounds how many map entries we scan per eviction call.
|
||||||
|
// Keeps eviction O(1) even at cap under sustained load; the sampled-LRU
|
||||||
|
// heuristic is good enough for a conntrack table that only overflows under
|
||||||
|
// abuse.
|
||||||
|
const evictSampleSize = 8
|
||||||
|
|
||||||
|
// envDuration parses an os.Getenv(name) as a time.Duration. Falls back to
|
||||||
|
// def on empty or invalid; logs a warning on invalid.
|
||||||
|
func envDuration(logger *nblog.Logger, name string, def time.Duration) time.Duration {
|
||||||
|
v := os.Getenv(name)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
d, err := time.ParseDuration(v)
|
||||||
|
if err != nil {
|
||||||
|
logger.Warn3("invalid %s=%q: %v, using default", name, v, err)
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
if d <= 0 {
|
||||||
|
logger.Warn2("invalid %s=%q: must be positive, using default", name, v)
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return d
|
||||||
|
}
|
||||||
|
|
||||||
|
// envInt parses an os.Getenv(name) as an int. Falls back to def on empty,
|
||||||
|
// invalid, or non-positive. Logs a warning on invalid input.
|
||||||
|
func envInt(logger *nblog.Logger, name string, def int) int {
|
||||||
|
v := os.Getenv(name)
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(v)
|
||||||
|
switch {
|
||||||
|
case err != nil:
|
||||||
|
logger.Warn3("invalid %s=%q: %v, using default", name, v, err)
|
||||||
|
return def
|
||||||
|
case n <= 0:
|
||||||
|
logger.Warn2("invalid %s=%q: must be positive, using default", name, v)
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
// BaseConnTrack provides common fields and locking for all connection types
|
// BaseConnTrack provides common fields and locking for all connection types
|
||||||
type BaseConnTrack struct {
|
type BaseConnTrack struct {
|
||||||
FlowId uuid.UUID
|
FlowId uuid.UUID
|
||||||
|
|||||||
11
client/firewall/uspfilter/conntrack/defaults_desktop.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
//go:build !ios && !android
|
||||||
|
|
||||||
|
package conntrack
|
||||||
|
|
||||||
|
// Default per-tracker entry caps on desktop/server platforms. These mirror
|
||||||
|
// typical Linux netfilter nf_conntrack_max territory with ample headroom.
|
||||||
|
const (
|
||||||
|
DefaultMaxTCPEntries = 65536
|
||||||
|
DefaultMaxUDPEntries = 16384
|
||||||
|
DefaultMaxICMPEntries = 2048
|
||||||
|
)
|
||||||
13
client/firewall/uspfilter/conntrack/defaults_mobile.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build ios || android
|
||||||
|
|
||||||
|
package conntrack
|
||||||
|
|
||||||
|
// Default per-tracker entry caps on mobile platforms. iOS network extensions
|
||||||
|
// are capped at ~50 MB; Android runs under aggressive memory pressure. These
|
||||||
|
// values keep conntrack footprint well under 5 MB worst case (TCPConnTrack
|
||||||
|
// is ~200 B plus map overhead).
|
||||||
|
const (
|
||||||
|
DefaultMaxTCPEntries = 4096
|
||||||
|
DefaultMaxUDPEntries = 2048
|
||||||
|
DefaultMaxICMPEntries = 512
|
||||||
|
)
|
||||||
@@ -50,6 +50,9 @@ type ICMPConnTrack struct {
|
|||||||
ICMPCode uint8
|
ICMPCode uint8
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// EnvICMPMaxEntries caps the ICMP conntrack table size.
|
||||||
|
const EnvICMPMaxEntries = "NB_CONNTRACK_ICMP_MAX"
|
||||||
|
|
||||||
// ICMPTracker manages ICMP connection states
|
// ICMPTracker manages ICMP connection states
|
||||||
type ICMPTracker struct {
|
type ICMPTracker struct {
|
||||||
logger *nblog.Logger
|
logger *nblog.Logger
|
||||||
@@ -58,6 +61,7 @@ type ICMPTracker struct {
|
|||||||
cleanupTicker *time.Ticker
|
cleanupTicker *time.Ticker
|
||||||
tickerCancel context.CancelFunc
|
tickerCancel context.CancelFunc
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
maxEntries int
|
||||||
flowLogger nftypes.FlowLogger
|
flowLogger nftypes.FlowLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,6 +175,7 @@ func NewICMPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nfty
|
|||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
cleanupTicker: time.NewTicker(ICMPCleanupInterval),
|
cleanupTicker: time.NewTicker(ICMPCleanupInterval),
|
||||||
tickerCancel: cancel,
|
tickerCancel: cancel,
|
||||||
|
maxEntries: envInt(logger, EnvICMPMaxEntries, DefaultMaxICMPEntries),
|
||||||
flowLogger: flowLogger,
|
flowLogger: flowLogger,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +262,9 @@ func (t *ICMPTracker) track(
|
|||||||
|
|
||||||
// non echo requests don't need tracking
|
// non echo requests don't need tracking
|
||||||
if typ != uint8(layers.ICMPv4TypeEchoRequest) {
|
if typ != uint8(layers.ICMPv4TypeEchoRequest) {
|
||||||
t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo)
|
if t.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo)
|
||||||
|
}
|
||||||
t.sendStartEvent(direction, srcIP, dstIP, typ, code, ruleId, size)
|
t.sendStartEvent(direction, srcIP, dstIP, typ, code, ruleId, size)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -276,10 +283,15 @@ func (t *ICMPTracker) track(
|
|||||||
conn.UpdateCounters(direction, size)
|
conn.UpdateCounters(direction, size)
|
||||||
|
|
||||||
t.mutex.Lock()
|
t.mutex.Lock()
|
||||||
|
if t.maxEntries > 0 && len(t.connections) >= t.maxEntries {
|
||||||
|
t.evictOneLocked()
|
||||||
|
}
|
||||||
t.connections[key] = conn
|
t.connections[key] = conn
|
||||||
t.mutex.Unlock()
|
t.mutex.Unlock()
|
||||||
|
|
||||||
t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo)
|
if t.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
t.logger.Trace3("New %s ICMP connection %s - %s", direction, key, icmpInfo)
|
||||||
|
}
|
||||||
t.sendEvent(nftypes.TypeStart, conn, ruleId)
|
t.sendEvent(nftypes.TypeStart, conn, ruleId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -323,6 +335,34 @@ func (t *ICMPTracker) cleanupRoutine(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evictOneLocked removes one entry to make room. Caller must hold t.mutex.
|
||||||
|
// Bounded sample scan: picks the oldest among up to evictSampleSize entries.
|
||||||
|
func (t *ICMPTracker) evictOneLocked() {
|
||||||
|
var candKey ICMPConnKey
|
||||||
|
var candSeen int64
|
||||||
|
haveCand := false
|
||||||
|
sampled := 0
|
||||||
|
|
||||||
|
for k, c := range t.connections {
|
||||||
|
seen := c.lastSeen.Load()
|
||||||
|
if !haveCand || seen < candSeen {
|
||||||
|
candKey = k
|
||||||
|
candSeen = seen
|
||||||
|
haveCand = true
|
||||||
|
}
|
||||||
|
sampled++
|
||||||
|
if sampled >= evictSampleSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if haveCand {
|
||||||
|
if evicted := t.connections[candKey]; evicted != nil {
|
||||||
|
t.sendEvent(nftypes.TypeEnd, evicted, nil)
|
||||||
|
}
|
||||||
|
delete(t.connections, candKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (t *ICMPTracker) cleanup() {
|
func (t *ICMPTracker) cleanup() {
|
||||||
t.mutex.Lock()
|
t.mutex.Lock()
|
||||||
defer t.mutex.Unlock()
|
defer t.mutex.Unlock()
|
||||||
@@ -331,8 +371,10 @@ func (t *ICMPTracker) cleanup() {
|
|||||||
if conn.timeoutExceeded(t.timeout) {
|
if conn.timeoutExceeded(t.timeout) {
|
||||||
delete(t.connections, key)
|
delete(t.connections, key)
|
||||||
|
|
||||||
t.logger.Trace5("Removed ICMP connection %s (timeout) [in: %d Pkts/%d B out: %d Pkts/%d B]",
|
if t.logger.Enabled(nblog.LevelTrace) {
|
||||||
key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
t.logger.Trace5("Removed ICMP connection %s (timeout) [in: %d Pkts/%d B out: %d Pkts/%d B]",
|
||||||
|
key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
||||||
|
}
|
||||||
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,27 @@ const (
|
|||||||
TCPHandshakeTimeout = 60 * time.Second
|
TCPHandshakeTimeout = 60 * time.Second
|
||||||
// TCPCleanupInterval is how often we check for stale connections
|
// TCPCleanupInterval is how often we check for stale connections
|
||||||
TCPCleanupInterval = 5 * time.Minute
|
TCPCleanupInterval = 5 * time.Minute
|
||||||
|
// FinWaitTimeout bounds FIN_WAIT_1 / FIN_WAIT_2 / CLOSING states.
|
||||||
|
// Matches Linux netfilter nf_conntrack_tcp_timeout_fin_wait.
|
||||||
|
FinWaitTimeout = 60 * time.Second
|
||||||
|
// CloseWaitTimeout bounds CLOSE_WAIT. Matches Linux default; apps
|
||||||
|
// holding CloseWait longer than this should bump the env var.
|
||||||
|
CloseWaitTimeout = 60 * time.Second
|
||||||
|
// LastAckTimeout bounds LAST_ACK. Matches Linux default.
|
||||||
|
LastAckTimeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// Env vars to override per-state teardown timeouts. Values parsed by
|
||||||
|
// time.ParseDuration (e.g. "120s", "2m"). Invalid values fall back to the
|
||||||
|
// defaults above with a warning.
|
||||||
|
const (
|
||||||
|
EnvTCPFinWaitTimeout = "NB_CONNTRACK_TCP_FIN_WAIT_TIMEOUT"
|
||||||
|
EnvTCPCloseWaitTimeout = "NB_CONNTRACK_TCP_CLOSE_WAIT_TIMEOUT"
|
||||||
|
EnvTCPLastAckTimeout = "NB_CONNTRACK_TCP_LAST_ACK_TIMEOUT"
|
||||||
|
|
||||||
|
// EnvTCPMaxEntries caps the TCP conntrack table size. Oldest entries
|
||||||
|
// (tombstones first) are evicted when the cap is reached.
|
||||||
|
EnvTCPMaxEntries = "NB_CONNTRACK_TCP_MAX"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TCPState represents the state of a TCP connection
|
// TCPState represents the state of a TCP connection
|
||||||
@@ -133,14 +154,18 @@ func (t *TCPConnTrack) SetTombstone() {
|
|||||||
|
|
||||||
// TCPTracker manages TCP connection states
|
// TCPTracker manages TCP connection states
|
||||||
type TCPTracker struct {
|
type TCPTracker struct {
|
||||||
logger *nblog.Logger
|
logger *nblog.Logger
|
||||||
connections map[ConnKey]*TCPConnTrack
|
connections map[ConnKey]*TCPConnTrack
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
cleanupTicker *time.Ticker
|
cleanupTicker *time.Ticker
|
||||||
tickerCancel context.CancelFunc
|
tickerCancel context.CancelFunc
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
waitTimeout time.Duration
|
waitTimeout time.Duration
|
||||||
flowLogger nftypes.FlowLogger
|
finWaitTimeout time.Duration
|
||||||
|
closeWaitTimeout time.Duration
|
||||||
|
lastAckTimeout time.Duration
|
||||||
|
maxEntries int
|
||||||
|
flowLogger nftypes.FlowLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewTCPTracker creates a new TCP connection tracker
|
// NewTCPTracker creates a new TCP connection tracker
|
||||||
@@ -155,13 +180,17 @@ func NewTCPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nftyp
|
|||||||
ctx, cancel := context.WithCancel(context.Background())
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
tracker := &TCPTracker{
|
tracker := &TCPTracker{
|
||||||
logger: logger,
|
logger: logger,
|
||||||
connections: make(map[ConnKey]*TCPConnTrack),
|
connections: make(map[ConnKey]*TCPConnTrack),
|
||||||
cleanupTicker: time.NewTicker(TCPCleanupInterval),
|
cleanupTicker: time.NewTicker(TCPCleanupInterval),
|
||||||
tickerCancel: cancel,
|
tickerCancel: cancel,
|
||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
waitTimeout: waitTimeout,
|
waitTimeout: waitTimeout,
|
||||||
flowLogger: flowLogger,
|
finWaitTimeout: envDuration(logger, EnvTCPFinWaitTimeout, FinWaitTimeout),
|
||||||
|
closeWaitTimeout: envDuration(logger, EnvTCPCloseWaitTimeout, CloseWaitTimeout),
|
||||||
|
lastAckTimeout: envDuration(logger, EnvTCPLastAckTimeout, LastAckTimeout),
|
||||||
|
maxEntries: envInt(logger, EnvTCPMaxEntries, DefaultMaxTCPEntries),
|
||||||
|
flowLogger: flowLogger,
|
||||||
}
|
}
|
||||||
|
|
||||||
go tracker.cleanupRoutine(ctx)
|
go tracker.cleanupRoutine(ctx)
|
||||||
@@ -209,6 +238,12 @@ func (t *TCPTracker) track(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, fla
|
|||||||
if exists || flags&TCPSyn == 0 {
|
if exists || flags&TCPSyn == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// Reject illegal SYN combinations (SYN+FIN, SYN+RST, …) so they don't
|
||||||
|
// create spurious conntrack entries. Not mandated by RFC 9293 but a
|
||||||
|
// common hardening (Linux netfilter/nftables rejects these too).
|
||||||
|
if !isValidFlagCombination(flags) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
conn := &TCPConnTrack{
|
conn := &TCPConnTrack{
|
||||||
BaseConnTrack: BaseConnTrack{
|
BaseConnTrack: BaseConnTrack{
|
||||||
@@ -225,20 +260,65 @@ func (t *TCPTracker) track(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, fla
|
|||||||
conn.state.Store(int32(TCPStateNew))
|
conn.state.Store(int32(TCPStateNew))
|
||||||
conn.DNATOrigPort.Store(uint32(origPort))
|
conn.DNATOrigPort.Store(uint32(origPort))
|
||||||
|
|
||||||
if origPort != 0 {
|
if t.logger.Enabled(nblog.LevelTrace) {
|
||||||
t.logger.Trace4("New %s TCP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort)
|
if origPort != 0 {
|
||||||
} else {
|
t.logger.Trace4("New %s TCP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort)
|
||||||
t.logger.Trace2("New %s TCP connection: %s", direction, key)
|
} else {
|
||||||
|
t.logger.Trace2("New %s TCP connection: %s", direction, key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
t.updateState(key, conn, flags, direction, size)
|
t.updateState(key, conn, flags, direction, size)
|
||||||
|
|
||||||
t.mutex.Lock()
|
t.mutex.Lock()
|
||||||
|
if t.maxEntries > 0 && len(t.connections) >= t.maxEntries {
|
||||||
|
t.evictOneLocked()
|
||||||
|
}
|
||||||
t.connections[key] = conn
|
t.connections[key] = conn
|
||||||
t.mutex.Unlock()
|
t.mutex.Unlock()
|
||||||
|
|
||||||
t.sendEvent(nftypes.TypeStart, conn, ruleID)
|
t.sendEvent(nftypes.TypeStart, conn, ruleID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evictOneLocked removes one entry to make room. Caller must hold t.mutex.
|
||||||
|
// Bounded scan: samples up to evictSampleSize pseudo-random entries (Go map
|
||||||
|
// iteration order is randomized), preferring a tombstone. If no tombstone
|
||||||
|
// found in the sample, evicts the oldest among the sampled entries. O(1)
|
||||||
|
// worst case — cheap enough to run on every insert at cap during abuse.
|
||||||
|
func (t *TCPTracker) evictOneLocked() {
|
||||||
|
var candKey ConnKey
|
||||||
|
var candSeen int64
|
||||||
|
haveCand := false
|
||||||
|
sampled := 0
|
||||||
|
|
||||||
|
for k, c := range t.connections {
|
||||||
|
if c.IsTombstone() {
|
||||||
|
delete(t.connections, k)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
seen := c.lastSeen.Load()
|
||||||
|
if !haveCand || seen < candSeen {
|
||||||
|
candKey = k
|
||||||
|
candSeen = seen
|
||||||
|
haveCand = true
|
||||||
|
}
|
||||||
|
sampled++
|
||||||
|
if sampled >= evictSampleSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if haveCand {
|
||||||
|
if evicted := t.connections[candKey]; evicted != nil {
|
||||||
|
// TypeEnd is already emitted at the state transition to
|
||||||
|
// TimeWait and when a connection is tombstoned. Only emit
|
||||||
|
// here when we're reaping a still-active flow.
|
||||||
|
if evicted.GetState() != TCPStateTimeWait && !evicted.IsTombstone() {
|
||||||
|
t.sendEvent(nftypes.TypeEnd, evicted, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(t.connections, candKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// IsValidInbound checks if an inbound TCP packet matches a tracked connection
|
// IsValidInbound checks if an inbound TCP packet matches a tracked connection
|
||||||
func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, flags uint8, size int) bool {
|
func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort uint16, flags uint8, size int) bool {
|
||||||
key := ConnKey{
|
key := ConnKey{
|
||||||
@@ -256,12 +336,19 @@ func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort ui
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reject illegal flag combinations regardless of state. These never belong
|
||||||
|
// to a legitimate flow and must not advance or tear down state.
|
||||||
|
if !isValidFlagCombination(flags) {
|
||||||
|
if t.logger.Enabled(nblog.LevelWarn) {
|
||||||
|
t.logger.Warn3("TCP illegal flag combination %x for connection %s (state %s)", flags, key, conn.GetState())
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
currentState := conn.GetState()
|
currentState := conn.GetState()
|
||||||
if !t.isValidStateForFlags(currentState, flags) {
|
if !t.isValidStateForFlags(currentState, flags) {
|
||||||
t.logger.Warn3("TCP state %s is not valid with flags %x for connection %s", currentState, flags, key)
|
if t.logger.Enabled(nblog.LevelWarn) {
|
||||||
// allow all flags for established for now
|
t.logger.Warn3("TCP state %s is not valid with flags %x for connection %s", currentState, flags, key)
|
||||||
if currentState == TCPStateEstablished {
|
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -270,116 +357,208 @@ func (t *TCPTracker) IsValidInbound(srcIP, dstIP netip.Addr, srcPort, dstPort ui
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// updateState updates the TCP connection state based on flags
|
// updateState updates the TCP connection state based on flags.
|
||||||
func (t *TCPTracker) updateState(key ConnKey, conn *TCPConnTrack, flags uint8, packetDir nftypes.Direction, size int) {
|
func (t *TCPTracker) updateState(key ConnKey, conn *TCPConnTrack, flags uint8, packetDir nftypes.Direction, size int) {
|
||||||
conn.UpdateLastSeen()
|
|
||||||
conn.UpdateCounters(packetDir, size)
|
conn.UpdateCounters(packetDir, size)
|
||||||
|
|
||||||
|
// Malformed flag combinations must not refresh lastSeen or drive state,
|
||||||
|
// otherwise spoofed packets keep a dead flow alive past its timeout.
|
||||||
|
if !isValidFlagCombination(flags) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
conn.UpdateLastSeen()
|
||||||
|
|
||||||
currentState := conn.GetState()
|
currentState := conn.GetState()
|
||||||
|
|
||||||
if flags&TCPRst != 0 {
|
if flags&TCPRst != 0 {
|
||||||
if conn.CompareAndSwapState(currentState, TCPStateClosed) {
|
// Hardening beyond RFC 9293 §3.10.7.4: without sequence tracking we
|
||||||
conn.SetTombstone()
|
// cannot apply the RFC 5961 in-window RST check, so we conservatively
|
||||||
t.logger.Trace6("TCP connection reset: %s (dir: %s) [in: %d Pkts/%d B, out: %d Pkts/%d B]",
|
// reject RSTs that the spec would accept (TIME-WAIT with in-window
|
||||||
key, packetDir, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
// SEQ, SynSent from same direction as own SYN, etc.).
|
||||||
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
t.handleRst(key, conn, currentState, packetDir)
|
||||||
}
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var newState TCPState
|
newState := nextState(currentState, conn.Direction, packetDir, flags)
|
||||||
switch currentState {
|
if newState == 0 || !conn.CompareAndSwapState(currentState, newState) {
|
||||||
case TCPStateNew:
|
return
|
||||||
if flags&TCPSyn != 0 && flags&TCPAck == 0 {
|
}
|
||||||
if conn.Direction == nftypes.Egress {
|
t.onTransition(key, conn, currentState, newState, packetDir)
|
||||||
newState = TCPStateSynSent
|
}
|
||||||
} else {
|
|
||||||
newState = TCPStateSynReceived
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case TCPStateSynSent:
|
// handleRst processes a RST segment. Late RSTs in TimeWait and spoofed RSTs
|
||||||
if flags&TCPSyn != 0 && flags&TCPAck != 0 {
|
// from the SYN direction are ignored; otherwise the flow is tombstoned.
|
||||||
if packetDir != conn.Direction {
|
func (t *TCPTracker) handleRst(key ConnKey, conn *TCPConnTrack, currentState TCPState, packetDir nftypes.Direction) {
|
||||||
newState = TCPStateEstablished
|
// TimeWait exists to absorb late segments; don't let a late RST
|
||||||
} else {
|
// tombstone the entry and break same-4-tuple reuse.
|
||||||
// Simultaneous open
|
if currentState == TCPStateTimeWait {
|
||||||
newState = TCPStateSynReceived
|
return
|
||||||
}
|
}
|
||||||
}
|
// A RST from the same direction as the SYN cannot be a legitimate
|
||||||
|
// response and must not tear down a half-open connection.
|
||||||
|
if currentState == TCPStateSynSent && packetDir == conn.Direction {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !conn.CompareAndSwapState(currentState, TCPStateClosed) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
conn.SetTombstone()
|
||||||
|
if t.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
t.logger.Trace6("TCP connection reset: %s (dir: %s) [in: %d Pkts/%d B, out: %d Pkts/%d B]",
|
||||||
|
key, packetDir, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
||||||
|
}
|
||||||
|
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
||||||
|
}
|
||||||
|
|
||||||
case TCPStateSynReceived:
|
// stateTransition describes one state's transition logic. It receives the
|
||||||
if flags&TCPAck != 0 && flags&TCPSyn == 0 {
|
// packet's flags plus whether the packet direction matches the connection's
|
||||||
if packetDir == conn.Direction {
|
// origin direction (same=true means same side as the SYN initiator). Return 0
|
||||||
newState = TCPStateEstablished
|
// for no transition.
|
||||||
}
|
type stateTransition func(flags uint8, connDir nftypes.Direction, same bool) TCPState
|
||||||
}
|
|
||||||
|
|
||||||
case TCPStateEstablished:
|
// stateTable maps each state to its transition function. Centralized here so
|
||||||
if flags&TCPFin != 0 {
|
// nextState stays trivial and each rule is easy to read in isolation.
|
||||||
if packetDir == conn.Direction {
|
var stateTable = map[TCPState]stateTransition{
|
||||||
newState = TCPStateFinWait1
|
TCPStateNew: transNew,
|
||||||
} else {
|
TCPStateSynSent: transSynSent,
|
||||||
newState = TCPStateCloseWait
|
TCPStateSynReceived: transSynReceived,
|
||||||
}
|
TCPStateEstablished: transEstablished,
|
||||||
}
|
TCPStateFinWait1: transFinWait1,
|
||||||
|
TCPStateFinWait2: transFinWait2,
|
||||||
|
TCPStateClosing: transClosing,
|
||||||
|
TCPStateCloseWait: transCloseWait,
|
||||||
|
TCPStateLastAck: transLastAck,
|
||||||
|
}
|
||||||
|
|
||||||
case TCPStateFinWait1:
|
// nextState returns the target TCP state for the given current state and
|
||||||
if packetDir != conn.Direction {
|
// packet, or 0 if the packet does not trigger a transition.
|
||||||
switch {
|
func nextState(currentState TCPState, connDir, packetDir nftypes.Direction, flags uint8) TCPState {
|
||||||
case flags&TCPFin != 0 && flags&TCPAck != 0:
|
fn, ok := stateTable[currentState]
|
||||||
newState = TCPStateClosing
|
if !ok {
|
||||||
case flags&TCPFin != 0:
|
return 0
|
||||||
newState = TCPStateClosing
|
}
|
||||||
case flags&TCPAck != 0:
|
return fn(flags, connDir, packetDir == connDir)
|
||||||
newState = TCPStateFinWait2
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
case TCPStateFinWait2:
|
func transNew(flags uint8, connDir nftypes.Direction, _ bool) TCPState {
|
||||||
if flags&TCPFin != 0 {
|
if flags&TCPSyn != 0 && flags&TCPAck == 0 {
|
||||||
newState = TCPStateTimeWait
|
if connDir == nftypes.Egress {
|
||||||
|
return TCPStateSynSent
|
||||||
}
|
}
|
||||||
|
return TCPStateSynReceived
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
case TCPStateClosing:
|
func transSynSent(flags uint8, _ nftypes.Direction, same bool) TCPState {
|
||||||
if flags&TCPAck != 0 {
|
if flags&TCPSyn != 0 && flags&TCPAck != 0 {
|
||||||
newState = TCPStateTimeWait
|
if same {
|
||||||
|
return TCPStateSynReceived // simultaneous open
|
||||||
}
|
}
|
||||||
|
return TCPStateEstablished
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
case TCPStateCloseWait:
|
func transSynReceived(flags uint8, _ nftypes.Direction, same bool) TCPState {
|
||||||
if flags&TCPFin != 0 {
|
if flags&TCPAck != 0 && flags&TCPSyn == 0 && same {
|
||||||
newState = TCPStateLastAck
|
return TCPStateEstablished
|
||||||
}
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
case TCPStateLastAck:
|
func transEstablished(flags uint8, _ nftypes.Direction, same bool) TCPState {
|
||||||
if flags&TCPAck != 0 {
|
if flags&TCPFin == 0 {
|
||||||
newState = TCPStateClosed
|
return 0
|
||||||
}
|
}
|
||||||
|
if same {
|
||||||
|
return TCPStateFinWait1
|
||||||
|
}
|
||||||
|
return TCPStateCloseWait
|
||||||
|
}
|
||||||
|
|
||||||
|
// transFinWait1 handles the active-close peer response. A FIN carrying our
|
||||||
|
// ACK piggybacked goes straight to TIME-WAIT (RFC 9293 §3.10.7.4, FIN-WAIT-1:
|
||||||
|
// "if our FIN has been ACKed... enter the TIME-WAIT state"); a lone FIN moves
|
||||||
|
// to CLOSING; a pure ACK of our FIN moves to FIN-WAIT-2.
|
||||||
|
func transFinWait1(flags uint8, _ nftypes.Direction, same bool) TCPState {
|
||||||
|
if same {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
if flags&TCPFin != 0 && flags&TCPAck != 0 {
|
||||||
|
return TCPStateTimeWait
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case flags&TCPFin != 0:
|
||||||
|
return TCPStateClosing
|
||||||
|
case flags&TCPAck != 0:
|
||||||
|
return TCPStateFinWait2
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// transFinWait2 ignores own-side FIN retransmits; only the peer's FIN advances.
|
||||||
|
func transFinWait2(flags uint8, _ nftypes.Direction, same bool) TCPState {
|
||||||
|
if flags&TCPFin != 0 && !same {
|
||||||
|
return TCPStateTimeWait
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// transClosing completes a simultaneous close on the peer's ACK.
|
||||||
|
func transClosing(flags uint8, _ nftypes.Direction, same bool) TCPState {
|
||||||
|
if flags&TCPAck != 0 && !same {
|
||||||
|
return TCPStateTimeWait
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// transCloseWait only advances to LastAck when WE send FIN, ignoring peer retransmits.
|
||||||
|
func transCloseWait(flags uint8, _ nftypes.Direction, same bool) TCPState {
|
||||||
|
if flags&TCPFin != 0 && same {
|
||||||
|
return TCPStateLastAck
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// transLastAck closes the flow only on the peer's ACK (not our own ACK retransmits).
|
||||||
|
func transLastAck(flags uint8, _ nftypes.Direction, same bool) TCPState {
|
||||||
|
if flags&TCPAck != 0 && !same {
|
||||||
|
return TCPStateClosed
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// onTransition handles logging and flow-event emission after a successful
|
||||||
|
// state transition. TimeWait and Closed are terminal for flow accounting.
|
||||||
|
func (t *TCPTracker) onTransition(key ConnKey, conn *TCPConnTrack, from, to TCPState, packetDir nftypes.Direction) {
|
||||||
|
traceOn := t.logger.Enabled(nblog.LevelTrace)
|
||||||
|
if traceOn {
|
||||||
|
t.logger.Trace4("TCP connection %s transitioned from %s to %s (dir: %s)", key, from, to, packetDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
if newState != 0 && conn.CompareAndSwapState(currentState, newState) {
|
switch to {
|
||||||
t.logger.Trace4("TCP connection %s transitioned from %s to %s (dir: %s)", key, currentState, newState, packetDir)
|
case TCPStateTimeWait:
|
||||||
|
if traceOn {
|
||||||
switch newState {
|
|
||||||
case TCPStateTimeWait:
|
|
||||||
t.logger.Trace5("TCP connection %s completed [in: %d Pkts/%d B, out: %d Pkts/%d B]",
|
t.logger.Trace5("TCP connection %s completed [in: %d Pkts/%d B, out: %d Pkts/%d B]",
|
||||||
key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
||||||
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
}
|
||||||
|
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
||||||
case TCPStateClosed:
|
case TCPStateClosed:
|
||||||
conn.SetTombstone()
|
conn.SetTombstone()
|
||||||
|
if traceOn {
|
||||||
t.logger.Trace5("TCP connection %s closed gracefully [in: %d Pkts/%d, B out: %d Pkts/%d B]",
|
t.logger.Trace5("TCP connection %s closed gracefully [in: %d Pkts/%d, B out: %d Pkts/%d B]",
|
||||||
key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
||||||
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
|
||||||
}
|
}
|
||||||
|
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// isValidStateForFlags checks if the TCP flags are valid for the current connection state
|
// isValidStateForFlags checks if the TCP flags are valid for the current
|
||||||
|
// connection state. Caller must have already verified the flag combination is
|
||||||
|
// legal via isValidFlagCombination.
|
||||||
func (t *TCPTracker) isValidStateForFlags(state TCPState, flags uint8) bool {
|
func (t *TCPTracker) isValidStateForFlags(state TCPState, flags uint8) bool {
|
||||||
if !isValidFlagCombination(flags) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if flags&TCPRst != 0 {
|
if flags&TCPRst != 0 {
|
||||||
if state == TCPStateSynSent {
|
if state == TCPStateSynSent {
|
||||||
return flags&TCPAck != 0
|
return flags&TCPAck != 0
|
||||||
@@ -449,15 +628,24 @@ func (t *TCPTracker) cleanup() {
|
|||||||
timeout = t.waitTimeout
|
timeout = t.waitTimeout
|
||||||
case TCPStateEstablished:
|
case TCPStateEstablished:
|
||||||
timeout = t.timeout
|
timeout = t.timeout
|
||||||
|
case TCPStateFinWait1, TCPStateFinWait2, TCPStateClosing:
|
||||||
|
timeout = t.finWaitTimeout
|
||||||
|
case TCPStateCloseWait:
|
||||||
|
timeout = t.closeWaitTimeout
|
||||||
|
case TCPStateLastAck:
|
||||||
|
timeout = t.lastAckTimeout
|
||||||
default:
|
default:
|
||||||
|
// SynSent / SynReceived / New
|
||||||
timeout = TCPHandshakeTimeout
|
timeout = TCPHandshakeTimeout
|
||||||
}
|
}
|
||||||
|
|
||||||
if conn.timeoutExceeded(timeout) {
|
if conn.timeoutExceeded(timeout) {
|
||||||
delete(t.connections, key)
|
delete(t.connections, key)
|
||||||
|
|
||||||
t.logger.Trace6("Cleaned up timed-out TCP connection %s (%s) [in: %d Pkts/%d, B out: %d Pkts/%d B]",
|
if t.logger.Enabled(nblog.LevelTrace) {
|
||||||
key, conn.GetState(), conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
t.logger.Trace6("Cleaned up timed-out TCP connection %s (%s) [in: %d Pkts/%d, B out: %d Pkts/%d B]",
|
||||||
|
key, conn.GetState(), conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
||||||
|
}
|
||||||
|
|
||||||
// event already handled by state change
|
// event already handled by state change
|
||||||
if currentState != TCPStateTimeWait {
|
if currentState != TCPStateTimeWait {
|
||||||
|
|||||||
100
client/firewall/uspfilter/conntrack/tcp_rst_bugs_test.go
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
package conntrack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RST hygiene tests: the tracker currently closes the flow on any RST that
|
||||||
|
// matches the 4-tuple, regardless of direction or state. These tests cover
|
||||||
|
// the minimum checks we want (no SEQ tracking).
|
||||||
|
|
||||||
|
func TestTCPRstInSynSentWrongDirection(t *testing.T) {
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
srcPort := uint16(12345)
|
||||||
|
dstPort := uint16(80)
|
||||||
|
key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}
|
||||||
|
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPSyn, 0)
|
||||||
|
conn := tracker.connections[key]
|
||||||
|
require.Equal(t, TCPStateSynSent, conn.GetState())
|
||||||
|
|
||||||
|
// A RST arriving in the same direction as the SYN (i.e. TrackOutbound)
|
||||||
|
// cannot be a legitimate response. It must not close the connection.
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPRst|TCPAck, 0)
|
||||||
|
require.Equal(t, TCPStateSynSent, conn.GetState(),
|
||||||
|
"RST in same direction as SYN must not close connection")
|
||||||
|
require.False(t, conn.IsTombstone())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPRstInTimeWaitIgnored(t *testing.T) {
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
srcPort := uint16(12345)
|
||||||
|
dstPort := uint16(80)
|
||||||
|
key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}
|
||||||
|
|
||||||
|
// Drive to TIME-WAIT via active close.
|
||||||
|
establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0)
|
||||||
|
require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0))
|
||||||
|
require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0))
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0)
|
||||||
|
|
||||||
|
conn := tracker.connections[key]
|
||||||
|
require.Equal(t, TCPStateTimeWait, conn.GetState())
|
||||||
|
require.False(t, conn.IsTombstone(), "TIME-WAIT must not be tombstoned")
|
||||||
|
|
||||||
|
// Late RST during TIME-WAIT must not tombstone the entry (TIME-WAIT
|
||||||
|
// exists to absorb late segments).
|
||||||
|
tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPRst, 0)
|
||||||
|
require.Equal(t, TCPStateTimeWait, conn.GetState(),
|
||||||
|
"RST in TIME-WAIT must not transition state")
|
||||||
|
require.False(t, conn.IsTombstone(),
|
||||||
|
"RST in TIME-WAIT must not tombstone the entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPIllegalFlagCombos(t *testing.T) {
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
srcPort := uint16(12345)
|
||||||
|
dstPort := uint16(80)
|
||||||
|
key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}
|
||||||
|
|
||||||
|
establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
conn := tracker.connections[key]
|
||||||
|
|
||||||
|
// Illegal combos must be rejected and must not change state.
|
||||||
|
combos := []struct {
|
||||||
|
name string
|
||||||
|
flags uint8
|
||||||
|
}{
|
||||||
|
{"SYN+RST", TCPSyn | TCPRst},
|
||||||
|
{"FIN+RST", TCPFin | TCPRst},
|
||||||
|
{"SYN+FIN", TCPSyn | TCPFin},
|
||||||
|
{"SYN+FIN+RST", TCPSyn | TCPFin | TCPRst},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range combos {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
before := conn.GetState()
|
||||||
|
valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, c.flags, 0)
|
||||||
|
require.False(t, valid, "illegal flag combo must be rejected: %s", c.name)
|
||||||
|
require.Equal(t, before, conn.GetState(),
|
||||||
|
"illegal flag combo must not change state")
|
||||||
|
require.False(t, conn.IsTombstone())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
235
client/firewall/uspfilter/conntrack/tcp_state_bugs_test.go
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
package conntrack
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/netip"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests exercise cases where the TCP state machine currently advances
|
||||||
|
// on retransmitted or wrong-direction segments and tears the flow down
|
||||||
|
// prematurely. They are expected to fail until the direction checks are added.
|
||||||
|
|
||||||
|
func TestTCPCloseWaitRetransmittedPeerFIN(t *testing.T) {
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
srcPort := uint16(12345)
|
||||||
|
dstPort := uint16(80)
|
||||||
|
key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}
|
||||||
|
|
||||||
|
establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
|
||||||
|
// Peer sends FIN -> CloseWait (our app has not yet closed).
|
||||||
|
valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)
|
||||||
|
require.True(t, valid)
|
||||||
|
conn := tracker.connections[key]
|
||||||
|
require.Equal(t, TCPStateCloseWait, conn.GetState())
|
||||||
|
|
||||||
|
// Peer retransmits their FIN (ACK may have been delayed). We have NOT
|
||||||
|
// sent our FIN yet, so state must remain CloseWait.
|
||||||
|
valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)
|
||||||
|
require.True(t, valid, "retransmitted peer FIN must still be accepted")
|
||||||
|
require.Equal(t, TCPStateCloseWait, conn.GetState(),
|
||||||
|
"retransmitted peer FIN must not advance CloseWait to LastAck")
|
||||||
|
|
||||||
|
// Our app finally closes -> LastAck.
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0)
|
||||||
|
require.Equal(t, TCPStateLastAck, conn.GetState())
|
||||||
|
|
||||||
|
// Peer ACK closes.
|
||||||
|
valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)
|
||||||
|
require.True(t, valid)
|
||||||
|
require.Equal(t, TCPStateClosed, conn.GetState())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPFinWait2RetransmittedOwnFIN(t *testing.T) {
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
srcPort := uint16(12345)
|
||||||
|
dstPort := uint16(80)
|
||||||
|
key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}
|
||||||
|
|
||||||
|
establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
|
||||||
|
// We initiate close.
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0)
|
||||||
|
valid := tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)
|
||||||
|
require.True(t, valid)
|
||||||
|
conn := tracker.connections[key]
|
||||||
|
require.Equal(t, TCPStateFinWait2, conn.GetState())
|
||||||
|
|
||||||
|
// Stray retransmit of our own FIN (same direction as originator) must
|
||||||
|
// NOT advance FinWait2 to TimeWait; only the peer's FIN should.
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0)
|
||||||
|
require.Equal(t, TCPStateFinWait2, conn.GetState(),
|
||||||
|
"own FIN retransmit must not advance FinWait2 to TimeWait")
|
||||||
|
|
||||||
|
// Peer FIN -> TimeWait.
|
||||||
|
valid = tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)
|
||||||
|
require.True(t, valid)
|
||||||
|
require.Equal(t, TCPStateTimeWait, conn.GetState())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPLastAckDirectionCheck(t *testing.T) {
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
srcPort := uint16(12345)
|
||||||
|
dstPort := uint16(80)
|
||||||
|
key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}
|
||||||
|
|
||||||
|
establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
|
||||||
|
// Drive to LastAck: peer FIN -> CloseWait, our FIN -> LastAck.
|
||||||
|
require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0))
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0)
|
||||||
|
conn := tracker.connections[key]
|
||||||
|
require.Equal(t, TCPStateLastAck, conn.GetState())
|
||||||
|
|
||||||
|
// Our own ACK retransmit (same direction as originator) must NOT close.
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0)
|
||||||
|
require.Equal(t, TCPStateLastAck, conn.GetState(),
|
||||||
|
"own ACK retransmit in LastAck must not transition to Closed")
|
||||||
|
|
||||||
|
// Peer's ACK -> Closed.
|
||||||
|
require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0))
|
||||||
|
require.Equal(t, TCPStateClosed, conn.GetState())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPFinWait1OwnAckDoesNotAdvance(t *testing.T) {
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
srcPort := uint16(12345)
|
||||||
|
dstPort := uint16(80)
|
||||||
|
key := ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}
|
||||||
|
|
||||||
|
establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0)
|
||||||
|
conn := tracker.connections[key]
|
||||||
|
require.Equal(t, TCPStateFinWait1, conn.GetState())
|
||||||
|
|
||||||
|
// Our own ACK retransmit (same direction as originator) must not advance.
|
||||||
|
tracker.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPAck, 0)
|
||||||
|
require.Equal(t, TCPStateFinWait1, conn.GetState(),
|
||||||
|
"own ACK in FinWait1 must not advance to FinWait2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPPerStateTeardownTimeouts(t *testing.T) {
|
||||||
|
// Verify cleanup reaps entries in each teardown state at the configured
|
||||||
|
// per-state timeout, not at the single handshake timeout.
|
||||||
|
t.Setenv(EnvTCPFinWaitTimeout, "50ms")
|
||||||
|
t.Setenv(EnvTCPCloseWaitTimeout, "80ms")
|
||||||
|
t.Setenv(EnvTCPLastAckTimeout, "30ms")
|
||||||
|
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
dstPort := uint16(80)
|
||||||
|
|
||||||
|
// Drives a connection to the target state, forces its lastSeen well
|
||||||
|
// beyond the configured timeout, runs cleanup, and asserts reaping.
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
// drive takes a fresh tracker and returns the conn key after
|
||||||
|
// transitioning the flow into the intended teardown state.
|
||||||
|
drive func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "FinWait1",
|
||||||
|
drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) {
|
||||||
|
establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // → FinWait1
|
||||||
|
return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateFinWait1
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "FinWait2",
|
||||||
|
drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) {
|
||||||
|
establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // FinWait1
|
||||||
|
require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0)) // → FinWait2
|
||||||
|
return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateFinWait2
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "CloseWait",
|
||||||
|
drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) {
|
||||||
|
establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) // → CloseWait
|
||||||
|
return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateCloseWait
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "LastAck",
|
||||||
|
drive: func(t *testing.T, tr *TCPTracker, srcIP netip.Addr, srcPort uint16) (ConnKey, TCPState) {
|
||||||
|
establishConnection(t, tr, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
require.True(t, tr.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0)) // CloseWait
|
||||||
|
tr.TrackOutbound(srcIP, dstIP, srcPort, dstPort, TCPFin|TCPAck, 0) // → LastAck
|
||||||
|
return ConnKey{SrcIP: srcIP, DstIP: dstIP, SrcPort: srcPort, DstPort: dstPort}, TCPStateLastAck
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use a unique source port per subtest so nothing aliases.
|
||||||
|
port := uint16(12345)
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
require.Equal(t, 50*time.Millisecond, tracker.finWaitTimeout)
|
||||||
|
require.Equal(t, 80*time.Millisecond, tracker.closeWaitTimeout)
|
||||||
|
require.Equal(t, 30*time.Millisecond, tracker.lastAckTimeout)
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
port++
|
||||||
|
key, wantState := c.drive(t, tracker, srcIP, port)
|
||||||
|
conn := tracker.connections[key]
|
||||||
|
require.NotNil(t, conn)
|
||||||
|
require.Equal(t, wantState, conn.GetState())
|
||||||
|
|
||||||
|
// Age the entry past the largest per-state timeout.
|
||||||
|
conn.lastSeen.Store(time.Now().Add(-500 * time.Millisecond).UnixNano())
|
||||||
|
tracker.cleanup()
|
||||||
|
_, exists := tracker.connections[key]
|
||||||
|
require.False(t, exists, "%s entry should be reaped", c.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTCPEstablishedPSHACKInFinStates(t *testing.T) {
|
||||||
|
// Verifies FIN|PSH|ACK and bare ACK keepalives are not dropped in FIN
|
||||||
|
// teardown states, which some stacks emit during close.
|
||||||
|
tracker := NewTCPTracker(DefaultTCPTimeout, logger, flowLogger)
|
||||||
|
defer tracker.Close()
|
||||||
|
|
||||||
|
srcIP := netip.MustParseAddr("100.64.0.1")
|
||||||
|
dstIP := netip.MustParseAddr("100.64.0.2")
|
||||||
|
srcPort := uint16(12345)
|
||||||
|
dstPort := uint16(80)
|
||||||
|
|
||||||
|
establishConnection(t, tracker, srcIP, dstIP, srcPort, dstPort)
|
||||||
|
|
||||||
|
// Peer FIN -> CloseWait.
|
||||||
|
require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPAck, 0))
|
||||||
|
|
||||||
|
// Peer pushes trailing data + FIN|PSH|ACK (legal).
|
||||||
|
require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPFin|TCPPush|TCPAck, 100),
|
||||||
|
"FIN|PSH|ACK in CloseWait must be accepted")
|
||||||
|
|
||||||
|
// Bare ACK keepalive from peer in CloseWait must be accepted.
|
||||||
|
require.True(t, tracker.IsValidInbound(dstIP, srcIP, dstPort, srcPort, TCPAck, 0),
|
||||||
|
"bare ACK in CloseWait must be accepted")
|
||||||
|
}
|
||||||
@@ -17,6 +17,9 @@ const (
|
|||||||
DefaultUDPTimeout = 30 * time.Second
|
DefaultUDPTimeout = 30 * time.Second
|
||||||
// UDPCleanupInterval is how often we check for stale connections
|
// UDPCleanupInterval is how often we check for stale connections
|
||||||
UDPCleanupInterval = 15 * time.Second
|
UDPCleanupInterval = 15 * time.Second
|
||||||
|
|
||||||
|
// EnvUDPMaxEntries caps the UDP conntrack table size.
|
||||||
|
EnvUDPMaxEntries = "NB_CONNTRACK_UDP_MAX"
|
||||||
)
|
)
|
||||||
|
|
||||||
// UDPConnTrack represents a UDP connection state
|
// UDPConnTrack represents a UDP connection state
|
||||||
@@ -34,6 +37,7 @@ type UDPTracker struct {
|
|||||||
cleanupTicker *time.Ticker
|
cleanupTicker *time.Ticker
|
||||||
tickerCancel context.CancelFunc
|
tickerCancel context.CancelFunc
|
||||||
mutex sync.RWMutex
|
mutex sync.RWMutex
|
||||||
|
maxEntries int
|
||||||
flowLogger nftypes.FlowLogger
|
flowLogger nftypes.FlowLogger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +55,7 @@ func NewUDPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nftyp
|
|||||||
timeout: timeout,
|
timeout: timeout,
|
||||||
cleanupTicker: time.NewTicker(UDPCleanupInterval),
|
cleanupTicker: time.NewTicker(UDPCleanupInterval),
|
||||||
tickerCancel: cancel,
|
tickerCancel: cancel,
|
||||||
|
maxEntries: envInt(logger, EnvUDPMaxEntries, DefaultMaxUDPEntries),
|
||||||
flowLogger: flowLogger,
|
flowLogger: flowLogger,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,13 +122,18 @@ func (t *UDPTracker) track(srcIP netip.Addr, dstIP netip.Addr, srcPort uint16, d
|
|||||||
conn.UpdateCounters(direction, size)
|
conn.UpdateCounters(direction, size)
|
||||||
|
|
||||||
t.mutex.Lock()
|
t.mutex.Lock()
|
||||||
|
if t.maxEntries > 0 && len(t.connections) >= t.maxEntries {
|
||||||
|
t.evictOneLocked()
|
||||||
|
}
|
||||||
t.connections[key] = conn
|
t.connections[key] = conn
|
||||||
t.mutex.Unlock()
|
t.mutex.Unlock()
|
||||||
|
|
||||||
if origPort != 0 {
|
if t.logger.Enabled(nblog.LevelTrace) {
|
||||||
t.logger.Trace4("New %s UDP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort)
|
if origPort != 0 {
|
||||||
} else {
|
t.logger.Trace4("New %s UDP connection: %s (port DNAT %d -> %d)", direction, key, origPort, dstPort)
|
||||||
t.logger.Trace2("New %s UDP connection: %s", direction, key)
|
} else {
|
||||||
|
t.logger.Trace2("New %s UDP connection: %s", direction, key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
t.sendEvent(nftypes.TypeStart, conn, ruleID)
|
t.sendEvent(nftypes.TypeStart, conn, ruleID)
|
||||||
}
|
}
|
||||||
@@ -151,6 +161,34 @@ func (t *UDPTracker) IsValidInbound(srcIP netip.Addr, dstIP netip.Addr, srcPort
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// evictOneLocked removes one entry to make room. Caller must hold t.mutex.
|
||||||
|
// Bounded sample: picks the oldest among up to evictSampleSize entries.
|
||||||
|
func (t *UDPTracker) evictOneLocked() {
|
||||||
|
var candKey ConnKey
|
||||||
|
var candSeen int64
|
||||||
|
haveCand := false
|
||||||
|
sampled := 0
|
||||||
|
|
||||||
|
for k, c := range t.connections {
|
||||||
|
seen := c.lastSeen.Load()
|
||||||
|
if !haveCand || seen < candSeen {
|
||||||
|
candKey = k
|
||||||
|
candSeen = seen
|
||||||
|
haveCand = true
|
||||||
|
}
|
||||||
|
sampled++
|
||||||
|
if sampled >= evictSampleSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if haveCand {
|
||||||
|
if evicted := t.connections[candKey]; evicted != nil {
|
||||||
|
t.sendEvent(nftypes.TypeEnd, evicted, nil)
|
||||||
|
}
|
||||||
|
delete(t.connections, candKey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// cleanupRoutine periodically removes stale connections
|
// cleanupRoutine periodically removes stale connections
|
||||||
func (t *UDPTracker) cleanupRoutine(ctx context.Context) {
|
func (t *UDPTracker) cleanupRoutine(ctx context.Context) {
|
||||||
defer t.cleanupTicker.Stop()
|
defer t.cleanupTicker.Stop()
|
||||||
@@ -173,8 +211,10 @@ func (t *UDPTracker) cleanup() {
|
|||||||
if conn.timeoutExceeded(t.timeout) {
|
if conn.timeoutExceeded(t.timeout) {
|
||||||
delete(t.connections, key)
|
delete(t.connections, key)
|
||||||
|
|
||||||
t.logger.Trace5("Removed UDP connection %s (timeout) [in: %d Pkts/%d B, out: %d Pkts/%d B]",
|
if t.logger.Enabled(nblog.LevelTrace) {
|
||||||
key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
t.logger.Trace5("Removed UDP connection %s (timeout) [in: %d Pkts/%d B, out: %d Pkts/%d B]",
|
||||||
|
key, conn.PacketsRx.Load(), conn.BytesRx.Load(), conn.PacketsTx.Load(), conn.BytesTx.Load())
|
||||||
|
}
|
||||||
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
t.sendEvent(nftypes.TypeEnd, conn, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -787,7 +787,9 @@ func (m *Manager) filterOutbound(packetData []byte, size int) bool {
|
|||||||
|
|
||||||
srcIP, dstIP := m.extractIPs(d)
|
srcIP, dstIP := m.extractIPs(d)
|
||||||
if !srcIP.IsValid() {
|
if !srcIP.IsValid() {
|
||||||
m.logger.Error1("Unknown network layer: %v", d.decoded[0])
|
if m.logger.Enabled(nblog.LevelError) {
|
||||||
|
m.logger.Error1("Unknown network layer: %v", d.decoded[0])
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -901,7 +903,9 @@ func (m *Manager) clampTCPMSS(packetData []byte, d *decoder) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logger.Trace2("Clamped TCP MSS from %d to %d", currentMSS, mssClampValue)
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace2("Clamped TCP MSS from %d to %d", currentMSS, mssClampValue)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1044,11 +1048,13 @@ func (m *Manager) filterInbound(packetData []byte, size int) bool {
|
|||||||
|
|
||||||
// TODO: pass fragments of routed packets to forwarder
|
// TODO: pass fragments of routed packets to forwarder
|
||||||
if fragment {
|
if fragment {
|
||||||
if d.decoded[0] == layers.LayerTypeIPv4 {
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
|
if d.decoded[0] == layers.LayerTypeIPv4 {
|
||||||
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
|
m.logger.Trace4("packet is a fragment: src=%v dst=%v id=%v flags=%v",
|
||||||
} else {
|
srcIP, dstIP, d.ip4.Id, d.ip4.Flags)
|
||||||
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
|
} else {
|
||||||
|
m.logger.Trace2("packet is an IPv6 fragment: src=%v dst=%v", srcIP, dstIP)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1091,8 +1097,10 @@ func (m *Manager) handleLocalTraffic(d *decoder, srcIP, dstIP netip.Addr, packet
|
|||||||
pnum := getProtocolFromPacket(d)
|
pnum := getProtocolFromPacket(d)
|
||||||
srcPort, dstPort := getPortsFromPacket(d)
|
srcPort, dstPort := getPortsFromPacket(d)
|
||||||
|
|
||||||
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
m.logger.Trace6("Dropping local packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
|
ruleID, pnum, srcIP, srcPort, dstIP, dstPort)
|
||||||
|
}
|
||||||
|
|
||||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||||
FlowID: uuid.New(),
|
FlowID: uuid.New(),
|
||||||
@@ -1142,8 +1150,10 @@ func (m *Manager) handleForwardedLocalTraffic(packetData []byte) bool {
|
|||||||
func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packetData []byte, size int) bool {
|
||||||
// Drop if routing is disabled
|
// Drop if routing is disabled
|
||||||
if !m.routingEnabled.Load() {
|
if !m.routingEnabled.Load() {
|
||||||
m.logger.Trace2("Dropping routed packet (routing disabled): src=%s dst=%s",
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
srcIP, dstIP)
|
m.logger.Trace2("Dropping routed packet (routing disabled): src=%s dst=%s",
|
||||||
|
srcIP, dstIP)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,8 +1170,10 @@ func (m *Manager) handleRoutedTraffic(d *decoder, srcIP, dstIP netip.Addr, packe
|
|||||||
if !pass {
|
if !pass {
|
||||||
proto := getProtocolFromPacket(d)
|
proto := getProtocolFromPacket(d)
|
||||||
|
|
||||||
m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
ruleID, proto, srcIP, srcPort, dstIP, dstPort)
|
m.logger.Trace6("Dropping routed packet (ACL denied): rule_id=%s proto=%v src=%s:%d dst=%s:%d",
|
||||||
|
ruleID, proto, srcIP, srcPort, dstIP, dstPort)
|
||||||
|
}
|
||||||
|
|
||||||
m.flowLogger.StoreEvent(nftypes.EventFields{
|
m.flowLogger.StoreEvent(nftypes.EventFields{
|
||||||
FlowID: uuid.New(),
|
FlowID: uuid.New(),
|
||||||
@@ -1287,7 +1299,9 @@ func getPortsFromPacket(d *decoder) (srcPort, dstPort uint16) {
|
|||||||
// It returns true, true if the packet is a fragment and valid.
|
// It returns true, true if the packet is a fragment and valid.
|
||||||
func (m *Manager) isValidPacket(d *decoder, packetData []byte) (bool, bool) {
|
func (m *Manager) isValidPacket(d *decoder, packetData []byte) (bool, bool) {
|
||||||
if err := d.decodePacket(packetData); err != nil {
|
if err := d.decodePacket(packetData); err != nil {
|
||||||
m.logger.Trace1("couldn't decode packet, err: %s", err)
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace1("couldn't decode packet, err: %s", err)
|
||||||
|
}
|
||||||
return false, false
|
return false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"gvisor.dev/gvisor/pkg/tcpip/header"
|
"gvisor.dev/gvisor/pkg/tcpip/header"
|
||||||
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
"gvisor.dev/gvisor/pkg/tcpip/stack"
|
||||||
|
|
||||||
|
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -97,8 +98,10 @@ func (f *Forwarder) forwardICMPPacket(id stack.TransportEndpointID, payload []by
|
|||||||
return nil, fmt.Errorf("write ICMP packet: %w", err)
|
return nil, fmt.Errorf("write ICMP packet: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
f.logger.Trace3("forwarder: Forwarded ICMP packet %v type %v code %v",
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
epID(id), icmpType, icmpCode)
|
f.logger.Trace3("forwarder: Forwarded ICMP packet %v type %v code %v",
|
||||||
|
epID(id), icmpType, icmpCode)
|
||||||
|
}
|
||||||
|
|
||||||
return conn, nil
|
return conn, nil
|
||||||
}
|
}
|
||||||
@@ -121,12 +124,14 @@ func (f *Forwarder) handleICMPViaSocket(flowID uuid.UUID, id stack.TransportEndp
|
|||||||
txBytes := f.handleEchoResponse(conn, id, v6)
|
txBytes := f.handleEchoResponse(conn, id, v6)
|
||||||
rtt := time.Since(sendTime).Round(10 * time.Microsecond)
|
rtt := time.Since(sendTime).Round(10 * time.Microsecond)
|
||||||
|
|
||||||
proto := "ICMP"
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
if v6 {
|
proto := "ICMP"
|
||||||
proto = "ICMPv6"
|
if v6 {
|
||||||
|
proto = "ICMPv6"
|
||||||
|
}
|
||||||
|
f.logger.Trace5("forwarder: Forwarded %s echo reply %v type %v code %v (rtt=%v, raw socket)",
|
||||||
|
proto, epID(id), icmpType, icmpCode, rtt)
|
||||||
}
|
}
|
||||||
f.logger.Trace5("forwarder: Forwarded %s echo reply %v type %v code %v (rtt=%v, raw socket)",
|
|
||||||
proto, epID(id), icmpType, icmpCode, rtt)
|
|
||||||
|
|
||||||
f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes))
|
f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes))
|
||||||
}
|
}
|
||||||
@@ -224,13 +229,17 @@ func (f *Forwarder) handleICMPViaPing(flowID uuid.UUID, id stack.TransportEndpoi
|
|||||||
}
|
}
|
||||||
rtt := time.Since(pingStart).Round(10 * time.Microsecond)
|
rtt := time.Since(pingStart).Round(10 * time.Microsecond)
|
||||||
|
|
||||||
f.logger.Trace3("forwarder: Forwarded ICMP echo request %v type %v code %v",
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
epID(id), icmpType, icmpCode)
|
f.logger.Trace3("forwarder: Forwarded ICMP echo request %v type %v code %v",
|
||||||
|
epID(id), icmpType, icmpCode)
|
||||||
|
}
|
||||||
|
|
||||||
txBytes := f.synthesizeEchoReply(id, icmpData)
|
txBytes := f.synthesizeEchoReply(id, icmpData)
|
||||||
|
|
||||||
f.logger.Trace4("forwarder: Forwarded ICMP echo reply %v type %v code %v (rtt=%v, ping binary)",
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
epID(id), icmpType, icmpCode, rtt)
|
f.logger.Trace4("forwarder: Forwarded ICMP echo reply %v type %v code %v (rtt=%v, ping binary)",
|
||||||
|
epID(id), icmpType, icmpCode, rtt)
|
||||||
|
}
|
||||||
|
|
||||||
f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes))
|
f.sendICMPEvent(nftypes.TypeEnd, flowID, id, icmpType, icmpCode, uint64(rxBytes), uint64(txBytes))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
package forwarder
|
package forwarder
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"io"
|
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
"strconv"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
@@ -15,7 +12,9 @@ import (
|
|||||||
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
|
"gvisor.dev/gvisor/pkg/tcpip/transport/tcp"
|
||||||
"gvisor.dev/gvisor/pkg/waiter"
|
"gvisor.dev/gvisor/pkg/waiter"
|
||||||
|
|
||||||
|
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||||
|
"github.com/netbirdio/netbird/util/netrelay"
|
||||||
)
|
)
|
||||||
|
|
||||||
// handleTCP is called by the TCP forwarder for new connections.
|
// handleTCP is called by the TCP forwarder for new connections.
|
||||||
@@ -37,7 +36,9 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) {
|
|||||||
outConn, err := (&net.Dialer{}).DialContext(f.ctx, "tcp", dialAddr)
|
outConn, err := (&net.Dialer{}).DialContext(f.ctx, "tcp", dialAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.Complete(true)
|
r.Complete(true)
|
||||||
f.logger.Trace2("forwarder: dial error for %v: %v", epID(id), err)
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
f.logger.Trace2("forwarder: dial error for %v: %v", epID(id), err)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,64 +61,22 @@ func (f *Forwarder) handleTCP(r *tcp.ForwarderRequest) {
|
|||||||
inConn := gonet.NewTCPConn(&wq, ep)
|
inConn := gonet.NewTCPConn(&wq, ep)
|
||||||
|
|
||||||
success = true
|
success = true
|
||||||
f.logger.Trace1("forwarder: established TCP connection %v", epID(id))
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
f.logger.Trace1("forwarder: established TCP connection %v", epID(id))
|
||||||
|
}
|
||||||
|
|
||||||
go f.proxyTCP(id, inConn, outConn, ep, flowID)
|
go f.proxyTCP(id, inConn, outConn, ep, flowID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn, outConn net.Conn, ep tcpip.Endpoint, flowID uuid.UUID) {
|
func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn, outConn net.Conn, ep tcpip.Endpoint, flowID uuid.UUID) {
|
||||||
|
// netrelay.Relay copies bidirectionally with proper half-close propagation
|
||||||
|
// and fully closes both conns before returning.
|
||||||
|
bytesFromInToOut, bytesFromOutToIn := netrelay.Relay(f.ctx, inConn, outConn, netrelay.Options{
|
||||||
|
Logger: f.logger,
|
||||||
|
})
|
||||||
|
|
||||||
ctx, cancel := context.WithCancel(f.ctx)
|
// Close the netstack endpoint after both conns are drained.
|
||||||
defer cancel()
|
ep.Close()
|
||||||
|
|
||||||
go func() {
|
|
||||||
<-ctx.Done()
|
|
||||||
// Close connections and endpoint.
|
|
||||||
if err := inConn.Close(); err != nil && !isClosedError(err) {
|
|
||||||
f.logger.Debug1("forwarder: inConn close error: %v", err)
|
|
||||||
}
|
|
||||||
if err := outConn.Close(); err != nil && !isClosedError(err) {
|
|
||||||
f.logger.Debug1("forwarder: outConn close error: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
ep.Close()
|
|
||||||
}()
|
|
||||||
|
|
||||||
var wg sync.WaitGroup
|
|
||||||
wg.Add(2)
|
|
||||||
|
|
||||||
var (
|
|
||||||
bytesFromInToOut int64 // bytes from client to server (tx for client)
|
|
||||||
bytesFromOutToIn int64 // bytes from server to client (rx for client)
|
|
||||||
errInToOut error
|
|
||||||
errOutToIn error
|
|
||||||
)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
bytesFromInToOut, errInToOut = io.Copy(outConn, inConn)
|
|
||||||
cancel()
|
|
||||||
wg.Done()
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
|
|
||||||
bytesFromOutToIn, errOutToIn = io.Copy(inConn, outConn)
|
|
||||||
cancel()
|
|
||||||
wg.Done()
|
|
||||||
}()
|
|
||||||
|
|
||||||
wg.Wait()
|
|
||||||
|
|
||||||
if errInToOut != nil {
|
|
||||||
if !isClosedError(errInToOut) {
|
|
||||||
f.logger.Error2("proxyTCP: copy error (in → out) for %s: %v", epID(id), errInToOut)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if errOutToIn != nil {
|
|
||||||
if !isClosedError(errOutToIn) {
|
|
||||||
f.logger.Error2("proxyTCP: copy error (out → in) for %s: %v", epID(id), errOutToIn)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var rxPackets, txPackets uint64
|
var rxPackets, txPackets uint64
|
||||||
if tcpStats, ok := ep.Stats().(*tcp.Stats); ok {
|
if tcpStats, ok := ep.Stats().(*tcp.Stats); ok {
|
||||||
@@ -126,7 +85,9 @@ func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn
|
|||||||
txPackets = tcpStats.SegmentsReceived.Value()
|
txPackets = tcpStats.SegmentsReceived.Value()
|
||||||
}
|
}
|
||||||
|
|
||||||
f.logger.Trace5("forwarder: Removed TCP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, bytesFromOutToIn, txPackets, bytesFromInToOut)
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
f.logger.Trace5("forwarder: Removed TCP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, bytesFromOutToIn, txPackets, bytesFromInToOut)
|
||||||
|
}
|
||||||
|
|
||||||
f.sendTCPEvent(nftypes.TypeEnd, flowID, id, uint64(bytesFromOutToIn), uint64(bytesFromInToOut), rxPackets, txPackets)
|
f.sendTCPEvent(nftypes.TypeEnd, flowID, id, uint64(bytesFromOutToIn), uint64(bytesFromInToOut), rxPackets, txPackets)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,7 +125,9 @@ func (f *udpForwarder) cleanup() {
|
|||||||
delete(f.conns, idle.id)
|
delete(f.conns, idle.id)
|
||||||
f.Unlock()
|
f.Unlock()
|
||||||
|
|
||||||
f.logger.Trace1("forwarder: cleaned up idle UDP connection %v", epID(idle.id))
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
f.logger.Trace1("forwarder: cleaned up idle UDP connection %v", epID(idle.id))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,7 +146,9 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool {
|
|||||||
_, exists := f.udpForwarder.conns[id]
|
_, exists := f.udpForwarder.conns[id]
|
||||||
f.udpForwarder.RUnlock()
|
f.udpForwarder.RUnlock()
|
||||||
if exists {
|
if exists {
|
||||||
f.logger.Trace1("forwarder: existing UDP connection for %v", epID(id))
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
f.logger.Trace1("forwarder: existing UDP connection for %v", epID(id))
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -206,7 +210,9 @@ func (f *Forwarder) handleUDP(r *udp.ForwarderRequest) bool {
|
|||||||
f.udpForwarder.Unlock()
|
f.udpForwarder.Unlock()
|
||||||
|
|
||||||
success = true
|
success = true
|
||||||
f.logger.Trace1("forwarder: established UDP connection %v", epID(id))
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
f.logger.Trace1("forwarder: established UDP connection %v", epID(id))
|
||||||
|
}
|
||||||
|
|
||||||
go f.proxyUDP(connCtx, pConn, id, ep)
|
go f.proxyUDP(connCtx, pConn, id, ep)
|
||||||
return true
|
return true
|
||||||
@@ -265,7 +271,9 @@ func (f *Forwarder) proxyUDP(ctx context.Context, pConn *udpPacketConn, id stack
|
|||||||
txPackets = udpStats.PacketsReceived.Value()
|
txPackets = udpStats.PacketsReceived.Value()
|
||||||
}
|
}
|
||||||
|
|
||||||
f.logger.Trace5("forwarder: Removed UDP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, rxBytes, txPackets, txBytes)
|
if f.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
f.logger.Trace5("forwarder: Removed UDP connection %s [in: %d Pkts/%d B, out: %d Pkts/%d B]", epID(id), rxPackets, rxBytes, txPackets, txBytes)
|
||||||
|
}
|
||||||
|
|
||||||
f.udpForwarder.Lock()
|
f.udpForwarder.Lock()
|
||||||
delete(f.udpForwarder.conns, id)
|
delete(f.udpForwarder.conns, id)
|
||||||
|
|||||||
@@ -53,16 +53,17 @@ var levelStrings = map[Level]string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type logMessage struct {
|
type logMessage struct {
|
||||||
level Level
|
level Level
|
||||||
format string
|
argCount uint8
|
||||||
arg1 any
|
format string
|
||||||
arg2 any
|
arg1 any
|
||||||
arg3 any
|
arg2 any
|
||||||
arg4 any
|
arg3 any
|
||||||
arg5 any
|
arg4 any
|
||||||
arg6 any
|
arg5 any
|
||||||
arg7 any
|
arg6 any
|
||||||
arg8 any
|
arg7 any
|
||||||
|
arg8 any
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logger is a high-performance, non-blocking logger
|
// Logger is a high-performance, non-blocking logger
|
||||||
@@ -107,6 +108,13 @@ func (l *Logger) SetLevel(level Level) {
|
|||||||
log.Debugf("Set uspfilter logger loglevel to %v", levelStrings[level])
|
log.Debugf("Set uspfilter logger loglevel to %v", levelStrings[level])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enabled reports whether the given level is currently logged. Callers on the
|
||||||
|
// hot path should guard log sites with this to avoid boxing arguments into
|
||||||
|
// any when the level is off.
|
||||||
|
func (l *Logger) Enabled(level Level) bool {
|
||||||
|
return l.level.Load() >= uint32(level)
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Logger) Error(format string) {
|
func (l *Logger) Error(format string) {
|
||||||
if l.level.Load() >= uint32(LevelError) {
|
if l.level.Load() >= uint32(LevelError) {
|
||||||
select {
|
select {
|
||||||
@@ -155,7 +163,7 @@ func (l *Logger) Trace(format string) {
|
|||||||
func (l *Logger) Error1(format string, arg1 any) {
|
func (l *Logger) Error1(format string, arg1 any) {
|
||||||
if l.level.Load() >= uint32(LevelError) {
|
if l.level.Load() >= uint32(LevelError) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelError, format: format, arg1: arg1}:
|
case l.msgChannel <- logMessage{level: LevelError, argCount: 1, format: format, arg1: arg1}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +172,16 @@ func (l *Logger) Error1(format string, arg1 any) {
|
|||||||
func (l *Logger) Error2(format string, arg1, arg2 any) {
|
func (l *Logger) Error2(format string, arg1, arg2 any) {
|
||||||
if l.level.Load() >= uint32(LevelError) {
|
if l.level.Load() >= uint32(LevelError) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelError, format: format, arg1: arg1, arg2: arg2}:
|
case l.msgChannel <- logMessage{level: LevelError, argCount: 2, format: format, arg1: arg1, arg2: arg2}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *Logger) Warn2(format string, arg1, arg2 any) {
|
||||||
|
if l.level.Load() >= uint32(LevelWarn) {
|
||||||
|
select {
|
||||||
|
case l.msgChannel <- logMessage{level: LevelWarn, argCount: 2, format: format, arg1: arg1, arg2: arg2}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,7 +190,7 @@ func (l *Logger) Error2(format string, arg1, arg2 any) {
|
|||||||
func (l *Logger) Warn3(format string, arg1, arg2, arg3 any) {
|
func (l *Logger) Warn3(format string, arg1, arg2, arg3 any) {
|
||||||
if l.level.Load() >= uint32(LevelWarn) {
|
if l.level.Load() >= uint32(LevelWarn) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelWarn, format: format, arg1: arg1, arg2: arg2, arg3: arg3}:
|
case l.msgChannel <- logMessage{level: LevelWarn, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -182,7 +199,7 @@ func (l *Logger) Warn3(format string, arg1, arg2, arg3 any) {
|
|||||||
func (l *Logger) Warn4(format string, arg1, arg2, arg3, arg4 any) {
|
func (l *Logger) Warn4(format string, arg1, arg2, arg3, arg4 any) {
|
||||||
if l.level.Load() >= uint32(LevelWarn) {
|
if l.level.Load() >= uint32(LevelWarn) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelWarn, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}:
|
case l.msgChannel <- logMessage{level: LevelWarn, argCount: 4, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +208,7 @@ func (l *Logger) Warn4(format string, arg1, arg2, arg3, arg4 any) {
|
|||||||
func (l *Logger) Debug1(format string, arg1 any) {
|
func (l *Logger) Debug1(format string, arg1 any) {
|
||||||
if l.level.Load() >= uint32(LevelDebug) {
|
if l.level.Load() >= uint32(LevelDebug) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1}:
|
case l.msgChannel <- logMessage{level: LevelDebug, argCount: 1, format: format, arg1: arg1}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -200,7 +217,7 @@ func (l *Logger) Debug1(format string, arg1 any) {
|
|||||||
func (l *Logger) Debug2(format string, arg1, arg2 any) {
|
func (l *Logger) Debug2(format string, arg1, arg2 any) {
|
||||||
if l.level.Load() >= uint32(LevelDebug) {
|
if l.level.Load() >= uint32(LevelDebug) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1, arg2: arg2}:
|
case l.msgChannel <- logMessage{level: LevelDebug, argCount: 2, format: format, arg1: arg1, arg2: arg2}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,16 +226,59 @@ func (l *Logger) Debug2(format string, arg1, arg2 any) {
|
|||||||
func (l *Logger) Debug3(format string, arg1, arg2, arg3 any) {
|
func (l *Logger) Debug3(format string, arg1, arg2, arg3 any) {
|
||||||
if l.level.Load() >= uint32(LevelDebug) {
|
if l.level.Load() >= uint32(LevelDebug) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelDebug, format: format, arg1: arg1, arg2: arg2, arg3: arg3}:
|
case l.msgChannel <- logMessage{level: LevelDebug, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Debugf is the variadic shape. Dispatches to Debug/Debug1/Debug2/Debug3
|
||||||
|
// to avoid allocating an args slice on the fast path when the arg count is
|
||||||
|
// known (0-3). Args beyond 3 land on the general variadic path; callers on
|
||||||
|
// the hot path should prefer DebugN for known counts.
|
||||||
|
func (l *Logger) Debugf(format string, args ...any) {
|
||||||
|
if l.level.Load() < uint32(LevelDebug) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch len(args) {
|
||||||
|
case 0:
|
||||||
|
l.Debug(format)
|
||||||
|
case 1:
|
||||||
|
l.Debug1(format, args[0])
|
||||||
|
case 2:
|
||||||
|
l.Debug2(format, args[0], args[1])
|
||||||
|
case 3:
|
||||||
|
l.Debug3(format, args[0], args[1], args[2])
|
||||||
|
default:
|
||||||
|
l.sendVariadic(LevelDebug, format, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// sendVariadic packs a slice of arguments into a logMessage and non-blocking
|
||||||
|
// enqueues it. Used for arg counts beyond the fixed-arity fast paths. Args
|
||||||
|
// beyond the 8-arg slot limit are dropped so callers don't produce silently
|
||||||
|
// empty log lines via uint8 wraparound in argCount.
|
||||||
|
func (l *Logger) sendVariadic(level Level, format string, args []any) {
|
||||||
|
const maxArgs = 8
|
||||||
|
n := len(args)
|
||||||
|
if n > maxArgs {
|
||||||
|
n = maxArgs
|
||||||
|
}
|
||||||
|
msg := logMessage{level: level, argCount: uint8(n), format: format}
|
||||||
|
slots := [maxArgs]*any{&msg.arg1, &msg.arg2, &msg.arg3, &msg.arg4, &msg.arg5, &msg.arg6, &msg.arg7, &msg.arg8}
|
||||||
|
for i := 0; i < n; i++ {
|
||||||
|
*slots[i] = args[i]
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case l.msgChannel <- msg:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (l *Logger) Trace1(format string, arg1 any) {
|
func (l *Logger) Trace1(format string, arg1 any) {
|
||||||
if l.level.Load() >= uint32(LevelTrace) {
|
if l.level.Load() >= uint32(LevelTrace) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1}:
|
case l.msgChannel <- logMessage{level: LevelTrace, argCount: 1, format: format, arg1: arg1}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,7 +287,7 @@ func (l *Logger) Trace1(format string, arg1 any) {
|
|||||||
func (l *Logger) Trace2(format string, arg1, arg2 any) {
|
func (l *Logger) Trace2(format string, arg1, arg2 any) {
|
||||||
if l.level.Load() >= uint32(LevelTrace) {
|
if l.level.Load() >= uint32(LevelTrace) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2}:
|
case l.msgChannel <- logMessage{level: LevelTrace, argCount: 2, format: format, arg1: arg1, arg2: arg2}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +296,7 @@ func (l *Logger) Trace2(format string, arg1, arg2 any) {
|
|||||||
func (l *Logger) Trace3(format string, arg1, arg2, arg3 any) {
|
func (l *Logger) Trace3(format string, arg1, arg2, arg3 any) {
|
||||||
if l.level.Load() >= uint32(LevelTrace) {
|
if l.level.Load() >= uint32(LevelTrace) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3}:
|
case l.msgChannel <- logMessage{level: LevelTrace, argCount: 3, format: format, arg1: arg1, arg2: arg2, arg3: arg3}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -245,7 +305,7 @@ func (l *Logger) Trace3(format string, arg1, arg2, arg3 any) {
|
|||||||
func (l *Logger) Trace4(format string, arg1, arg2, arg3, arg4 any) {
|
func (l *Logger) Trace4(format string, arg1, arg2, arg3, arg4 any) {
|
||||||
if l.level.Load() >= uint32(LevelTrace) {
|
if l.level.Load() >= uint32(LevelTrace) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}:
|
case l.msgChannel <- logMessage{level: LevelTrace, argCount: 4, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -254,7 +314,7 @@ func (l *Logger) Trace4(format string, arg1, arg2, arg3, arg4 any) {
|
|||||||
func (l *Logger) Trace5(format string, arg1, arg2, arg3, arg4, arg5 any) {
|
func (l *Logger) Trace5(format string, arg1, arg2, arg3, arg4, arg5 any) {
|
||||||
if l.level.Load() >= uint32(LevelTrace) {
|
if l.level.Load() >= uint32(LevelTrace) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5}:
|
case l.msgChannel <- logMessage{level: LevelTrace, argCount: 5, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,7 +323,7 @@ func (l *Logger) Trace5(format string, arg1, arg2, arg3, arg4, arg5 any) {
|
|||||||
func (l *Logger) Trace6(format string, arg1, arg2, arg3, arg4, arg5, arg6 any) {
|
func (l *Logger) Trace6(format string, arg1, arg2, arg3, arg4, arg5, arg6 any) {
|
||||||
if l.level.Load() >= uint32(LevelTrace) {
|
if l.level.Load() >= uint32(LevelTrace) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6}:
|
case l.msgChannel <- logMessage{level: LevelTrace, argCount: 6, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -273,7 +333,7 @@ func (l *Logger) Trace6(format string, arg1, arg2, arg3, arg4, arg5, arg6 any) {
|
|||||||
func (l *Logger) Trace8(format string, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 any) {
|
func (l *Logger) Trace8(format string, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8 any) {
|
||||||
if l.level.Load() >= uint32(LevelTrace) {
|
if l.level.Load() >= uint32(LevelTrace) {
|
||||||
select {
|
select {
|
||||||
case l.msgChannel <- logMessage{level: LevelTrace, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6, arg7: arg7, arg8: arg8}:
|
case l.msgChannel <- logMessage{level: LevelTrace, argCount: 8, format: format, arg1: arg1, arg2: arg2, arg3: arg3, arg4: arg4, arg5: arg5, arg6: arg6, arg7: arg7, arg8: arg8}:
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -286,35 +346,8 @@ func (l *Logger) formatMessage(buf *[]byte, msg logMessage) {
|
|||||||
*buf = append(*buf, levelStrings[msg.level]...)
|
*buf = append(*buf, levelStrings[msg.level]...)
|
||||||
*buf = append(*buf, ' ')
|
*buf = append(*buf, ' ')
|
||||||
|
|
||||||
// Count non-nil arguments for switch
|
|
||||||
argCount := 0
|
|
||||||
if msg.arg1 != nil {
|
|
||||||
argCount++
|
|
||||||
if msg.arg2 != nil {
|
|
||||||
argCount++
|
|
||||||
if msg.arg3 != nil {
|
|
||||||
argCount++
|
|
||||||
if msg.arg4 != nil {
|
|
||||||
argCount++
|
|
||||||
if msg.arg5 != nil {
|
|
||||||
argCount++
|
|
||||||
if msg.arg6 != nil {
|
|
||||||
argCount++
|
|
||||||
if msg.arg7 != nil {
|
|
||||||
argCount++
|
|
||||||
if msg.arg8 != nil {
|
|
||||||
argCount++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var formatted string
|
var formatted string
|
||||||
switch argCount {
|
switch msg.argCount {
|
||||||
case 0:
|
case 0:
|
||||||
formatted = msg.format
|
formatted = msg.format
|
||||||
case 1:
|
case 1:
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
"github.com/google/gopacket/layers"
|
"github.com/google/gopacket/layers"
|
||||||
|
|
||||||
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
firewall "github.com/netbirdio/netbird/client/firewall/manager"
|
||||||
|
nblog "github.com/netbirdio/netbird/client/firewall/uspfilter/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -262,11 +263,15 @@ func (m *Manager) translateOutboundDNAT(packetData []byte, d *decoder) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := m.rewritePacketIP(packetData, d, translatedIP, false); err != nil {
|
if err := m.rewritePacketIP(packetData, d, translatedIP, false); err != nil {
|
||||||
m.logger.Error1("failed to rewrite packet destination: %v", err)
|
if m.logger.Enabled(nblog.LevelError) {
|
||||||
|
m.logger.Error1("failed to rewrite packet destination: %v", err)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logger.Trace2("DNAT: %s -> %s", dstIP, translatedIP)
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace2("DNAT: %s -> %s", dstIP, translatedIP)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,11 +288,15 @@ func (m *Manager) translateInboundReverse(packetData []byte, d *decoder) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := m.rewritePacketIP(packetData, d, originalIP, true); err != nil {
|
if err := m.rewritePacketIP(packetData, d, originalIP, true); err != nil {
|
||||||
m.logger.Error1("failed to rewrite packet source: %v", err)
|
if m.logger.Enabled(nblog.LevelError) {
|
||||||
|
m.logger.Error1("failed to rewrite packet source: %v", err)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
m.logger.Trace2("Reverse DNAT: %s -> %s", srcIP, originalIP)
|
if m.logger.Enabled(nblog.LevelTrace) {
|
||||||
|
m.logger.Trace2("Reverse DNAT: %s -> %s", srcIP, originalIP)
|
||||||
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -612,7 +621,9 @@ func (m *Manager) applyPortRule(packetData []byte, d *decoder, srcIP, dstIP neti
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err := rewriteFn(packetData, d, rule.targetPort, destinationPortOffset); err != nil {
|
if err := rewriteFn(packetData, d, rule.targetPort, destinationPortOffset); err != nil {
|
||||||
m.logger.Error1("failed to rewrite port: %v", err)
|
if m.logger.Enabled(nblog.LevelError) {
|
||||||
|
m.logger.Error1("failed to rewrite port: %v", err)
|
||||||
|
}
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
d.dnatOrigPort = rule.origPort
|
d.dnatOrigPort = rule.origPort
|
||||||
|
|||||||
@@ -280,6 +280,43 @@ CreateShortCut "$SMPROGRAMS\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
|||||||
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
CreateShortCut "$DESKTOP\${APP_NAME}.lnk" "$INSTDIR\${UI_APP_EXE}"
|
||||||
SectionEnd
|
SectionEnd
|
||||||
|
|
||||||
|
# Install the Microsoft Edge WebView2 runtime if it isn't already present.
|
||||||
|
# Macro adapted from Wails3's NSIS template (wails_tools.nsh): a registry
|
||||||
|
# probe followed by a silent install of the embedded evergreen bootstrapper.
|
||||||
|
# The MicrosoftEdgeWebview2Setup.exe payload is staged next to this script
|
||||||
|
# by the sign-pipelines build step (`wails3 generate webview2bootstrapper`).
|
||||||
|
!macro nb.webview2runtime
|
||||||
|
SetRegView 64
|
||||||
|
# Per-machine install marker — populated when the runtime ships with
|
||||||
|
# Edge or has been installed by an admin previously.
|
||||||
|
ReadRegStr $0 HKLM "SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||||
|
${If} $0 != ""
|
||||||
|
Goto webview2_ok
|
||||||
|
${EndIf}
|
||||||
|
# Per-user fallback for HKCU installs.
|
||||||
|
ReadRegStr $0 HKCU "Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" "pv"
|
||||||
|
${If} $0 != ""
|
||||||
|
Goto webview2_ok
|
||||||
|
${EndIf}
|
||||||
|
|
||||||
|
SetDetailsPrint both
|
||||||
|
DetailPrint "Installing: WebView2 Runtime"
|
||||||
|
SetDetailsPrint listonly
|
||||||
|
|
||||||
|
InitPluginsDir
|
||||||
|
CreateDirectory "$pluginsdir\webview2bootstrapper"
|
||||||
|
SetOutPath "$pluginsdir\webview2bootstrapper"
|
||||||
|
File "MicrosoftEdgeWebview2Setup.exe"
|
||||||
|
ExecWait '"$pluginsdir\webview2bootstrapper\MicrosoftEdgeWebview2Setup.exe" /silent /install'
|
||||||
|
|
||||||
|
SetDetailsPrint both
|
||||||
|
webview2_ok:
|
||||||
|
!macroend
|
||||||
|
|
||||||
|
Section -WebView2
|
||||||
|
!insertmacro nb.webview2runtime
|
||||||
|
SectionEnd
|
||||||
|
|
||||||
Section -Post
|
Section -Post
|
||||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service install'
|
||||||
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service start'
|
||||||
@@ -326,9 +363,9 @@ DetailPrint "Deleting application files..."
|
|||||||
Delete "$INSTDIR\${UI_APP_EXE}"
|
Delete "$INSTDIR\${UI_APP_EXE}"
|
||||||
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
Delete "$INSTDIR\${MAIN_APP_EXE}"
|
||||||
Delete "$INSTDIR\wintun.dll"
|
Delete "$INSTDIR\wintun.dll"
|
||||||
!if ${ARCH} == "amd64"
|
# Legacy: pre-Wails installs shipped opengl32.dll (Mesa3D for Fyne); remove
|
||||||
|
# any leftover copy on uninstall so old upgrades don't leave it behind.
|
||||||
Delete "$INSTDIR\opengl32.dll"
|
Delete "$INSTDIR\opengl32.dll"
|
||||||
!endif
|
|
||||||
DetailPrint "Removing application directory..."
|
DetailPrint "Removing application directory..."
|
||||||
RmDir /r "$INSTDIR"
|
RmDir /r "$INSTDIR"
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,27 @@ import (
|
|||||||
|
|
||||||
var currentMTU uint16 = iface.DefaultMTU
|
var currentMTU uint16 = iface.DefaultMTU
|
||||||
|
|
||||||
|
// nonRetryableEDECodes lists EDE info codes (RFC 8914) for which a SERVFAIL
|
||||||
|
// from one upstream means another upstream would return the same answer:
|
||||||
|
// DNSSEC validation outcomes and policy-based blocks. Transient errors
|
||||||
|
// (network, cached, not ready) are not included.
|
||||||
|
var nonRetryableEDECodes = map[uint16]struct{}{
|
||||||
|
dns.ExtendedErrorCodeUnsupportedDNSKEYAlgorithm: {},
|
||||||
|
dns.ExtendedErrorCodeUnsupportedDSDigestType: {},
|
||||||
|
dns.ExtendedErrorCodeDNSSECIndeterminate: {},
|
||||||
|
dns.ExtendedErrorCodeDNSBogus: {},
|
||||||
|
dns.ExtendedErrorCodeSignatureExpired: {},
|
||||||
|
dns.ExtendedErrorCodeSignatureNotYetValid: {},
|
||||||
|
dns.ExtendedErrorCodeDNSKEYMissing: {},
|
||||||
|
dns.ExtendedErrorCodeRRSIGsMissing: {},
|
||||||
|
dns.ExtendedErrorCodeNoZoneKeyBitSet: {},
|
||||||
|
dns.ExtendedErrorCodeNSECMissing: {},
|
||||||
|
dns.ExtendedErrorCodeBlocked: {},
|
||||||
|
dns.ExtendedErrorCodeCensored: {},
|
||||||
|
dns.ExtendedErrorCodeFiltered: {},
|
||||||
|
dns.ExtendedErrorCodeProhibited: {},
|
||||||
|
}
|
||||||
|
|
||||||
// privateClientIface is the subset of the WireGuard interface needed by GetClientPrivate.
|
// privateClientIface is the subset of the WireGuard interface needed by GetClientPrivate.
|
||||||
type privateClientIface interface {
|
type privateClientIface interface {
|
||||||
Name() string
|
Name() string
|
||||||
@@ -250,6 +271,18 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, w dns.Re
|
|||||||
var t time.Duration
|
var t time.Duration
|
||||||
var err error
|
var err error
|
||||||
|
|
||||||
|
// Advertise EDNS0 so the upstream may include Extended DNS Errors
|
||||||
|
// (RFC 8914) in failure responses; we use those to short-circuit
|
||||||
|
// failover for definitive answers like DNSSEC validation failures.
|
||||||
|
// Operate on a copy so the inbound request is unchanged: a client that
|
||||||
|
// did not advertise EDNS0 must not see an OPT in the response.
|
||||||
|
hadEdns := r.IsEdns0() != nil
|
||||||
|
reqUp := r
|
||||||
|
if !hadEdns {
|
||||||
|
reqUp = r.Copy()
|
||||||
|
reqUp.SetEdns0(upstreamUDPSize(), false)
|
||||||
|
}
|
||||||
|
|
||||||
var startTime time.Time
|
var startTime time.Time
|
||||||
var upstreamProto *upstreamProtocolResult
|
var upstreamProto *upstreamProtocolResult
|
||||||
func() {
|
func() {
|
||||||
@@ -257,7 +290,7 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, w dns.Re
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
ctx, upstreamProto = contextWithupstreamProtocolResult(ctx)
|
ctx, upstreamProto = contextWithupstreamProtocolResult(ctx)
|
||||||
startTime = time.Now()
|
startTime = time.Now()
|
||||||
rm, t, err = u.upstreamClient.exchange(ctx, upstream.String(), r)
|
rm, t, err = u.upstreamClient.exchange(ctx, upstream.String(), reqUp)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -269,13 +302,49 @@ func (u *upstreamResolverBase) queryUpstream(parentCtx context.Context, w dns.Re
|
|||||||
}
|
}
|
||||||
|
|
||||||
if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused {
|
if rm.Rcode == dns.RcodeServerFailure || rm.Rcode == dns.RcodeRefused {
|
||||||
|
if code, ok := nonRetryableEDE(rm); ok {
|
||||||
|
resutil.SetMeta(w, "ede", edeName(code))
|
||||||
|
if !hadEdns {
|
||||||
|
stripOPT(rm)
|
||||||
|
}
|
||||||
|
u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, upstreamProto, logger)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
return &upstreamFailure{upstream: upstream, reason: dns.RcodeToString[rm.Rcode]}
|
return &upstreamFailure{upstream: upstream, reason: dns.RcodeToString[rm.Rcode]}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !hadEdns {
|
||||||
|
stripOPT(rm)
|
||||||
|
}
|
||||||
u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, upstreamProto, logger)
|
u.writeSuccessResponse(w, rm, upstream, r.Question[0].Name, t, upstreamProto, logger)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// upstreamUDPSize returns the EDNS0 UDP buffer size we advertise to upstreams,
|
||||||
|
// derived from the tunnel MTU and bounded against underflow.
|
||||||
|
func upstreamUDPSize() uint16 {
|
||||||
|
if currentMTU > ipUDPHeaderSize {
|
||||||
|
return currentMTU - ipUDPHeaderSize
|
||||||
|
}
|
||||||
|
return dns.MinMsgSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// stripOPT removes any OPT pseudo-RRs from the response's Extra section so
|
||||||
|
// the response complies with RFC 6891 when the client did not advertise EDNS0.
|
||||||
|
func stripOPT(rm *dns.Msg) {
|
||||||
|
if len(rm.Extra) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := rm.Extra[:0]
|
||||||
|
for _, rr := range rm.Extra {
|
||||||
|
if _, ok := rr.(*dns.OPT); ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, rr)
|
||||||
|
}
|
||||||
|
rm.Extra = out
|
||||||
|
}
|
||||||
|
|
||||||
func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure {
|
func (u *upstreamResolverBase) handleUpstreamError(err error, upstream netip.AddrPort, startTime time.Time) *upstreamFailure {
|
||||||
if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) {
|
if !errors.Is(err, context.DeadlineExceeded) && !isTimeout(err) {
|
||||||
return &upstreamFailure{upstream: upstream, reason: err.Error()}
|
return &upstreamFailure{upstream: upstream, reason: err.Error()}
|
||||||
@@ -337,6 +406,34 @@ func formatFailures(failures []upstreamFailure) string {
|
|||||||
return strings.Join(parts, ", ")
|
return strings.Join(parts, ", ")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nonRetryableEDE returns the first non-retryable EDE code carried in the
|
||||||
|
// response, if any.
|
||||||
|
func nonRetryableEDE(rm *dns.Msg) (uint16, bool) {
|
||||||
|
opt := rm.IsEdns0()
|
||||||
|
if opt == nil {
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
for _, o := range opt.Option {
|
||||||
|
ede, ok := o.(*dns.EDNS0_EDE)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := nonRetryableEDECodes[ede.InfoCode]; ok {
|
||||||
|
return ede.InfoCode, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// edeName returns a human-readable name for an EDE code, falling back to
|
||||||
|
// the numeric code when unknown.
|
||||||
|
func edeName(code uint16) string {
|
||||||
|
if name, ok := dns.ExtendedErrorCodeToString[code]; ok {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("EDE %d", code)
|
||||||
|
}
|
||||||
|
|
||||||
// ProbeAvailability tests all upstream servers simultaneously and
|
// ProbeAvailability tests all upstream servers simultaneously and
|
||||||
// disables the resolver if none work
|
// disables the resolver if none work
|
||||||
func (u *upstreamResolverBase) ProbeAvailability(ctx context.Context) {
|
func (u *upstreamResolverBase) ProbeAvailability(ctx context.Context) {
|
||||||
|
|||||||
@@ -770,3 +770,132 @@ func TestExchangeWithFallback_TCPTruncatesToClientSize(t *testing.T) {
|
|||||||
assert.Less(t, len(rm2.Answer), 20, "small EDNS0 client should get fewer records")
|
assert.Less(t, len(rm2.Answer), 20, "small EDNS0 client should get fewer records")
|
||||||
assert.True(t, rm2.Truncated, "response should be truncated for small buffer client")
|
assert.True(t, rm2.Truncated, "response should be truncated for small buffer client")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func msgWithEDE(rcode int, codes ...uint16) *dns.Msg {
|
||||||
|
m := new(dns.Msg)
|
||||||
|
m.Response = true
|
||||||
|
m.Rcode = rcode
|
||||||
|
if len(codes) == 0 {
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
|
||||||
|
opt.SetUDPSize(dns.MinMsgSize)
|
||||||
|
for _, c := range codes {
|
||||||
|
opt.Option = append(opt.Option, &dns.EDNS0_EDE{InfoCode: c})
|
||||||
|
}
|
||||||
|
m.Extra = append(m.Extra, opt)
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNonRetryableEDE(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
msg *dns.Msg
|
||||||
|
wantOK bool
|
||||||
|
wantCode uint16
|
||||||
|
}{
|
||||||
|
{name: "no edns0", msg: msgWithEDE(dns.RcodeServerFailure)},
|
||||||
|
{
|
||||||
|
name: "opt without ede",
|
||||||
|
msg: func() *dns.Msg {
|
||||||
|
m := msgWithEDE(dns.RcodeServerFailure)
|
||||||
|
opt := &dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}}
|
||||||
|
opt.Option = append(opt.Option, &dns.EDNS0_NSID{Code: dns.EDNS0NSID})
|
||||||
|
m.Extra = []dns.RR{opt}
|
||||||
|
return m
|
||||||
|
}(),
|
||||||
|
},
|
||||||
|
{name: "ede dnsbogus", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeDNSBogus), wantOK: true, wantCode: dns.ExtendedErrorCodeDNSBogus},
|
||||||
|
{name: "ede signature expired", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeSignatureExpired), wantOK: true, wantCode: dns.ExtendedErrorCodeSignatureExpired},
|
||||||
|
{name: "ede blocked", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeBlocked), wantOK: true, wantCode: dns.ExtendedErrorCodeBlocked},
|
||||||
|
{name: "ede prohibited", msg: msgWithEDE(dns.RcodeRefused, dns.ExtendedErrorCodeProhibited), wantOK: true, wantCode: dns.ExtendedErrorCodeProhibited},
|
||||||
|
{name: "ede cached error retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeCachedError)},
|
||||||
|
{name: "ede network error retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNetworkError)},
|
||||||
|
{name: "ede not ready retryable", msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNotReady)},
|
||||||
|
{
|
||||||
|
name: "first non-retryable wins",
|
||||||
|
msg: msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeNetworkError, dns.ExtendedErrorCodeDNSBogus),
|
||||||
|
wantOK: true,
|
||||||
|
wantCode: dns.ExtendedErrorCodeDNSBogus,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
code, ok := nonRetryableEDE(tc.msg)
|
||||||
|
assert.Equal(t, tc.wantOK, ok, "ok should match")
|
||||||
|
if tc.wantOK {
|
||||||
|
assert.Equal(t, tc.wantCode, code, "code should match")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEDEName(t *testing.T) {
|
||||||
|
assert.Equal(t, "DNSSEC Bogus", edeName(dns.ExtendedErrorCodeDNSBogus))
|
||||||
|
assert.Equal(t, "Signature Expired", edeName(dns.ExtendedErrorCodeSignatureExpired))
|
||||||
|
assert.Equal(t, "EDE 9999", edeName(9999), "unknown code falls back to numeric")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripOPT(t *testing.T) {
|
||||||
|
rm := &dns.Msg{
|
||||||
|
Extra: []dns.RR{
|
||||||
|
&dns.OPT{Hdr: dns.RR_Header{Name: ".", Rrtype: dns.TypeOPT}},
|
||||||
|
&dns.A{Hdr: dns.RR_Header{Name: "x.", Rrtype: dns.TypeA}, A: net.IPv4(1, 2, 3, 4)},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
stripOPT(rm)
|
||||||
|
assert.Len(t, rm.Extra, 1, "OPT should be removed, A kept")
|
||||||
|
_, isOPT := rm.Extra[0].(*dns.OPT)
|
||||||
|
assert.False(t, isOPT, "remaining record must not be OPT")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUpstreamResolver_NonRetryableEDEShortCircuits(t *testing.T) {
|
||||||
|
upstream1 := netip.MustParseAddrPort("192.0.2.1:53")
|
||||||
|
upstream2 := netip.MustParseAddrPort("192.0.2.2:53")
|
||||||
|
|
||||||
|
servfailWithEDE := msgWithEDE(dns.RcodeServerFailure, dns.ExtendedErrorCodeDNSBogus)
|
||||||
|
successResp := buildMockResponse(dns.RcodeSuccess, "192.0.2.100")
|
||||||
|
|
||||||
|
var queried []string
|
||||||
|
tracking := &trackingMockClient{
|
||||||
|
inner: &mockUpstreamResolverPerServer{
|
||||||
|
responses: map[string]mockUpstreamResponse{
|
||||||
|
upstream1.String(): {msg: servfailWithEDE},
|
||||||
|
upstream2.String(): {msg: successResp},
|
||||||
|
},
|
||||||
|
rtt: time.Millisecond,
|
||||||
|
},
|
||||||
|
queriedUpstreams: &queried,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
resolver := &upstreamResolverBase{
|
||||||
|
ctx: ctx,
|
||||||
|
upstreamClient: tracking,
|
||||||
|
upstreamServers: []netip.AddrPort{upstream1, upstream2},
|
||||||
|
upstreamTimeout: UpstreamTimeout,
|
||||||
|
}
|
||||||
|
|
||||||
|
var written *dns.Msg
|
||||||
|
w := &test.MockResponseWriter{
|
||||||
|
WriteMsgFunc: func(m *dns.Msg) error {
|
||||||
|
written = m
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client query without EDNS0 must not see an OPT in the response.
|
||||||
|
q := new(dns.Msg).SetQuestion("example.com.", dns.TypeA)
|
||||||
|
resolver.ServeDNS(w, q)
|
||||||
|
|
||||||
|
require.NotNil(t, written, "response must be written")
|
||||||
|
assert.Equal(t, dns.RcodeServerFailure, written.Rcode, "SERVFAIL must propagate")
|
||||||
|
assert.Len(t, queried, 1, "only first upstream should be queried")
|
||||||
|
assert.Equal(t, upstream1.String(), queried[0])
|
||||||
|
for _, rr := range written.Extra {
|
||||||
|
_, isOPT := rr.(*dns.OPT)
|
||||||
|
assert.False(t, isOPT, "synthetic OPT must not leak to a non-EDNS0 client")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1979,29 +1979,6 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
|
|||||||
return e.clientMetrics
|
return e.clientMetrics
|
||||||
}
|
}
|
||||||
|
|
||||||
// WGTuning bundles runtime-adjustable WireGuard pool knobs.
|
|
||||||
// See Engine.SetWGTuning. Nil fields are ignored.
|
|
||||||
type WGTuning struct {
|
|
||||||
PreallocatedBuffersPerPool *uint32
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetWGTuning applies the given tuning to this engine's live Device.
|
|
||||||
func (e *Engine) SetWGTuning(t WGTuning) error {
|
|
||||||
e.syncMsgMux.Lock()
|
|
||||||
defer e.syncMsgMux.Unlock()
|
|
||||||
if e.wgInterface == nil {
|
|
||||||
return fmt.Errorf("wg interface not initialized")
|
|
||||||
}
|
|
||||||
dev := e.wgInterface.GetWGDevice()
|
|
||||||
if dev == nil {
|
|
||||||
return fmt.Errorf("wg device not initialized")
|
|
||||||
}
|
|
||||||
if t.PreallocatedBuffersPerPool != nil {
|
|
||||||
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
|
||||||
iface, err := net.InterfaceByName(ifaceName)
|
iface, err := net.InterfaceByName(ifaceName)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -217,6 +217,14 @@ type Status struct {
|
|||||||
eventStreams map[string]chan *proto.SystemEvent
|
eventStreams map[string]chan *proto.SystemEvent
|
||||||
eventQueue *EventQueue
|
eventQueue *EventQueue
|
||||||
|
|
||||||
|
// stateChangeStreams fan-out connection-state changes (connected /
|
||||||
|
// disconnected / connecting / address change / peers list change) to
|
||||||
|
// every active SubscribeStatus gRPC stream. Each subscriber gets a
|
||||||
|
// buffered chan; the notifier non-blockingly pings them so a slow
|
||||||
|
// consumer can never stall the daemon.
|
||||||
|
stateChangeMux sync.Mutex
|
||||||
|
stateChangeStreams map[string]chan struct{}
|
||||||
|
|
||||||
ingressGwMgr *ingressgw.Manager
|
ingressGwMgr *ingressgw.Manager
|
||||||
|
|
||||||
routeIDLookup routeIDLookup
|
routeIDLookup routeIDLookup
|
||||||
@@ -230,6 +238,7 @@ func NewRecorder(mgmAddress string) *Status {
|
|||||||
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
changeNotify: make(map[string]map[string]*StatusChangeSubscription),
|
||||||
eventStreams: make(map[string]chan *proto.SystemEvent),
|
eventStreams: make(map[string]chan *proto.SystemEvent),
|
||||||
eventQueue: NewEventQueue(eventQueueSize),
|
eventQueue: NewEventQueue(eventQueueSize),
|
||||||
|
stateChangeStreams: make(map[string]chan struct{}),
|
||||||
offlinePeers: make([]State, 0),
|
offlinePeers: make([]State, 0),
|
||||||
notifier: newNotifier(),
|
notifier: newNotifier(),
|
||||||
mgmAddress: mgmAddress,
|
mgmAddress: mgmAddress,
|
||||||
@@ -360,6 +369,7 @@ func (d *Status) UpdatePeerState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,6 +395,7 @@ func (d *Status) AddPeerStateRoute(peer string, route string, resourceId route.R
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -410,6 +421,7 @@ func (d *Status) RemovePeerStateRoute(peer string, route string) error {
|
|||||||
|
|
||||||
// todo: consider to make sense of this notification or not
|
// todo: consider to make sense of this notification or not
|
||||||
d.notifier.peerListChanged(numPeers)
|
d.notifier.peerListChanged(numPeers)
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,6 +471,7 @@ func (d *Status) UpdatePeerICEState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -495,6 +508,7 @@ func (d *Status) UpdatePeerRelayedState(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -530,6 +544,7 @@ func (d *Status) UpdatePeerRelayedStateToDisconnected(receivedState State) error
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -568,6 +583,7 @@ func (d *Status) UpdatePeerICEStateToDisconnected(receivedState State) error {
|
|||||||
if notifyRouter {
|
if notifyRouter {
|
||||||
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
d.dispatchRouterPeers(receivedState.PubKey, routerSnapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -661,6 +677,7 @@ func (d *Status) FinishPeerListModifications() {
|
|||||||
for _, rd := range dispatches {
|
for _, rd := range dispatches {
|
||||||
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
d.dispatchRouterPeers(rd.peerID, rd.snapshot)
|
||||||
}
|
}
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
func (d *Status) SubscribeToPeerStateChanges(ctx context.Context, peerID string) *StatusChangeSubscription {
|
||||||
@@ -719,6 +736,7 @@ func (d *Status) UpdateLocalPeerState(localPeerState LocalPeerState) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
d.notifier.localAddressChanged(fqdn, ip)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// AddLocalPeerStateRoute adds a route to the local peer state
|
// AddLocalPeerStateRoute adds a route to the local peer state
|
||||||
@@ -787,6 +805,7 @@ func (d *Status) CleanLocalPeerState() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.localAddressChanged(fqdn, ip)
|
d.notifier.localAddressChanged(fqdn, ip)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkManagementDisconnected sets ManagementState to disconnected
|
// MarkManagementDisconnected sets ManagementState to disconnected
|
||||||
@@ -799,6 +818,7 @@ func (d *Status) MarkManagementDisconnected(err error) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkManagementConnected sets ManagementState to connected
|
// MarkManagementConnected sets ManagementState to connected
|
||||||
@@ -811,6 +831,7 @@ func (d *Status) MarkManagementConnected() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSignalAddress update the address of the signal server
|
// UpdateSignalAddress update the address of the signal server
|
||||||
@@ -851,6 +872,7 @@ func (d *Status) MarkSignalDisconnected(err error) {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MarkSignalConnected sets SignalState to connected
|
// MarkSignalConnected sets SignalState to connected
|
||||||
@@ -863,6 +885,7 @@ func (d *Status) MarkSignalConnected() {
|
|||||||
d.mux.Unlock()
|
d.mux.Unlock()
|
||||||
|
|
||||||
d.notifier.updateServerStates(mgm, sig)
|
d.notifier.updateServerStates(mgm, sig)
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
func (d *Status) UpdateRelayStates(relayResults []relay.ProbeResult) {
|
||||||
@@ -1060,16 +1083,19 @@ func (d *Status) GetFullStatus() FullStatus {
|
|||||||
// ClientStart will notify all listeners about the new service state
|
// ClientStart will notify all listeners about the new service state
|
||||||
func (d *Status) ClientStart() {
|
func (d *Status) ClientStart() {
|
||||||
d.notifier.clientStart()
|
d.notifier.clientStart()
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientStop will notify all listeners about the new service state
|
// ClientStop will notify all listeners about the new service state
|
||||||
func (d *Status) ClientStop() {
|
func (d *Status) ClientStop() {
|
||||||
d.notifier.clientStop()
|
d.notifier.clientStop()
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientTeardown will notify all listeners about the service is under teardown
|
// ClientTeardown will notify all listeners about the service is under teardown
|
||||||
func (d *Status) ClientTeardown() {
|
func (d *Status) ClientTeardown() {
|
||||||
d.notifier.clientTearDown()
|
d.notifier.clientTearDown()
|
||||||
|
d.notifyStateChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetConnectionListener set a listener to the notifier
|
// SetConnectionListener set a listener to the notifier
|
||||||
@@ -1211,6 +1237,50 @@ func (d *Status) GetEventHistory() []*proto.SystemEvent {
|
|||||||
return d.eventQueue.GetAll()
|
return d.eventQueue.GetAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SubscribeToStateChanges hands back a channel that receives a tick on
|
||||||
|
// every connection-state change (connected / disconnected / connecting /
|
||||||
|
// address change / peers-list change). The channel is buffered to one
|
||||||
|
// pending tick so a coalesced burst still wakes the consumer exactly
|
||||||
|
// once. Pass the returned id to UnsubscribeFromStateChanges to detach.
|
||||||
|
func (d *Status) SubscribeToStateChanges() (string, <-chan struct{}) {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
id := uuid.New().String()
|
||||||
|
ch := make(chan struct{}, 1)
|
||||||
|
d.stateChangeStreams[id] = ch
|
||||||
|
return id, ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnsubscribeFromStateChanges releases a SubscribeToStateChanges channel
|
||||||
|
// and closes it so any consumer goroutine selecting on the channel
|
||||||
|
// unblocks cleanly.
|
||||||
|
func (d *Status) UnsubscribeFromStateChanges(id string) {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
if ch, ok := d.stateChangeStreams[id]; ok {
|
||||||
|
close(ch)
|
||||||
|
delete(d.stateChangeStreams, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// notifyStateChange wakes every SubscribeToStateChanges subscriber. Drops
|
||||||
|
// the tick if a subscriber's buffer is full — by definition the consumer
|
||||||
|
// is already going to fetch the latest snapshot, so multiple pending ticks
|
||||||
|
// would be redundant.
|
||||||
|
func (d *Status) notifyStateChange() {
|
||||||
|
d.stateChangeMux.Lock()
|
||||||
|
defer d.stateChangeMux.Unlock()
|
||||||
|
|
||||||
|
for _, ch := range d.stateChangeStreams {
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||||
d.mux.Lock()
|
d.mux.Lock()
|
||||||
defer d.mux.Unlock()
|
defer d.mux.Unlock()
|
||||||
|
|||||||
@@ -32,9 +32,6 @@
|
|||||||
</File>
|
</File>
|
||||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
|
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\wintun.dll" />
|
||||||
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
|
<File Id="NetbirdToastIcon" Name="netbird.png" Source=".\client\ui\assets\netbird.png" />
|
||||||
<?if $(var.ArchSuffix) = "amd64" ?>
|
|
||||||
<File ProcessorArchitecture="$(var.ProcessorArchitecture)" Source=".\dist\netbird_windows_$(var.ArchSuffix)\opengl32.dll" />
|
|
||||||
<?endif ?>
|
|
||||||
|
|
||||||
<ServiceInstall
|
<ServiceInstall
|
||||||
Id="NetBirdService"
|
Id="NetBirdService"
|
||||||
@@ -62,6 +59,14 @@
|
|||||||
<Component Id="NetbirdAumidRegistry" Guid="*">
|
<Component Id="NetbirdAumidRegistry" Guid="*">
|
||||||
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
<RegistryKey Root="HKCU" Key="Software\Classes\AppUserModelId\NetBird" ForceDeleteOnUninstall="yes">
|
||||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||||
|
<!-- Pre-seed the CLSID the Wails notifications service reads on
|
||||||
|
first startup (notifications_windows.go:getGUID looks for
|
||||||
|
the CustomActivator value under this key). Without this
|
||||||
|
the service generates a fresh per-install UUID, which
|
||||||
|
diverges from the ToastActivatorCLSID set on the Start
|
||||||
|
Menu / Desktop shortcuts above and the COM activator
|
||||||
|
never fires when a toast is clicked. -->
|
||||||
|
<RegistryValue Name="CustomActivator" Type="string" Value="{0E1B4DE7-E148-432B-9814-544F941826EC}" />
|
||||||
</RegistryKey>
|
</RegistryKey>
|
||||||
</Component>
|
</Component>
|
||||||
</StandardDirectory>
|
</StandardDirectory>
|
||||||
@@ -85,7 +90,37 @@
|
|||||||
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
||||||
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
<util:CloseApplication Id="CloseNetBirdUI" CloseMessage="no" Target="netbird-ui.exe" RebootPrompt="no" TerminateProcess="0" />
|
||||||
|
|
||||||
|
<!-- WebView2 evergreen runtime detection.
|
||||||
|
Probe both the per-machine and per-user EdgeUpdate keys; if either
|
||||||
|
reports a non-empty `pv` value the runtime is already installed
|
||||||
|
and we skip the bootstrapper. -->
|
||||||
|
<Property Id="WEBVIEW2_VERSION_HKLM">
|
||||||
|
<RegistrySearch Id="WV2HKLM" Root="HKLM"
|
||||||
|
Key="SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
||||||
|
Name="pv" Type="raw" Bitness="always64" />
|
||||||
|
</Property>
|
||||||
|
<Property Id="WEBVIEW2_VERSION_HKCU">
|
||||||
|
<RegistrySearch Id="WV2HKCU" Root="HKCU"
|
||||||
|
Key="Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}"
|
||||||
|
Name="pv" Type="raw" />
|
||||||
|
</Property>
|
||||||
|
|
||||||
|
<!-- Embed the bootstrapper payload. Path is relative to the WiX
|
||||||
|
working directory; sign-pipelines stages it next to client/
|
||||||
|
via `wails3 generate webview2bootstrapper`. -->
|
||||||
|
<Binary Id="WebView2Bootstrapper" SourceFile=".\client\MicrosoftEdgeWebview2Setup.exe" />
|
||||||
|
|
||||||
|
<CustomAction Id="InstallWebView2"
|
||||||
|
BinaryRef="WebView2Bootstrapper"
|
||||||
|
ExeCommand="/silent /install"
|
||||||
|
Execute="deferred"
|
||||||
|
Impersonate="no"
|
||||||
|
Return="check" />
|
||||||
|
|
||||||
|
<InstallExecuteSequence>
|
||||||
|
<Custom Action="InstallWebView2" Before="InstallFinalize"
|
||||||
|
Condition="NOT WEBVIEW2_VERSION_HKLM AND NOT WEBVIEW2_VERSION_HKCU AND NOT REMOVE" />
|
||||||
|
</InstallExecuteSequence>
|
||||||
|
|
||||||
<!-- Icons -->
|
<!-- Icons -->
|
||||||
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\assets\netbird.ico" />
|
<Icon Id="NetbirdIcon" SourceFile=".\client\ui\assets\netbird.ico" />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// protoc-gen-go v1.36.6
|
// protoc-gen-go v1.36.6
|
||||||
// protoc v6.33.1
|
// protoc v7.34.1
|
||||||
// source: daemon.proto
|
// source: daemon.proto
|
||||||
|
|
||||||
package proto
|
package proto
|
||||||
@@ -6773,12 +6773,13 @@ const file_daemon_proto_rawDesc = "" +
|
|||||||
"\n" +
|
"\n" +
|
||||||
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
|
"EXPOSE_UDP\x10\x03\x12\x0e\n" +
|
||||||
"\n" +
|
"\n" +
|
||||||
"EXPOSE_TLS\x10\x042\xaf\x17\n" +
|
"EXPOSE_TLS\x10\x042\xf5\x17\n" +
|
||||||
"\rDaemonService\x126\n" +
|
"\rDaemonService\x126\n" +
|
||||||
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
|
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
|
||||||
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
|
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
|
||||||
"\x02Up\x12\x11.daemon.UpRequest\x1a\x12.daemon.UpResponse\"\x00\x129\n" +
|
"\x02Up\x12\x11.daemon.UpRequest\x1a\x12.daemon.UpResponse\"\x00\x129\n" +
|
||||||
"\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x123\n" +
|
"\x06Status\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x00\x12D\n" +
|
||||||
|
"\x0fSubscribeStatus\x12\x15.daemon.StatusRequest\x1a\x16.daemon.StatusResponse\"\x000\x01\x123\n" +
|
||||||
"\x04Down\x12\x13.daemon.DownRequest\x1a\x14.daemon.DownResponse\"\x00\x12B\n" +
|
"\x04Down\x12\x13.daemon.DownRequest\x1a\x14.daemon.DownResponse\"\x00\x12B\n" +
|
||||||
"\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" +
|
"\tGetConfig\x12\x18.daemon.GetConfigRequest\x1a\x19.daemon.GetConfigResponse\"\x00\x12K\n" +
|
||||||
"\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" +
|
"\fListNetworks\x12\x1b.daemon.ListNetworksRequest\x1a\x1c.daemon.ListNetworksResponse\"\x00\x12Q\n" +
|
||||||
@@ -6979,82 +6980,84 @@ var file_daemon_proto_depIdxs = []int32{
|
|||||||
7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
|
7, // 38: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
|
||||||
9, // 39: daemon.DaemonService.Up:input_type -> daemon.UpRequest
|
9, // 39: daemon.DaemonService.Up:input_type -> daemon.UpRequest
|
||||||
11, // 40: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
|
11, // 40: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
|
||||||
13, // 41: daemon.DaemonService.Down:input_type -> daemon.DownRequest
|
11, // 41: daemon.DaemonService.SubscribeStatus:input_type -> daemon.StatusRequest
|
||||||
15, // 42: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
|
13, // 42: daemon.DaemonService.Down:input_type -> daemon.DownRequest
|
||||||
26, // 43: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
|
15, // 43: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
|
||||||
28, // 44: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
|
26, // 44: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
|
||||||
28, // 45: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
|
28, // 45: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||||
4, // 46: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
|
28, // 46: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
|
||||||
35, // 47: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
|
4, // 47: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
|
||||||
37, // 48: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
|
35, // 48: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
|
||||||
39, // 49: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
|
37, // 49: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
|
||||||
42, // 50: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
|
39, // 50: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
|
||||||
44, // 51: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
|
42, // 51: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
|
||||||
46, // 52: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
|
44, // 52: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
|
||||||
48, // 53: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
|
46, // 53: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
|
||||||
51, // 54: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
|
48, // 54: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
|
||||||
92, // 55: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
|
51, // 55: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
|
||||||
94, // 56: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
|
92, // 56: daemon.DaemonService.StartCapture:input_type -> daemon.StartCaptureRequest
|
||||||
96, // 57: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
|
94, // 57: daemon.DaemonService.StartBundleCapture:input_type -> daemon.StartBundleCaptureRequest
|
||||||
54, // 58: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
|
96, // 58: daemon.DaemonService.StopBundleCapture:input_type -> daemon.StopBundleCaptureRequest
|
||||||
56, // 59: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
|
54, // 59: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
|
||||||
58, // 60: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
|
56, // 60: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
|
||||||
60, // 61: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
|
58, // 61: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
|
||||||
62, // 62: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
|
60, // 62: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
|
||||||
64, // 63: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
|
62, // 63: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
|
||||||
66, // 64: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
|
64, // 64: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
|
||||||
69, // 65: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
|
66, // 65: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
|
||||||
71, // 66: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
|
69, // 66: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
|
||||||
73, // 67: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
|
71, // 67: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
|
||||||
75, // 68: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
|
73, // 68: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
|
||||||
77, // 69: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
|
75, // 69: daemon.DaemonService.TriggerUpdate:input_type -> daemon.TriggerUpdateRequest
|
||||||
79, // 70: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
|
77, // 70: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
|
||||||
81, // 71: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
|
79, // 71: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
|
||||||
83, // 72: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
|
81, // 72: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
|
||||||
85, // 73: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
|
83, // 73: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
|
||||||
87, // 74: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
|
85, // 74: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
|
||||||
89, // 75: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
|
87, // 75: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
|
||||||
6, // 76: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
|
89, // 76: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
|
||||||
8, // 77: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
|
6, // 77: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
|
||||||
10, // 78: daemon.DaemonService.Up:output_type -> daemon.UpResponse
|
8, // 78: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
|
||||||
12, // 79: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
|
10, // 79: daemon.DaemonService.Up:output_type -> daemon.UpResponse
|
||||||
14, // 80: daemon.DaemonService.Down:output_type -> daemon.DownResponse
|
12, // 80: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
|
||||||
16, // 81: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
|
12, // 81: daemon.DaemonService.SubscribeStatus:output_type -> daemon.StatusResponse
|
||||||
27, // 82: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
|
14, // 82: daemon.DaemonService.Down:output_type -> daemon.DownResponse
|
||||||
29, // 83: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
|
16, // 83: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
|
||||||
29, // 84: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
|
27, // 84: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
|
||||||
34, // 85: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
|
29, // 85: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||||
36, // 86: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
|
29, // 86: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
|
||||||
38, // 87: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
|
34, // 87: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
|
||||||
40, // 88: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
|
36, // 88: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
|
||||||
43, // 89: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
|
38, // 89: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
|
||||||
45, // 90: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
|
40, // 90: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
|
||||||
47, // 91: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
|
43, // 91: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
|
||||||
49, // 92: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
|
45, // 92: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
|
||||||
53, // 93: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
|
47, // 93: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
|
||||||
93, // 94: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
|
49, // 94: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
|
||||||
95, // 95: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
|
53, // 95: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
|
||||||
97, // 96: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
|
93, // 96: daemon.DaemonService.StartCapture:output_type -> daemon.CapturePacket
|
||||||
55, // 97: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
|
95, // 97: daemon.DaemonService.StartBundleCapture:output_type -> daemon.StartBundleCaptureResponse
|
||||||
57, // 98: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
|
97, // 98: daemon.DaemonService.StopBundleCapture:output_type -> daemon.StopBundleCaptureResponse
|
||||||
59, // 99: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
|
55, // 99: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
|
||||||
61, // 100: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
|
57, // 100: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
|
||||||
63, // 101: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
|
59, // 101: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
|
||||||
65, // 102: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
|
61, // 102: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
|
||||||
67, // 103: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
|
63, // 103: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
|
||||||
70, // 104: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
|
65, // 104: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
|
||||||
72, // 105: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
|
67, // 105: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
|
||||||
74, // 106: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
|
70, // 106: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
|
||||||
76, // 107: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
|
72, // 107: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
|
||||||
78, // 108: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
|
74, // 108: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
|
||||||
80, // 109: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
|
76, // 109: daemon.DaemonService.TriggerUpdate:output_type -> daemon.TriggerUpdateResponse
|
||||||
82, // 110: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
|
78, // 110: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
|
||||||
84, // 111: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
|
80, // 111: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
|
||||||
86, // 112: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
|
82, // 112: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
|
||||||
88, // 113: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
|
84, // 113: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
|
||||||
90, // 114: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
|
86, // 114: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
|
||||||
76, // [76:115] is the sub-list for method output_type
|
88, // 115: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
|
||||||
37, // [37:76] is the sub-list for method input_type
|
90, // 116: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
|
||||||
|
77, // [77:117] is the sub-list for method output_type
|
||||||
|
37, // [37:77] is the sub-list for method input_type
|
||||||
37, // [37:37] is the sub-list for extension type_name
|
37, // [37:37] is the sub-list for extension type_name
|
||||||
37, // [37:37] is the sub-list for extension extendee
|
37, // [37:37] is the sub-list for extension extendee
|
||||||
0, // [0:37] is the sub-list for field type_name
|
0, // [0:37] is the sub-list for field type_name
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ service DaemonService {
|
|||||||
// Status of the service.
|
// Status of the service.
|
||||||
rpc Status(StatusRequest) returns (StatusResponse) {}
|
rpc Status(StatusRequest) returns (StatusResponse) {}
|
||||||
|
|
||||||
|
// SubscribeStatus pushes a fresh StatusResponse on connection state
|
||||||
|
// changes (Connected / Disconnected / Connecting / address change /
|
||||||
|
// peers list change). The first message on the stream is the current
|
||||||
|
// snapshot, so a freshly-subscribed UI doesn't need to also call Status.
|
||||||
|
rpc SubscribeStatus(StatusRequest) returns (stream StatusResponse) {}
|
||||||
|
|
||||||
// Down stops engine work in the daemon.
|
// Down stops engine work in the daemon.
|
||||||
rpc Down(DownRequest) returns (DownResponse) {}
|
rpc Down(DownRequest) returns (DownResponse) {}
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,15 @@ func (s *Server) connectWithRetryRuns(ctx context.Context, profileConfig *profil
|
|||||||
runOperation := func() error {
|
runOperation := func() error {
|
||||||
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
|
err := s.connect(ctx, profileConfig, statusRecorder, runningChan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// PermissionDenied means the daemon transitioned to NeedsLogin
|
||||||
|
// inside connect(). Without backoff.Permanent the outer retry
|
||||||
|
// re-enters connect(), which resets the state to Connecting and
|
||||||
|
// makes the tray flicker between NeedsLogin and Connecting until
|
||||||
|
// the user logs in. Stop retrying and let the state stick.
|
||||||
|
if s, ok := gstatus.FromError(err); ok && s.Code() == codes.PermissionDenied {
|
||||||
|
log.Debugf("run client connection exited with PermissionDenied, waiting for login")
|
||||||
|
return backoff.Permanent(err)
|
||||||
|
}
|
||||||
log.Debugf("run client connection exited with error: %v. Will retry in the background", err)
|
log.Debugf("run client connection exited with error: %v. Will retry in the background", err)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -341,9 +350,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
|||||||
}
|
}
|
||||||
|
|
||||||
if msg.OptionalPreSharedKey != nil {
|
if msg.OptionalPreSharedKey != nil {
|
||||||
if *msg.OptionalPreSharedKey != "" {
|
config.PreSharedKey = msg.OptionalPreSharedKey
|
||||||
config.PreSharedKey = msg.OptionalPreSharedKey
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if msg.CleanDNSLabels {
|
if msg.CleanDNSLabels {
|
||||||
@@ -1109,6 +1116,13 @@ func (s *Server) Status(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return s.buildStatusResponse(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildStatusResponse composes a StatusResponse from the current daemon
|
||||||
|
// state. Shared between the unary Status RPC and the SubscribeStatus
|
||||||
|
// stream so both paths return identical snapshots.
|
||||||
|
func (s *Server) buildStatusResponse(msg *proto.StatusRequest) (*proto.StatusResponse, error) {
|
||||||
status, err := internal.CtxGetState(s.rootCtx).Status()
|
status, err := internal.CtxGetState(s.rootCtx).Status()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
57
client/server/status_stream.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package server
|
||||||
|
|
||||||
|
import (
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
|
||||||
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SubscribeStatus pushes a fresh StatusResponse on every connection state
|
||||||
|
// change. The first message is the current snapshot, so a re-subscribing
|
||||||
|
// client doesn't need to also call Status. Subsequent messages fire when
|
||||||
|
// the peer recorder reports any of: connected/disconnected/connecting,
|
||||||
|
// management or signal flip, address change, or peers list change.
|
||||||
|
//
|
||||||
|
// The change channel coalesces bursts to a single tick. If the consumer
|
||||||
|
// is slow the daemon drops extras (not blocks), and the next snapshot
|
||||||
|
// the consumer pulls already reflects everything.
|
||||||
|
func (s *Server) SubscribeStatus(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
||||||
|
subID, ch := s.statusRecorder.SubscribeToStateChanges()
|
||||||
|
defer func() {
|
||||||
|
s.statusRecorder.UnsubscribeFromStateChanges(subID)
|
||||||
|
log.Debug("client unsubscribed from status updates")
|
||||||
|
}()
|
||||||
|
|
||||||
|
log.Debug("client subscribed to status updates")
|
||||||
|
|
||||||
|
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := s.sendStatusSnapshot(req, stream); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
case <-stream.Context().Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) sendStatusSnapshot(req *proto.StatusRequest, stream proto.DaemonService_SubscribeStatusServer) error {
|
||||||
|
resp, err := s.buildStatusResponse(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Warnf("build status snapshot for stream: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := stream.Send(resp); err != nil {
|
||||||
|
log.Warnf("send status snapshot to stream: %v", err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@ import (
|
|||||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||||
"github.com/netbirdio/netbird/util"
|
"github.com/netbirdio/netbird/util"
|
||||||
|
"github.com/netbirdio/netbird/util/netrelay"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -536,7 +537,7 @@ func (c *Client) LocalPortForward(ctx context.Context, localAddr, remoteAddr str
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
go c.handleLocalForward(localConn, remoteAddr)
|
go c.handleLocalForward(ctx, localConn, remoteAddr)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@@ -548,7 +549,7 @@ func (c *Client) LocalPortForward(ctx context.Context, localAddr, remoteAddr str
|
|||||||
}
|
}
|
||||||
|
|
||||||
// handleLocalForward handles a single local port forwarding connection
|
// handleLocalForward handles a single local port forwarding connection
|
||||||
func (c *Client) handleLocalForward(localConn net.Conn, remoteAddr string) {
|
func (c *Client) handleLocalForward(ctx context.Context, localConn net.Conn, remoteAddr string) {
|
||||||
defer func() {
|
defer func() {
|
||||||
if err := localConn.Close(); err != nil {
|
if err := localConn.Close(); err != nil {
|
||||||
log.Debugf("local port forwarding: close local connection: %v", err)
|
log.Debugf("local port forwarding: close local connection: %v", err)
|
||||||
@@ -571,7 +572,7 @@ func (c *Client) handleLocalForward(localConn net.Conn, remoteAddr string) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
nbssh.BidirectionalCopy(log.NewEntry(log.StandardLogger()), localConn, channel)
|
netrelay.Relay(ctx, localConn, channel, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())})
|
||||||
}
|
}
|
||||||
|
|
||||||
// RemotePortForward sets up remote port forwarding, binding on remote and forwarding to localAddr
|
// RemotePortForward sets up remote port forwarding, binding on remote and forwarding to localAddr
|
||||||
@@ -653,16 +654,19 @@ func (c *Client) handleRemoteForwardChannels(ctx context.Context, localAddr stri
|
|||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return
|
return
|
||||||
case newChan := <-channelRequests:
|
case newChan, ok := <-channelRequests:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
if newChan != nil {
|
if newChan != nil {
|
||||||
go c.handleRemoteForwardChannel(newChan, localAddr)
|
go c.handleRemoteForwardChannel(ctx, newChan, localAddr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleRemoteForwardChannel handles a single forwarded-tcpip channel
|
// handleRemoteForwardChannel handles a single forwarded-tcpip channel
|
||||||
func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr string) {
|
func (c *Client) handleRemoteForwardChannel(ctx context.Context, newChan ssh.NewChannel, localAddr string) {
|
||||||
channel, reqs, err := newChan.Accept()
|
channel, reqs, err := newChan.Accept()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
@@ -675,8 +679,14 @@ func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr st
|
|||||||
|
|
||||||
go ssh.DiscardRequests(reqs)
|
go ssh.DiscardRequests(reqs)
|
||||||
|
|
||||||
localConn, err := net.Dial("tcp", localAddr)
|
// Bound the dial so a black-holed localAddr can't pin the accepted SSH
|
||||||
|
// channel open indefinitely; the relay itself runs under the outer ctx.
|
||||||
|
dialCtx, cancelDial := context.WithTimeout(ctx, 10*time.Second)
|
||||||
|
var dialer net.Dialer
|
||||||
|
localConn, err := dialer.DialContext(dialCtx, "tcp", localAddr)
|
||||||
|
cancelDial()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
log.Debugf("remote port forwarding: dial %s: %v", localAddr, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
@@ -685,7 +695,7 @@ func (c *Client) handleRemoteForwardChannel(newChan ssh.NewChannel, localAddr st
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
nbssh.BidirectionalCopy(log.NewEntry(log.StandardLogger()), localConn, channel)
|
netrelay.Relay(ctx, localConn, channel, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())})
|
||||||
}
|
}
|
||||||
|
|
||||||
// tcpipForwardMsg represents the structure for tcpip-forward requests
|
// tcpipForwardMsg represents the structure for tcpip-forward requests
|
||||||
|
|||||||
@@ -194,63 +194,3 @@ func buildAddressList(hostname string, remote net.Addr) []string {
|
|||||||
return addresses
|
return addresses
|
||||||
}
|
}
|
||||||
|
|
||||||
// BidirectionalCopy copies data bidirectionally between two io.ReadWriter connections.
|
|
||||||
// It waits for both directions to complete before returning.
|
|
||||||
// The caller is responsible for closing the connections.
|
|
||||||
func BidirectionalCopy(logger *log.Entry, rw1, rw2 io.ReadWriter) {
|
|
||||||
done := make(chan struct{}, 2)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if _, err := io.Copy(rw2, rw1); err != nil && !isExpectedCopyError(err) {
|
|
||||||
logger.Debugf("copy error (1->2): %v", err)
|
|
||||||
}
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if _, err := io.Copy(rw1, rw2); err != nil && !isExpectedCopyError(err) {
|
|
||||||
logger.Debugf("copy error (2->1): %v", err)
|
|
||||||
}
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
|
||||||
|
|
||||||
<-done
|
|
||||||
<-done
|
|
||||||
}
|
|
||||||
|
|
||||||
func isExpectedCopyError(err error) bool {
|
|
||||||
return errors.Is(err, io.EOF) || errors.Is(err, context.Canceled)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BidirectionalCopyWithContext copies data bidirectionally between two io.ReadWriteCloser connections.
|
|
||||||
// It waits for both directions to complete or for context cancellation before returning.
|
|
||||||
// Both connections are closed when the function returns.
|
|
||||||
func BidirectionalCopyWithContext(logger *log.Entry, ctx context.Context, conn1, conn2 io.ReadWriteCloser) {
|
|
||||||
done := make(chan struct{}, 2)
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if _, err := io.Copy(conn2, conn1); err != nil && !isExpectedCopyError(err) {
|
|
||||||
logger.Debugf("copy error (1->2): %v", err)
|
|
||||||
}
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
if _, err := io.Copy(conn1, conn2); err != nil && !isExpectedCopyError(err) {
|
|
||||||
logger.Debugf("copy error (2->1): %v", err)
|
|
||||||
}
|
|
||||||
done <- struct{}{}
|
|
||||||
}()
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case <-done:
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
case <-done:
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_ = conn1.Close()
|
|
||||||
_ = conn2.Close()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -229,18 +229,31 @@ func (m *Manager) buildHostPatterns(peer PeerSSHInfo) []string {
|
|||||||
|
|
||||||
func (m *Manager) writeSSHConfig(sshConfig string) error {
|
func (m *Manager) writeSSHConfig(sshConfig string) error {
|
||||||
sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile)
|
sshConfigPath := filepath.Join(m.sshConfigDir, m.sshConfigFile)
|
||||||
sshConfigPathTmp := sshConfigPath + ".tmp"
|
|
||||||
|
|
||||||
if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil {
|
if err := os.MkdirAll(m.sshConfigDir, 0755); err != nil {
|
||||||
return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, err)
|
return fmt.Errorf("create SSH config directory %s: %w", m.sshConfigDir, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := writeFileWithTimeout(sshConfigPathTmp, []byte(sshConfig), 0644); err != nil {
|
tmp, err := os.CreateTemp(m.sshConfigDir, m.sshConfigFile+".*.tmp")
|
||||||
return fmt.Errorf("write SSH config file %s: %w", sshConfigPath, err)
|
if err != nil {
|
||||||
|
return fmt.Errorf("create temp SSH config: %w", err)
|
||||||
|
}
|
||||||
|
tmpPath := tmp.Name()
|
||||||
|
defer func() {
|
||||||
|
if err := os.Remove(tmpPath); err != nil && !os.IsNotExist(err) {
|
||||||
|
log.Debugf("remove temp SSH config %s: %v", tmpPath, err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
if err := tmp.Close(); err != nil {
|
||||||
|
return fmt.Errorf("close temp SSH config %s: %w", tmpPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := os.Rename(sshConfigPathTmp, sshConfigPath); err != nil {
|
if err := writeFileWithTimeout(tmpPath, []byte(sshConfig), 0644); err != nil {
|
||||||
return fmt.Errorf("rename ssh config %s -> %s: %w", sshConfigPathTmp, sshConfigPath, err)
|
return fmt.Errorf("write SSH config file %s: %w", tmpPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.Rename(tmpPath, sshConfigPath); err != nil {
|
||||||
|
return fmt.Errorf("rename SSH config %s -> %s: %w", tmpPath, sshConfigPath, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infof("Created NetBird SSH client config: %s", sshConfigPath)
|
log.Infof("Created NetBird SSH client config: %s", sshConfigPath)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/proto"
|
"github.com/netbirdio/netbird/client/proto"
|
||||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
nbssh "github.com/netbirdio/netbird/client/ssh"
|
||||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||||
|
"github.com/netbirdio/netbird/util/netrelay"
|
||||||
"github.com/netbirdio/netbird/version"
|
"github.com/netbirdio/netbird/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -352,7 +353,7 @@ func (p *SSHProxy) directTCPIPHandler(_ *ssh.Server, _ *cryptossh.ServerConn, ne
|
|||||||
}
|
}
|
||||||
go cryptossh.DiscardRequests(clientReqs)
|
go cryptossh.DiscardRequests(clientReqs)
|
||||||
|
|
||||||
nbssh.BidirectionalCopyWithContext(log.NewEntry(log.StandardLogger()), sshCtx, clientChan, backendChan)
|
netrelay.Relay(sshCtx, clientChan, backendChan, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SSHProxy) sftpSubsystemHandler(s ssh.Session, jwtToken string) {
|
func (p *SSHProxy) sftpSubsystemHandler(s ssh.Session, jwtToken string) {
|
||||||
@@ -591,7 +592,7 @@ func (p *SSHProxy) handleForwardedChannel(sshCtx ssh.Context, sshConn *cryptossh
|
|||||||
}
|
}
|
||||||
go cryptossh.DiscardRequests(clientReqs)
|
go cryptossh.DiscardRequests(clientReqs)
|
||||||
|
|
||||||
nbssh.BidirectionalCopyWithContext(log.NewEntry(log.StandardLogger()), sshCtx, clientChan, backendChan)
|
netrelay.Relay(sshCtx, clientChan, backendChan, netrelay.Options{Logger: log.NewEntry(log.StandardLogger())})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string) (*cryptossh.Client, error) {
|
func (p *SSHProxy) dialBackend(ctx context.Context, addr, user, jwtToken string) (*cryptossh.Client, error) {
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
cryptossh "golang.org/x/crypto/ssh"
|
cryptossh "golang.org/x/crypto/ssh"
|
||||||
|
|
||||||
nbssh "github.com/netbirdio/netbird/client/ssh"
|
"github.com/netbirdio/netbird/util/netrelay"
|
||||||
)
|
)
|
||||||
|
|
||||||
const privilegedPortThreshold = 1024
|
const privilegedPortThreshold = 1024
|
||||||
@@ -357,7 +357,7 @@ func (s *Server) handleRemoteForwardConnection(ctx ssh.Context, conn net.Conn, h
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
nbssh.BidirectionalCopyWithContext(logger, ctx, conn, channel)
|
netrelay.Relay(ctx, conn, channel, netrelay.Options{Logger: logger})
|
||||||
}
|
}
|
||||||
|
|
||||||
// openForwardChannel creates an SSH forwarded-tcpip channel
|
// openForwardChannel creates an SSH forwarded-tcpip channel
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"strconv"
|
|
||||||
"net/netip"
|
"net/netip"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/netbirdio/netbird/client/ssh/detection"
|
"github.com/netbirdio/netbird/client/ssh/detection"
|
||||||
"github.com/netbirdio/netbird/shared/auth"
|
"github.com/netbirdio/netbird/shared/auth"
|
||||||
"github.com/netbirdio/netbird/shared/auth/jwt"
|
"github.com/netbirdio/netbird/shared/auth/jwt"
|
||||||
|
"github.com/netbirdio/netbird/util/netrelay"
|
||||||
"github.com/netbirdio/netbird/version"
|
"github.com/netbirdio/netbird/version"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -53,6 +54,10 @@ const (
|
|||||||
DefaultJWTMaxTokenAge = 10 * 60
|
DefaultJWTMaxTokenAge = 10 * 60
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// directTCPIPDialTimeout bounds how long relayDirectTCPIP waits on a dial to
|
||||||
|
// the forwarded destination before rejecting the SSH channel.
|
||||||
|
const directTCPIPDialTimeout = 30 * time.Second
|
||||||
|
|
||||||
var (
|
var (
|
||||||
ErrPrivilegedUserDisabled = errors.New(msgPrivilegedUserDisabled)
|
ErrPrivilegedUserDisabled = errors.New(msgPrivilegedUserDisabled)
|
||||||
ErrUserNotFound = errors.New("user not found")
|
ErrUserNotFound = errors.New("user not found")
|
||||||
@@ -933,5 +938,29 @@ func (s *Server) directTCPIPHandler(srv *ssh.Server, conn *cryptossh.ServerConn,
|
|||||||
s.addConnectionPortForward(ctx.User(), ctx.RemoteAddr(), forwardAddr)
|
s.addConnectionPortForward(ctx.User(), ctx.RemoteAddr(), forwardAddr)
|
||||||
logger.Infof("local port forwarding: %s", hostPort)
|
logger.Infof("local port forwarding: %s", hostPort)
|
||||||
|
|
||||||
ssh.DirectTCPIPHandler(srv, conn, newChan, ctx)
|
s.relayDirectTCPIP(ctx, newChan, payload.Host, int(payload.Port), logger)
|
||||||
|
}
|
||||||
|
|
||||||
|
// relayDirectTCPIP is a netrelay-based replacement for gliderlabs'
|
||||||
|
// DirectTCPIPHandler. The upstream handler closes both sides on the first
|
||||||
|
// EOF; netrelay.Relay propagates CloseWrite so each direction drains on its
|
||||||
|
// own terms.
|
||||||
|
func (s *Server) relayDirectTCPIP(ctx ssh.Context, newChan cryptossh.NewChannel, host string, port int, logger *log.Entry) {
|
||||||
|
dest := net.JoinHostPort(host, strconv.Itoa(port))
|
||||||
|
|
||||||
|
dialer := net.Dialer{Timeout: directTCPIPDialTimeout}
|
||||||
|
dconn, err := dialer.DialContext(ctx, "tcp", dest)
|
||||||
|
if err != nil {
|
||||||
|
_ = newChan.Reject(cryptossh.ConnectionFailed, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ch, reqs, err := newChan.Accept()
|
||||||
|
if err != nil {
|
||||||
|
_ = dconn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go cryptossh.DiscardRequests(reqs)
|
||||||
|
|
||||||
|
netrelay.Relay(ctx, dconn, ch, netrelay.Options{Logger: logger})
|
||||||
}
|
}
|
||||||
|
|||||||
8
client/ui/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
.task
|
||||||
|
bin
|
||||||
|
frontend/dist
|
||||||
|
frontend/node_modules
|
||||||
|
frontend/bindings
|
||||||
|
frontend/.vite
|
||||||
|
build/linux/appimage/build
|
||||||
|
build/windows/nsis/MicrosoftEdgeWebview2Setup.exe
|
||||||
100
client/ui/README.md
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
# NetBird desktop UI (Wails3 + React)
|
||||||
|
|
||||||
|
Replaces `client/ui` (Fyne). One binary on Windows / macOS / Linux,
|
||||||
|
talks to the NetBird daemon over gRPC, renders a React frontend in a
|
||||||
|
WebView.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Go ≥ 1.25, Node ≥ 20, **pnpm** (`corepack enable && corepack prepare pnpm@latest --activate`)
|
||||||
|
- `wails3` CLI: `go install github.com/wailsapp/wails/v3/cmd/wails3@latest`
|
||||||
|
- `task`: `go install github.com/go-task/task/v3/cmd/task@latest`
|
||||||
|
- A running NetBird daemon (default: `unix:///var/run/netbird.sock`,
|
||||||
|
Windows `tcp://127.0.0.1:41731`)
|
||||||
|
- Linux only: `libwebkit2gtk-4.1-dev`, `libgtk-3-dev`,
|
||||||
|
`libayatana-appindicator3-dev`
|
||||||
|
|
||||||
|
## Develop without rebuilding
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client/ui
|
||||||
|
task dev
|
||||||
|
```
|
||||||
|
|
||||||
|
`task dev` runs Vite (port 9245) + the Go binary + a `*.go` watcher.
|
||||||
|
Frontend edits hot-reload instantly. Go edits trigger a rebuild and
|
||||||
|
relaunch. Pass daemon flags after `--`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task dev -- --daemon-addr=tcp://127.0.0.1:41731
|
||||||
|
```
|
||||||
|
|
||||||
|
For pure UI work (no native window, fastest loop):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd frontend && pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task build
|
||||||
|
```
|
||||||
|
|
||||||
|
Output in `bin/`. Frontend assets are embedded into the binary.
|
||||||
|
|
||||||
|
### Cross-compile Windows from Linux
|
||||||
|
|
||||||
|
Install the mingw-w64 toolchain once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install gcc-mingw-w64-x86-64 # Debian/Ubuntu
|
||||||
|
sudo dnf install mingw64-gcc # Fedora
|
||||||
|
sudo pacman -S mingw-w64-gcc # Arch
|
||||||
|
```
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=1 task windows:build
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces `bin/netbird-ui.exe`. macOS cross-compile from Linux is not
|
||||||
|
supported (signing and notarization need a real Mac).
|
||||||
|
|
||||||
|
### Windows console build (logs in the terminal)
|
||||||
|
|
||||||
|
Default `windows:build` links the binary as a Windows GUI app, which
|
||||||
|
detaches from the launching console — `logrus` output, `fmt.Println`,
|
||||||
|
and panics go nowhere visible. To debug tray/event/daemon issues:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CGO_ENABLED=1 task windows:build:console
|
||||||
|
```
|
||||||
|
|
||||||
|
Produces `bin/netbird-ui-console.exe`. Run it from `cmd.exe` /
|
||||||
|
PowerShell / Windows Terminal and stdout/stderr land in that
|
||||||
|
terminal. Same flag works on a native Windows build (drop the
|
||||||
|
`CGO_ENABLED=1` if your toolchain already has it set).
|
||||||
|
|
||||||
|
## Regenerating bindings
|
||||||
|
|
||||||
|
When a Go service signature changes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
wails3 generate bindings
|
||||||
|
```
|
||||||
|
|
||||||
|
`task dev` does this automatically on `*.go` save.
|
||||||
|
|
||||||
|
## Tray icons
|
||||||
|
|
||||||
|
Source SVGs live in `assets/svg/` (state.svg + state-macos.svg). After editing
|
||||||
|
any SVG, rasterize to the PNGs the Go side embeds:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
task common:generate:tray:icons
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires Inkscape. Commit the resulting `assets/*.png` files alongside the
|
||||||
|
SVG change so CI doesn't need Inkscape installed.
|
||||||
58
client/ui/Taskfile.yml
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ./build/Taskfile.yml
|
||||||
|
windows: ./build/windows/Taskfile.yml
|
||||||
|
darwin: ./build/darwin/Taskfile.yml
|
||||||
|
linux: ./build/linux/Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
APP_NAME: "netbird-ui"
|
||||||
|
BIN_DIR: "bin"
|
||||||
|
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{OS}}:build"
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages a production build of the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{OS}}:package"
|
||||||
|
|
||||||
|
run:
|
||||||
|
summary: Runs the application
|
||||||
|
cmds:
|
||||||
|
- task: "{{OS}}:run"
|
||||||
|
|
||||||
|
dev:
|
||||||
|
summary: Runs the application in development mode
|
||||||
|
cmds:
|
||||||
|
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
|
||||||
|
|
||||||
|
setup:docker:
|
||||||
|
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||||
|
cmds:
|
||||||
|
- task: common:setup:docker
|
||||||
|
|
||||||
|
build:server:
|
||||||
|
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||||
|
cmds:
|
||||||
|
- task: common:build:server
|
||||||
|
|
||||||
|
run:server:
|
||||||
|
summary: Runs the application in server mode
|
||||||
|
cmds:
|
||||||
|
- task: common:run:server
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Builds a Docker image for server mode deployment
|
||||||
|
cmds:
|
||||||
|
- task: common:build:docker
|
||||||
|
|
||||||
|
run:docker:
|
||||||
|
summary: Builds and runs the Docker image
|
||||||
|
cmds:
|
||||||
|
- task: common:run:docker
|
||||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |
|
Before Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 103 KiB |
BIN
client/ui/assets/netbird-systemtray-needs-login-macos.png
Normal file
|
After Width: | Height: | Size: 3.5 KiB |
BIN
client/ui/assets/netbird-systemtray-needs-login.png
Normal file
|
After Width: | Height: | Size: 9.7 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 103 KiB |
|
Before Width: | Height: | Size: 104 KiB |
10
client/ui/assets/svg/needs-login.svg
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<g transform="translate(0.5 4.5) scale(1.0)" opacity="0.7">
|
||||||
|
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||||
|
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||||
|
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||||
|
</g>
|
||||||
|
<circle cx="25" cy="25" r="7" fill="white"/>
|
||||||
|
<path d="M 22.6 24.5 v -1.4 a 2.4 2.4 0 0 1 4.8 0 v 1.4" fill="none" stroke="#D97706" stroke-width="1.5" stroke-linecap="round"/>
|
||||||
|
<rect x="21.6" y="24.5" width="6.8" height="4.9" rx="0.9" fill="none" stroke="#D97706" stroke-width="1.5"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 847 B |
295
client/ui/build/Taskfile.yml
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
go:mod:tidy:
|
||||||
|
summary: Runs `go mod tidy`
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
install:frontend:deps:
|
||||||
|
summary: Install frontend dependencies
|
||||||
|
dir: frontend
|
||||||
|
sources:
|
||||||
|
- package.json
|
||||||
|
- pnpm-lock.yaml
|
||||||
|
generates:
|
||||||
|
- node_modules
|
||||||
|
preconditions:
|
||||||
|
- sh: pnpm --version
|
||||||
|
msg: "Looks like pnpm isn't installed. Install with: corepack enable && corepack prepare pnpm@latest --activate"
|
||||||
|
cmds:
|
||||||
|
- pnpm install
|
||||||
|
|
||||||
|
build:frontend:
|
||||||
|
label: build:frontend (DEV={{.DEV}})
|
||||||
|
summary: Build the frontend project
|
||||||
|
dir: frontend
|
||||||
|
sources:
|
||||||
|
- "**/*"
|
||||||
|
- exclude: node_modules/**/*
|
||||||
|
generates:
|
||||||
|
- dist/**/*
|
||||||
|
deps:
|
||||||
|
- task: install:frontend:deps
|
||||||
|
- task: generate:bindings
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
cmds:
|
||||||
|
- pnpm run {{.BUILD_COMMAND}}
|
||||||
|
env:
|
||||||
|
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
|
||||||
|
vars:
|
||||||
|
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
|
||||||
|
|
||||||
|
|
||||||
|
frontend:vendor:puppertino:
|
||||||
|
summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
|
||||||
|
sources:
|
||||||
|
- frontend/public/puppertino/puppertino.css
|
||||||
|
generates:
|
||||||
|
- frontend/public/puppertino/puppertino.css
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
mkdir -p frontend/public/puppertino
|
||||||
|
# If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
|
||||||
|
if [ ! -f frontend/public/puppertino/puppertino.css ]; then
|
||||||
|
echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
|
||||||
|
if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
|
||||||
|
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
|
||||||
|
echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
|
||||||
|
else
|
||||||
|
echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
|
||||||
|
fi
|
||||||
|
# Ensure index.html includes Puppertino CSS and button classes
|
||||||
|
INDEX_HTML=frontend/index.html
|
||||||
|
if [ -f "$INDEX_HTML" ]; then
|
||||||
|
if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
|
||||||
|
# Insert Puppertino link tag after style.css link
|
||||||
|
awk '
|
||||||
|
/href="\/style.css"\/?/ && !x { print; print " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; x=1; next }1
|
||||||
|
' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
|
||||||
|
fi
|
||||||
|
# Replace default .btn with Puppertino primary button classes if present
|
||||||
|
sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
|
||||||
|
generate:bindings:
|
||||||
|
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
|
||||||
|
summary: Generates bindings for the frontend
|
||||||
|
deps:
|
||||||
|
- task: go:mod:tidy
|
||||||
|
sources:
|
||||||
|
- "**/*.[jt]s"
|
||||||
|
- exclude: frontend/**/*
|
||||||
|
- frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
|
||||||
|
- "**/*.go"
|
||||||
|
- go.mod
|
||||||
|
- go.sum
|
||||||
|
generates:
|
||||||
|
- frontend/bindings/**/*
|
||||||
|
cmds:
|
||||||
|
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -ts
|
||||||
|
|
||||||
|
generate:icons:
|
||||||
|
summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
|
||||||
|
dir: build
|
||||||
|
sources:
|
||||||
|
- "appicon.png"
|
||||||
|
- "appicon.icon"
|
||||||
|
generates:
|
||||||
|
- "darwin/icons.icns"
|
||||||
|
- "windows/icon.ico"
|
||||||
|
cmds:
|
||||||
|
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
|
||||||
|
|
||||||
|
generate:tray:icons:
|
||||||
|
summary: Rebuild Windows multi-res .ico files from the per-state PNGs.
|
||||||
|
desc: |
|
||||||
|
The colored tray PNGs (assets/netbird-systemtray-<state>.png) and the
|
||||||
|
macOS template variants are committed to the repo as the canonical
|
||||||
|
source. This task only regenerates the Windows multi-resolution .ico
|
||||||
|
files from those PNGs by downscaling each to 16/24/32/48 px and
|
||||||
|
packing them with icotool, so Shell_NotifyIcon picks the frame
|
||||||
|
matching the user's DPI instead of downscaling a single large PNG.
|
||||||
|
|
||||||
|
Run after replacing any of the colored PNGs (e.g. when copying a new
|
||||||
|
version of the icons from client/ui/assets). The SVG sources in
|
||||||
|
assets/svg/ are kept for reference but are not built by default.
|
||||||
|
dir: assets
|
||||||
|
sources:
|
||||||
|
- "netbird-systemtray-connected.png"
|
||||||
|
- "netbird-systemtray-disconnected.png"
|
||||||
|
- "netbird-systemtray-connecting.png"
|
||||||
|
- "netbird-systemtray-error.png"
|
||||||
|
- "netbird-systemtray-update-connected.png"
|
||||||
|
- "netbird-systemtray-update-disconnected.png"
|
||||||
|
generates:
|
||||||
|
- "netbird-systemtray-*.ico"
|
||||||
|
preconditions:
|
||||||
|
- sh: command -v magick >/dev/null 2>&1 || command -v convert >/dev/null 2>&1
|
||||||
|
msg: "ImageMagick is required to downscale PNGs (apt install imagemagick)"
|
||||||
|
- sh: command -v icotool >/dev/null 2>&1
|
||||||
|
msg: "icotool is required to pack tray .ico files (apt install icoutils)"
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
tmp=$(mktemp -d)
|
||||||
|
trap 'rm -rf "$tmp"' EXIT
|
||||||
|
resize=$(command -v magick || echo convert)
|
||||||
|
for state in connected disconnected connecting error update-connected update-disconnected; do
|
||||||
|
for sz in 16 24 32 48; do
|
||||||
|
"$resize" "netbird-systemtray-$state.png" -resize ${sz}x${sz} "$tmp/$state-$sz.png"
|
||||||
|
done
|
||||||
|
icotool -c -o "netbird-systemtray-$state.ico" \
|
||||||
|
"$tmp/$state-16.png" "$tmp/$state-24.png" "$tmp/$state-32.png" "$tmp/$state-48.png"
|
||||||
|
done
|
||||||
|
|
||||||
|
dev:frontend:
|
||||||
|
summary: Runs the frontend in development mode
|
||||||
|
dir: frontend
|
||||||
|
deps:
|
||||||
|
- task: install:frontend:deps
|
||||||
|
cmds:
|
||||||
|
- pnpm exec vite --port {{.VITE_PORT}} --strictPort
|
||||||
|
|
||||||
|
update:build-assets:
|
||||||
|
summary: Updates the build assets
|
||||||
|
dir: build
|
||||||
|
cmds:
|
||||||
|
- wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
|
||||||
|
|
||||||
|
build:server:
|
||||||
|
summary: Builds the application in server mode (no GUI, HTTP server only)
|
||||||
|
desc: |
|
||||||
|
Builds the application with the server build tag enabled.
|
||||||
|
Server mode runs as a pure HTTP server without native GUI dependencies.
|
||||||
|
Usage: task build:server
|
||||||
|
deps:
|
||||||
|
- task: build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
cmds:
|
||||||
|
- go build -tags server {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: "{{.BUILD_FLAGS}}"
|
||||||
|
|
||||||
|
run:server:
|
||||||
|
summary: Builds and runs the application in server mode
|
||||||
|
deps:
|
||||||
|
- task: build:server
|
||||||
|
cmds:
|
||||||
|
- ./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Builds a Docker image for server mode deployment
|
||||||
|
desc: |
|
||||||
|
Creates a minimal Docker image containing the server mode binary.
|
||||||
|
The image is based on distroless for security and small size.
|
||||||
|
Usage: task build:docker [TAG=myapp:latest]
|
||||||
|
cmds:
|
||||||
|
- docker build -t {{.TAG | default (printf "%s:latest" .APP_NAME)}} -f build/docker/Dockerfile.server .
|
||||||
|
vars:
|
||||||
|
TAG: "{{.TAG}}"
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required. Please install Docker first."
|
||||||
|
- sh: test -f build/docker/Dockerfile.server
|
||||||
|
msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
|
||||||
|
|
||||||
|
run:docker:
|
||||||
|
summary: Builds and runs the Docker image
|
||||||
|
desc: |
|
||||||
|
Builds the Docker image and runs it, exposing port 8080.
|
||||||
|
Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
|
||||||
|
Note: The internal container port is always 8080. The PORT variable
|
||||||
|
only changes the host port mapping. Ensure your app uses port 8080
|
||||||
|
or modify the Dockerfile to match your ServerOptions.Port setting.
|
||||||
|
deps:
|
||||||
|
- task: build:docker
|
||||||
|
vars:
|
||||||
|
TAG:
|
||||||
|
ref: .TAG
|
||||||
|
cmds:
|
||||||
|
- docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
|
||||||
|
vars:
|
||||||
|
TAG: "{{.TAG}}"
|
||||||
|
PORT: "{{.PORT}}"
|
||||||
|
|
||||||
|
setup:docker:
|
||||||
|
summary: Builds Docker image for cross-compilation (~800MB download)
|
||||||
|
desc: |
|
||||||
|
Builds the Docker image needed for cross-compiling to any platform.
|
||||||
|
Run this once to enable cross-platform builds from any OS.
|
||||||
|
cmds:
|
||||||
|
- docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required. Please install Docker first."
|
||||||
|
|
||||||
|
ios:device:list:
|
||||||
|
summary: Lists connected iOS devices (UDIDs)
|
||||||
|
cmds:
|
||||||
|
- xcrun xcdevice list
|
||||||
|
|
||||||
|
ios:run:device:
|
||||||
|
summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
|
||||||
|
vars:
|
||||||
|
PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/<YourProject>.xcodeproj
|
||||||
|
SCHEME: '{{.SCHEME}}' # e.g., ios.dev
|
||||||
|
CONFIG: '{{.CONFIG | default "Debug"}}'
|
||||||
|
DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
|
||||||
|
UDID: '{{.UDID}}' # from `task ios:device:list`
|
||||||
|
BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
|
||||||
|
TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
|
||||||
|
preconditions:
|
||||||
|
- sh: xcrun -f xcodebuild
|
||||||
|
msg: "xcodebuild not found. Please install Xcode."
|
||||||
|
- sh: xcrun -f devicectl
|
||||||
|
msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
|
||||||
|
- sh: test -n '{{.PROJECT}}'
|
||||||
|
msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
|
||||||
|
- sh: test -n '{{.SCHEME}}'
|
||||||
|
msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
|
||||||
|
- sh: test -n '{{.UDID}}'
|
||||||
|
msg: "Set UDID to your device UDID (see: task ios:device:list)."
|
||||||
|
- sh: test -n '{{.BUNDLE_ID}}'
|
||||||
|
msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
|
||||||
|
cmds:
|
||||||
|
- |
|
||||||
|
set -euo pipefail
|
||||||
|
echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
|
||||||
|
XCB_ARGS=(
|
||||||
|
-project "{{.PROJECT}}"
|
||||||
|
-scheme "{{.SCHEME}}"
|
||||||
|
-configuration "{{.CONFIG}}"
|
||||||
|
-destination "id={{.UDID}}"
|
||||||
|
-derivedDataPath "{{.DERIVED}}"
|
||||||
|
-allowProvisioningUpdates
|
||||||
|
-allowProvisioningDeviceRegistration
|
||||||
|
)
|
||||||
|
# Optionally inject signing identifiers if provided
|
||||||
|
if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
|
||||||
|
if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
|
||||||
|
xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
|
||||||
|
# If xcpretty isn't installed, run without it
|
||||||
|
if [ "${PIPESTATUS[0]}" -ne 0 ]; then
|
||||||
|
xcodebuild "${XCB_ARGS[@]}" build
|
||||||
|
fi
|
||||||
|
# Find built .app
|
||||||
|
APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
|
||||||
|
if [ -z "$APP_PATH" ]; then
|
||||||
|
echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "Installing: $APP_PATH"
|
||||||
|
xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
|
||||||
|
echo "Launching: {{.BUNDLE_ID}}"
|
||||||
|
xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"
|
||||||
13
client/ui/build/appicon.icon/Assets/wails_icon_vector.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<!--
|
||||||
|
macOS Icon Composer source. Designed on a 1024x1024 canvas with the bird
|
||||||
|
glyph centered and sized to ~75% of canvas width, leaving padding for
|
||||||
|
the system squircle treatment.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1024" height="1024" viewBox="0 0 1024 1024">
|
||||||
|
<g transform="translate(128, 227) scale(24.77)">
|
||||||
|
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
|
||||||
|
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
|
||||||
|
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 810 B |
26
client/ui/build/appicon.icon/icon.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"fill" : {
|
||||||
|
"solid" : "srgb:1.00000,1.00000,1.00000,1.00000"
|
||||||
|
},
|
||||||
|
"groups" : [
|
||||||
|
{
|
||||||
|
"layers" : [
|
||||||
|
{
|
||||||
|
"image-name" : "wails_icon_vector.svg",
|
||||||
|
"name" : "wails_icon_vector"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"shadow" : {
|
||||||
|
"kind" : "neutral",
|
||||||
|
"opacity" : 0.5
|
||||||
|
},
|
||||||
|
"specular" : true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"supported-platforms" : {
|
||||||
|
"circles" : [
|
||||||
|
"watchOS"
|
||||||
|
],
|
||||||
|
"squares" : "shared"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
client/ui/build/appicon.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 26 KiB |
@@ -1,5 +0,0 @@
|
|||||||
#!/bin/bash
|
|
||||||
sudo apt update
|
|
||||||
sudo apt remove gir1.2-appindicator3-0.1
|
|
||||||
sudo apt install -y libayatana-appindicator3-dev
|
|
||||||
go build
|
|
||||||
78
client/ui/build/config.yml
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
# This file contains the configuration for this project.
|
||||||
|
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
|
||||||
|
# Note that this will overwrite any changes you have made to the assets.
|
||||||
|
version: '3'
|
||||||
|
|
||||||
|
# This information is used to generate the build assets.
|
||||||
|
info:
|
||||||
|
companyName: "My Company" # The name of the company
|
||||||
|
productName: "My Product" # The name of the application
|
||||||
|
productIdentifier: "com.mycompany.myproduct" # The unique product identifier
|
||||||
|
description: "A program that does X" # The application description
|
||||||
|
copyright: "(c) 2025, My Company" # Copyright text
|
||||||
|
comments: "Some Product Comments" # Comments
|
||||||
|
version: "0.0.1" # The application version
|
||||||
|
# cfBundleIconName: "appicon" # The macOS icon name in Assets.car icon bundles (optional)
|
||||||
|
# # Should match the name of your .icon file without the extension
|
||||||
|
# # If not set and Assets.car exists, defaults to "appicon"
|
||||||
|
|
||||||
|
# iOS build configuration (uncomment to customise iOS project generation)
|
||||||
|
# Note: Keys under `ios` OVERRIDE values under `info` when set.
|
||||||
|
# ios:
|
||||||
|
# # The iOS bundle identifier used in the generated Xcode project (CFBundleIdentifier)
|
||||||
|
# bundleID: "com.mycompany.myproduct"
|
||||||
|
# # The display name shown under the app icon (CFBundleDisplayName/CFBundleName)
|
||||||
|
# displayName: "My Product"
|
||||||
|
# # The app version to embed in Info.plist (CFBundleShortVersionString/CFBundleVersion)
|
||||||
|
# version: "0.0.1"
|
||||||
|
# # The company/organisation name for templates and project settings
|
||||||
|
# company: "My Company"
|
||||||
|
# # Additional comments to embed in Info.plist metadata
|
||||||
|
# comments: "Some Product Comments"
|
||||||
|
|
||||||
|
# Dev mode configuration
|
||||||
|
dev_mode:
|
||||||
|
root_path: .
|
||||||
|
log_level: warn
|
||||||
|
debounce: 1000
|
||||||
|
ignore:
|
||||||
|
dir:
|
||||||
|
- .git
|
||||||
|
- node_modules
|
||||||
|
- frontend
|
||||||
|
- bin
|
||||||
|
file:
|
||||||
|
- .DS_Store
|
||||||
|
- .gitignore
|
||||||
|
- .gitkeep
|
||||||
|
watched_extension:
|
||||||
|
- "*.go"
|
||||||
|
- "*.js" # Watch for changes to JS/TS files included using the //wails:include directive.
|
||||||
|
- "*.ts" # The frontend directory will be excluded entirely by the setting above.
|
||||||
|
git_ignore: true
|
||||||
|
executes:
|
||||||
|
- cmd: wails3 build DEV=true
|
||||||
|
type: blocking
|
||||||
|
- cmd: wails3 task common:dev:frontend
|
||||||
|
type: background
|
||||||
|
- cmd: wails3 task run
|
||||||
|
type: primary
|
||||||
|
|
||||||
|
# File Associations
|
||||||
|
# More information at: https://v3.wails.io/noit/done/yet
|
||||||
|
fileAssociations:
|
||||||
|
# - ext: wails
|
||||||
|
# name: Wails
|
||||||
|
# description: Wails Application File
|
||||||
|
# iconName: wailsFileIcon
|
||||||
|
# role: Editor
|
||||||
|
# - ext: jpg
|
||||||
|
# name: JPEG
|
||||||
|
# description: Image File
|
||||||
|
# iconName: jpegFileIcon
|
||||||
|
# role: Editor
|
||||||
|
# mimeType: image/jpeg # (optional)
|
||||||
|
|
||||||
|
# Other data
|
||||||
|
other:
|
||||||
|
- name: My Other Data
|
||||||
BIN
client/ui/build/darwin/Assets.car
Normal file
38
client/ui/build/darwin/Info.dev.plist
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>NetBird</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>NetBird</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>netbird-ui</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.netbird.client</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.0.1</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>This is a comment</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.0.1</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>icons</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>appicon</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.15.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026, My Company</string>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
36
client/ui/build/darwin/Info.plist
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>NetBird</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>NetBird</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>netbird-ui</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>io.netbird.client</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>0.0.1</string>
|
||||||
|
<key>CFBundleGetInfoString</key>
|
||||||
|
<string>This is a comment</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.0.1</string>
|
||||||
|
<key>CFBundleIconFile</key>
|
||||||
|
<string>icons</string>
|
||||||
|
<key>CFBundleIconName</key>
|
||||||
|
<string>appicon</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>10.15.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<string>true</string>
|
||||||
|
<!-- Accessory mode: tray-only app, no Dock entry, no Cmd-Tab
|
||||||
|
presence. Matches the legacy Fyne client and the sign-pipelines
|
||||||
|
Info.plist used in signed .pkg releases. -->
|
||||||
|
<key>LSUIElement</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>NSHumanReadableCopyright</key>
|
||||||
|
<string>© 2026, My Company</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
210
client/ui/build/darwin/Taskfile.yml
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Signing configuration - edit these values for your project
|
||||||
|
# SIGN_IDENTITY: "Developer ID Application: Your Company (TEAMID)"
|
||||||
|
# KEYCHAIN_PROFILE: "my-notarize-profile"
|
||||||
|
# ENTITLEMENTS: "build/darwin/entitlements.plist"
|
||||||
|
|
||||||
|
# Docker image for cross-compilation (used when building on non-macOS)
|
||||||
|
CROSS_IMAGE: wails-cross
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application
|
||||||
|
cmds:
|
||||||
|
- task: '{{if eq OS "darwin"}}build:native{{else}}build:docker{{end}}'
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH}}'
|
||||||
|
DEV: '{{.DEV}}'
|
||||||
|
OUTPUT: '{{.OUTPUT}}'
|
||||||
|
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
|
||||||
|
vars:
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
|
||||||
|
build:native:
|
||||||
|
summary: Builds the application natively on macOS
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
DEV:
|
||||||
|
ref: .DEV
|
||||||
|
- task: common:generate:icons
|
||||||
|
cmds:
|
||||||
|
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
env:
|
||||||
|
GOOS: darwin
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
GOARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
CGO_CFLAGS: "-mmacosx-version-min=10.15"
|
||||||
|
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
|
||||||
|
MACOSX_DEPLOYMENT_TARGET: "10.15"
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Cross-compiles for macOS using Docker (for Linux/Windows hosts)
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:build:frontend
|
||||||
|
- task: common:generate:icons
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required for cross-compilation. Please install Docker."
|
||||||
|
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
|
||||||
|
msg: |
|
||||||
|
Docker image '{{.CROSS_IMAGE}}' not found.
|
||||||
|
Build it first: wails3 task setup:docker
|
||||||
|
cmds:
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} darwin {{.DOCKER_ARCH}}
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
|
||||||
|
- mkdir -p {{.BIN_DIR}}
|
||||||
|
- mv "bin/{{.APP_NAME}}-darwin-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
|
||||||
|
vars:
|
||||||
|
DOCKER_ARCH: '{{if eq .ARCH "arm64"}}arm64{{else if eq .ARCH "amd64"}}amd64{{else}}arm64{{end}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
# Mount Go module cache for faster builds
|
||||||
|
GO_CACHE_MOUNT:
|
||||||
|
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
|
||||||
|
# Extract replace directives from go.mod and create -v mounts for each
|
||||||
|
# Handles both relative (=> ../) and absolute (=> /) paths
|
||||||
|
REPLACE_MOUNTS:
|
||||||
|
sh: |
|
||||||
|
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
|
||||||
|
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
|
||||||
|
# Convert relative paths to absolute
|
||||||
|
if [ "${path#/}" = "$path" ]; then
|
||||||
|
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
|
||||||
|
fi
|
||||||
|
# Only mount if directory exists
|
||||||
|
if [ -d "$path" ]; then
|
||||||
|
echo "-v $path:$path:ro"
|
||||||
|
fi
|
||||||
|
done | tr '\n' ' '
|
||||||
|
|
||||||
|
build:universal:
|
||||||
|
summary: Builds darwin universal binary (arm64 + amd64)
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
ARCH: amd64
|
||||||
|
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
|
||||||
|
- task: build
|
||||||
|
vars:
|
||||||
|
ARCH: arm64
|
||||||
|
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
cmds:
|
||||||
|
- task: '{{if eq OS "darwin"}}build:universal:lipo:native{{else}}build:universal:lipo:go{{end}}'
|
||||||
|
|
||||||
|
build:universal:lipo:native:
|
||||||
|
summary: Creates universal binary using native lipo (macOS)
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
- rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
|
||||||
|
build:universal:lipo:go:
|
||||||
|
summary: Creates universal binary using wails3 tool lipo (Linux/Windows)
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- wails3 tool lipo -output "{{.BIN_DIR}}/{{.APP_NAME}}" -input "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" -input "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
- rm -f "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages the application into a `.app` bundle
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: create:app:bundle
|
||||||
|
|
||||||
|
package:universal:
|
||||||
|
summary: Packages darwin universal binary (arm64 + amd64)
|
||||||
|
deps:
|
||||||
|
- task: build:universal
|
||||||
|
cmds:
|
||||||
|
- task: create:app:bundle
|
||||||
|
|
||||||
|
|
||||||
|
create:app:bundle:
|
||||||
|
summary: Creates an `.app` bundle
|
||||||
|
cmds:
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
|
||||||
|
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
|
||||||
|
- |
|
||||||
|
if [ -f build/darwin/Assets.car ]; then
|
||||||
|
cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources"
|
||||||
|
fi
|
||||||
|
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS"
|
||||||
|
- cp build/darwin/Info.plist "{{.BIN_DIR}}/{{.APP_NAME}}.app/Contents"
|
||||||
|
- task: '{{if eq OS "darwin"}}codesign:adhoc{{else}}codesign:skip{{end}}'
|
||||||
|
|
||||||
|
codesign:adhoc:
|
||||||
|
summary: Ad-hoc signs the app bundle (macOS only)
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.app"
|
||||||
|
|
||||||
|
codesign:skip:
|
||||||
|
summary: Skips codesigning when cross-compiling
|
||||||
|
internal: true
|
||||||
|
cmds:
|
||||||
|
- 'echo "Skipping codesign (not available on {{OS}}). Sign the .app on macOS before distribution."'
|
||||||
|
|
||||||
|
run:
|
||||||
|
deps:
|
||||||
|
- task: common:generate:icons
|
||||||
|
cmds:
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
|
||||||
|
- mkdir -p "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||||
|
- cp build/darwin/icons.icns "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||||
|
- |
|
||||||
|
if [ -f build/darwin/Assets.car ]; then
|
||||||
|
cp build/darwin/Assets.car "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources"
|
||||||
|
fi
|
||||||
|
- cp "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS"
|
||||||
|
- cp "build/darwin/Info.dev.plist" "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist"
|
||||||
|
- codesign --force --deep --sign - "{{.BIN_DIR}}/{{.APP_NAME}}.dev.app"
|
||||||
|
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}'
|
||||||
|
|
||||||
|
sign:
|
||||||
|
summary: Signs the application bundle with Developer ID
|
||||||
|
desc: |
|
||||||
|
Signs the .app bundle for distribution.
|
||||||
|
Configure SIGN_IDENTITY in the vars section at the top of this file.
|
||||||
|
deps:
|
||||||
|
- task: package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
|
||||||
|
msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
|
||||||
|
|
||||||
|
sign:notarize:
|
||||||
|
summary: Signs and notarizes the application bundle
|
||||||
|
desc: |
|
||||||
|
Signs the .app bundle and submits it for notarization.
|
||||||
|
Configure SIGN_IDENTITY and KEYCHAIN_PROFILE in the vars section at the top of this file.
|
||||||
|
|
||||||
|
Setup (one-time):
|
||||||
|
wails3 signing credentials --apple-id "you@email.com" --team-id "TEAMID" --password "app-specific-password" --profile "my-profile"
|
||||||
|
deps:
|
||||||
|
- task: package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.app" --identity "{{.SIGN_IDENTITY}}" {{if .ENTITLEMENTS}}--entitlements {{.ENTITLEMENTS}}{{end}} --notarize --keychain-profile {{.KEYCHAIN_PROFILE}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.SIGN_IDENTITY}}" ]'
|
||||||
|
msg: "SIGN_IDENTITY is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
|
||||||
|
- sh: '[ -n "{{.KEYCHAIN_PROFILE}}" ]'
|
||||||
|
msg: "KEYCHAIN_PROFILE is required. Set it in the vars section at the top of build/darwin/Taskfile.yml"
|
||||||
BIN
client/ui/build/darwin/icons.icns
Normal file
203
client/ui/build/docker/Dockerfile.cross
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
# Cross-compile Wails v3 apps to any platform
|
||||||
|
#
|
||||||
|
# Darwin: Zig + macOS SDK
|
||||||
|
# Linux: Native GCC when host matches target, Zig for cross-arch
|
||||||
|
# Windows: Zig + bundled mingw
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# docker build -t wails-cross -f Dockerfile.cross .
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross darwin arm64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross darwin amd64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross linux amd64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross linux arm64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross windows amd64
|
||||||
|
# docker run --rm -v $(pwd):/app wails-cross windows arm64
|
||||||
|
|
||||||
|
FROM golang:1.25-bookworm
|
||||||
|
|
||||||
|
ARG TARGETARCH
|
||||||
|
|
||||||
|
# Install base tools, GCC, and GTK/WebKit dev packages
|
||||||
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
curl xz-utils nodejs npm pkg-config gcc libc6-dev \
|
||||||
|
libgtk-3-dev libwebkit2gtk-4.1-dev \
|
||||||
|
libgtk-4-dev libwebkitgtk-6.0-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Install Zig - automatically selects correct binary for host architecture
|
||||||
|
ARG ZIG_VERSION=0.14.0
|
||||||
|
RUN ZIG_ARCH=$(case "${TARGETARCH}" in arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||||
|
curl -L "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}.tar.xz" \
|
||||||
|
| tar -xJ -C /opt \
|
||||||
|
&& ln -s /opt/zig-linux-${ZIG_ARCH}-${ZIG_VERSION}/zig /usr/local/bin/zig
|
||||||
|
|
||||||
|
# Download macOS SDK (required for darwin targets)
|
||||||
|
ARG MACOS_SDK_VERSION=14.5
|
||||||
|
RUN curl -L "https://github.com/joseluisq/macosx-sdks/releases/download/${MACOS_SDK_VERSION}/MacOSX${MACOS_SDK_VERSION}.sdk.tar.xz" \
|
||||||
|
| tar -xJ -C /opt \
|
||||||
|
&& mv /opt/MacOSX${MACOS_SDK_VERSION}.sdk /opt/macos-sdk
|
||||||
|
|
||||||
|
ENV MACOS_SDK_PATH=/opt/macos-sdk
|
||||||
|
|
||||||
|
# Create Zig CC wrappers for cross-compilation targets
|
||||||
|
# Darwin and Windows use Zig; Linux uses native GCC (run with --platform for cross-arch)
|
||||||
|
|
||||||
|
# Darwin arm64
|
||||||
|
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-arm64
|
||||||
|
#!/bin/sh
|
||||||
|
ARGS=""
|
||||||
|
SKIP_NEXT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ $SKIP_NEXT -eq 1 ]; then
|
||||||
|
SKIP_NEXT=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-target) SKIP_NEXT=1 ;;
|
||||||
|
-mmacosx-version-min=*) ;;
|
||||||
|
*) ARGS="$ARGS $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec zig cc -fno-sanitize=all -target aarch64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
|
||||||
|
ZIGWRAP
|
||||||
|
RUN chmod +x /usr/local/bin/zcc-darwin-arm64
|
||||||
|
|
||||||
|
# Darwin amd64
|
||||||
|
COPY <<'ZIGWRAP' /usr/local/bin/zcc-darwin-amd64
|
||||||
|
#!/bin/sh
|
||||||
|
ARGS=""
|
||||||
|
SKIP_NEXT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ $SKIP_NEXT -eq 1 ]; then
|
||||||
|
SKIP_NEXT=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-target) SKIP_NEXT=1 ;;
|
||||||
|
-mmacosx-version-min=*) ;;
|
||||||
|
*) ARGS="$ARGS $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec zig cc -fno-sanitize=all -target x86_64-macos-none -isysroot /opt/macos-sdk -I/opt/macos-sdk/usr/include -L/opt/macos-sdk/usr/lib -F/opt/macos-sdk/System/Library/Frameworks -w $ARGS
|
||||||
|
ZIGWRAP
|
||||||
|
RUN chmod +x /usr/local/bin/zcc-darwin-amd64
|
||||||
|
|
||||||
|
# Windows amd64 - uses Zig's bundled mingw
|
||||||
|
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-amd64
|
||||||
|
#!/bin/sh
|
||||||
|
ARGS=""
|
||||||
|
SKIP_NEXT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ $SKIP_NEXT -eq 1 ]; then
|
||||||
|
SKIP_NEXT=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-target) SKIP_NEXT=1 ;;
|
||||||
|
-Wl,*) ;;
|
||||||
|
*) ARGS="$ARGS $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec zig cc -target x86_64-windows-gnu $ARGS
|
||||||
|
ZIGWRAP
|
||||||
|
RUN chmod +x /usr/local/bin/zcc-windows-amd64
|
||||||
|
|
||||||
|
# Windows arm64 - uses Zig's bundled mingw
|
||||||
|
COPY <<'ZIGWRAP' /usr/local/bin/zcc-windows-arm64
|
||||||
|
#!/bin/sh
|
||||||
|
ARGS=""
|
||||||
|
SKIP_NEXT=0
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ $SKIP_NEXT -eq 1 ]; then
|
||||||
|
SKIP_NEXT=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
case "$arg" in
|
||||||
|
-target) SKIP_NEXT=1 ;;
|
||||||
|
-Wl,*) ;;
|
||||||
|
*) ARGS="$ARGS $arg" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
exec zig cc -target aarch64-windows-gnu $ARGS
|
||||||
|
ZIGWRAP
|
||||||
|
RUN chmod +x /usr/local/bin/zcc-windows-arm64
|
||||||
|
|
||||||
|
# Build script
|
||||||
|
COPY <<'SCRIPT' /usr/local/bin/build.sh
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
OS=${1:-darwin}
|
||||||
|
ARCH=${2:-arm64}
|
||||||
|
|
||||||
|
case "${OS}-${ARCH}" in
|
||||||
|
darwin-arm64|darwin-aarch64)
|
||||||
|
export CC=zcc-darwin-arm64
|
||||||
|
export GOARCH=arm64
|
||||||
|
export GOOS=darwin
|
||||||
|
;;
|
||||||
|
darwin-amd64|darwin-x86_64)
|
||||||
|
export CC=zcc-darwin-amd64
|
||||||
|
export GOARCH=amd64
|
||||||
|
export GOOS=darwin
|
||||||
|
;;
|
||||||
|
linux-arm64|linux-aarch64)
|
||||||
|
export CC=gcc
|
||||||
|
export GOARCH=arm64
|
||||||
|
export GOOS=linux
|
||||||
|
;;
|
||||||
|
linux-amd64|linux-x86_64)
|
||||||
|
export CC=gcc
|
||||||
|
export GOARCH=amd64
|
||||||
|
export GOOS=linux
|
||||||
|
;;
|
||||||
|
windows-arm64|windows-aarch64)
|
||||||
|
export CC=zcc-windows-arm64
|
||||||
|
export GOARCH=arm64
|
||||||
|
export GOOS=windows
|
||||||
|
;;
|
||||||
|
windows-amd64|windows-x86_64)
|
||||||
|
export CC=zcc-windows-amd64
|
||||||
|
export GOARCH=amd64
|
||||||
|
export GOOS=windows
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "Usage: <os> <arch>"
|
||||||
|
echo " os: darwin, linux, windows"
|
||||||
|
echo " arch: amd64, arm64"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
export CGO_ENABLED=1
|
||||||
|
export CGO_CFLAGS="-w"
|
||||||
|
|
||||||
|
# Build frontend if exists and not already built (host may have built it)
|
||||||
|
if [ -d "frontend" ] && [ -f "frontend/package.json" ] && [ ! -d "frontend/dist" ]; then
|
||||||
|
(cd frontend && npm install --silent && npm run build --silent)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Build
|
||||||
|
APP=${APP_NAME:-$(basename $(pwd))}
|
||||||
|
mkdir -p bin
|
||||||
|
|
||||||
|
EXT=""
|
||||||
|
LDFLAGS="-s -w"
|
||||||
|
if [ "$GOOS" = "windows" ]; then
|
||||||
|
EXT=".exe"
|
||||||
|
LDFLAGS="-s -w -H windowsgui"
|
||||||
|
fi
|
||||||
|
|
||||||
|
TAGS="production"
|
||||||
|
if [ -n "$EXTRA_TAGS" ]; then
|
||||||
|
TAGS="${TAGS},${EXTRA_TAGS}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
go build -tags "$TAGS" -trimpath -buildvcs=false -ldflags="$LDFLAGS" -o bin/${APP}-${GOOS}-${GOARCH}${EXT} .
|
||||||
|
echo "Built: bin/${APP}-${GOOS}-${GOARCH}${EXT}"
|
||||||
|
SCRIPT
|
||||||
|
RUN chmod +x /usr/local/bin/build.sh
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
ENTRYPOINT ["/usr/local/bin/build.sh"]
|
||||||
|
CMD ["darwin", "arm64"]
|
||||||
41
client/ui/build/docker/Dockerfile.server
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
# Wails Server Mode Dockerfile
|
||||||
|
# Multi-stage build for minimal image size
|
||||||
|
|
||||||
|
# Build stage
|
||||||
|
FROM golang:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install build dependencies
|
||||||
|
RUN apk add --no-cache git
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Remove local replace directive if present (for production builds)
|
||||||
|
RUN sed -i '/^replace/d' go.mod || true
|
||||||
|
|
||||||
|
# Download dependencies
|
||||||
|
RUN go mod tidy
|
||||||
|
|
||||||
|
# Build the server binary
|
||||||
|
RUN go build -tags server -ldflags="-s -w" -o server .
|
||||||
|
|
||||||
|
# Runtime stage - minimal image
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
|
||||||
|
# Copy the binary
|
||||||
|
COPY --from=builder /app/server /server
|
||||||
|
|
||||||
|
# Copy frontend assets
|
||||||
|
COPY --from=builder /app/frontend/dist /frontend/dist
|
||||||
|
|
||||||
|
# Expose the default port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Bind to all interfaces (required for Docker)
|
||||||
|
# Can be overridden at runtime with -e WAILS_SERVER_HOST=...
|
||||||
|
ENV WAILS_SERVER_HOST=0.0.0.0
|
||||||
|
|
||||||
|
# Run the server
|
||||||
|
ENTRYPOINT ["/server"]
|
||||||
235
client/ui/build/linux/Taskfile.yml
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Signing configuration - edit these values for your project
|
||||||
|
# PGP_KEY: "path/to/signing-key.asc"
|
||||||
|
# SIGN_ROLE: "builder" # Options: origin, maint, archive, builder
|
||||||
|
#
|
||||||
|
# Password is stored securely in system keychain. Run: wails3 setup signing
|
||||||
|
|
||||||
|
# Docker image for cross-compilation (used when building on non-Linux or no CC available)
|
||||||
|
CROSS_IMAGE: wails-cross
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application for Linux
|
||||||
|
cmds:
|
||||||
|
# Linux requires CGO - use Docker when:
|
||||||
|
# 1. Cross-compiling from non-Linux, OR
|
||||||
|
# 2. No C compiler is available, OR
|
||||||
|
# 3. Target architecture differs from host architecture (cross-arch compilation)
|
||||||
|
- task: '{{if and (eq OS "linux") (eq .HAS_CC "true") (eq .TARGET_ARCH ARCH)}}build:native{{else}}build:docker{{end}}'
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH}}'
|
||||||
|
DEV: '{{.DEV}}'
|
||||||
|
OUTPUT: '{{.OUTPUT}}'
|
||||||
|
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
|
||||||
|
vars:
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
# Determine target architecture (defaults to host ARCH if not specified)
|
||||||
|
TARGET_ARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
# Check if a C compiler is available (gcc or clang)
|
||||||
|
HAS_CC:
|
||||||
|
sh: '(command -v gcc >/dev/null 2>&1 || command -v clang >/dev/null 2>&1) && echo "true" || echo "false"'
|
||||||
|
|
||||||
|
build:native:
|
||||||
|
summary: Builds the application natively on Linux
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
DEV:
|
||||||
|
ref: .DEV
|
||||||
|
- task: common:generate:icons
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
cmds:
|
||||||
|
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
env:
|
||||||
|
GOOS: linux
|
||||||
|
CGO_ENABLED: 1
|
||||||
|
GOARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Builds for Linux using Docker (for non-Linux hosts or when no C compiler available)
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:build:frontend
|
||||||
|
- task: common:generate:icons
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required for cross-compilation to Linux. Please install Docker."
|
||||||
|
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
|
||||||
|
msg: |
|
||||||
|
Docker image '{{.CROSS_IMAGE}}' not found.
|
||||||
|
Build it first: wails3 task setup:docker
|
||||||
|
cmds:
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} "{{.CROSS_IMAGE}}" linux {{.DOCKER_ARCH}}
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
|
||||||
|
- mkdir -p {{.BIN_DIR}}
|
||||||
|
- mv "bin/{{.APP_NAME}}-linux-{{.DOCKER_ARCH}}" "{{.OUTPUT}}"
|
||||||
|
vars:
|
||||||
|
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
|
||||||
|
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
|
||||||
|
# Mount Go module cache for faster builds
|
||||||
|
GO_CACHE_MOUNT:
|
||||||
|
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
|
||||||
|
# Extract replace directives from go.mod and create -v mounts for each
|
||||||
|
REPLACE_MOUNTS:
|
||||||
|
sh: |
|
||||||
|
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
|
||||||
|
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
|
||||||
|
# Convert relative paths to absolute
|
||||||
|
if [ "${path#/}" = "$path" ]; then
|
||||||
|
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
|
||||||
|
fi
|
||||||
|
# Only mount if directory exists
|
||||||
|
if [ -d "$path" ]; then
|
||||||
|
echo "-v $path:$path:ro"
|
||||||
|
fi
|
||||||
|
done | tr '\n' ' '
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages the application for Linux
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: create:appimage
|
||||||
|
- task: create:deb
|
||||||
|
- task: create:rpm
|
||||||
|
- task: create:aur
|
||||||
|
|
||||||
|
create:appimage:
|
||||||
|
summary: Creates an AppImage
|
||||||
|
dir: build/linux/appimage
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
cmds:
|
||||||
|
- cp "{{.APP_BINARY}}" "{{.APP_NAME}}"
|
||||||
|
- cp ../../appicon.png "{{.APP_NAME}}.png"
|
||||||
|
- wails3 generate appimage -binary "{{.APP_NAME}}" -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
|
||||||
|
vars:
|
||||||
|
APP_NAME: '{{.APP_NAME}}'
|
||||||
|
APP_BINARY: '../../../bin/{{.APP_NAME}}'
|
||||||
|
ICON: '{{.APP_NAME}}.png'
|
||||||
|
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
|
||||||
|
OUTPUT_DIR: '../../../bin'
|
||||||
|
|
||||||
|
create:deb:
|
||||||
|
summary: Creates a deb package
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
- task: generate:deb
|
||||||
|
|
||||||
|
create:rpm:
|
||||||
|
summary: Creates a rpm package
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
- task: generate:rpm
|
||||||
|
|
||||||
|
create:aur:
|
||||||
|
summary: Creates a arch linux packager package
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- task: generate:dotdesktop
|
||||||
|
- task: generate:aur
|
||||||
|
|
||||||
|
generate:deb:
|
||||||
|
summary: Creates a deb package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool package -name "{{.APP_NAME}}" -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||||
|
|
||||||
|
generate:rpm:
|
||||||
|
summary: Creates a rpm package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool package -name "{{.APP_NAME}}" -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||||
|
|
||||||
|
generate:aur:
|
||||||
|
summary: Creates a arch linux packager package
|
||||||
|
cmds:
|
||||||
|
- wails3 tool package -name "{{.APP_NAME}}" -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
|
||||||
|
|
||||||
|
generate:dotdesktop:
|
||||||
|
summary: Generates a `.desktop` file
|
||||||
|
dir: build
|
||||||
|
cmds:
|
||||||
|
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
|
||||||
|
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile "{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop" -categories "{{.CATEGORIES}}"
|
||||||
|
# Wrap Exec= with `env WEBKIT_DISABLE_DMABUF_RENDERER=1 ...` so launches
|
||||||
|
# from any desktop environment use the working renderer. See build/linux/Taskfile.yml :run for the matching dev-mode env block.
|
||||||
|
- sed -i -E 's|^Exec=([^ ]+)(.*)$|Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 \1\2|' {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop
|
||||||
|
vars:
|
||||||
|
APP_NAME: '{{.APP_NAME}}'
|
||||||
|
EXEC: '{{.APP_NAME}}'
|
||||||
|
ICON: '{{.APP_NAME}}'
|
||||||
|
CATEGORIES: 'Development;'
|
||||||
|
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
|
||||||
|
|
||||||
|
run:
|
||||||
|
cmds:
|
||||||
|
- '{{.BIN_DIR}}/{{.APP_NAME}}'
|
||||||
|
env:
|
||||||
|
# WebKitGTK 2.50's default DMA-BUF renderer fails on RDP, VirtualBox/QEMU,
|
||||||
|
# and some bare WMs (Fluxbox, dwm) where DRM dumb-buffer access is
|
||||||
|
# restricted. Disabling it falls back to the GLES2/cairo path which works
|
||||||
|
# everywhere. Production launchers must set this too.
|
||||||
|
WEBKIT_DISABLE_DMABUF_RENDERER: "1"
|
||||||
|
|
||||||
|
sign:deb:
|
||||||
|
summary: Signs the DEB package
|
||||||
|
desc: |
|
||||||
|
Signs the .deb package with a PGP key.
|
||||||
|
Configure PGP_KEY in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
deps:
|
||||||
|
- task: create:deb
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.deb" --pgp-key {{.PGP_KEY}} {{if .SIGN_ROLE}}--role {{.SIGN_ROLE}}{{end}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||||
|
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||||
|
|
||||||
|
sign:rpm:
|
||||||
|
summary: Signs the RPM package
|
||||||
|
desc: |
|
||||||
|
Signs the .rpm package with a PGP key.
|
||||||
|
Configure PGP_KEY in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
deps:
|
||||||
|
- task: create:rpm
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}*.rpm" --pgp-key {{.PGP_KEY}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||||
|
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||||
|
|
||||||
|
sign:packages:
|
||||||
|
summary: Signs all Linux packages (DEB and RPM)
|
||||||
|
desc: |
|
||||||
|
Signs both .deb and .rpm packages with a PGP key.
|
||||||
|
Configure PGP_KEY in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
cmds:
|
||||||
|
- task: sign:deb
|
||||||
|
- task: sign:rpm
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.PGP_KEY}}" ]'
|
||||||
|
msg: "PGP_KEY is required. Set it in the vars section at the top of build/linux/Taskfile.yml"
|
||||||
35
client/ui/build/linux/appimage/build.sh
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Copyright (c) 2018-Present Lea Anthony
|
||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
|
||||||
|
# Fail script on any error
|
||||||
|
set -euxo pipefail
|
||||||
|
|
||||||
|
# Define variables
|
||||||
|
APP_DIR="${APP_NAME}.AppDir"
|
||||||
|
|
||||||
|
# Create AppDir structure
|
||||||
|
mkdir -p "${APP_DIR}/usr/bin"
|
||||||
|
cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
|
||||||
|
cp "${ICON_PATH}" "${APP_DIR}/"
|
||||||
|
cp "${DESKTOP_FILE}" "${APP_DIR}/"
|
||||||
|
|
||||||
|
if [[ $(uname -m) == *x86_64* ]]; then
|
||||||
|
# Download linuxdeploy and make it executable
|
||||||
|
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
|
||||||
|
chmod +x linuxdeploy-x86_64.AppImage
|
||||||
|
|
||||||
|
# Run linuxdeploy to bundle the application
|
||||||
|
./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
|
||||||
|
else
|
||||||
|
# Download linuxdeploy and make it executable (arm64)
|
||||||
|
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
|
||||||
|
chmod +x linuxdeploy-aarch64.AppImage
|
||||||
|
|
||||||
|
# Run linuxdeploy to bundle the application (arm64)
|
||||||
|
./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Rename the generated AppImage
|
||||||
|
mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"
|
||||||
|
|
||||||
13
client/ui/build/linux/desktop
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Version=1.0
|
||||||
|
Name=NetBird
|
||||||
|
Comment=NetBird desktop client
|
||||||
|
# The Exec line includes %u to pass the URL to the application
|
||||||
|
Exec=/usr/local/bin/netbird-ui %u
|
||||||
|
Terminal=false
|
||||||
|
Type=Application
|
||||||
|
Icon=netbird-ui
|
||||||
|
Categories=Utility;
|
||||||
|
StartupWMClass=netbird-ui
|
||||||
|
|
||||||
|
|
||||||
10
client/ui/build/linux/netbird-ui.desktop
Executable file
@@ -0,0 +1,10 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=netbird-ui
|
||||||
|
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 netbird-ui
|
||||||
|
Icon=netbird-ui
|
||||||
|
Categories=Development;
|
||||||
|
Terminal=false
|
||||||
|
Keywords=wails
|
||||||
|
Version=1.0
|
||||||
|
StartupNotify=false
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
[Desktop Entry]
|
[Desktop Entry]
|
||||||
Name=Netbird
|
Name=Netbird
|
||||||
Exec=/usr/bin/netbird-ui
|
Exec=env WEBKIT_DISABLE_DMABUF_RENDERER=1 /usr/bin/netbird-ui
|
||||||
Icon=netbird
|
Icon=netbird
|
||||||
Type=Application
|
Type=Application
|
||||||
Terminal=false
|
Terminal=false
|
||||||
Categories=Utility;
|
Categories=Utility;
|
||||||
Keywords=netbird;
|
Keywords=netbird;
|
||||||
67
client/ui/build/linux/nfpm/nfpm.yaml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# Feel free to remove those if you don't want/need to use them.
|
||||||
|
# Make sure to check the documentation at https://nfpm.goreleaser.com
|
||||||
|
#
|
||||||
|
# The lines below are called `modelines`. See `:help modeline`
|
||||||
|
|
||||||
|
name: "netbird-ui"
|
||||||
|
arch: ${GOARCH}
|
||||||
|
platform: "linux"
|
||||||
|
version: "0.0.1"
|
||||||
|
section: "default"
|
||||||
|
priority: "extra"
|
||||||
|
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
|
||||||
|
description: "NetBird desktop client"
|
||||||
|
vendor: "NetBird"
|
||||||
|
homepage: "https://wails.io"
|
||||||
|
license: "MIT"
|
||||||
|
release: "1"
|
||||||
|
|
||||||
|
contents:
|
||||||
|
- src: "./bin/netbird-ui"
|
||||||
|
dst: "/usr/local/bin/netbird-ui"
|
||||||
|
- src: "./build/appicon.png"
|
||||||
|
dst: "/usr/share/icons/hicolor/128x128/apps/netbird-ui.png"
|
||||||
|
- src: "./build/linux/netbird-ui.desktop"
|
||||||
|
dst: "/usr/share/applications/netbird-ui.desktop"
|
||||||
|
|
||||||
|
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
|
||||||
|
depends:
|
||||||
|
- libgtk-3-0
|
||||||
|
- libwebkit2gtk-4.1-0
|
||||||
|
|
||||||
|
# Distribution-specific overrides for different package formats and WebKit versions
|
||||||
|
overrides:
|
||||||
|
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
|
||||||
|
rpm:
|
||||||
|
depends:
|
||||||
|
- gtk3
|
||||||
|
- webkit2gtk4.1
|
||||||
|
|
||||||
|
# Arch Linux packages (WebKit 4.1)
|
||||||
|
archlinux:
|
||||||
|
depends:
|
||||||
|
- gtk3
|
||||||
|
- webkit2gtk-4.1
|
||||||
|
|
||||||
|
# scripts section to ensure desktop database is updated after install
|
||||||
|
scripts:
|
||||||
|
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
|
||||||
|
# You can also add preremove, postremove if needed
|
||||||
|
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
|
||||||
|
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
|
||||||
|
|
||||||
|
# replaces:
|
||||||
|
# - foobar
|
||||||
|
# provides:
|
||||||
|
# - bar
|
||||||
|
# depends:
|
||||||
|
# - gtk3
|
||||||
|
# - libwebkit2gtk
|
||||||
|
# recommends:
|
||||||
|
# - whatever
|
||||||
|
# suggests:
|
||||||
|
# - something-else
|
||||||
|
# conflicts:
|
||||||
|
# - not-foo
|
||||||
|
# - not-bar
|
||||||
|
# changelog: "changelog.yaml"
|
||||||
21
client/ui/build/linux/nfpm/scripts/postinstall.sh
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
# Update desktop database for .desktop file changes
|
||||||
|
# This makes the application appear in application menus and registers its capabilities.
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then
|
||||||
|
echo "Updating desktop database..."
|
||||||
|
update-desktop-database -q /usr/share/applications
|
||||||
|
else
|
||||||
|
echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Update MIME database for custom URL schemes (x-scheme-handler)
|
||||||
|
# This ensures the system knows how to handle your custom protocols.
|
||||||
|
if command -v update-mime-database >/dev/null 2>&1; then
|
||||||
|
echo "Updating MIME database..."
|
||||||
|
update-mime-database -n /usr/share/mime
|
||||||
|
else
|
||||||
|
echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
1
client/ui/build/linux/nfpm/scripts/postremove.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#!/bin/bash
|
||||||
1
client/ui/build/linux/nfpm/scripts/preinstall.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#!/bin/bash
|
||||||
1
client/ui/build/linux/nfpm/scripts/preremove.sh
Normal file
@@ -0,0 +1 @@
|
|||||||
|
#!/bin/bash
|
||||||
236
client/ui/build/windows/Taskfile.yml
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
version: '3'
|
||||||
|
|
||||||
|
includes:
|
||||||
|
common: ../Taskfile.yml
|
||||||
|
|
||||||
|
vars:
|
||||||
|
# Signing configuration - edit these values for your project
|
||||||
|
# SIGN_CERTIFICATE: "path/to/certificate.pfx"
|
||||||
|
# SIGN_THUMBPRINT: "certificate-thumbprint" # Alternative to SIGN_CERTIFICATE
|
||||||
|
# TIMESTAMP_SERVER: "http://timestamp.digicert.com"
|
||||||
|
#
|
||||||
|
# Password is stored securely in system keychain. Run: wails3 setup signing
|
||||||
|
|
||||||
|
# Docker image for cross-compilation with CGO (used when CGO_ENABLED=1 on non-Windows)
|
||||||
|
CROSS_IMAGE: wails-cross
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
build:
|
||||||
|
summary: Builds the application for Windows
|
||||||
|
cmds:
|
||||||
|
# CGO Windows builds from Linux use mingw-w64 (lighter than docker).
|
||||||
|
# Docker is only needed if mingw-w64 is unavailable.
|
||||||
|
- task: build:native
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH}}'
|
||||||
|
DEV: '{{.DEV}}'
|
||||||
|
EXTRA_TAGS: '{{.EXTRA_TAGS}}'
|
||||||
|
vars:
|
||||||
|
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||||
|
|
||||||
|
build:console:
|
||||||
|
summary: Builds a console-attached Windows binary so logs go to the terminal.
|
||||||
|
desc: |
|
||||||
|
Same as `windows:build` but links against the console PE subsystem
|
||||||
|
instead of windowsgui, so stdout/stderr (logrus, panics) print to the
|
||||||
|
terminal that launched the .exe. Useful for chasing tray, event-stream,
|
||||||
|
or daemon-RPC bugs that have no other feedback channel on Windows.
|
||||||
|
|
||||||
|
Output is bin/netbird-ui-console.exe — kept distinct so the production
|
||||||
|
binary built by `windows:build` isn't shadowed.
|
||||||
|
|
||||||
|
Cross-compile from Linux works the same way:
|
||||||
|
CGO_ENABLED=1 task windows:build:console
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
DEV:
|
||||||
|
ref: .DEV
|
||||||
|
- task: common:generate:icons
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}'
|
||||||
|
msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)"
|
||||||
|
cmds:
|
||||||
|
- task: generate:syso
|
||||||
|
- go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-console.exe"
|
||||||
|
- cmd: powershell Remove-item *.syso
|
||||||
|
platforms: [windows]
|
||||||
|
- cmd: rm -f *.syso
|
||||||
|
platforms: [linux, darwin]
|
||||||
|
vars:
|
||||||
|
# Identical to build:native's flags except no -H windowsgui, so the
|
||||||
|
# binary attaches to the launching console.
|
||||||
|
BUILD_FLAGS: '-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"'
|
||||||
|
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||||
|
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
|
||||||
|
env:
|
||||||
|
GOOS: windows
|
||||||
|
CGO_ENABLED: '{{.CGO_ENABLED}}'
|
||||||
|
GOARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
CC: '{{.CC}}'
|
||||||
|
|
||||||
|
build:native:
|
||||||
|
summary: Builds for Windows natively, or cross-compiles from Linux/macOS via mingw-w64.
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:go:mod:tidy
|
||||||
|
- task: common:build:frontend
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS:
|
||||||
|
ref: .BUILD_FLAGS
|
||||||
|
DEV:
|
||||||
|
ref: .DEV
|
||||||
|
- task: common:generate:icons
|
||||||
|
preconditions:
|
||||||
|
# When cross-compiling with CGO from a non-Windows host, the mingw-w64
|
||||||
|
# cross-gcc must be present. Native Windows builds skip this check.
|
||||||
|
- sh: '[ "{{OS}}" = "windows" ] || [ "{{.CGO_ENABLED}}" != "1" ] || command -v {{.CC}}'
|
||||||
|
msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64 (Debian/Ubuntu) / sudo dnf install mingw64-gcc (Fedora)"
|
||||||
|
cmds:
|
||||||
|
- task: generate:syso
|
||||||
|
- go build {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}.exe"
|
||||||
|
- cmd: powershell Remove-item *.syso
|
||||||
|
platforms: [windows]
|
||||||
|
- cmd: rm -f *.syso
|
||||||
|
platforms: [linux, darwin]
|
||||||
|
vars:
|
||||||
|
BUILD_FLAGS: '{{if eq .DEV "true"}}{{if .EXTRA_TAGS}}-tags {{.EXTRA_TAGS}} {{end}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{end}}'
|
||||||
|
CGO_ENABLED: '{{.CGO_ENABLED | default "0"}}'
|
||||||
|
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
|
||||||
|
env:
|
||||||
|
GOOS: windows
|
||||||
|
CGO_ENABLED: '{{.CGO_ENABLED}}'
|
||||||
|
GOARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
CC: '{{.CC}}'
|
||||||
|
|
||||||
|
build:docker:
|
||||||
|
summary: Cross-compiles for Windows using Docker with Zig (for CGO builds on non-Windows)
|
||||||
|
internal: true
|
||||||
|
deps:
|
||||||
|
- task: common:build:frontend
|
||||||
|
- task: common:generate:icons
|
||||||
|
preconditions:
|
||||||
|
- sh: docker info > /dev/null 2>&1
|
||||||
|
msg: "Docker is required for CGO cross-compilation. Please install Docker."
|
||||||
|
- sh: docker image inspect {{.CROSS_IMAGE}} > /dev/null 2>&1
|
||||||
|
msg: |
|
||||||
|
Docker image '{{.CROSS_IMAGE}}' not found.
|
||||||
|
Build it first: wails3 task setup:docker
|
||||||
|
cmds:
|
||||||
|
- task: generate:syso
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" {{.GO_CACHE_MOUNT}} {{.REPLACE_MOUNTS}} -e APP_NAME="{{.APP_NAME}}" {{if .EXTRA_TAGS}}-e EXTRA_TAGS="{{.EXTRA_TAGS}}"{{end}} {{.CROSS_IMAGE}} windows {{.DOCKER_ARCH}}
|
||||||
|
- docker run --rm -v "{{.ROOT_DIR}}:/app" alpine chown -R $(id -u):$(id -g) /app/bin
|
||||||
|
- rm -f *.syso
|
||||||
|
vars:
|
||||||
|
DOCKER_ARCH: '{{.ARCH | default "amd64"}}'
|
||||||
|
# Mount Go module cache for faster builds
|
||||||
|
GO_CACHE_MOUNT:
|
||||||
|
sh: 'echo "-v ${GOPATH:-$HOME/go}/pkg/mod:/go/pkg/mod"'
|
||||||
|
# Extract replace directives from go.mod and create -v mounts for each
|
||||||
|
REPLACE_MOUNTS:
|
||||||
|
sh: |
|
||||||
|
grep -E '^replace .* => ' go.mod 2>/dev/null | while read -r line; do
|
||||||
|
path=$(echo "$line" | sed -E 's/^replace .* => //' | tr -d '\r')
|
||||||
|
# Convert relative paths to absolute
|
||||||
|
if [ "${path#/}" = "$path" ]; then
|
||||||
|
path="$(cd "$(dirname "$path")" 2>/dev/null && pwd)/$(basename "$path")"
|
||||||
|
fi
|
||||||
|
# Only mount if directory exists
|
||||||
|
if [ -d "$path" ]; then
|
||||||
|
echo "-v $path:$path:ro"
|
||||||
|
fi
|
||||||
|
done | tr '\n' ' '
|
||||||
|
|
||||||
|
package:
|
||||||
|
summary: Packages the application
|
||||||
|
cmds:
|
||||||
|
- task: '{{if eq (.FORMAT | default "nsis") "msix"}}create:msix:package{{else}}create:nsis:installer{{end}}'
|
||||||
|
vars:
|
||||||
|
FORMAT: '{{.FORMAT | default "nsis"}}'
|
||||||
|
|
||||||
|
generate:syso:
|
||||||
|
summary: Generates Windows `.syso` file
|
||||||
|
dir: build
|
||||||
|
cmds:
|
||||||
|
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
|
||||||
|
create:nsis:installer:
|
||||||
|
summary: Creates an NSIS installer
|
||||||
|
dir: build/windows/nsis
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
# Create the Microsoft WebView2 bootstrapper if it doesn't exist
|
||||||
|
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
|
||||||
|
- |
|
||||||
|
{{if eq OS "windows"}}
|
||||||
|
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}\{{.BIN_DIR}}\{{.APP_NAME}}.exe" project.nsi
|
||||||
|
{{else}}
|
||||||
|
makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
|
||||||
|
{{end}}
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
|
||||||
|
|
||||||
|
create:msix:package:
|
||||||
|
summary: Creates an MSIX package
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- |-
|
||||||
|
wails3 tool msix \
|
||||||
|
--config "{{.ROOT_DIR}}/wails.json" \
|
||||||
|
--name "{{.APP_NAME}}" \
|
||||||
|
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
|
||||||
|
--arch "{{.ARCH}}" \
|
||||||
|
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
|
||||||
|
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
|
||||||
|
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
|
||||||
|
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
|
||||||
|
vars:
|
||||||
|
ARCH: '{{.ARCH | default ARCH}}'
|
||||||
|
CERT_PATH: '{{.CERT_PATH | default ""}}'
|
||||||
|
PUBLISHER: '{{.PUBLISHER | default ""}}'
|
||||||
|
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
|
||||||
|
|
||||||
|
install:msix:tools:
|
||||||
|
summary: Installs tools required for MSIX packaging
|
||||||
|
cmds:
|
||||||
|
- wails3 tool msix-install-tools
|
||||||
|
|
||||||
|
run:
|
||||||
|
cmds:
|
||||||
|
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
|
||||||
|
|
||||||
|
sign:
|
||||||
|
summary: Signs the Windows executable
|
||||||
|
desc: |
|
||||||
|
Signs the .exe with an Authenticode certificate.
|
||||||
|
Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
deps:
|
||||||
|
- task: build
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "{{.BIN_DIR}}/{{.APP_NAME}}.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
|
||||||
|
msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"
|
||||||
|
|
||||||
|
sign:installer:
|
||||||
|
summary: Signs the NSIS installer
|
||||||
|
desc: |
|
||||||
|
Creates and signs the NSIS installer.
|
||||||
|
Configure SIGN_CERTIFICATE or SIGN_THUMBPRINT in the vars section at the top of this file.
|
||||||
|
Password is retrieved from system keychain (run: wails3 setup signing)
|
||||||
|
deps:
|
||||||
|
- task: create:nsis:installer
|
||||||
|
cmds:
|
||||||
|
- wails3 tool sign --input "build/windows/nsis/{{.APP_NAME}}-installer.exe" {{if .SIGN_CERTIFICATE}}--certificate {{.SIGN_CERTIFICATE}}{{end}} {{if .SIGN_THUMBPRINT}}--thumbprint {{.SIGN_THUMBPRINT}}{{end}} {{if .TIMESTAMP_SERVER}}--timestamp {{.TIMESTAMP_SERVER}}{{end}}
|
||||||
|
preconditions:
|
||||||
|
- sh: '[ -n "{{.SIGN_CERTIFICATE}}" ] || [ -n "{{.SIGN_THUMBPRINT}}" ]'
|
||||||
|
msg: "Either SIGN_CERTIFICATE or SIGN_THUMBPRINT is required. Set it in the vars section at the top of build/windows/Taskfile.yml"
|
||||||
BIN
client/ui/build/windows/icon.ico
Normal file
|
After Width: | Height: | Size: 18 KiB |
15
client/ui/build/windows/info.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"fixed": {
|
||||||
|
"file_version": "0.0.1"
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"0000": {
|
||||||
|
"ProductVersion": "0.0.1",
|
||||||
|
"CompanyName": "NetBird",
|
||||||
|
"FileDescription": "NetBird desktop client",
|
||||||
|
"LegalCopyright": "© 2026, My Company",
|
||||||
|
"ProductName": "NetBird",
|
||||||
|
"Comments": "This is a comment"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
client/ui/build/windows/msix/app_manifest.xml
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Package
|
||||||
|
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||||
|
xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10"
|
||||||
|
xmlns:uap3="http://schemas.microsoft.com/appx/manifest/uap/windows10/3"
|
||||||
|
xmlns:rescap="http://schemas.microsoft.com/appx/manifest/foundation/windows10/restrictedcapabilities"
|
||||||
|
xmlns:desktop="http://schemas.microsoft.com/appx/manifest/desktop/windows10"
|
||||||
|
IgnorableNamespaces="uap3">
|
||||||
|
|
||||||
|
<Identity
|
||||||
|
Name="io.netbird.client"
|
||||||
|
Publisher="CN=NetBird"
|
||||||
|
Version="0.0.1.0"
|
||||||
|
ProcessorArchitecture="x64" />
|
||||||
|
|
||||||
|
<Properties>
|
||||||
|
<DisplayName>NetBird</DisplayName>
|
||||||
|
<PublisherDisplayName>NetBird</PublisherDisplayName>
|
||||||
|
<Description>NetBird desktop client</Description>
|
||||||
|
<Logo>Assets\StoreLogo.png</Logo>
|
||||||
|
</Properties>
|
||||||
|
|
||||||
|
<Dependencies>
|
||||||
|
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
</Dependencies>
|
||||||
|
|
||||||
|
<Resources>
|
||||||
|
<Resource Language="en-us" />
|
||||||
|
</Resources>
|
||||||
|
|
||||||
|
<Applications>
|
||||||
|
<Application Id="io.netbird.client" Executable="netbird-ui" EntryPoint="Windows.FullTrustApplication">
|
||||||
|
<uap:VisualElements
|
||||||
|
DisplayName="NetBird"
|
||||||
|
Description="NetBird desktop client"
|
||||||
|
BackgroundColor="transparent"
|
||||||
|
Square150x150Logo="Assets\Square150x150Logo.png"
|
||||||
|
Square44x44Logo="Assets\Square44x44Logo.png">
|
||||||
|
<uap:DefaultTile Wide310x150Logo="Assets\Wide310x150Logo.png" />
|
||||||
|
<uap:SplashScreen Image="Assets\SplashScreen.png" />
|
||||||
|
</uap:VisualElements>
|
||||||
|
|
||||||
|
<Extensions>
|
||||||
|
<desktop:Extension Category="windows.fullTrustProcess" Executable="netbird-ui" />
|
||||||
|
|
||||||
|
|
||||||
|
</Extensions>
|
||||||
|
</Application>
|
||||||
|
</Applications>
|
||||||
|
|
||||||
|
<Capabilities>
|
||||||
|
<rescap:Capability Name="runFullTrust" />
|
||||||
|
|
||||||
|
</Capabilities>
|
||||||
|
</Package>
|
||||||
54
client/ui/build/windows/msix/template.xml
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<MsixPackagingToolTemplate
|
||||||
|
xmlns="http://schemas.microsoft.com/msix/packaging/msixpackagingtool/template/2022">
|
||||||
|
<Settings
|
||||||
|
AllowTelemetry="false"
|
||||||
|
ApplyACLsToPackageFiles="true"
|
||||||
|
GenerateCommandLineFile="true"
|
||||||
|
AllowPromptForPassword="false">
|
||||||
|
</Settings>
|
||||||
|
<Installer
|
||||||
|
Path="netbird-ui"
|
||||||
|
Arguments=""
|
||||||
|
InstallLocation="C:\Program Files\NetBird\NetBird">
|
||||||
|
</Installer>
|
||||||
|
<PackageInformation
|
||||||
|
PackageName="NetBird"
|
||||||
|
PackageDisplayName="NetBird"
|
||||||
|
PublisherName="CN=NetBird"
|
||||||
|
PublisherDisplayName="NetBird"
|
||||||
|
Version="0.0.1.0"
|
||||||
|
PackageDescription="NetBird desktop client">
|
||||||
|
<Capabilities>
|
||||||
|
<Capability Name="runFullTrust" />
|
||||||
|
|
||||||
|
</Capabilities>
|
||||||
|
<Applications>
|
||||||
|
<Application
|
||||||
|
Id="io.netbird.client"
|
||||||
|
Description="NetBird desktop client"
|
||||||
|
DisplayName="NetBird"
|
||||||
|
ExecutableName="netbird-ui"
|
||||||
|
EntryPoint="Windows.FullTrustApplication">
|
||||||
|
|
||||||
|
</Application>
|
||||||
|
</Applications>
|
||||||
|
<Resources>
|
||||||
|
<Resource Language="en-us" />
|
||||||
|
</Resources>
|
||||||
|
<Dependencies>
|
||||||
|
<TargetDeviceFamily Name="Windows.Desktop" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" />
|
||||||
|
</Dependencies>
|
||||||
|
<Properties>
|
||||||
|
<Framework>false</Framework>
|
||||||
|
<DisplayName>NetBird</DisplayName>
|
||||||
|
<PublisherDisplayName>NetBird</PublisherDisplayName>
|
||||||
|
<Description>NetBird desktop client</Description>
|
||||||
|
<Logo>Assets\AppIcon.png</Logo>
|
||||||
|
</Properties>
|
||||||
|
</PackageInformation>
|
||||||
|
<SaveLocation PackagePath="netbird-ui.msix" />
|
||||||
|
<PackageIntegrity>
|
||||||
|
<CertificatePath></CertificatePath>
|
||||||
|
</PackageIntegrity>
|
||||||
|
</MsixPackagingToolTemplate>
|
||||||