mirror of
https://github.com/netbirdio/netbird.git
synced 2026-05-30 20:49:57 +00:00
Compare commits
11 Commits
main
...
claude/vnc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8bb7c8998c | ||
|
|
d6d3fa95c7 | ||
|
|
fa90283781 | ||
|
|
8bf13b0d0c | ||
|
|
a8541a1529 | ||
|
|
94068d3ebc | ||
|
|
738c585ee7 | ||
|
|
9b5541d17d | ||
|
|
7123e6d1f4 | ||
|
|
62cf9e873b | ||
|
|
9f0aa1ce26 |
45
.github/dependabot.yml
vendored
45
.github/dependabot.yml
vendored
@@ -1,45 +0,0 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 15
|
||||
groups:
|
||||
actions:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
# git-town/action v1.3.x crashes on cyclic PR graphs (self-loop main->main
|
||||
# fork PRs) via its topological-sort visualization. Pinned to v1.2.1 in
|
||||
# git-town.yml; block v1.3.x until upstream tolerates cyclic edges.
|
||||
- dependency-name: "git-town/action"
|
||||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-major"
|
||||
|
||||
- package-ecosystem: "gomod"
|
||||
directories:
|
||||
- "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
open-pull-requests-limit: 15
|
||||
groups:
|
||||
aws-sdk:
|
||||
patterns:
|
||||
- "github.com/aws/aws-sdk-go-v2/*"
|
||||
pion:
|
||||
patterns:
|
||||
- "github.com/pion/*"
|
||||
gorm:
|
||||
patterns:
|
||||
- "gorm.io/*"
|
||||
otel:
|
||||
patterns:
|
||||
- "go.opentelemetry.io/*"
|
||||
testcontainers:
|
||||
patterns:
|
||||
- "github.com/testcontainers/testcontainers-go/*"
|
||||
wireguard:
|
||||
patterns:
|
||||
- "golang.zx2c4.com/wireguard*"
|
||||
1
.github/pull_request_template.md
vendored
1
.github/pull_request_template.md
vendored
@@ -12,7 +12,6 @@
|
||||
- [ ] Is a feature enhancement
|
||||
- [ ] It is a refactor
|
||||
- [ ] Created tests that fail without the change (if possible)
|
||||
- [ ] This change does **not** modify the public API, gRPC protocols, functionality behavior, CLI / service flags, or introduce a new feature — **OR** I have discussed it with the NetBird team beforehand (link the issue / Slack thread in the description). See [CONTRIBUTING.md](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTING.md#discuss-changes-with-the-netbird-team-first).
|
||||
|
||||
> By submitting this pull request, you confirm that you have read and agree to the terms of the [Contributor License Agreement](https://github.com/netbirdio/netbird/blob/main/CONTRIBUTOR_LICENSE_AGREEMENT.md).
|
||||
|
||||
|
||||
105
.github/workflows/check-license-dependencies.yml
vendored
105
.github/workflows/check-license-dependencies.yml
vendored
@@ -2,16 +2,16 @@ name: Check License Dependencies
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- ".github/workflows/check-license-dependencies.yml"
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '.github/workflows/check-license-dependencies.yml'
|
||||
pull_request:
|
||||
paths:
|
||||
- "go.mod"
|
||||
- "go.sum"
|
||||
- ".github/workflows/check-license-dependencies.yml"
|
||||
- 'go.mod'
|
||||
- 'go.sum'
|
||||
- '.github/workflows/check-license-dependencies.yml'
|
||||
|
||||
jobs:
|
||||
check-internal-dependencies:
|
||||
@@ -19,10 +19,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Check for problematic license dependencies
|
||||
run: |
|
||||
@@ -59,57 +56,55 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: true
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
cache: true
|
||||
|
||||
- name: Install go-licenses
|
||||
run: go install github.com/google/go-licenses@v1.6.0
|
||||
- name: Install go-licenses
|
||||
run: go install github.com/google/go-licenses@v1.6.0
|
||||
|
||||
- name: Check for GPL/AGPL licensed dependencies
|
||||
run: |
|
||||
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
||||
- name: Check for GPL/AGPL licensed dependencies
|
||||
run: |
|
||||
echo "Checking for GPL/AGPL/LGPL licensed dependencies..."
|
||||
echo ""
|
||||
|
||||
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
||||
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
||||
|
||||
if [ -n "$COPYLEFT_DEPS" ]; then
|
||||
echo "Found copyleft licensed dependencies:"
|
||||
echo "$COPYLEFT_DEPS"
|
||||
echo ""
|
||||
|
||||
# Check all Go packages for copyleft licenses, excluding internal netbird packages
|
||||
COPYLEFT_DEPS=$(go-licenses report ./... 2>/dev/null | grep -E 'GPL|AGPL|LGPL' | grep -v 'github.com/netbirdio/netbird/' || true)
|
||||
# Filter out dependencies that are only pulled in by internal AGPL packages
|
||||
INCOMPATIBLE=""
|
||||
while IFS=',' read -r package url license; do
|
||||
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
||||
# Find ALL packages that import this GPL package using go list
|
||||
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||
|
||||
if [ -n "$COPYLEFT_DEPS" ]; then
|
||||
echo "Found copyleft licensed dependencies:"
|
||||
echo "$COPYLEFT_DEPS"
|
||||
echo ""
|
||||
# Check if any importer is NOT in management/signal/relay
|
||||
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
||||
|
||||
# Filter out dependencies that are only pulled in by internal AGPL packages
|
||||
INCOMPATIBLE=""
|
||||
while IFS=',' read -r package url license; do
|
||||
if echo "$license" | grep -qE 'GPL-[0-9]|AGPL-[0-9]|LGPL-[0-9]'; then
|
||||
# Find ALL packages that import this GPL package using go list
|
||||
IMPORTERS=$(go list -json -deps ./... 2>/dev/null | jq -r "select(.Imports[]? == \"$package\") | .ImportPath")
|
||||
|
||||
# Check if any importer is NOT in management/signal/relay
|
||||
BSD_IMPORTER=$(echo "$IMPORTERS" | grep -v "github.com/netbirdio/netbird/\(management\|signal\|relay\|proxy\|combined\|tools/idp-migrate\)" | head -1)
|
||||
|
||||
if [ -n "$BSD_IMPORTER" ]; then
|
||||
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
||||
else
|
||||
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
||||
fi
|
||||
if [ -n "$BSD_IMPORTER" ]; then
|
||||
echo "❌ $package ($license) is imported by BSD-licensed code: $BSD_IMPORTER"
|
||||
INCOMPATIBLE="${INCOMPATIBLE}${package},${url},${license}\n"
|
||||
else
|
||||
echo "✓ $package ($license) is only used by internal AGPL packages - OK"
|
||||
fi
|
||||
done <<< "$COPYLEFT_DEPS"
|
||||
|
||||
if [ -n "$INCOMPATIBLE" ]; then
|
||||
echo ""
|
||||
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
||||
echo -e "$INCOMPATIBLE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
done <<< "$COPYLEFT_DEPS"
|
||||
|
||||
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
||||
if [ -n "$INCOMPATIBLE" ]; then
|
||||
echo ""
|
||||
echo "❌ INCOMPATIBLE licenses found that are used by BSD-licensed code:"
|
||||
echo -e "$INCOMPATIBLE"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "✅ All external license dependencies are compatible with BSD-3-Clause"
|
||||
|
||||
2
.github/workflows/docs-ack.yml
vendored
2
.github/workflows/docs-ack.yml
vendored
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Verify docs PR exists (and is open or merged)
|
||||
if: steps.validate.outputs.mode == 'added'
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@v7
|
||||
id: verify
|
||||
with:
|
||||
pr_number: ${{ steps.extract.outputs.pr_number }}
|
||||
|
||||
5
.github/workflows/forum.yml
vendored
5
.github/workflows/forum.yml
vendored
@@ -8,10 +8,11 @@ jobs:
|
||||
post:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: roots/discourse-topic-github-release-action@557d74ea05b6cc0c47f555c1d5d28a89d904005b # v1.1.0
|
||||
- uses: roots/discourse-topic-github-release-action@main
|
||||
with:
|
||||
discourse-api-key: ${{ secrets.DISCOURSE_RELEASES_API_KEY }}
|
||||
discourse-base-url: https://forum.netbird.io
|
||||
discourse-author-username: NetBird
|
||||
discourse-category: 17
|
||||
discourse-tags: releases
|
||||
discourse-tags:
|
||||
releases
|
||||
|
||||
8
.github/workflows/git-town.yml
vendored
8
.github/workflows/git-town.yml
vendored
@@ -3,7 +3,7 @@ name: Git Town
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
- "**"
|
||||
- '**'
|
||||
|
||||
jobs:
|
||||
git-town:
|
||||
@@ -15,9 +15,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: git-town/action@3d8b878379abb1ee393fb49865a28b4a6c2cd3b0 # v1.2.1
|
||||
- uses: actions/checkout@v4
|
||||
- uses: git-town/action@v1.2.1
|
||||
with:
|
||||
skip-single-stacks: true
|
||||
|
||||
9
.github/workflows/golang-test-darwin.yml
vendored
9
.github/workflows/golang-test-darwin.yml
vendored
@@ -16,18 +16,16 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: macos-gotest-${{ hashFiles('**/go.sum') }}
|
||||
@@ -46,3 +44,4 @@ jobs:
|
||||
|
||||
- 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)
|
||||
|
||||
|
||||
21
.github/workflows/golang-test-freebsd.yml
vendored
21
.github/workflows/golang-test-freebsd.yml
vendored
@@ -15,31 +15,20 @@ jobs:
|
||||
name: "Client / Unit"
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Read Go version from go.mod
|
||||
id: goversion
|
||||
run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
- name: Test in FreeBSD
|
||||
id: test
|
||||
env:
|
||||
GO_VERSION: ${{ steps.goversion.outputs.version }}
|
||||
uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
|
||||
uses: vmactions/freebsd-vm@v1
|
||||
with:
|
||||
usesh: true
|
||||
copyback: false
|
||||
release: "15.0"
|
||||
envs: "GO_VERSION"
|
||||
release: "14.2"
|
||||
prepare: |
|
||||
pkg install -y curl pkgconf xorg
|
||||
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
||||
GO_TARBALL="go1.25.3.freebsd-amd64.tar.gz"
|
||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||
curl -vLO "$GO_URL"
|
||||
tar -C /usr/local -vxzf "$GO_TARBALL"
|
||||
tar -C /usr/local -vxzf "$GO_TARBALL"
|
||||
|
||||
# -x - to print all executed commands
|
||||
# -e - to faile on first error
|
||||
|
||||
138
.github/workflows/golang-test-linux.yml
vendored
138
.github/workflows/golang-test-linux.yml
vendored
@@ -18,11 +18,9 @@ jobs:
|
||||
management: ${{ steps.filter.outputs.management }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
||||
- uses: dorny/paths-filter@v3
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -30,7 +28,7 @@ jobs:
|
||||
- 'management/**'
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -38,10 +36,10 @@ jobs:
|
||||
- name: Get Go environment
|
||||
run: |
|
||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v4
|
||||
id: cache
|
||||
with:
|
||||
path: |
|
||||
@@ -115,16 +113,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["386", "amd64"]
|
||||
arch: [ '386','amd64' ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -132,10 +128,10 @@ jobs:
|
||||
- name: Get Go environment
|
||||
run: |
|
||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
@@ -162,16 +158,14 @@ jobs:
|
||||
|
||||
test_client_on_docker:
|
||||
name: "Client (Docker) / Unit"
|
||||
needs: [build-cache]
|
||||
needs: [ build-cache ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -183,7 +177,7 @@ jobs:
|
||||
echo "modcache_dir=$(go env GOMODCACHE)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
id: cache-restore
|
||||
with:
|
||||
path: |
|
||||
@@ -237,12 +231,10 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -254,10 +246,10 @@ jobs:
|
||||
- name: Get Go environment
|
||||
run: |
|
||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
@@ -285,16 +277,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["386", "amd64"]
|
||||
arch: [ '386','amd64' ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -308,7 +298,7 @@ jobs:
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
@@ -334,16 +324,14 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["386", "amd64"]
|
||||
arch: [ '386','amd64' ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -355,10 +343,10 @@ jobs:
|
||||
- name: Get Go environment
|
||||
run: |
|
||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
@@ -382,21 +370,19 @@ jobs:
|
||||
|
||||
test_management:
|
||||
name: "Management / Unit"
|
||||
needs: [build-cache]
|
||||
needs: [ build-cache ]
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["amd64"]
|
||||
store: ["sqlite", "postgres", "mysql"]
|
||||
arch: [ 'amd64' ]
|
||||
store: [ 'sqlite', 'postgres', 'mysql' ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -404,10 +390,10 @@ jobs:
|
||||
- name: Get Go environment
|
||||
run: |
|
||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
@@ -424,7 +410,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker hub
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
@@ -441,7 +427,7 @@ jobs:
|
||||
run: docker pull mlsmaycon/warmed-mysql:8
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
run: |
|
||||
CGO_ENABLED=1 GOARCH=${{ matrix.arch }} \
|
||||
NETBIRD_STORE_ENGINE=${{ matrix.store }} \
|
||||
CI=true \
|
||||
@@ -451,13 +437,13 @@ jobs:
|
||||
|
||||
benchmark:
|
||||
name: "Management / Benchmark"
|
||||
needs: [build-cache]
|
||||
needs: [ build-cache ]
|
||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["amd64"]
|
||||
store: ["sqlite", "postgres"]
|
||||
arch: [ 'amd64' ]
|
||||
store: [ 'sqlite', 'postgres' ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Create Docker network
|
||||
@@ -488,12 +474,10 @@ jobs:
|
||||
prom/prometheus
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -501,10 +485,10 @@ jobs:
|
||||
- name: Get Go environment
|
||||
run: |
|
||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
@@ -521,7 +505,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker hub
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
@@ -545,13 +529,13 @@ jobs:
|
||||
|
||||
api_benchmark:
|
||||
name: "Management / Benchmark (API)"
|
||||
needs: [build-cache]
|
||||
needs: [ build-cache ]
|
||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["amd64"]
|
||||
store: ["sqlite", "postgres"]
|
||||
arch: [ 'amd64' ]
|
||||
store: [ 'sqlite', 'postgres' ]
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Create Docker network
|
||||
@@ -582,12 +566,10 @@ jobs:
|
||||
prom/prometheus
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -595,10 +577,10 @@ jobs:
|
||||
- name: Get Go environment
|
||||
run: |
|
||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
@@ -615,7 +597,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker hub
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
@@ -641,22 +623,20 @@ jobs:
|
||||
|
||||
api_integration_test:
|
||||
name: "Management / Integration"
|
||||
needs: [build-cache]
|
||||
needs: [ build-cache ]
|
||||
if: ${{ needs.build-cache.outputs.management == 'true' || github.event_name != 'pull_request' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
arch: ["amd64"]
|
||||
store: ["sqlite", "postgres"]
|
||||
arch: [ 'amd64' ]
|
||||
store: [ 'sqlite', 'postgres']
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -664,10 +644,10 @@ jobs:
|
||||
- name: Get Go environment
|
||||
run: |
|
||||
echo "cache=$(go env GOCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache/restore@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
|
||||
19
.github/workflows/golang-test-windows.yml
vendored
19
.github/workflows/golang-test-windows.yml
vendored
@@ -18,12 +18,10 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
id: go
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
@@ -35,7 +33,7 @@ jobs:
|
||||
echo "modcache=$(go env GOMODCACHE)" >> $env:GITHUB_ENV
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
${{ env.cache }}
|
||||
@@ -46,15 +44,16 @@ jobs:
|
||||
${{ runner.os }}-go-
|
||||
|
||||
- name: Download wintun
|
||||
uses: carlosperate/download-file-action@v2
|
||||
id: download-wintun
|
||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
with:
|
||||
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||
destination: ${{ env.downloadPath }}\wintun.zip
|
||||
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
||||
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||
file-name: wintun.zip
|
||||
location: ${{ env.downloadPath }}
|
||||
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
||||
|
||||
- name: Decompressing wintun files
|
||||
run: tar -xvf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
||||
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
||||
|
||||
- run: mv ${{ env.downloadPath }}/wintun/bin/amd64/wintun.dll 'C:\Windows\System32\'
|
||||
|
||||
|
||||
14
.github/workflows/golangci-lint.yml
vendored
14
.github/workflows/golangci-lint.yml
vendored
@@ -15,11 +15,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
- name: codespell
|
||||
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
with:
|
||||
ignore_words_list: erro,clienta,hastable,iif,groupd,testin,groupe,cros,ans,deriver,te,userA,ede,additionals
|
||||
skip: go.mod,go.sum,**/proxy/web/**
|
||||
@@ -40,15 +38,13 @@ jobs:
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
- name: Check for duplicate constants
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
! awk '/const \(/,/)/{print $0}' management/server/activity/codes.go | grep -o '= [0-9]*' | sort | uniq -d | grep .
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
@@ -56,7 +52,7 @@ jobs:
|
||||
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
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||
with:
|
||||
version: latest
|
||||
skip-cache: true
|
||||
|
||||
4
.github/workflows/install-script-test.yml
vendored
4
.github/workflows/install-script-test.yml
vendored
@@ -22,9 +22,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: run install script
|
||||
env:
|
||||
|
||||
18
.github/workflows/mobile-build-validation.yml
vendored
18
.github/workflows/mobile-build-validation.yml
vendored
@@ -16,25 +16,23 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Setup Android SDK
|
||||
uses: android-actions/setup-android@40fd30fb8d7440372e1316f5d1809ec01dcd3699 # v4.0.1
|
||||
uses: android-actions/setup-android@v3
|
||||
with:
|
||||
cmdline-tools-version: 8512546
|
||||
- name: Setup Java
|
||||
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: "11"
|
||||
distribution: "adopt"
|
||||
- name: NDK Cache
|
||||
id: ndk-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: /usr/local/lib/android/sdk/ndk
|
||||
key: ndk-cache-23.1.7779620
|
||||
@@ -54,11 +52,9 @@ jobs:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: install gomobile
|
||||
|
||||
2
.github/workflows/pr-title-check.yml
vendored
2
.github/workflows/pr-title-check.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Validate PR title prefix
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const title = context.payload.pull_request.title;
|
||||
|
||||
68
.github/workflows/proto-version-check.yml
vendored
68
.github/workflows/proto-version-check.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Check for proto tool version changes
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const files = await github.paginate(github.rest.pulls.listFiles, {
|
||||
@@ -20,66 +20,34 @@ jobs:
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
const modifiedPbFiles = files.filter(
|
||||
f => f.filename.endsWith('.pb.go') && f.status === 'modified'
|
||||
);
|
||||
if (modifiedPbFiles.length === 0) {
|
||||
console.log('No modified .pb.go files to check');
|
||||
const pbFiles = files.filter(f => f.filename.endsWith('.pb.go'));
|
||||
const missingPatch = pbFiles.filter(f => !f.patch).map(f => f.filename);
|
||||
if (missingPatch.length > 0) {
|
||||
core.setFailed(
|
||||
`Cannot inspect patch data for:\n` +
|
||||
missingPatch.map(f => `- ${f}`).join('\n') +
|
||||
`\nThis can happen with very large PRs. Verify proto versions manually.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const versionPattern = /^\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||
const baseSha = context.payload.pull_request.base.sha;
|
||||
const headSha = context.payload.pull_request.head.sha;
|
||||
|
||||
async function getVersionHeader(path, ref) {
|
||||
try {
|
||||
const res = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path,
|
||||
ref,
|
||||
});
|
||||
if (!res.data.content) {
|
||||
return { ok: false, reason: 'no inline content (file too large)' };
|
||||
}
|
||||
const content = Buffer.from(res.data.content, 'base64').toString('utf8');
|
||||
const lines = content
|
||||
.split('\n')
|
||||
.slice(0, 20)
|
||||
.filter(line => versionPattern.test(line));
|
||||
return { ok: true, lines };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
const versionPattern = /^[+-]\s*\/\/\s+protoc(?:-gen-go)?\s+v[\d.]+/;
|
||||
const violations = [];
|
||||
for (const file of modifiedPbFiles) {
|
||||
const [base, head] = await Promise.all([
|
||||
getVersionHeader(file.filename, baseSha),
|
||||
getVersionHeader(file.filename, headSha),
|
||||
]);
|
||||
if (!base.ok || !head.ok) {
|
||||
core.warning(
|
||||
`Skipping ${file.filename}: base=${base.ok ? 'ok' : base.reason}, head=${head.ok ? 'ok' : head.reason}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
if (base.lines.join('\n') !== head.lines.join('\n')) {
|
||||
|
||||
for (const file of pbFiles) {
|
||||
const changed = file.patch
|
||||
.split('\n')
|
||||
.filter(line => versionPattern.test(line));
|
||||
if (changed.length > 0) {
|
||||
violations.push({
|
||||
file: file.filename,
|
||||
base: base.lines,
|
||||
head: head.lines,
|
||||
lines: changed,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
const details = violations.map(v =>
|
||||
`${v.file}:\n` +
|
||||
` base:\n${v.base.map(l => ' ' + l).join('\n') || ' (none)'}\n` +
|
||||
` head:\n${v.head.map(l => ' ' + l).join('\n') || ' (none)'}`
|
||||
`${v.file}:\n${v.lines.map(l => ' ' + l).join('\n')}`
|
||||
).join('\n\n');
|
||||
|
||||
core.setFailed(
|
||||
|
||||
168
.github/workflows/release.yml
vendored
168
.github/workflows/release.yml
vendored
@@ -9,7 +9,7 @@ on:
|
||||
pull_request:
|
||||
|
||||
env:
|
||||
SIGN_PIPE_VER: "v0.1.5"
|
||||
SIGN_PIPE_VER: "v0.1.4"
|
||||
GORELEASER_VER: "v2.14.3"
|
||||
PRODUCT_NAME: "NetBird"
|
||||
COPYRIGHT: "NetBird GmbH"
|
||||
@@ -24,9 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-22.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Generate FreeBSD port diff
|
||||
run: bash release_files/freebsd-port-diff.sh
|
||||
@@ -53,26 +51,19 @@ jobs:
|
||||
echo "Generated files for version: $VERSION"
|
||||
cat netbird-*.diff
|
||||
|
||||
- name: Read Go version from go.mod
|
||||
id: goversion
|
||||
run: echo "version=$(awk '/^go / {print $2}' go.mod)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Test FreeBSD port
|
||||
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||
env:
|
||||
GO_VERSION: ${{ steps.goversion.outputs.version }}
|
||||
uses: vmactions/freebsd-vm@d1e65811565151536c0c894fff74f06351ed26e6 # v1.4.5
|
||||
uses: vmactions/freebsd-vm@v1
|
||||
with:
|
||||
usesh: true
|
||||
copyback: false
|
||||
release: "15.0"
|
||||
envs: "GO_VERSION"
|
||||
prepare: |
|
||||
# Install required packages
|
||||
pkg install -y git curl portlint
|
||||
pkg install -y git curl portlint go
|
||||
|
||||
# Install Go for building
|
||||
GO_TARBALL="go${GO_VERSION}.freebsd-amd64.tar.gz"
|
||||
GO_TARBALL="go1.25.5.freebsd-amd64.tar.gz"
|
||||
GO_URL="https://go.dev/dl/$GO_TARBALL"
|
||||
curl -LO "$GO_URL"
|
||||
tar -C /usr/local -xzf "$GO_TARBALL"
|
||||
@@ -102,19 +93,19 @@ jobs:
|
||||
|
||||
# Show patched Makefile
|
||||
version=$(cat security/netbird/Makefile | grep -E '^DISTVERSION=' | awk '{print $NF}')
|
||||
|
||||
|
||||
cd /usr/ports/security/netbird
|
||||
export BATCH=yes
|
||||
make package
|
||||
pkg add ./work/pkg/netbird-*.pkg
|
||||
|
||||
|
||||
netbird version | grep "$version"
|
||||
|
||||
echo "FreeBSD port test completed successfully!"
|
||||
|
||||
- name: Upload FreeBSD port files
|
||||
if: steps.check_diff.outputs.diff_exists == 'true'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: freebsd-port-files
|
||||
path: |
|
||||
@@ -133,25 +124,26 @@ jobs:
|
||||
env:
|
||||
flags: ""
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
persist-credentials: false
|
||||
|
||||
- name: Parse semver string
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
uses: booxmedialtd/ws-action-parse-semver@v1
|
||||
with:
|
||||
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
||||
version_extractor_regex: '\/v(.*)$'
|
||||
|
||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
@@ -164,18 +156,18 @@ jobs:
|
||||
- name: check git status
|
||||
run: git --no-pager diff --exit-code
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@ce360397dd3f832beb865e1373c09c0e9f86d70a #v4.0.0
|
||||
uses: docker/setup-qemu-action@v2
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
||||
uses: docker/setup-buildx-action@v2
|
||||
- name: Login to Docker hub
|
||||
if: github.event_name != 'pull_request'
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
- name: Log in to the GitHub container registry
|
||||
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
@@ -199,7 +191,7 @@ jobs:
|
||||
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
|
||||
- name: Run GoReleaser
|
||||
id: goreleaser
|
||||
uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --clean ${{ env.flags }}
|
||||
@@ -290,28 +282,28 @@ jobs:
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: upload non tags for debug purposes
|
||||
id: upload_release
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release
|
||||
path: dist/
|
||||
retention-days: 7
|
||||
- name: upload linux packages
|
||||
id: upload_linux_packages
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-packages
|
||||
path: dist/netbird_linux**
|
||||
retention-days: 7
|
||||
- name: upload windows packages
|
||||
id: upload_windows_packages
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-packages
|
||||
path: dist/netbird_windows**
|
||||
retention-days: 7
|
||||
- name: upload macos packages
|
||||
id: upload_macos_packages
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-packages
|
||||
path: dist/netbird_darwin**
|
||||
@@ -322,26 +314,27 @@ jobs:
|
||||
outputs:
|
||||
release_ui_artifact_url: ${{ steps.upload_release_ui.outputs.artifact-url }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
persist-credentials: false
|
||||
|
||||
- name: Parse semver string
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
uses: booxmedialtd/ws-action-parse-semver@v1
|
||||
with:
|
||||
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
||||
version_extractor_regex: '\/v(.*)$'
|
||||
|
||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
@@ -382,7 +375,7 @@ jobs:
|
||||
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
|
||||
|
||||
- name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --config .goreleaser_ui.yaml --clean ${{ env.flags }}
|
||||
@@ -411,7 +404,7 @@ jobs:
|
||||
run: rm -f /tmp/gpg-rpm-signing-key.asc
|
||||
- name: upload non tags for debug purposes
|
||||
id: upload_release_ui
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-ui
|
||||
path: dist/
|
||||
@@ -425,17 +418,16 @@ jobs:
|
||||
- if: ${{ !startsWith(github.ref, 'refs/tags/v') }}
|
||||
run: echo "flags=--snapshot" >> $GITHUB_ENV
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # It is required for GoReleaser to work properly
|
||||
persist-credentials: false
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
cache: false
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: |
|
||||
~/go/pkg/mod
|
||||
@@ -449,7 +441,7 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
- name: Run GoReleaser
|
||||
id: goreleaser
|
||||
uses: goreleaser/goreleaser-action@4c6ab561adb47e50c45ef534e2155934e91c40c1 # v7.2.0
|
||||
uses: goreleaser/goreleaser-action@v4
|
||||
with:
|
||||
version: ${{ env.GORELEASER_VER }}
|
||||
args: release --config .goreleaser_ui_darwin.yaml --clean ${{ env.flags }}
|
||||
@@ -457,7 +449,7 @@ jobs:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: upload non tags for debug purposes
|
||||
id: upload_release_ui_darwin
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-ui-darwin
|
||||
path: dist/
|
||||
@@ -482,26 +474,27 @@ jobs:
|
||||
PackageWorkdir: netbird_windows_${{ matrix.arch }}
|
||||
downloadPath: '${{ github.workspace }}\temp'
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Parse semver string
|
||||
id: semver_parser
|
||||
uses: netbirdio/shared-actions/actions/parse-semver@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
uses: booxmedialtd/ws-action-parse-semver@v1
|
||||
with:
|
||||
input_string: ${{ (startsWith(github.ref, 'refs/tags/v') && github.ref) || 'refs/tags/v0.0.0' }}
|
||||
version_extractor_regex: '\/v(.*)$'
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Add 7-Zip to PATH
|
||||
run: echo "C:\Program Files\7-Zip" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
|
||||
|
||||
- name: Download release artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release
|
||||
path: release
|
||||
|
||||
- name: Download UI release artifacts
|
||||
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.1
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: release-ui
|
||||
path: release-ui
|
||||
@@ -521,27 +514,29 @@ jobs:
|
||||
Get-ChildItem $workdir
|
||||
|
||||
- name: Download wintun
|
||||
uses: carlosperate/download-file-action@v2
|
||||
id: download-wintun
|
||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
with:
|
||||
url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||
destination: ${{ env.downloadPath }}\wintun.zip
|
||||
sha256: 07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51
|
||||
file-url: https://pkgs.netbird.io/wintun/wintun-0.14.1.zip
|
||||
file-name: wintun.zip
|
||||
location: ${{ env.downloadPath }}
|
||||
sha256: '07c256185d6ee3652e09fa55c0b673e2624b565e02c4b9091c79ca7d2f24ef51'
|
||||
|
||||
- name: Decompress wintun files
|
||||
run: tar -xvf "${{ env.downloadPath }}\wintun.zip" -C ${{ env.downloadPath }}
|
||||
run: tar -zvxf "${{ steps.download-wintun.outputs.file-path }}" -C ${{ env.downloadPath }}
|
||||
|
||||
- name: Move wintun.dll into dist
|
||||
run: mv ${{ env.downloadPath }}\wintun\bin\${{ matrix.wintun_arch }}\wintun.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||
|
||||
- name: Download Mesa3D (amd64 only)
|
||||
uses: carlosperate/download-file-action@v2
|
||||
id: download-mesa3d
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
with:
|
||||
url: https://pkgs.netbird.io/mesa3d/MesaForWindows-x64-20.1.8.7z
|
||||
destination: ${{ env.downloadPath }}\mesa3d.7z
|
||||
sha256: 71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9
|
||||
file-url: https://downloads.fdossena.com/Projects/Mesa3D/Builds/MesaForWindows-x64-20.1.8.7z
|
||||
file-name: mesa3d.7z
|
||||
location: ${{ env.downloadPath }}
|
||||
sha256: '71c7cb64ec229a1d6b8d62fa08e1889ed2bd17c0eeede8689daf0f25cb31d6b9'
|
||||
|
||||
- name: Extract Mesa3D driver (amd64 only)
|
||||
if: matrix.arch == 'amd64'
|
||||
@@ -552,38 +547,35 @@ jobs:
|
||||
run: mv ${{ env.downloadPath }}\opengl32.dll ${{ github.workspace }}\dist\${{ env.PackageWorkdir }}\
|
||||
|
||||
- name: Download EnVar plugin for NSIS
|
||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
uses: carlosperate/download-file-action@v2
|
||||
with:
|
||||
url: https://pkgs.netbird.io/nsis/EnVar_plugin.zip
|
||||
destination: ${{ github.workspace }}\envar_plugin.zip
|
||||
sha256: e9aa92de351345ed82795251d838f1ae9041ba35af9d381a5780c7843b01f56a
|
||||
file-url: https://nsis.sourceforge.io/mediawiki/images/7/7f/EnVar_plugin.zip
|
||||
file-name: envar_plugin.zip
|
||||
location: ${{ github.workspace }}
|
||||
|
||||
- name: Extract EnVar plugin
|
||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/envar_plugin.zip"
|
||||
|
||||
- name: Download ShellExecAsUser plugin for NSIS (amd64 only)
|
||||
uses: carlosperate/download-file-action@v2
|
||||
if: matrix.arch == 'amd64'
|
||||
uses: netbirdio/shared-actions/actions/win-download-and-verify@be5df6047383da2236e02243cceb857d8567c27e # v0.0.2
|
||||
with:
|
||||
url: https://pkgs.netbird.io/nsis/ShellExecAsUser_amd64-Unicode.7z
|
||||
destination: ${{ github.workspace }}\ShellExecAsUser_amd64-Unicode.7z
|
||||
sha256: 0a55ea25c7330a92cec028eda8afcaf1b1a7092e0dfb77c21c8f654564b4ff9d
|
||||
file-url: https://nsis.sourceforge.io/mediawiki/images/6/68/ShellExecAsUser_amd64-Unicode.7z
|
||||
file-name: ShellExecAsUser_amd64-Unicode.7z
|
||||
location: ${{ github.workspace }}
|
||||
|
||||
- name: Extract ShellExecAsUser plugin (amd64 only)
|
||||
if: matrix.arch == 'amd64'
|
||||
run: 7z x -o"${{ github.workspace }}/NSIS_Plugins" "${{ github.workspace }}/ShellExecAsUser_amd64-Unicode.7z"
|
||||
|
||||
- name: Build NSIS installer
|
||||
shell: pwsh
|
||||
uses: joncloud/makensis-action@v3.3
|
||||
with:
|
||||
additional-plugin-paths: ${{ github.workspace }}/NSIS_Plugins/Plugins
|
||||
script-file: client/installer.nsis
|
||||
arguments: "/V4 /DARCH=${{ matrix.arch }}"
|
||||
env:
|
||||
APPVER: ${{ steps.semver_parser.outputs.major }}.${{ steps.semver_parser.outputs.minor }}.${{ steps.semver_parser.outputs.patch }}.${{ github.run_id }}
|
||||
run: |
|
||||
$nsisPluginDir = "C:\Program Files (x86)\NSIS\Plugins\x86-unicode"
|
||||
$srcPlugins = "${{ github.workspace }}\NSIS_Plugins\Plugins"
|
||||
Get-ChildItem -Path $srcPlugins -Recurse -Filter *.dll |
|
||||
Copy-Item -Destination $nsisPluginDir -Force
|
||||
& "C:\Program Files (x86)\NSIS\makensis.exe" /V4 "/DARCH=${{ matrix.arch }}" client\installer.nsis
|
||||
if ($LASTEXITCODE -ne 0) { throw "makensis failed with exit code $LASTEXITCODE" }
|
||||
|
||||
- name: Rename NSIS installer
|
||||
run: mv netbird-installer.exe netbird_installer_test_windows_${{ matrix.arch }}.exe
|
||||
@@ -600,7 +592,7 @@ jobs:
|
||||
|
||||
- name: Upload installer artifacts
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a #v7.0.1
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-installer-test-${{ matrix.arch }}
|
||||
path: |
|
||||
@@ -619,7 +611,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Create or update PR comment
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
RELEASE_RESULT: ${{ needs.release.result }}
|
||||
RELEASE_UI_RESULT: ${{ needs.release_ui.result }}
|
||||
@@ -711,7 +703,7 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Trigger binaries sign pipelines
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: Sign bin and installer
|
||||
repo: netbirdio/sign-pipelines
|
||||
|
||||
4
.github/workflows/sync-main.yml
vendored
4
.github/workflows/sync-main.yml
vendored
@@ -14,9 +14,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger main branch sync
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: sync-main.yml
|
||||
repo: ${{ secrets.UPSTREAM_REPO }}
|
||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||
inputs: '{ "sha": "${{ github.sha }}" }'
|
||||
inputs: '{ "sha": "${{ github.sha }}" }'
|
||||
10
.github/workflows/sync-tag.yml
vendored
10
.github/workflows/sync-tag.yml
vendored
@@ -3,7 +3,7 @@ name: sync tag
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- 'v*'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
@@ -16,7 +16,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger release tag sync
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: sync-tag.yml
|
||||
ref: main
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||
steps:
|
||||
- name: Trigger android-client submodule bump
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
||||
with:
|
||||
workflow: bump-netbird.yml
|
||||
ref: main
|
||||
@@ -42,10 +42,10 @@ jobs:
|
||||
if: github.event.created && !github.event.deleted && startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-')
|
||||
steps:
|
||||
- name: Trigger ios-client submodule bump
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
uses: benc-uk/workflow-dispatch@7a027648b88c2413826b6ddd6c76114894dc5ec4 # v1.3.1
|
||||
with:
|
||||
workflow: bump-netbird.yml
|
||||
ref: main
|
||||
repo: netbirdio/ios-client
|
||||
token: ${{ secrets.NC_GITHUB_TOKEN }}
|
||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||
inputs: '{ "tag": "${{ github.ref_name }}" }'
|
||||
26
.github/workflows/test-infrastructure-files.yml
vendored
26
.github/workflows/test-infrastructure-files.yml
vendored
@@ -6,10 +6,10 @@ on:
|
||||
- main
|
||||
pull_request:
|
||||
paths:
|
||||
- "infrastructure_files/**"
|
||||
- ".github/workflows/test-infrastructure-files.yml"
|
||||
- "management/cmd/**"
|
||||
- "signal/cmd/**"
|
||||
- 'infrastructure_files/**'
|
||||
- '.github/workflows/test-infrastructure-files.yml'
|
||||
- 'management/cmd/**'
|
||||
- 'signal/cmd/**'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
|
||||
@@ -20,7 +20,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
store: ["sqlite", "postgres", "mysql"]
|
||||
store: [ 'sqlite', 'postgres', 'mysql' ]
|
||||
services:
|
||||
postgres:
|
||||
image: ${{ (matrix.store == 'postgres') && 'postgres' || '' }}
|
||||
@@ -68,17 +68,15 @@ jobs:
|
||||
run: sudo apt-get install -y curl
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
|
||||
- name: Cache Go modules
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ~/go/pkg/mod
|
||||
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
|
||||
@@ -141,8 +139,8 @@ jobs:
|
||||
CI_NETBIRD_IDP_MGMT_CLIENT_SECRET: testing.client.secret
|
||||
CI_NETBIRD_SIGNAL_PORT: 12345
|
||||
CI_NETBIRD_STORE_CONFIG_ENGINE: ${{ matrix.store }}
|
||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: "${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$"
|
||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: "${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$"
|
||||
NETBIRD_STORE_ENGINE_POSTGRES_DSN: '${{ env.NETBIRD_STORE_ENGINE_POSTGRES_DSN }}$'
|
||||
NETBIRD_STORE_ENGINE_MYSQL_DSN: '${{ env.NETBIRD_STORE_ENGINE_MYSQL_DSN }}$'
|
||||
CI_NETBIRD_MGMT_IDP_SIGNKEY_REFRESH: false
|
||||
CI_NETBIRD_TURN_EXTERNAL_IP: "1.2.3.4"
|
||||
CI_NETBIRD_MGMT_DISABLE_DEFAULT_POLICY: false
|
||||
@@ -256,9 +254,7 @@ jobs:
|
||||
run: sudo apt-get install -y jq
|
||||
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: run script with Zitadel PostgreSQL
|
||||
run: NETBIRD_DOMAIN=use-ip bash -x infrastructure_files/getting-started-with-zitadel.sh
|
||||
|
||||
8
.github/workflows/update-docs.yml
vendored
8
.github/workflows/update-docs.yml
vendored
@@ -3,9 +3,9 @@ name: update docs
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- 'v*'
|
||||
paths:
|
||||
- "shared/management/http/api/openapi.yml"
|
||||
- 'shared/management/http/api/openapi.yml'
|
||||
|
||||
jobs:
|
||||
trigger_docs_api_update:
|
||||
@@ -13,10 +13,10 @@ jobs:
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
steps:
|
||||
- name: Trigger API pages generation
|
||||
uses: benc-uk/workflow-dispatch@31e2b3319479a63f0ab15bf800eff9e913504e26 # v1.3.2
|
||||
uses: benc-uk/workflow-dispatch@v1
|
||||
with:
|
||||
workflow: generate api pages
|
||||
repo: netbirdio/docs
|
||||
ref: "refs/heads/main"
|
||||
token: ${{ secrets.SIGN_GITHUB_TOKEN }}
|
||||
inputs: '{ "tag": "${{ github.ref }}" }'
|
||||
inputs: '{ "tag": "${{ github.ref }}" }'
|
||||
19
.github/workflows/wasm-build-validation.yml
vendored
19
.github/workflows/wasm-build-validation.yml
vendored
@@ -19,17 +19,15 @@ jobs:
|
||||
GOARCH: wasm
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Install dependencies
|
||||
run: sudo apt update && sudo apt install -y -q libgtk-3-dev libayatana-appindicator3-dev libgl1-mesa-dev xorg-dev libpcap-dev
|
||||
- name: Install golangci-lint
|
||||
uses: golangci/golangci-lint-action@82606bf257cbaff209d206a39f5134f0cfbfd2ee #v9.2.1
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8.0.0
|
||||
with:
|
||||
version: latest
|
||||
install-mode: binary
|
||||
@@ -44,11 +42,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
uses: actions/checkout@v4
|
||||
- name: Install Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 # v6.3.0
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: "go.mod"
|
||||
- name: Build Wasm client
|
||||
@@ -65,7 +61,8 @@ jobs:
|
||||
|
||||
echo "Size: ${SIZE} bytes (${SIZE_MB} MB)"
|
||||
|
||||
if [ ${SIZE} -gt 58720256 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 56MB limit!"
|
||||
if [ ${SIZE} -gt 62914560 ]; then
|
||||
echo "Wasm binary size (${SIZE_MB}MB) exceeds 60MB limit!"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ If you haven't already, join our slack workspace [here](https://docs.netbird.io/
|
||||
- [Contributing to NetBird](#contributing-to-netbird)
|
||||
- [Contents](#contents)
|
||||
- [Code of conduct](#code-of-conduct)
|
||||
- [Discuss changes with the NetBird team first](#discuss-changes-with-the-netbird-team-first)
|
||||
- [Directory structure](#directory-structure)
|
||||
- [Development setup](#development-setup)
|
||||
- [Requirements](#requirements)
|
||||
@@ -34,14 +33,6 @@ Conduct which can be found in the file [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md).
|
||||
By participating, you are expected to uphold this code. Please report
|
||||
unacceptable behavior to community@netbird.io.
|
||||
|
||||
## Discuss changes with the NetBird team first
|
||||
|
||||
Changes to the **public API**, **gRPC protocols**, **functionality behavior**, **CLI / service flags**, or **new features** should be discussed with the NetBird team before you start the work. These surfaces are part of NetBird's contract with operators, self-hosters, and downstream integrators, and changes to them have compatibility, security, and release-planning implications that benefit from an early conversation.
|
||||
|
||||
Open an issue or reach out on [Slack](https://docs.netbird.io/slack-url) to talk through what you have in mind. We'll help shape the change, flag any constraints we know about, and confirm the direction so the PR review can focus on implementation rather than design.
|
||||
|
||||
Typical bug fixes, internal refactors, documentation updates, and tests do not need pre-discussion — open the PR directly.
|
||||
|
||||
## Directory structure
|
||||
|
||||
The NetBird project monorepo is organized to maintain most of its individual dependencies code within their directories, except for a few auxiliary or shared packages.
|
||||
|
||||
153
README.md
153
README.md
@@ -1,134 +1,147 @@
|
||||
|
||||
<div align="center">
|
||||
<p align="center">
|
||||
<img width="234" src="docs/media/logo-full.png" alt="NetBird logo"/>
|
||||
</p>
|
||||
<p align="center">
|
||||
<a href="https://sonarcloud.io/dashboard?id=netbirdio_netbird">
|
||||
<img src="https://sonarcloud.io/api/project_badges/measure?project=netbirdio_netbird&metric=alert_status" alt="SonarCloud alert status"/>
|
||||
</a>
|
||||
<a href="https://github.com/netbirdio/netbird/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-BSD--3-blue" alt="BSD-3 License"/>
|
||||
</a>
|
||||
<br/>
|
||||
<br/>
|
||||
<p align="center">
|
||||
<img width="234" src="docs/media/logo-full.png"/>
|
||||
</p>
|
||||
<p>
|
||||
<a href="https://img.shields.io/badge/license-BSD--3-blue)">
|
||||
<img src="https://sonarcloud.io/api/project_badges/measure?project=netbirdio_netbird&metric=alert_status" />
|
||||
</a>
|
||||
<a href="https://github.com/netbirdio/netbird/blob/main/LICENSE">
|
||||
<img src="https://img.shields.io/badge/license-BSD--3-blue" />
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://docs.netbird.io/slack-url">
|
||||
<img src="https://img.shields.io/badge/slack-@netbird-red.svg?logo=slack" alt="NetBird Slack"/>
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/slack-@netbird-red.svg?logo=slack"/>
|
||||
</a>
|
||||
<a href="https://forum.netbird.io">
|
||||
<img src="https://img.shields.io/badge/community%20forum-@netbird-red.svg?logo=discourse" alt="Community forum"/>
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/community forum-@netbird-red.svg?logo=discourse"/>
|
||||
</a>
|
||||
<br>
|
||||
<a href="https://gurubase.io/g/netbird">
|
||||
<img src="https://img.shields.io/badge/Gurubase-Ask%20NetBird%20Guru-006BFF" alt="Gurubase: Ask NetBird Guru"/>
|
||||
</a>
|
||||
<img src="https://img.shields.io/badge/Gurubase-Ask%20NetBird%20Guru-006BFF"/>
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
<p align="center">
|
||||
<strong>
|
||||
Start using NetBird at <a href="https://netbird.io/pricing">netbird.io</a>
|
||||
<br/>
|
||||
See <a href="https://netbird.io/docs/">Documentation</a>
|
||||
<br/>
|
||||
Join our <a href="https://docs.netbird.io/slack-url">Slack channel</a> or our <a href="https://forum.netbird.io">Community forum</a>
|
||||
</strong>
|
||||
<strong>
|
||||
Start using NetBird at <a href="https://netbird.io/pricing">netbird.io</a>
|
||||
<br/>
|
||||
See <a href="https://netbird.io/docs/">Documentation</a>
|
||||
<br/>
|
||||
<strong>
|
||||
🚀 <a href="https://careers.netbird.io">We are hiring! Join us at careers.netbird.io</a>
|
||||
</strong>
|
||||
Join our <a href="https://docs.netbird.io/slack-url">Slack channel</a> or our <a href="https://forum.netbird.io">Community forum</a>
|
||||
<br/>
|
||||
|
||||
</strong>
|
||||
<br>
|
||||
<strong>
|
||||
🚀 <a href="https://careers.netbird.io">We are hiring! Join us at careers.netbird.io</a>
|
||||
</strong>
|
||||
<br>
|
||||
<br>
|
||||
<a href="https://registry.terraform.io/providers/netbirdio/netbird/latest">
|
||||
New: NetBird terraform provider
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<br>
|
||||
|
||||
**NetBird combines a configuration-free peer-to-peer private network and a centralized access control system in a single platform, making it easy to create secure private networks for your organization or home.**
|
||||
|
||||
**Connect.** NetBird creates a WireGuard-based overlay network that automatically connects your machines over an encrypted tunnel, leaving behind the hassle of opening ports, complex firewall rules, VPN gateways, and so forth.
|
||||
|
||||
**Secure.** NetBird enables secure remote access by applying granular access policies while allowing you to manage them intuitively from a single place. Works universally on any infrastructure.
|
||||
|
||||
### Open Source Network Security in a Single Platform
|
||||
|
||||
https://github.com/user-attachments/assets/10cec749-bb56-4ab3-97af-4e38850108d2
|
||||
|
||||
### Self-host NetBird (video)
|
||||
|
||||
### Self-Host NetBird (Video)
|
||||
[](https://youtu.be/bZAgpT6nzaQ)
|
||||
|
||||
### Key features
|
||||
|
||||
| Connectivity | Management | Security | Automation | Platforms |
|
||||
|---|---|---|---|---|
|
||||
| ✓ [Kernel WireGuard](https://docs.netbird.io/about-netbird/why-wireguard-with-netbird) | ✓ [Admin Web UI](https://github.com/netbirdio/dashboard) | ✓ [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login) | ✓ [Public API](https://docs.netbird.io/api) | ✓ [Linux](https://docs.netbird.io/get-started/install/linux) |
|
||||
| ✓ [Peer-to-peer connections](https://docs.netbird.io/about-netbird/how-netbird-works) | ✓ Auto peer discovery and configuration | ✓ [Access control: groups & rules](https://docs.netbird.io/how-to/manage-network-access) | ✓ [Setup keys for bulk provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys) | ✓ [macOS](https://docs.netbird.io/get-started/install/macos) |
|
||||
| ✓ Connection relay fallback | ✓ [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers) | ✓ [Activity logging](https://docs.netbird.io/how-to/audit-events-logging) | ✓ [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart) | ✓ [Windows](https://docs.netbird.io/get-started/install/windows) |
|
||||
| ✓ [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks) | ✓ [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network) | ✓ [Traffic events](https://docs.netbird.io/manage/activity/traffic-events-logging) | ✓ [IdP groups sync with JWT](https://docs.netbird.io/manage/team/idp-sync) | ✓ [Android](https://docs.netbird.io/get-started/install/android) |
|
||||
| ✓ [Domain-based DNS routes](https://docs.netbird.io/manage/dns/dns-aliases-for-routed-networks) | ✓ [Custom DNS zones](https://docs.netbird.io/manage/dns/custom-zones) | ✓ [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks) | ✓ [Terraform provider](https://registry.terraform.io/providers/netbirdio/netbird/latest) | ✓ [Android TV](https://docs.netbird.io/get-started/install/android-tv) |
|
||||
| ✓ [Exit nodes](https://docs.netbird.io/manage/network-routes/use-cases/exit-nodes) | ✓ [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network) | ✓ Peer-to-peer encryption | ✓ [Ansible collection](https://github.com/netbirdio/ansible-netbird) | ✓ [iOS](https://docs.netbird.io/get-started/install/ios) |
|
||||
| ✓ [IPv6 dual-stack overlay](https://docs.netbird.io/manage/settings/ipv6) | ✓ [Multi-account profile switching](https://docs.netbird.io/client/profiles) | ✓ [SSH with central access policies](https://docs.netbird.io/manage/peers/ssh) | | ✓ [Apple TV](https://docs.netbird.io/get-started/install/tvos) |
|
||||
| ✓ [Browser SSH & RDP](https://docs.netbird.io/manage/peers/browser-client) | | ✓ [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn) | | ✓ FreeBSD |
|
||||
| ✓ [Reverse proxy with auto-TLS](https://docs.netbird.io/manage/reverse-proxy) | | ✓ [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication) | | ✓ [pfSense](https://docs.netbird.io/get-started/install/pfsense) |
|
||||
| | | | | ✓ [OPNsense](https://docs.netbird.io/get-started/install/opnsense) |
|
||||
| | | | | ✓ [MikroTik RouterOS](https://docs.netbird.io/use-cases/homelab/client-on-mikrotik-router) |
|
||||
| | | | | ✓ OpenWRT |
|
||||
| | | | | ✓ [Synology](https://docs.netbird.io/get-started/install/synology) |
|
||||
| | | | | ✓ [TrueNAS](https://docs.netbird.io/get-started/install/truenas) |
|
||||
| | | | | ✓ [Proxmox](https://docs.netbird.io/get-started/install/proxmox-ve) |
|
||||
| | | | | ✓ [Raspberry Pi](https://docs.netbird.io/get-started/install/raspberrypi) |
|
||||
| | | | | ✓ [Serverless](https://docs.netbird.io/how-to/netbird-on-faas) |
|
||||
| | | | | ✓ [Container](https://docs.netbird.io/get-started/install/docker) |
|
||||
| Connectivity | Management | Security | Automation| Platforms |
|
||||
|----|----|----|----|----|
|
||||
| <ul><li>- \[x] Kernel WireGuard</ul></li> | <ul><li>- \[x] [Admin Web UI](https://github.com/netbirdio/dashboard)</ul></li> | <ul><li>- \[x] [SSO & MFA support](https://docs.netbird.io/how-to/installation#running-net-bird-with-sso-login)</ul></li> | <ul><li>- \[x] [Public API](https://docs.netbird.io/api)</ul></li> | <ul><li>- \[x] Linux</ul></li> |
|
||||
| <ul><li>- \[x] Peer-to-peer connections</ul></li> | <ul><li>- \[x] Auto peer discovery and configuration</ui></li> | <ul><li>- \[x] [Access control - groups & rules](https://docs.netbird.io/how-to/manage-network-access)</ui></li> | <ul><li>- \[x] [Setup keys for bulk network provisioning](https://docs.netbird.io/how-to/register-machines-using-setup-keys)</ui></li> | <ul><li>- \[x] Mac</ui></li> |
|
||||
| <ul><li>- \[x] Connection relay fallback</ui></li> | <ul><li>- \[x] [IdP integrations](https://docs.netbird.io/selfhosted/identity-providers)</ui></li> | <ul><li>- \[x] [Activity logging](https://docs.netbird.io/how-to/audit-events-logging)</ui></li> | <ul><li>- \[x] [Self-hosting quickstart script](https://docs.netbird.io/selfhosted/selfhosted-quickstart)</ui></li> | <ul><li>- \[x] Windows</ui></li> |
|
||||
| <ul><li>- \[x] [Routes to external networks](https://docs.netbird.io/how-to/routing-traffic-to-private-networks)</ui></li> | <ul><li>- \[x] [Private DNS](https://docs.netbird.io/how-to/manage-dns-in-your-network)</ui></li> | <ul><li>- \[x] [Device posture checks](https://docs.netbird.io/how-to/manage-posture-checks)</ui></li> | <ul><li>- \[x] IdP groups sync with JWT</ui></li> | <ul><li>- \[x] Android</ui></li> |
|
||||
| <ul><li>- \[x] NAT traversal with BPF</ui></li> | <ul><li>- \[x] [Multiuser support](https://docs.netbird.io/how-to/add-users-to-your-network)</ui></li> | <ul><li>- \[x] Peer-to-peer encryption</ui></li> || <ul><li>- \[x] iOS</ui></li> |
|
||||
||| <ul><li>- \[x] [Quantum-resistance with Rosenpass](https://netbird.io/knowledge-hub/the-first-quantum-resistant-mesh-vpn)</ui></li> || <ul><li>- \[x] OpenWRT</ui></li> |
|
||||
||| <ul><li>- \[x] [Periodic re-authentication](https://docs.netbird.io/how-to/enforce-periodic-user-authentication)</ui></li> || <ul><li>- \[x] [Serverless](https://docs.netbird.io/how-to/netbird-on-faas)</ui></li> |
|
||||
||||| <ul><li>- \[x] Docker</ui></li> |
|
||||
|
||||
### Quickstart with NetBird Cloud
|
||||
|
||||
- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install).
|
||||
- Follow the steps to sign up with Google, Microsoft, GitHub or your email address.
|
||||
- Check the NetBird [admin UI](https://app.netbird.io/).
|
||||
- Download and install NetBird at [https://app.netbird.io/install](https://app.netbird.io/install)
|
||||
- Follow the steps to sign-up with Google, Microsoft, GitHub or your email address.
|
||||
- Check NetBird [admin UI](https://app.netbird.io/).
|
||||
- Add more machines.
|
||||
|
||||
### Quickstart with self-hosted NetBird
|
||||
|
||||
This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM. Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IdPs.
|
||||
> This is the quickest way to try self-hosted NetBird. It should take around 5 minutes to get started if you already have a public domain and a VM.
|
||||
Follow the [Advanced guide with a custom identity provider](https://docs.netbird.io/selfhosted/selfhosted-guide#advanced-guide-with-a-custom-identity-provider) for installations with different IDPs.
|
||||
|
||||
**Infrastructure requirements:**
|
||||
- A Linux VM with at least **1 CPU** and **2 GB** of memory.
|
||||
- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port **3478**.
|
||||
- A **public domain** name pointing to the VM.
|
||||
- A Linux VM with at least **1CPU** and **2GB** of memory.
|
||||
- The VM should be publicly accessible on TCP ports **80** and **443** and UDP port: **3478**.
|
||||
- **Public domain** name pointing to the VM.
|
||||
|
||||
**Software requirements:**
|
||||
- Docker with the Compose plugin (Compose v2 or higher). See the [Docker installation guide](https://docs.docker.com/engine/install/).
|
||||
- Docker installed on the VM with the docker-compose plugin ([Docker installation guide](https://docs.docker.com/engine/install/)) or docker with docker-compose in version 2 or higher.
|
||||
- [jq](https://jqlang.github.io/jq/) installed. In most distributions
|
||||
Usually available in the official repositories and can be installed with `sudo apt install jq` or `sudo yum install jq`
|
||||
- [curl](https://curl.se/) installed.
|
||||
Usually available in the official repositories and can be installed with `sudo apt install curl` or `sudo yum install curl`
|
||||
|
||||
**Steps**
|
||||
- Download and run the installation script:
|
||||
```bash
|
||||
export NETBIRD_DOMAIN=netbird.example.com; curl -fsSL https://github.com/netbirdio/netbird/releases/latest/download/getting-started.sh | bash
|
||||
```
|
||||
- Once finished, you can manage the resources via `docker-compose`
|
||||
|
||||
### A bit on NetBird internals
|
||||
- Every machine in the network runs the [NetBird agent](client/), which manages WireGuard.
|
||||
- Every agent connects to the [Management Service](management/), which holds network state, manages peer IPs, and distributes updates to agents.
|
||||
- Agents use ICE (via [pion/ice](https://github.com/pion/ice)) to discover connection candidates for peer-to-peer connections.
|
||||
- Candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers.
|
||||
- Agents negotiate a connection through the [Signal Service](signal/), exchanging end-to-end encrypted messages with candidates.
|
||||
- When NAT traversal fails (e.g. mobile carrier-grade NAT) and a direct p2p connection isn't possible, the system falls back to a [Relay Service](relay/) and a secure WireGuard tunnel is established through it.
|
||||
- Every machine in the network runs [NetBird Agent (or Client)](client/) that manages WireGuard.
|
||||
- Every agent connects to [Management Service](management/) that holds network state, manages peer IPs, and distributes network updates to agents (peers).
|
||||
- NetBird agent uses WebRTC ICE implemented in [pion/ice library](https://github.com/pion/ice) to discover connection candidates when establishing a peer-to-peer connection between machines.
|
||||
- Connection candidates are discovered with the help of [STUN](https://en.wikipedia.org/wiki/STUN) servers.
|
||||
- Agents negotiate a connection through [Signal Service](signal/) passing p2p encrypted messages with candidates.
|
||||
- Sometimes the NAT traversal is unsuccessful due to strict NATs (e.g. mobile carrier-grade NAT) and a p2p connection isn't possible. When this occurs the system falls back to a relay server called [TURN](https://en.wikipedia.org/wiki/Traversal_Using_Relays_around_NAT), and a secure WireGuard tunnel is established via the TURN server.
|
||||
|
||||
[Coturn](https://github.com/coturn/coturn) is the one that has been successfully used for STUN and TURN in NetBird setups.
|
||||
|
||||
<p float="left" align="middle">
|
||||
<img src="https://docs.netbird.io/docs-static/img/about-netbird/high-level-dia.png" width="700" alt="NetBird high-level architecture diagram"/>
|
||||
<img src="https://docs.netbird.io/docs-static/img/about-netbird/high-level-dia.png" width="700"/>
|
||||
</p>
|
||||
|
||||
See a complete [architecture overview](https://docs.netbird.io/about-netbird/how-netbird-works#architecture) for details.
|
||||
|
||||
### Community projects
|
||||
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
||||
- [netbird-tui](https://github.com/n0pashkov/netbird-tui) - terminal UI for managing NetBird peers, routes, and settings
|
||||
- [caddy-netbird](https://github.com/lixmal/caddy-netbird) - Caddy plugin that embeds a NetBird client for proxying HTTP and TCP/UDP traffic through NetBird networks
|
||||
- [NetBird installer script](https://github.com/physk/netbird-installer)
|
||||
- [NetBird ansible collection by Dominion Solutions](https://galaxy.ansible.com/ui/repo/published/dominion_solutions/netbird/)
|
||||
- [netbird-tui](https://github.com/n0pashkov/netbird-tui) — terminal UI for managing NetBird peers, routes, and settings
|
||||
|
||||
**Note**: The `main` branch may be in an *unstable or even broken state* during development.
|
||||
For stable versions, see [releases](https://github.com/netbirdio/netbird/releases).
|
||||
|
||||
### Support acknowledgement
|
||||
|
||||
In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by the Federal Ministry of Education and Research of the Federal Republic of Germany. Together with the [CISPA Helmholtz Center for Information Security](https://cispa.de/en), NetBird brings security best practices and simplicity to private networking.
|
||||
In November 2022, NetBird joined the [StartUpSecure program](https://www.forschung-it-sicherheit-kommunikationssysteme.de/foerderung/bekanntmachungen/startup-secure) sponsored by The Federal Ministry of Education and Research of The Federal Republic of Germany. Together with [CISPA Helmholtz Center for Information Security](https://cispa.de/en) NetBird brings the security best practices and simplicity to private networking.
|
||||
|
||||

|
||||
|
||||
### Acknowledgements
|
||||
We build on open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE](https://github.com/pion/ice), and [Rosenpass](https://rosenpass.eu). We greatly appreciate the work these projects are doing, and we'd love it if you could support them too (e.g., by starring or contributing).
|
||||
### Testimonials
|
||||
We use open-source technologies like [WireGuard®](https://www.wireguard.com/), [Pion ICE (WebRTC)](https://github.com/pion/ice), [Coturn](https://github.com/coturn/coturn), and [Rosenpass](https://rosenpass.eu). We very much appreciate the work these guys are doing and we'd greatly appreciate if you could support them in any way (e.g., by giving a star or a contribution).
|
||||
|
||||
### Legal
|
||||
This repository is licensed under the BSD-3-Clause license, which applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
||||
This repository is licensed under BSD-3-Clause license that applies to all parts of the repository except for the directories management/, signal/ and relay/.
|
||||
Those directories are licensed under the GNU Affero General Public License version 3.0 (AGPLv3). See the respective LICENSE files inside each directory.
|
||||
|
||||
_WireGuard_ and the _WireGuard_ logo are [registered trademarks](https://www.wireguard.com/trademark-policy/) of Jason A. Donenfeld.
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"go.opentelemetry.io/otel"
|
||||
"google.golang.org/grpc"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
|
||||
nbcache "github.com/netbirdio/netbird/management/server/cache"
|
||||
|
||||
@@ -109,7 +109,7 @@ func startManagement(t *testing.T, config *config.Config, testFile string) (*grp
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
iv, _ := validator.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
||||
iv, _ := integrations.NewIntegratedValidator(ctx, peersmanager, settingsManagerMock, eventStore, cacheStore)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(ctx)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -361,6 +361,9 @@ func setupSetConfigReq(customDNSAddressConverted []byte, cmd *cobra.Command, pro
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
req.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
req.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
req.EnableSSHRoot = &enableSSHRoot
|
||||
}
|
||||
@@ -467,6 +470,9 @@ func setupConfig(customDNSAddressConverted []byte, cmd *cobra.Command, configFil
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
ic.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
ic.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
ic.EnableSSHRoot = &enableSSHRoot
|
||||
@@ -595,6 +601,9 @@ func setupLoginRequest(providedSetupKey string, customDNSAddressConverted []byte
|
||||
if cmd.Flag(serverSSHAllowedFlag).Changed {
|
||||
loginRequest.ServerSSHAllowed = &serverSSHAllowed
|
||||
}
|
||||
if cmd.Flag(serverVNCAllowedFlag).Changed {
|
||||
loginRequest.ServerVNCAllowed = &serverVNCAllowed
|
||||
}
|
||||
|
||||
if cmd.Flag(enableSSHRootFlag).Changed {
|
||||
loginRequest.EnableSSHRoot = &enableSSHRoot
|
||||
|
||||
67
client/cmd/vnc_agent.go
Normal file
67
client/cmd/vnc_agent.go
Normal file
@@ -0,0 +1,67 @@
|
||||
//go:build windows
|
||||
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
var vncAgentPort string
|
||||
|
||||
func init() {
|
||||
vncAgentCmd.Flags().StringVar(&vncAgentPort, "port", "15900", "Port for the VNC agent to listen on")
|
||||
rootCmd.AddCommand(vncAgentCmd)
|
||||
}
|
||||
|
||||
// vncAgentCmd runs a VNC server in the current user session, listening on
|
||||
// localhost. It is spawned by the NetBird service (Session 0) via
|
||||
// CreateProcessAsUser into the interactive console session.
|
||||
var vncAgentCmd = &cobra.Command{
|
||||
Use: "vnc-agent",
|
||||
Short: "Run VNC capture agent (internal, spawned by service)",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// Agent's stderr is piped to the service which relogs it.
|
||||
// Use JSON format with caller info for structured parsing.
|
||||
log.SetReportCaller(true)
|
||||
log.SetFormatter(&log.JSONFormatter{})
|
||||
log.SetOutput(os.Stderr)
|
||||
|
||||
sessionID := vncserver.GetCurrentSessionID()
|
||||
log.Infof("VNC agent starting on 127.0.0.1:%s (session %d)", vncAgentPort, sessionID)
|
||||
|
||||
token := os.Getenv("NB_VNC_AGENT_TOKEN")
|
||||
if token == "" {
|
||||
return fmt.Errorf("NB_VNC_AGENT_TOKEN not set; agent requires a token from the service")
|
||||
}
|
||||
|
||||
capturer := vncserver.NewDesktopCapturer()
|
||||
injector := vncserver.NewWindowsInputInjector()
|
||||
srv := vncserver.New(capturer, injector, "")
|
||||
srv.SetDisableAuth(true)
|
||||
srv.SetAgentToken(token)
|
||||
|
||||
port, err := netip.ParseAddrPort("127.0.0.1:" + vncAgentPort)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse listen addr: %w", err)
|
||||
}
|
||||
|
||||
loopback := netip.PrefixFrom(netip.AddrFrom4([4]byte{127, 0, 0, 0}), 8)
|
||||
if err := srv.Start(cmd.Context(), port, loopback); err != nil {
|
||||
return fmt.Errorf("start vnc server: %w", err)
|
||||
}
|
||||
log.Infof("vnc-agent listening on 127.0.0.1:%s, ready", vncAgentPort)
|
||||
|
||||
<-cmd.Context().Done()
|
||||
log.Info("vnc-agent context cancelled, shutting down")
|
||||
return srv.Stop()
|
||||
},
|
||||
SilenceUsage: true,
|
||||
}
|
||||
9
client/cmd/vnc_flags.go
Normal file
9
client/cmd/vnc_flags.go
Normal file
@@ -0,0 +1,9 @@
|
||||
package cmd
|
||||
|
||||
const serverVNCAllowedFlag = "allow-server-vnc"
|
||||
|
||||
var serverVNCAllowed bool
|
||||
|
||||
func init() {
|
||||
upCmd.PersistentFlags().BoolVar(&serverVNCAllowed, serverVNCAllowedFlag, false, "Allow embedded VNC server on peer")
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import (
|
||||
"sync"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
wgdevice "golang.zx2c4.com/wireguard/device"
|
||||
wgnetstack "golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
@@ -85,12 +84,6 @@ type Options struct {
|
||||
DisableIPv6 bool
|
||||
// BlockInbound blocks all inbound connections from peers
|
||||
BlockInbound bool
|
||||
// BlockLANAccess blocks the embedded peer from reaching the host's
|
||||
// LAN (RFC 1918, link-local, loopback) when it's used as a routing
|
||||
// peer. Mirrors profilemanager.ConfigInput.BlockLANAccess. Useful
|
||||
// when the embedded client must never act as a stepping stone into
|
||||
// the host's local network (e.g. the proxy's overlay peer).
|
||||
BlockLANAccess bool
|
||||
// WireguardPort is the port for the tunnel interface. Use 0 for a random port.
|
||||
WireguardPort *int
|
||||
// MTU is the MTU for the tunnel interface.
|
||||
@@ -101,26 +94,6 @@ type Options struct {
|
||||
MTU *uint16
|
||||
// DNSLabels defines additional DNS labels configured in the peer.
|
||||
DNSLabels []string
|
||||
// Performance configures the tunnel's buffer pool cap and batch size.
|
||||
Performance Performance
|
||||
}
|
||||
|
||||
// Performance configures the embedded client's tunnel memory/throughput knobs.
|
||||
//
|
||||
// These settings are process-global: any non-nil field also becomes the
|
||||
// default for Clients constructed by later embed.New calls in the same
|
||||
// process. Nil fields are ignored.
|
||||
type Performance struct {
|
||||
// PreallocatedBuffersPerPool caps the per-tunnel buffer pool. Zero
|
||||
// leaves the pool unbounded. Lower values trade throughput for a
|
||||
// tighter memory ceiling. May also be changed on a running Client via
|
||||
// Client.SetPerformance, provided this field was nonzero at construction.
|
||||
PreallocatedBuffersPerPool *uint32
|
||||
// MaxBatchSize overrides the number of packets the tunnel reads or
|
||||
// writes per syscall, which also bounds eager buffer allocation per
|
||||
// worker. Zero uses the platform default. Applied at construction
|
||||
// only; ignored by Client.SetPerformance.
|
||||
MaxBatchSize *uint32
|
||||
}
|
||||
|
||||
// validateCredentials checks that exactly one credential type is provided
|
||||
@@ -202,7 +175,6 @@ func New(opts Options) (*Client, error) {
|
||||
DisableClientRoutes: &opts.DisableClientRoutes,
|
||||
DisableIPv6: &opts.DisableIPv6,
|
||||
BlockInbound: &opts.BlockInbound,
|
||||
BlockLANAccess: &opts.BlockLANAccess,
|
||||
WireguardPort: opts.WireguardPort,
|
||||
MTU: opts.MTU,
|
||||
DNSLabels: parsedLabels,
|
||||
@@ -220,13 +192,6 @@ func New(opts Options) (*Client, error) {
|
||||
config.PrivateKey = opts.PrivateKey
|
||||
}
|
||||
|
||||
if opts.Performance.PreallocatedBuffersPerPool != nil {
|
||||
wgdevice.SetPreallocatedBuffersPerPool(*opts.Performance.PreallocatedBuffersPerPool)
|
||||
}
|
||||
if opts.Performance.MaxBatchSize != nil {
|
||||
wgdevice.SetMaxBatchSizeOverride(*opts.Performance.MaxBatchSize)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
deviceName: opts.DeviceName,
|
||||
setupKey: opts.SetupKey,
|
||||
@@ -440,21 +405,6 @@ func (c *Client) Expose(ctx context.Context, req ExposeRequest) (*ExposeSession,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// IdentityForIP looks up a remote peer by its tunnel IP using the
|
||||
// embedded client's status recorder. Returns the peer's WireGuard public
|
||||
// key and FQDN. ok=false means the IP isn't in this client's peer
|
||||
// roster — callers should treat that as "unknown peer".
|
||||
func (c *Client) IdentityForIP(ip netip.Addr) (pubKey, fqdn string, ok bool) {
|
||||
if !ip.IsValid() || c.recorder == nil {
|
||||
return "", "", false
|
||||
}
|
||||
state, found := c.recorder.PeerStateByIP(ip.String())
|
||||
if !found {
|
||||
return "", "", false
|
||||
}
|
||||
return state.PubKey, state.FQDN, true
|
||||
}
|
||||
|
||||
// Status returns the current status of the client.
|
||||
func (c *Client) Status() (peer.FullStatus, error) {
|
||||
c.mu.Lock()
|
||||
@@ -523,25 +473,6 @@ func (c *Client) VerifySSHHostKey(peerAddress string, key []byte) error {
|
||||
return sshcommon.VerifyHostKey(storedKey, key, peerAddress)
|
||||
}
|
||||
|
||||
// SetPerformance retunes a running Client. Only PreallocatedBuffersPerPool
|
||||
// takes effect, and only when it was nonzero at construction;
|
||||
// MaxBatchSize is construction-only and returns an error if set here.
|
||||
//
|
||||
// Returns ErrClientNotStarted / ErrEngineNotStarted if the Client is not
|
||||
// running yet.
|
||||
func (c *Client) SetPerformance(t Performance) error {
|
||||
if t.MaxBatchSize != nil {
|
||||
return errors.New("MaxBatchSize is construction-only and cannot be changed at runtime")
|
||||
}
|
||||
engine, err := c.getEngine()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return engine.SetPerformance(internal.Performance{
|
||||
PreallocatedBuffersPerPool: t.PreallocatedBuffersPerPool,
|
||||
})
|
||||
}
|
||||
|
||||
// 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.
|
||||
// Call StopCapture (or CaptureSession.Stop) to end it.
|
||||
|
||||
@@ -52,10 +52,9 @@ func (m *externalChainMonitor) start() {
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
m.cancel = cancel
|
||||
done := make(chan struct{})
|
||||
m.done = done
|
||||
m.done = make(chan struct{})
|
||||
|
||||
go m.run(ctx, done)
|
||||
go m.run(ctx)
|
||||
}
|
||||
|
||||
func (m *externalChainMonitor) stop() {
|
||||
@@ -73,8 +72,8 @@ func (m *externalChainMonitor) stop() {
|
||||
<-done
|
||||
}
|
||||
|
||||
func (m *externalChainMonitor) run(ctx context.Context, done chan struct{}) {
|
||||
defer close(done)
|
||||
func (m *externalChainMonitor) run(ctx context.Context) {
|
||||
defer close(m.done)
|
||||
|
||||
bo := &backoff.ExponentialBackOff{
|
||||
InitialInterval: externalMonitorInitInterval,
|
||||
|
||||
@@ -260,15 +260,23 @@ WriteRegStr ${REG_ROOT} "${UNINSTALL_PATH}" "Publisher" "${COMP_NAME}"
|
||||
|
||||
WriteRegStr ${REG_ROOT} "${UI_REG_APP_PATH}" "" "$INSTDIR\${UI_APP_EXE}"
|
||||
|
||||
; Create autostart registry entry based on checkbox
|
||||
; Drop Run, App Paths and Uninstall entries left in the 32-bit registry view
|
||||
; or HKCU by legacy installers.
|
||||
DetailPrint "Cleaning legacy 32-bit / HKCU entries..."
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
SetRegView 32
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DeleteRegKey HKLM "${REG_APP_PATH}"
|
||||
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
||||
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
||||
SetRegView 64
|
||||
|
||||
DetailPrint "Autostart enabled: $AutostartEnabled"
|
||||
${If} $AutostartEnabled == "1"
|
||||
WriteRegStr HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}" '"$INSTDIR\${UI_APP_EXE}.exe"'
|
||||
DetailPrint "Added autostart registry entry: $INSTDIR\${UI_APP_EXE}.exe"
|
||||
${Else}
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DetailPrint "Autostart not enabled by user"
|
||||
${EndIf}
|
||||
|
||||
@@ -299,11 +307,16 @@ ExecWait '"$INSTDIR\${MAIN_APP_EXE}" service uninstall'
|
||||
DetailPrint "Terminating Netbird UI process..."
|
||||
ExecWait `taskkill /im ${UI_APP_EXE}.exe /f`
|
||||
|
||||
; Remove autostart registry entry
|
||||
; Remove autostart entries from every view a previous installer may have used.
|
||||
DetailPrint "Removing autostart registry entry if exists..."
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
; Legacy: pre-HKLM installs wrote to HKCU; clean that up too.
|
||||
DeleteRegValue HKCU "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
SetRegView 32
|
||||
DeleteRegValue HKLM "${AUTOSTART_REG_KEY}" "${APP_NAME}"
|
||||
DeleteRegKey HKLM "${REG_APP_PATH}"
|
||||
DeleteRegKey HKLM "${UI_REG_APP_PATH}"
|
||||
DeleteRegKey HKLM "${UNINSTALL_PATH}"
|
||||
SetRegView 64
|
||||
|
||||
; Handle data deletion based on checkbox
|
||||
DetailPrint "Checking if user requested data deletion..."
|
||||
|
||||
@@ -315,6 +315,7 @@ func (a *Auth) setSystemInfoFlags(info *system.Info) {
|
||||
a.config.RosenpassEnabled,
|
||||
a.config.RosenpassPermissive,
|
||||
a.config.ServerSSHAllowed,
|
||||
a.config.ServerVNCAllowed,
|
||||
a.config.DisableClientRoutes,
|
||||
a.config.DisableServerRoutes,
|
||||
a.config.DisableDNS,
|
||||
|
||||
@@ -360,13 +360,7 @@ func isRedirectURLPortUsed(redirectURL string, excludedRanges []excludedPortRang
|
||||
return true
|
||||
}
|
||||
|
||||
// FreeBSD 15 disables connecting to INADDR_ANY (0.0.0.0) as a localhost
|
||||
// alias by default, ensure explicit ip for localhost.
|
||||
host := parsedURL.Hostname()
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
addr := net.JoinHostPort(host, port)
|
||||
addr := fmt.Sprintf(":%s", port)
|
||||
conn, err := net.DialTimeout("tcp", addr, 3*time.Second)
|
||||
if err != nil {
|
||||
return false
|
||||
|
||||
@@ -562,6 +562,7 @@ func createEngineConfig(key wgtypes.Key, config *profilemanager.Config, peerConf
|
||||
RosenpassEnabled: config.RosenpassEnabled,
|
||||
RosenpassPermissive: config.RosenpassPermissive,
|
||||
ServerSSHAllowed: util.ReturnBoolWithDefaultTrue(config.ServerSSHAllowed),
|
||||
ServerVNCAllowed: config.ServerVNCAllowed != nil && *config.ServerVNCAllowed,
|
||||
EnableSSHRoot: config.EnableSSHRoot,
|
||||
EnableSSHSFTP: config.EnableSSHSFTP,
|
||||
EnableSSHLocalPortForwarding: config.EnableSSHLocalPortForwarding,
|
||||
@@ -644,6 +645,7 @@ func loginToManagement(ctx context.Context, client mgm.Client, pubSSHKey []byte,
|
||||
config.RosenpassEnabled,
|
||||
config.RosenpassPermissive,
|
||||
config.ServerSSHAllowed,
|
||||
config.ServerVNCAllowed,
|
||||
config.DisableClientRoutes,
|
||||
config.DisableServerRoutes,
|
||||
config.DisableDNS,
|
||||
|
||||
@@ -636,6 +636,9 @@ func (g *BundleGenerator) addCommonConfigFields(configContent *strings.Builder)
|
||||
if g.internalConfig.SSHJWTCacheTTL != nil {
|
||||
configContent.WriteString(fmt.Sprintf("SSHJWTCacheTTL: %d\n", *g.internalConfig.SSHJWTCacheTTL))
|
||||
}
|
||||
if g.internalConfig.ServerVNCAllowed != nil {
|
||||
configContent.WriteString(fmt.Sprintf("ServerVNCAllowed: %v\n", *g.internalConfig.ServerVNCAllowed))
|
||||
}
|
||||
|
||||
configContent.WriteString(fmt.Sprintf("DisableClientRoutes: %v\n", g.internalConfig.DisableClientRoutes))
|
||||
configContent.WriteString(fmt.Sprintf("DisableServerRoutes: %v\n", g.internalConfig.DisableServerRoutes))
|
||||
|
||||
@@ -862,6 +862,7 @@ func TestAddConfig_AllFieldsCovered(t *testing.T) {
|
||||
RosenpassEnabled: true,
|
||||
RosenpassPermissive: true,
|
||||
ServerSSHAllowed: &bTrue,
|
||||
ServerVNCAllowed: &bTrue,
|
||||
EnableSSHRoot: &bTrue,
|
||||
EnableSSHSFTP: &bTrue,
|
||||
EnableSSHLocalPortForwarding: &bTrue,
|
||||
|
||||
@@ -339,7 +339,8 @@ func (c *HandlerChain) isHandlerMatch(qname string, entry HandlerEntry) bool {
|
||||
case entry.Pattern == ".":
|
||||
return true
|
||||
case entry.IsWildcard:
|
||||
return strings.HasSuffix(qname, "."+entry.Pattern)
|
||||
parts := strings.Split(strings.TrimSuffix(qname, entry.Pattern), ".")
|
||||
return len(parts) >= 2 && strings.HasSuffix(qname, entry.Pattern)
|
||||
default:
|
||||
// For non-wildcard patterns:
|
||||
// If handler wants subdomain matching, allow suffix match
|
||||
|
||||
@@ -164,54 +164,6 @@ func TestHandlerChain_ServeDNS_DomainMatching(t *testing.T) {
|
||||
matchSubdomains: true,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard label-boundary mismatch (suffix overlap)",
|
||||
handlerDomain: "*.b.test.",
|
||||
queryDomain: "x.ab.test.",
|
||||
isWildcard: true,
|
||||
matchSubdomains: false,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard label-boundary match",
|
||||
handlerDomain: "*.b.test.",
|
||||
queryDomain: "x.b.test.",
|
||||
isWildcard: true,
|
||||
matchSubdomains: false,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard multi-label match",
|
||||
handlerDomain: "*.b.test.",
|
||||
queryDomain: "x.y.b.test.",
|
||||
isWildcard: true,
|
||||
matchSubdomains: false,
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
name: "wildcard no match on multi-label apex",
|
||||
handlerDomain: "*.b.test.",
|
||||
queryDomain: "b.test.",
|
||||
isWildcard: true,
|
||||
matchSubdomains: false,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard no match on unrelated suffix containment",
|
||||
handlerDomain: "*.example.com.",
|
||||
queryDomain: "notexample.com.",
|
||||
isWildcard: true,
|
||||
matchSubdomains: false,
|
||||
shouldMatch: false,
|
||||
},
|
||||
{
|
||||
name: "wildcard accepts pattern registered without trailing dot",
|
||||
handlerDomain: "*.b.test",
|
||||
queryDomain: "x.b.test.",
|
||||
isWildcard: true,
|
||||
matchSubdomains: false,
|
||||
shouldMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
@@ -321,19 +273,6 @@ func TestHandlerChain_ServeDNS_OverlappingDomains(t *testing.T) {
|
||||
expectedCalls: 1,
|
||||
expectedHandler: 2, // highest priority matching handler should be called
|
||||
},
|
||||
{
|
||||
name: "overlapping wildcard suffixes route to correct handler",
|
||||
handlers: []struct {
|
||||
pattern string
|
||||
priority int
|
||||
}{
|
||||
{pattern: "*.b.test.", priority: nbdns.PriorityDNSRoute},
|
||||
{pattern: "*.ab.test.", priority: nbdns.PriorityDNSRoute},
|
||||
},
|
||||
queryDomain: "app.ab.test.",
|
||||
expectedCalls: 1,
|
||||
expectedHandler: 1,
|
||||
},
|
||||
{
|
||||
name: "root zone with specific domain",
|
||||
handlers: []struct {
|
||||
|
||||
@@ -26,19 +26,6 @@ type resolver interface {
|
||||
LookupNetIP(ctx context.Context, network, host string) ([]netip.Addr, error)
|
||||
}
|
||||
|
||||
// PeerConnectivity reports whether a tunnel IP belongs to a peer the
|
||||
// client knows about and whether that peer is currently connected. The
|
||||
// local resolver uses this to suppress A/AAAA answers whose RDATA points
|
||||
// at a disconnected peer (typical case: a synthesized private-service
|
||||
// record pointing at an embedded proxy peer that just went offline).
|
||||
//
|
||||
// known=false means the IP isn't in the local peerstore at all — the
|
||||
// record is left alone (it points at something outside our mesh, e.g.
|
||||
// a non-peer upstream).
|
||||
type PeerConnectivity interface {
|
||||
IsConnectedByIP(ip string) (known, connected bool)
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
mu sync.RWMutex
|
||||
records map[dns.Question][]dns.RR
|
||||
@@ -46,11 +33,6 @@ type Resolver struct {
|
||||
// zones maps zone domain -> NonAuthoritative (true = non-authoritative, user-created zone)
|
||||
zones map[domain.Domain]bool
|
||||
resolver resolver
|
||||
// peerConn, when non-nil, is consulted on every A/AAAA answer to
|
||||
// drop records pointing at disconnected peers. nil disables the
|
||||
// filter and preserves the legacy "return whatever is registered"
|
||||
// behaviour for callers that never wire a status source.
|
||||
peerConn PeerConnectivity
|
||||
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -67,15 +49,6 @@ func NewResolver() *Resolver {
|
||||
}
|
||||
}
|
||||
|
||||
// SetPeerConnectivity wires the per-IP connectivity check used to filter
|
||||
// out A/AAAA answers pointing at disconnected peers. Pass nil to disable.
|
||||
// Safe to call multiple times; the latest value wins.
|
||||
func (d *Resolver) SetPeerConnectivity(p PeerConnectivity) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.peerConn = p
|
||||
}
|
||||
|
||||
func (d *Resolver) MatchSubdomains() bool {
|
||||
return true
|
||||
}
|
||||
@@ -122,7 +95,6 @@ func (d *Resolver) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
|
||||
replyMessage.RecursionAvailable = true
|
||||
|
||||
result := d.lookupRecords(logger, question)
|
||||
result.records = d.filterDisconnectedPeerAnswers(logger, question, result.records)
|
||||
replyMessage.Authoritative = !result.hasExternalData
|
||||
replyMessage.Answer = result.records
|
||||
replyMessage.Rcode = d.determineRcode(question, result)
|
||||
@@ -464,78 +436,6 @@ func (d *Resolver) logDNSError(logger *log.Entry, hostname string, qtype uint16,
|
||||
}
|
||||
}
|
||||
|
||||
// filterDisconnectedPeerAnswers drops A/AAAA records whose RDATA matches
|
||||
// a known but disconnected peer. The synthesized private-service zones
|
||||
// emit one A record per connected proxy peer in a cluster; when a peer
|
||||
// goes offline, the server-side refresh removes the record from the
|
||||
// next netmap, but the client may still hold the previous netmap for a
|
||||
// short window. This filter is the local belt to that braces — even on
|
||||
// the stale netmap, the resolver hides the offline target.
|
||||
//
|
||||
// Records pointing at unknown IPs (outside the local peerstore, e.g.
|
||||
// non-mesh upstreams) are never dropped. Non-A/AAAA records pass
|
||||
// through untouched.
|
||||
//
|
||||
// Escape hatch: if filtering would leave the answer empty AND at least
|
||||
// one record was filtered, the original list is returned. Better to
|
||||
// hand the client a record that may not respond than NXDOMAIN it
|
||||
// completely when every proxy peer is offline (the upstream may still
|
||||
// be reachable some other way, or the peerstore may be stale).
|
||||
func (d *Resolver) filterDisconnectedPeerAnswers(logger *log.Entry, question dns.Question, records []dns.RR) []dns.RR {
|
||||
if len(records) == 0 {
|
||||
return records
|
||||
}
|
||||
d.mu.RLock()
|
||||
checker := d.peerConn
|
||||
d.mu.RUnlock()
|
||||
if checker == nil {
|
||||
return records
|
||||
}
|
||||
|
||||
kept := make([]dns.RR, 0, len(records))
|
||||
var dropped int
|
||||
for _, rr := range records {
|
||||
ip := extractRecordIP(rr)
|
||||
if ip == "" {
|
||||
kept = append(kept, rr)
|
||||
continue
|
||||
}
|
||||
known, connected := checker.IsConnectedByIP(ip)
|
||||
if known && !connected {
|
||||
dropped++
|
||||
continue
|
||||
}
|
||||
kept = append(kept, rr)
|
||||
}
|
||||
if dropped == 0 {
|
||||
return records
|
||||
}
|
||||
if len(kept) == 0 {
|
||||
logger.Debugf("all %d answers for %s point at disconnected peers; returning the original list", dropped, question.Name)
|
||||
return records
|
||||
}
|
||||
logger.Tracef("dropped %d disconnected-peer answer(s) for %s, returning %d", dropped, question.Name, len(kept))
|
||||
return kept
|
||||
}
|
||||
|
||||
// extractRecordIP returns the dotted-decimal / colon-hex IP carried by
|
||||
// an A or AAAA record, or "" for any other record type.
|
||||
func extractRecordIP(rr dns.RR) string {
|
||||
switch r := rr.(type) {
|
||||
case *dns.A:
|
||||
if r.A == nil {
|
||||
return ""
|
||||
}
|
||||
return r.A.String()
|
||||
case *dns.AAAA:
|
||||
if r.AAAA == nil {
|
||||
return ""
|
||||
}
|
||||
return r.AAAA.String()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Update replaces all zones and their records
|
||||
func (d *Resolver) Update(customZones []nbdns.CustomZone) {
|
||||
d.mu.Lock()
|
||||
|
||||
@@ -30,21 +30,6 @@ func (m *mockResolver) LookupNetIP(ctx context.Context, network, host string) ([
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// mockPeerConnectivity returns canned (known, connected) results per IP.
|
||||
// Used by the disconnected-peer filter tests below. IPs not in the map
|
||||
// are reported as unknown so the filter leaves them alone.
|
||||
type mockPeerConnectivity struct {
|
||||
byIP map[string]struct{ known, connected bool }
|
||||
}
|
||||
|
||||
func (m mockPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
v, ok := m.byIP[ip]
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return v.known, v.connected
|
||||
}
|
||||
|
||||
func TestLocalResolver_ServeDNS(t *testing.T) {
|
||||
recordA := nbdns.SimpleRecord{
|
||||
Name: "peera.netbird.cloud.",
|
||||
@@ -2667,114 +2652,3 @@ func BenchmarkIsInManagedZone_ManyZones(b *testing.B) {
|
||||
resolver.isInManagedZone(qname)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLocalResolver_FilterDisconnectedPeerAnswers verifies the
|
||||
// connectivity-aware filtering layered on top of lookupRecords:
|
||||
// when an A record's IP belongs to a known peer that's disconnected,
|
||||
// the record is dropped from the answer. Records for unknown IPs pass
|
||||
// through. If filtering would empty the answer entirely and at least
|
||||
// one record was dropped, the original list is restored (escape hatch
|
||||
// for the "all proxies offline" case).
|
||||
func TestLocalResolver_FilterDisconnectedPeerAnswers(t *testing.T) {
|
||||
zone := "svc.cluster.netbird."
|
||||
connectedRec := nbdns.SimpleRecord{
|
||||
Name: zone,
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 5,
|
||||
RData: "100.64.0.10",
|
||||
}
|
||||
disconnectedRec := nbdns.SimpleRecord{
|
||||
Name: zone,
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 5,
|
||||
RData: "100.64.0.11",
|
||||
}
|
||||
unknownRec := nbdns.SimpleRecord{
|
||||
Name: zone,
|
||||
Type: int(dns.TypeA),
|
||||
Class: nbdns.DefaultClass,
|
||||
TTL: 5,
|
||||
RData: "203.0.113.5",
|
||||
}
|
||||
|
||||
type ipState struct{ known, connected bool }
|
||||
tests := []struct {
|
||||
name string
|
||||
records []nbdns.SimpleRecord
|
||||
connByIP map[string]ipState
|
||||
wantInOrder []string
|
||||
}{
|
||||
{
|
||||
name: "drops disconnected peer, keeps connected",
|
||||
records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
|
||||
connByIP: map[string]ipState{
|
||||
"100.64.0.10": {known: true, connected: true},
|
||||
"100.64.0.11": {known: true, connected: false},
|
||||
},
|
||||
wantInOrder: []string{"100.64.0.10"},
|
||||
},
|
||||
{
|
||||
name: "unknown IPs pass through untouched",
|
||||
records: []nbdns.SimpleRecord{unknownRec, disconnectedRec},
|
||||
connByIP: map[string]ipState{
|
||||
"100.64.0.11": {known: true, connected: false},
|
||||
},
|
||||
wantInOrder: []string{"203.0.113.5"},
|
||||
},
|
||||
{
|
||||
name: "all disconnected falls back to original list",
|
||||
records: []nbdns.SimpleRecord{disconnectedRec, connectedRec},
|
||||
connByIP: map[string]ipState{
|
||||
"100.64.0.10": {known: true, connected: false},
|
||||
"100.64.0.11": {known: true, connected: false},
|
||||
},
|
||||
wantInOrder: []string{"100.64.0.11", "100.64.0.10"},
|
||||
},
|
||||
{
|
||||
name: "no checker wired returns all records",
|
||||
records: []nbdns.SimpleRecord{connectedRec, disconnectedRec},
|
||||
connByIP: nil,
|
||||
wantInOrder: []string{"100.64.0.10", "100.64.0.11"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
resolver := NewResolver()
|
||||
if tc.connByIP != nil {
|
||||
cm := mockPeerConnectivity{byIP: make(map[string]struct{ known, connected bool }, len(tc.connByIP))}
|
||||
for ip, st := range tc.connByIP {
|
||||
cm.byIP[ip] = struct{ known, connected bool }{st.known, st.connected}
|
||||
}
|
||||
resolver.SetPeerConnectivity(cm)
|
||||
}
|
||||
resolver.Update([]nbdns.CustomZone{{
|
||||
Domain: strings.TrimSuffix(zone, "."),
|
||||
Records: tc.records,
|
||||
NonAuthoritative: true,
|
||||
}})
|
||||
|
||||
var got *dns.Msg
|
||||
writer := &test.MockResponseWriter{
|
||||
WriteMsgFunc: func(m *dns.Msg) error {
|
||||
got = m
|
||||
return nil
|
||||
},
|
||||
}
|
||||
req := new(dns.Msg).SetQuestion(zone, dns.TypeA)
|
||||
resolver.ServeDNS(writer, req)
|
||||
|
||||
require.NotNil(t, got, "resolver must produce a response")
|
||||
require.Len(t, got.Answer, len(tc.wantInOrder),
|
||||
"answer count must match expected: %v", tc.wantInOrder)
|
||||
for i, want := range tc.wantInOrder {
|
||||
a, ok := got.Answer[i].(*dns.A)
|
||||
require.True(t, ok, "answer[%d] must be an A record", i)
|
||||
assert.Equal(t, want, a.A.String(),
|
||||
"answer[%d] expected %s got %s", i, want, a.A.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -301,11 +301,6 @@ func newDefaultServer(
|
||||
warningDelayBase: defaultWarningDelayBase,
|
||||
healthRefresh: make(chan struct{}, 1),
|
||||
}
|
||||
// Wire the local resolver against the peer status recorder so it can
|
||||
// suppress A/AAAA answers that point at disconnected peers (typical
|
||||
// case: synthesised private-service records pointing at an embedded
|
||||
// proxy peer that just went offline).
|
||||
defaultServer.localResolver.SetPeerConnectivity(localPeerConnectivity{statusRecorder})
|
||||
|
||||
// register with root zone, handler chain takes care of the routing
|
||||
dnsService.RegisterMux(".", handlerChain)
|
||||
@@ -1391,25 +1386,3 @@ func (s *DefaultServer) PopulateManagementDomain(mgmtURL *url.URL) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// localPeerConnectivity adapts *peer.Status to local.PeerConnectivity so
|
||||
// the local resolver can ask "is this IP a known peer and is it
|
||||
// connected?" without taking on the peer package as a dependency.
|
||||
// A nil status recorder always reports known=false so the resolver
|
||||
// short-circuits to the legacy "return everything" path.
|
||||
type localPeerConnectivity struct {
|
||||
status *peer.Status
|
||||
}
|
||||
|
||||
// IsConnectedByIP looks the IP up in the peerstore and surfaces both
|
||||
// the known and connected bits. Used by Resolver.filterDisconnectedPeerAnswers.
|
||||
func (l localPeerConnectivity) IsConnectedByIP(ip string) (known, connected bool) {
|
||||
if l.status == nil {
|
||||
return false, false
|
||||
}
|
||||
state, ok := l.status.PeerStateByIP(ip)
|
||||
if !ok {
|
||||
return false, false
|
||||
}
|
||||
return true, state.ConnStatus == peer.StatusConnected
|
||||
}
|
||||
|
||||
@@ -123,6 +123,7 @@ type EngineConfig struct {
|
||||
RosenpassPermissive bool
|
||||
|
||||
ServerSSHAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -205,6 +206,7 @@ type Engine struct {
|
||||
networkMonitor *networkmonitor.NetworkMonitor
|
||||
|
||||
sshServer sshServer
|
||||
vncSrv vncServer
|
||||
|
||||
statusRecorder *peer.Status
|
||||
|
||||
@@ -320,6 +322,10 @@ func (e *Engine) Stop() error {
|
||||
log.Warnf("failed to stop SSH server: %v", err)
|
||||
}
|
||||
|
||||
if err := e.stopVNCServer(); err != nil {
|
||||
log.Warnf("failed to stop VNC server: %v", err)
|
||||
}
|
||||
|
||||
e.cleanupSSHConfig()
|
||||
|
||||
if e.ingressGatewayMgr != nil {
|
||||
@@ -1010,6 +1016,7 @@ func (e *Engine) updateChecksIfNew(checks []*mgmProto.Checks) error {
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1057,6 +1064,10 @@ func (e *Engine) updateConfig(conf *mgmProto.PeerConfig) error {
|
||||
}
|
||||
}
|
||||
|
||||
if err := e.updateVNC(conf.GetSshConfig()); err != nil {
|
||||
log.Warnf("failed handling VNC server setup: %v", err)
|
||||
}
|
||||
|
||||
state := e.statusRecorder.GetLocalPeerState()
|
||||
state.IP = e.wgInterface.Address().String()
|
||||
state.IPv6 = e.wgInterface.Address().IPv6String()
|
||||
@@ -1182,6 +1193,7 @@ func (e *Engine) receiveManagementEvents() {
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1371,6 +1383,11 @@ func (e *Engine) updateNetworkMap(networkMap *mgmProto.NetworkMap) error {
|
||||
e.updateSSHServerAuth(networkMap.GetSshAuth())
|
||||
}
|
||||
|
||||
// VNC auth: always sync, including nil so cleared auth on the management
|
||||
// side is applied locally, and so it isn't skipped on the RemotePeersIsEmpty
|
||||
// cleanup path.
|
||||
e.updateVNCServerAuth(networkMap.GetVncAuth())
|
||||
|
||||
// must set the exclude list after the peers are added. Without it the manager can not figure out the peers parameters from the store
|
||||
excludedLazyPeers := e.toExcludedLazyPeers(forwardingRules, remotePeers)
|
||||
e.connMgr.SetExcludeList(e.ctx, excludedLazyPeers)
|
||||
@@ -1826,6 +1843,7 @@ func (e *Engine) readInitialSettings() ([]*route.Route, *nbdns.Config, bool, err
|
||||
e.config.RosenpassEnabled,
|
||||
e.config.RosenpassPermissive,
|
||||
&e.config.ServerSSHAllowed,
|
||||
&e.config.ServerVNCAllowed,
|
||||
e.config.DisableClientRoutes,
|
||||
e.config.DisableServerRoutes,
|
||||
e.config.DisableDNS,
|
||||
@@ -1967,29 +1985,6 @@ func (e *Engine) GetClientMetrics() *metrics.ClientMetrics {
|
||||
return e.clientMetrics
|
||||
}
|
||||
|
||||
// Performance bundles runtime-adjustable tunnel pool knobs.
|
||||
// See Engine.SetPerformance. Nil fields are ignored.
|
||||
type Performance struct {
|
||||
PreallocatedBuffersPerPool *uint32
|
||||
}
|
||||
|
||||
// SetPerformance applies the given tuning to this engine's live Device.
|
||||
func (e *Engine) SetPerformance(t Performance) error {
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
if e.wgInterface == nil {
|
||||
return fmt.Errorf("wg interface not initialized")
|
||||
}
|
||||
dev := e.wgInterface.GetWGDevice()
|
||||
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) {
|
||||
iface, err := net.InterfaceByName(ifaceName)
|
||||
if err != nil {
|
||||
|
||||
@@ -27,7 +27,7 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/management/server/job"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
@@ -66,8 +66,8 @@ import (
|
||||
"github.com/netbirdio/netbird/route"
|
||||
mgmt "github.com/netbirdio/netbird/shared/management/client"
|
||||
mgmtProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
"github.com/netbirdio/netbird/shared/netiputil"
|
||||
signal "github.com/netbirdio/netbird/shared/signal/client"
|
||||
"github.com/netbirdio/netbird/shared/signal/proto"
|
||||
signalServer "github.com/netbirdio/netbird/signal/server"
|
||||
@@ -1641,7 +1641,7 @@ func startManagement(t *testing.T, dataDir, testFile string) (*grpc.Server, stri
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, nil, eventStore, cacheStore)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
243
client/internal/engine_vnc.go
Normal file
243
client/internal/engine_vnc.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
|
||||
sshuserhash "github.com/netbirdio/netbird/shared/sshauth"
|
||||
)
|
||||
|
||||
const (
|
||||
vncExternalPort uint16 = 5900
|
||||
vncInternalPort uint16 = 25900
|
||||
)
|
||||
|
||||
type vncServer interface {
|
||||
Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error
|
||||
Stop() error
|
||||
ActiveSessions() []vncserver.ActiveSessionInfo
|
||||
}
|
||||
|
||||
func (e *Engine) setupVNCPortRedirection() error {
|
||||
if e.firewall == nil || e.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
localAddr := e.wgInterface.Address().IP
|
||||
if !localAddr.IsValid() {
|
||||
return errors.New("invalid local NetBird address")
|
||||
}
|
||||
|
||||
if err := e.firewall.AddInboundDNAT(localAddr, firewallManager.ProtocolTCP, vncExternalPort, vncInternalPort); err != nil {
|
||||
return fmt.Errorf("add VNC port redirection: %w", err)
|
||||
}
|
||||
log.Infof("VNC port redirection: %s:%d -> %s:%d", localAddr, vncExternalPort, localAddr, vncInternalPort)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Engine) cleanupVNCPortRedirection() error {
|
||||
if e.firewall == nil || e.wgInterface == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
localAddr := e.wgInterface.Address().IP
|
||||
if !localAddr.IsValid() {
|
||||
return errors.New("invalid local NetBird address")
|
||||
}
|
||||
|
||||
if err := e.firewall.RemoveInboundDNAT(localAddr, firewallManager.ProtocolTCP, vncExternalPort, vncInternalPort); err != nil {
|
||||
return fmt.Errorf("remove VNC port redirection: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVNC handles starting/stopping the VNC server based on the config flag.
|
||||
// sshConf provides the JWT identity provider config (shared with SSH).
|
||||
func (e *Engine) updateVNC(sshConf *mgmProto.SSHConfig) error {
|
||||
if !e.config.ServerVNCAllowed {
|
||||
if e.vncSrv != nil {
|
||||
log.Info("VNC server disabled, stopping")
|
||||
}
|
||||
return e.stopVNCServer()
|
||||
}
|
||||
|
||||
if e.config.BlockInbound {
|
||||
log.Info("VNC server disabled because inbound connections are blocked")
|
||||
return e.stopVNCServer()
|
||||
}
|
||||
|
||||
if e.vncSrv != nil {
|
||||
// Update JWT config on existing server in case management sent new config.
|
||||
e.updateVNCServerJWT(sshConf)
|
||||
return nil
|
||||
}
|
||||
|
||||
return e.startVNCServer(sshConf)
|
||||
}
|
||||
|
||||
func (e *Engine) startVNCServer(sshConf *mgmProto.SSHConfig) error {
|
||||
if e.wgInterface == nil {
|
||||
return errors.New("wg interface not initialized")
|
||||
}
|
||||
|
||||
capturer, injector, ok := newPlatformVNC()
|
||||
if !ok {
|
||||
log.Debug("VNC server not supported on this platform")
|
||||
return nil
|
||||
}
|
||||
|
||||
netbirdIP := e.wgInterface.Address().IP
|
||||
|
||||
srv := vncserver.New(capturer, injector, "")
|
||||
if vncNeedsServiceMode() {
|
||||
log.Info("VNC: running in Session 0, enabling service mode (agent proxy)")
|
||||
srv.SetServiceMode(true)
|
||||
}
|
||||
|
||||
if protoJWT := sshConf.GetJwtConfig(); protoJWT != nil {
|
||||
audiences := protoJWT.GetAudiences()
|
||||
if len(audiences) == 0 && protoJWT.GetAudience() != "" {
|
||||
audiences = []string{protoJWT.GetAudience()}
|
||||
}
|
||||
srv.SetJWTConfig(&vncserver.JWTConfig{
|
||||
Issuer: protoJWT.GetIssuer(),
|
||||
Audiences: audiences,
|
||||
KeysLocation: protoJWT.GetKeysLocation(),
|
||||
MaxTokenAge: protoJWT.GetMaxTokenAge(),
|
||||
})
|
||||
log.Debugf("VNC: JWT authentication configured (issuer=%s)", protoJWT.GetIssuer())
|
||||
}
|
||||
|
||||
if netstackNet := e.wgInterface.GetNet(); netstackNet != nil {
|
||||
srv.SetNetstackNet(netstackNet)
|
||||
}
|
||||
|
||||
listenAddr := netip.AddrPortFrom(netbirdIP, vncInternalPort)
|
||||
network := e.wgInterface.Address().Network
|
||||
if err := srv.Start(e.ctx, listenAddr, network); err != nil {
|
||||
return fmt.Errorf("start VNC server: %w", err)
|
||||
}
|
||||
|
||||
e.vncSrv = srv
|
||||
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
RegisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.RegisterNetstackService(nftypes.TCP, vncInternalPort)
|
||||
log.Debugf("registered VNC service for TCP:%d", vncInternalPort)
|
||||
}
|
||||
|
||||
if err := e.setupVNCPortRedirection(); err != nil {
|
||||
log.Warnf("setup VNC port redirection: %v", err)
|
||||
}
|
||||
|
||||
log.Info("VNC server enabled")
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateVNCServerJWT configures the JWT validation for the VNC server using
|
||||
// the same JWT config as SSH (same identity provider).
|
||||
func (e *Engine) updateVNCServerJWT(sshConf *mgmProto.SSHConfig) {
|
||||
if e.vncSrv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
vncSrv, ok := e.vncSrv.(*vncserver.Server)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
protoJWT := sshConf.GetJwtConfig()
|
||||
if protoJWT == nil {
|
||||
return
|
||||
}
|
||||
|
||||
audiences := protoJWT.GetAudiences()
|
||||
if len(audiences) == 0 && protoJWT.GetAudience() != "" {
|
||||
audiences = []string{protoJWT.GetAudience()}
|
||||
}
|
||||
|
||||
vncSrv.SetJWTConfig(&vncserver.JWTConfig{
|
||||
Issuer: protoJWT.GetIssuer(),
|
||||
Audiences: audiences,
|
||||
KeysLocation: protoJWT.GetKeysLocation(),
|
||||
MaxTokenAge: protoJWT.GetMaxTokenAge(),
|
||||
})
|
||||
}
|
||||
|
||||
// updateVNCServerAuth updates VNC fine-grained access control from management.
|
||||
func (e *Engine) updateVNCServerAuth(vncAuth *mgmProto.VNCAuth) {
|
||||
if vncAuth == nil || e.vncSrv == nil {
|
||||
return
|
||||
}
|
||||
|
||||
vncSrv, ok := e.vncSrv.(*vncserver.Server)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
protoUsers := vncAuth.GetAuthorizedUsers()
|
||||
authorizedUsers := make([]sshuserhash.UserIDHash, len(protoUsers))
|
||||
for i, hash := range protoUsers {
|
||||
if len(hash) != 16 {
|
||||
log.Warnf("invalid VNC auth hash length %d, expected 16", len(hash))
|
||||
return
|
||||
}
|
||||
authorizedUsers[i] = sshuserhash.UserIDHash(hash)
|
||||
}
|
||||
|
||||
machineUsers := make(map[string][]uint32)
|
||||
for osUser, indexes := range vncAuth.GetMachineUsers() {
|
||||
machineUsers[osUser] = indexes.GetIndexes()
|
||||
}
|
||||
|
||||
vncSrv.UpdateVNCAuth(&sshauth.Config{
|
||||
UserIDClaim: vncAuth.GetUserIDClaim(),
|
||||
AuthorizedUsers: authorizedUsers,
|
||||
MachineUsers: machineUsers,
|
||||
})
|
||||
}
|
||||
|
||||
// GetVNCServerStatus returns whether the VNC server is running and the list
|
||||
// of active VNC sessions.
|
||||
func (e *Engine) GetVNCServerStatus() (enabled bool, sessions []vncserver.ActiveSessionInfo) {
|
||||
if e.vncSrv == nil {
|
||||
return false, nil
|
||||
}
|
||||
return true, e.vncSrv.ActiveSessions()
|
||||
}
|
||||
|
||||
func (e *Engine) stopVNCServer() error {
|
||||
if e.vncSrv == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := e.cleanupVNCPortRedirection(); err != nil {
|
||||
log.Warnf("cleanup VNC port redirection: %v", err)
|
||||
}
|
||||
|
||||
if registrar, ok := e.firewall.(interface {
|
||||
UnregisterNetstackService(protocol nftypes.Protocol, port uint16)
|
||||
}); ok {
|
||||
registrar.UnregisterNetstackService(nftypes.TCP, vncInternalPort)
|
||||
}
|
||||
|
||||
log.Info("stopping VNC server")
|
||||
err := e.vncSrv.Stop()
|
||||
e.vncSrv = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("stop VNC server: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
31
client/internal/engine_vnc_console_freebsd.go
Normal file
31
client/internal/engine_vnc_console_freebsd.go
Normal file
@@ -0,0 +1,31 @@
|
||||
//go:build freebsd
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
// newConsoleVNC builds the FreeBSD console fallback: vt(4) framebuffer
|
||||
// for capture, /dev/uinput for input. The uinput device requires the
|
||||
// `uinput` kernel module (`kldload uinput`); without it, input init
|
||||
// fails and we drop to a stub injector so the user still gets a
|
||||
// view-only screen mirror.
|
||||
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
poller := vncserver.NewFBPoller("")
|
||||
w, h := poller.Width(), poller.Height()
|
||||
if w == 0 || h == 0 {
|
||||
poller.Close()
|
||||
return nil, nil, fmt.Errorf("vt framebuffer init failed (vt may not allow mmap on this driver)")
|
||||
}
|
||||
if inj, err := vncserver.NewUInputInjector(w, h); err == nil {
|
||||
return poller, inj, nil
|
||||
} else {
|
||||
log.Infof("VNC console: uinput unavailable (%v); view-only mode. Run `kldload uinput` to enable input.", err)
|
||||
return poller, &vncserver.StubInputInjector{}, nil
|
||||
}
|
||||
}
|
||||
30
client/internal/engine_vnc_console_linux.go
Normal file
30
client/internal/engine_vnc_console_linux.go
Normal file
@@ -0,0 +1,30 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
// newConsoleVNC builds a framebuffer + uinput VNC backend for boxes
|
||||
// without a running X server. Used as the auto-fallback when
|
||||
// newPlatformVNC can't reach X. Returns an error when /dev/fb0 or
|
||||
// /dev/uinput aren't usable so the caller can drop back to a stub.
|
||||
func newConsoleVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, error) {
|
||||
poller := vncserver.NewFBPoller("")
|
||||
w, h := poller.Width(), poller.Height()
|
||||
if w == 0 || h == 0 {
|
||||
poller.Close()
|
||||
return nil, nil, fmt.Errorf("framebuffer capturer init failed (is /dev/fb0 readable?)")
|
||||
}
|
||||
inj, err := vncserver.NewUInputInjector(w, h)
|
||||
if err != nil {
|
||||
log.Debugf("uinput unavailable, falling back to view-only VNC: %v", err)
|
||||
return poller, &vncserver.StubInputInjector{}, nil
|
||||
}
|
||||
return poller, inj, nil
|
||||
}
|
||||
23
client/internal/engine_vnc_darwin.go
Normal file
23
client/internal/engine_vnc_darwin.go
Normal file
@@ -0,0 +1,23 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
capturer := vncserver.NewMacPoller()
|
||||
injector, err := vncserver.NewMacInputInjector()
|
||||
if err != nil {
|
||||
log.Debugf("VNC: macOS input injector: %v", err)
|
||||
return capturer, &vncserver.StubInputInjector{}, true
|
||||
}
|
||||
return capturer, injector, true
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return false
|
||||
}
|
||||
13
client/internal/engine_vnc_stub.go
Normal file
13
client/internal/engine_vnc_stub.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build (!windows && !darwin && !freebsd && !(linux && !android)) || (darwin && ios)
|
||||
|
||||
package internal
|
||||
|
||||
import vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
return nil, nil, false
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return false
|
||||
}
|
||||
13
client/internal/engine_vnc_windows.go
Normal file
13
client/internal/engine_vnc_windows.go
Normal file
@@ -0,0 +1,13 @@
|
||||
//go:build windows
|
||||
|
||||
package internal
|
||||
|
||||
import vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
return vncserver.NewDesktopCapturer(), vncserver.NewWindowsInputInjector(), true
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return vncserver.GetCurrentSessionID() == 0
|
||||
}
|
||||
35
client/internal/engine_vnc_x11.go
Normal file
35
client/internal/engine_vnc_x11.go
Normal file
@@ -0,0 +1,35 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package internal
|
||||
|
||||
import (
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
vncserver "github.com/netbirdio/netbird/client/vnc/server"
|
||||
)
|
||||
|
||||
func newPlatformVNC() (vncserver.ScreenCapturer, vncserver.InputInjector, bool) {
|
||||
// Prefer X11 when an X server is reachable. NewX11InputInjector probes
|
||||
// DISPLAY (and /proc) eagerly, so a non-nil error here means no X.
|
||||
injector, err := vncserver.NewX11InputInjector("")
|
||||
if err == nil {
|
||||
return vncserver.NewX11Poller(""), injector, true
|
||||
}
|
||||
log.Debugf("VNC: X11 not available: %v", err)
|
||||
|
||||
// Fallback for headless / pre-X states (kernel console, login manager
|
||||
// without X, physical server in recovery): stream the framebuffer and
|
||||
// inject input via /dev/uinput.
|
||||
consoleCap, consoleInj, err := newConsoleVNC()
|
||||
if err == nil {
|
||||
log.Infof("VNC: using framebuffer console capture (%dx%d)", consoleCap.Width(), consoleCap.Height())
|
||||
return consoleCap, consoleInj, true
|
||||
}
|
||||
log.Debugf("VNC: framebuffer console fallback unavailable: %v", err)
|
||||
|
||||
return &vncserver.StubCapturer{}, &vncserver.StubInputInjector{}, false
|
||||
}
|
||||
|
||||
func vncNeedsServiceMode() bool {
|
||||
return false
|
||||
}
|
||||
@@ -50,7 +50,7 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
||||
switch msg.Type {
|
||||
// handle route changes
|
||||
case unix.RTM_ADD, syscall.RTM_DELETE:
|
||||
route, flags, err := parseRouteMessage(buf[:n])
|
||||
route, err := parseRouteMessage(buf[:n])
|
||||
if err != nil {
|
||||
log.Debugf("Network monitor: error parsing routing message: %v", err)
|
||||
continue
|
||||
@@ -66,10 +66,6 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
||||
}
|
||||
switch msg.Type {
|
||||
case unix.RTM_ADD:
|
||||
if systemops.IgnoreAddedDefaultRoute(flags) {
|
||||
log.Debugf("Network monitor: ignoring added default route via %s, interface %s, flags %#x", route.Gw, intf, flags)
|
||||
continue
|
||||
}
|
||||
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
|
||||
return nil
|
||||
case unix.RTM_DELETE:
|
||||
@@ -82,26 +78,22 @@ func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Next
|
||||
}
|
||||
}
|
||||
|
||||
func parseRouteMessage(buf []byte) (*systemops.Route, int, error) {
|
||||
func parseRouteMessage(buf []byte) (*systemops.Route, error) {
|
||||
msgs, err := route.ParseRIB(route.RIBTypeRoute, buf)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("parse RIB: %v", err)
|
||||
return nil, fmt.Errorf("parse RIB: %v", err)
|
||||
}
|
||||
|
||||
if len(msgs) != 1 {
|
||||
return nil, 0, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
|
||||
return nil, fmt.Errorf("unexpected RIB message msgs: %v", msgs)
|
||||
}
|
||||
|
||||
msg, ok := msgs[0].(*route.RouteMessage)
|
||||
if !ok {
|
||||
return nil, 0, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
|
||||
return nil, fmt.Errorf("unexpected RIB message type: %T", msgs[0])
|
||||
}
|
||||
|
||||
r, err := systemops.MsgToRoute(msg)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return r, msg.Flags, nil
|
||||
return systemops.MsgToRoute(msg)
|
||||
}
|
||||
|
||||
// waitReadable blocks until fd has data to read, or ctx is cancelled.
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"github.com/netbirdio/netbird/client/internal/peer/id"
|
||||
"github.com/netbirdio/netbird/client/internal/peer/worker"
|
||||
"github.com/netbirdio/netbird/client/internal/portforward"
|
||||
"github.com/netbirdio/netbird/client/internal/rosenpass"
|
||||
"github.com/netbirdio/netbird/client/internal/stdnet"
|
||||
"github.com/netbirdio/netbird/route"
|
||||
relayClient "github.com/netbirdio/netbird/shared/relay/client"
|
||||
@@ -900,7 +899,7 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
|
||||
}
|
||||
|
||||
// Fallback to deterministic key if no NetBird PSK is configured
|
||||
determKey, err := rosenpass.DeterministicSeedKey(conn.config.LocalKey, conn.config.Key)
|
||||
determKey, err := conn.rosenpassDetermKey()
|
||||
if err != nil {
|
||||
conn.Log.Errorf("failed to generate Rosenpass initial key: %v", err)
|
||||
return nil
|
||||
@@ -909,6 +908,26 @@ func (conn *Conn) presharedKey(remoteRosenpassKey []byte) *wgtypes.Key {
|
||||
return determKey
|
||||
}
|
||||
|
||||
// todo: move this logic into Rosenpass package
|
||||
func (conn *Conn) rosenpassDetermKey() (*wgtypes.Key, error) {
|
||||
lk := []byte(conn.config.LocalKey)
|
||||
rk := []byte(conn.config.Key) // remote key
|
||||
var keyInput []byte
|
||||
if string(lk) > string(rk) {
|
||||
//nolint:gocritic
|
||||
keyInput = append(lk[:16], rk[:16]...)
|
||||
} else {
|
||||
//nolint:gocritic
|
||||
keyInput = append(rk[:16], lk[:16]...)
|
||||
}
|
||||
|
||||
key, err := wgtypes.NewKey(keyInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
|
||||
func isController(config ConnConfig) bool {
|
||||
return config.LocalKey > config.Key
|
||||
}
|
||||
|
||||
@@ -185,12 +185,9 @@ func (s *StatusChangeSubscription) Events() chan map[string]RouterState {
|
||||
return s.eventsChan
|
||||
}
|
||||
|
||||
// Status holds a state of peers, signal, management connections and relays.
|
||||
// mux is an RWMutex so hot read paths (notably PeerStateByIP, called for
|
||||
// every private-service request) don't contend against each other.
|
||||
// Pure read methods take RLock; anything that mutates state takes Lock.
|
||||
// Status holds a state of peers, signal, management connections and relays
|
||||
type Status struct {
|
||||
mux sync.RWMutex
|
||||
mux sync.Mutex
|
||||
peers map[string]State
|
||||
changeNotify map[string]map[string]*StatusChangeSubscription // map[peerID]map[subscriptionID]*StatusChangeSubscription
|
||||
signalState bool
|
||||
@@ -286,8 +283,8 @@ func (d *Status) AddPeer(peerPubKey string, fqdn string, ip string, ipv6 string)
|
||||
|
||||
// GetPeer adds peer to Daemon status map
|
||||
func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
|
||||
state, ok := d.peers[peerPubKey]
|
||||
if !ok {
|
||||
@@ -297,8 +294,8 @@ func (d *Status) GetPeer(peerPubKey string) (State, error) {
|
||||
}
|
||||
|
||||
func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
|
||||
for _, state := range d.peers {
|
||||
if state.IP == ip {
|
||||
@@ -308,25 +305,6 @@ func (d *Status) PeerByIP(ip string) (string, bool) {
|
||||
return "", false
|
||||
}
|
||||
|
||||
// PeerStateByIP returns the full peer State for the given tunnel IP.
|
||||
// Matches against either the IPv4 (State.IP) or IPv6 (State.IPv6) tunnel
|
||||
// address so dual-stack peers are reachable on either family. Returns the
|
||||
// zero State and false when no peer matches or the input is empty.
|
||||
func (d *Status) PeerStateByIP(ip string) (State, bool) {
|
||||
if ip == "" {
|
||||
return State{}, false
|
||||
}
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
|
||||
for _, state := range d.peers {
|
||||
if (state.IP != "" && state.IP == ip) || (state.IPv6 != "" && state.IPv6 == ip) {
|
||||
return state, true
|
||||
}
|
||||
}
|
||||
return State{}, false
|
||||
}
|
||||
|
||||
// RemovePeer removes peer from Daemon status map
|
||||
func (d *Status) RemovePeer(peerPubKey string) error {
|
||||
d.mux.Lock()
|
||||
@@ -724,8 +702,8 @@ func (d *Status) UnsubscribePeerStateChanges(subscription *StatusChangeSubscript
|
||||
|
||||
// GetLocalPeerState returns the local peer state
|
||||
func (d *Status) GetLocalPeerState() LocalPeerState {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
return d.localPeer.Clone()
|
||||
}
|
||||
|
||||
@@ -931,8 +909,8 @@ func (d *Status) DeleteResolvedDomainsStates(domain domain.Domain) {
|
||||
}
|
||||
|
||||
func (d *Status) GetRosenpassState() RosenpassState {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
return RosenpassState{
|
||||
d.rosenpassEnabled,
|
||||
d.rosenpassPermissive,
|
||||
@@ -940,14 +918,14 @@ func (d *Status) GetRosenpassState() RosenpassState {
|
||||
}
|
||||
|
||||
func (d *Status) GetLazyConnection() bool {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
return d.lazyConnectionEnabled
|
||||
}
|
||||
|
||||
func (d *Status) GetManagementState() ManagementState {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
return ManagementState{
|
||||
d.mgmAddress,
|
||||
d.managementState,
|
||||
@@ -973,8 +951,8 @@ func (d *Status) UpdateLatency(pubKey string, latency time.Duration) error {
|
||||
|
||||
// IsLoginRequired determines if a peer's login has expired.
|
||||
func (d *Status) IsLoginRequired() bool {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
|
||||
// if peer is connected to the management then login is not expired
|
||||
if d.managementState {
|
||||
@@ -989,8 +967,8 @@ func (d *Status) IsLoginRequired() bool {
|
||||
}
|
||||
|
||||
func (d *Status) GetSignalState() SignalState {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
return SignalState{
|
||||
d.signalAddress,
|
||||
d.signalState,
|
||||
@@ -1000,8 +978,8 @@ func (d *Status) GetSignalState() SignalState {
|
||||
|
||||
// GetRelayStates returns the stun/turn/permanent relay states
|
||||
func (d *Status) GetRelayStates() []relay.ProbeResult {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
if d.relayMgr == nil {
|
||||
return d.relayStates
|
||||
}
|
||||
@@ -1030,8 +1008,8 @@ func (d *Status) GetRelayStates() []relay.ProbeResult {
|
||||
}
|
||||
|
||||
func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
if d.ingressGwMgr == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -1040,16 +1018,16 @@ func (d *Status) ForwardingRules() []firewall.ForwardRule {
|
||||
}
|
||||
|
||||
func (d *Status) GetDNSStates() []NSGroupState {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
|
||||
// shallow copy is good enough, as slices fields are currently not updated
|
||||
return slices.Clone(d.nsGroupStates)
|
||||
}
|
||||
|
||||
func (d *Status) GetResolvedDomainsStates() map[domain.Domain]ResolvedDomainInfo {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
return maps.Clone(d.resolvedDomainsStates)
|
||||
}
|
||||
|
||||
@@ -1065,8 +1043,8 @@ func (d *Status) GetFullStatus() FullStatus {
|
||||
LazyConnectionEnabled: d.GetLazyConnection(),
|
||||
}
|
||||
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
|
||||
fullStatus.LocalPeerState = d.localPeer
|
||||
|
||||
@@ -1241,8 +1219,8 @@ func (d *Status) SetWgIface(wgInterface WGIfaceStatus) {
|
||||
}
|
||||
|
||||
func (d *Status) PeersStatus() (*configurer.Stats, error) {
|
||||
d.mux.RLock()
|
||||
defer d.mux.RUnlock()
|
||||
d.mux.Lock()
|
||||
defer d.mux.Unlock()
|
||||
if d.wgIface == nil {
|
||||
return nil, fmt.Errorf("wgInterface is nil, cannot retrieve peers status")
|
||||
}
|
||||
|
||||
@@ -63,33 +63,6 @@ func TestUpdatePeerState(t *testing.T) {
|
||||
assert.Equal(t, ip, state.IP, "ip should be equal")
|
||||
}
|
||||
|
||||
func TestStatus_PeerStateByIP(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", ""))
|
||||
req.NoError(status.AddPeer("pk-2", "peer-2.netbird", "100.64.0.11", ""))
|
||||
|
||||
state, ok := status.PeerStateByIP("100.64.0.10")
|
||||
req.True(ok, "known tunnel IP should resolve to a peer state")
|
||||
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
||||
req.Equal("peer-1.netbird", state.FQDN, "matching state must carry the right FQDN")
|
||||
|
||||
_, ok = status.PeerStateByIP("100.64.0.99")
|
||||
req.False(ok, "unknown IP must report ok=false")
|
||||
}
|
||||
|
||||
func TestStatus_PeerStateByIP_MatchesIPv6(t *testing.T) {
|
||||
status := NewRecorder("https://mgm")
|
||||
req := require.New(t)
|
||||
|
||||
req.NoError(status.AddPeer("pk-1", "peer-1.netbird", "100.64.0.10", "fd00::1"))
|
||||
|
||||
state, ok := status.PeerStateByIP("fd00::1")
|
||||
req.True(ok, "IPv6-only match must resolve to the peer state")
|
||||
req.Equal("pk-1", state.PubKey, "matching state must carry the right pub key")
|
||||
}
|
||||
|
||||
func TestStatus_UpdatePeerFQDN(t *testing.T) {
|
||||
key := "abc"
|
||||
fqdn := "peer-a.netbird.local"
|
||||
|
||||
@@ -179,10 +179,8 @@ func getDefaultGateway() (gateway net.IP, localIP net.IP, err error) {
|
||||
}
|
||||
|
||||
dst := net.IPv4zero
|
||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux/Android.
|
||||
// TODO: on android/ios, use platform APIs (ConnectivityManager.getLinkProperties /
|
||||
// NWPathMonitor) when netlink-based lookup is restricted or unavailable.
|
||||
if runtime.GOOS == "linux" {
|
||||
// go-netroute v0.4.0 rejects unspecified destinations client-side on Linux.
|
||||
dst = net.IPv4(0, 0, 0, 1)
|
||||
}
|
||||
_, gateway, localIP, err = router.Route(dst)
|
||||
@@ -205,7 +203,7 @@ func getDefaultGateway6() (gateway net.IP, localIP net.IP, err error) {
|
||||
}
|
||||
|
||||
dst := net.IPv6zero
|
||||
if runtime.GOOS == "linux" || runtime.GOOS == "android" {
|
||||
if runtime.GOOS == "linux" {
|
||||
// ::2
|
||||
dst = net.IP{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ type ConfigInput struct {
|
||||
StateFilePath string
|
||||
PreSharedKey *string
|
||||
ServerSSHAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -116,6 +117,7 @@ type Config struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed *bool
|
||||
ServerVNCAllowed *bool
|
||||
EnableSSHRoot *bool
|
||||
EnableSSHSFTP *bool
|
||||
EnableSSHLocalPortForwarding *bool
|
||||
@@ -418,6 +420,21 @@ func (config *Config) apply(input ConfigInput) (updated bool, err error) {
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.ServerVNCAllowed != nil {
|
||||
if config.ServerVNCAllowed == nil || *input.ServerVNCAllowed != *config.ServerVNCAllowed {
|
||||
if *input.ServerVNCAllowed {
|
||||
log.Infof("enabling VNC server")
|
||||
} else {
|
||||
log.Infof("disabling VNC server")
|
||||
}
|
||||
config.ServerVNCAllowed = input.ServerVNCAllowed
|
||||
updated = true
|
||||
}
|
||||
} else if config.ServerVNCAllowed == nil {
|
||||
config.ServerVNCAllowed = util.True()
|
||||
updated = true
|
||||
}
|
||||
|
||||
if input.EnableSSHRoot != nil && input.EnableSSHRoot != config.EnableSSHRoot {
|
||||
if *input.EnableSSHRoot {
|
||||
log.Infof("enabling SSH root login")
|
||||
|
||||
@@ -28,15 +28,6 @@ func hashRosenpassKey(key []byte) string {
|
||||
return hex.EncodeToString(hasher.Sum(nil))
|
||||
}
|
||||
|
||||
// rpServer is the subset of rp.Server used by Manager. Defined as an interface
|
||||
// so tests can substitute a mock without spinning up a real UDP server.
|
||||
type rpServer interface {
|
||||
AddPeer(rp.PeerConfig) (rp.PeerID, error)
|
||||
RemovePeer(rp.PeerID) error
|
||||
Run() error
|
||||
Close() error
|
||||
}
|
||||
|
||||
type Manager struct {
|
||||
ifaceName string
|
||||
spk []byte
|
||||
@@ -45,7 +36,7 @@ type Manager struct {
|
||||
preSharedKey *[32]byte
|
||||
rpPeerIDs map[string]*rp.PeerID
|
||||
rpWgHandler *NetbirdHandler
|
||||
server rpServer
|
||||
server *rp.Server
|
||||
lock sync.Mutex
|
||||
port int
|
||||
wgIface PresharedKeySetter
|
||||
@@ -60,22 +51,7 @@ func NewManager(preSharedKey *wgtypes.Key, wgIfaceName string) (*Manager, error)
|
||||
|
||||
rpKeyHash := hashRosenpassKey(public)
|
||||
log.Tracef("generated new rosenpass key pair with public key %s", rpKeyHash)
|
||||
return &Manager{
|
||||
ifaceName: wgIfaceName,
|
||||
rpKeyHash: rpKeyHash,
|
||||
spk: public,
|
||||
ssk: secret,
|
||||
preSharedKey: (*[32]byte)(preSharedKey),
|
||||
rpPeerIDs: make(map[string]*rp.PeerID),
|
||||
// rpWgHandler is created here (instead of only in generateConfig) so it
|
||||
// is never nil between NewManager and Run(). Otherwise an early
|
||||
// OnConnected call (race observed on Android, issue #4341) panics on
|
||||
// nil receiver in addPeer -> m.rpWgHandler.AddPeer. generateConfig will
|
||||
// replace it with a fresh handler on each Run() to clear stale peer
|
||||
// state from previous engine sessions.
|
||||
rpWgHandler: NewNetbirdHandler(),
|
||||
lock: sync.Mutex{},
|
||||
}, nil
|
||||
return &Manager{ifaceName: wgIfaceName, rpKeyHash: rpKeyHash, spk: public, ssk: secret, preSharedKey: (*[32]byte)(preSharedKey), rpPeerIDs: make(map[string]*rp.PeerID), lock: sync.Mutex{}}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) GetPubKey() []byte {
|
||||
@@ -89,16 +65,6 @@ func (m *Manager) GetAddress() *net.UDPAddr {
|
||||
|
||||
// addPeer adds a new peer to the Rosenpass server
|
||||
func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuardIP string, wireGuardPubKey string) error {
|
||||
// Defense in depth against issue #4341 (Android crash): if Run() has not
|
||||
// completed yet, m.server / m.rpWgHandler may be nil. Return an explicit
|
||||
// error instead of panicking on nil-receiver dereference.
|
||||
if m.server == nil {
|
||||
return fmt.Errorf("rosenpass server not initialized")
|
||||
}
|
||||
if m.rpWgHandler == nil {
|
||||
return fmt.Errorf("rosenpass wg handler not initialized")
|
||||
}
|
||||
|
||||
var err error
|
||||
pcfg := rp.PeerConfig{PublicKey: rosenpassPubKey}
|
||||
if m.preSharedKey != nil {
|
||||
@@ -113,16 +79,6 @@ func (m *Manager) addPeer(rosenpassPubKey []byte, rosenpassAddr string, wireGuar
|
||||
if pcfg.Endpoint, err = net.ResolveUDPAddr("udp", peerAddr); err != nil {
|
||||
return fmt.Errorf("failed to resolve peer endpoint address: %w", err)
|
||||
}
|
||||
// Our local Rosenpass UDP server binds on the IPv6 wildcard ([::]) — see
|
||||
// GetAddress(). The remote peer's endpoint (pcfg.Endpoint) is the destination
|
||||
// our server will sendto when initiating handshakes. ResolveUDPAddr returns a
|
||||
// 4-byte IPv4 for IPv4 hosts, which the kernel rejects (EDESTADDRREQ) when
|
||||
// sent from an AF_INET6 socket. Normalize the remote endpoint to IPv4-mapped
|
||||
// IPv6 so its address family matches our listening socket.
|
||||
// TODO: maybe bind the Rosenpass UDP server to the peer wg IP addr
|
||||
if v4 := pcfg.Endpoint.IP.To4(); v4 != nil {
|
||||
pcfg.Endpoint.IP = v4.To16()
|
||||
}
|
||||
}
|
||||
peerID, err := m.server.AddPeer(pcfg)
|
||||
if err != nil {
|
||||
@@ -226,31 +182,24 @@ func (m *Manager) Run() error {
|
||||
return err
|
||||
}
|
||||
|
||||
server, err := rp.NewUDPServer(conf)
|
||||
m.server, err = rp.NewUDPServer(conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
m.lock.Lock()
|
||||
m.server = server
|
||||
m.lock.Unlock()
|
||||
|
||||
log.Infof("starting rosenpass server on port %d", m.port)
|
||||
|
||||
return server.Run()
|
||||
return m.server.Run()
|
||||
}
|
||||
|
||||
// Close closes the Rosenpass server
|
||||
func (m *Manager) Close() error {
|
||||
m.lock.Lock()
|
||||
server := m.server
|
||||
m.server = nil
|
||||
m.lock.Unlock()
|
||||
if server == nil {
|
||||
return nil
|
||||
}
|
||||
if err := server.Close(); err != nil {
|
||||
log.Errorf("failed closing local rosenpass server: %v", err)
|
||||
if m.server != nil {
|
||||
err := m.server.Close()
|
||||
if err != nil {
|
||||
log.Errorf("failed closing local rosenpass server")
|
||||
}
|
||||
m.server = nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1,412 +1,14 @@
|
||||
package rosenpass
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
rp "cunicu.li/go-rosenpass"
|
||||
"github.com/stretchr/testify/require"
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// --- test doubles -----------------------------------------------------------
|
||||
|
||||
type addPeerCall struct {
|
||||
cfg rp.PeerConfig
|
||||
}
|
||||
|
||||
type removePeerCall struct {
|
||||
id rp.PeerID
|
||||
}
|
||||
|
||||
type mockServer struct {
|
||||
mu sync.Mutex
|
||||
addCalls []addPeerCall
|
||||
removed []removePeerCall
|
||||
nextID rp.PeerID
|
||||
addErr error
|
||||
removeErr error
|
||||
closed bool
|
||||
ran bool
|
||||
}
|
||||
|
||||
func (m *mockServer) AddPeer(cfg rp.PeerConfig) (rp.PeerID, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.addCalls = append(m.addCalls, addPeerCall{cfg: cfg})
|
||||
if m.addErr != nil {
|
||||
return rp.PeerID{}, m.addErr
|
||||
}
|
||||
// Increment a byte in nextID so distinct peers get distinct IDs.
|
||||
m.nextID[0]++
|
||||
return m.nextID, nil
|
||||
}
|
||||
|
||||
func (m *mockServer) RemovePeer(id rp.PeerID) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.removed = append(m.removed, removePeerCall{id: id})
|
||||
return m.removeErr
|
||||
}
|
||||
|
||||
func (m *mockServer) Run() error { m.ran = true; return nil }
|
||||
func (m *mockServer) Close() error { m.closed = true; return nil }
|
||||
|
||||
type setPSKCall struct {
|
||||
peerKey string
|
||||
psk wgtypes.Key
|
||||
updateOnly bool
|
||||
}
|
||||
|
||||
type mockIface struct {
|
||||
mu sync.Mutex
|
||||
calls []setPSKCall
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockIface) SetPresharedKey(peerKey string, psk wgtypes.Key, updateOnly bool) error {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.calls = append(m.calls, setPSKCall{peerKey: peerKey, psk: psk, updateOnly: updateOnly})
|
||||
return m.err
|
||||
}
|
||||
|
||||
// newTestManager builds a Manager with deterministic spk so tie-break
|
||||
// against a peer pubkey is controllable from tests. The provided spk byte
|
||||
// becomes the first byte; remaining bytes are zero.
|
||||
func newTestManager(spkFirstByte byte, mock *mockServer) *Manager {
|
||||
spk := make([]byte, 32)
|
||||
spk[0] = spkFirstByte
|
||||
return &Manager{
|
||||
ifaceName: "wt0",
|
||||
spk: spk,
|
||||
ssk: make([]byte, 32),
|
||||
rpKeyHash: "test-hash",
|
||||
rpPeerIDs: make(map[string]*rp.PeerID),
|
||||
rpWgHandler: NewNetbirdHandler(),
|
||||
server: mock,
|
||||
}
|
||||
}
|
||||
|
||||
// validWGKey returns a deterministic 32-byte wireguard public key (base64).
|
||||
func validWGKey(t *testing.T, lastByte byte) string {
|
||||
t.Helper()
|
||||
var k wgtypes.Key
|
||||
k[31] = lastByte
|
||||
return k.String()
|
||||
}
|
||||
|
||||
// --- pure helpers ----------------------------------------------------------
|
||||
|
||||
func TestHashRosenpassKey_Deterministic(t *testing.T) {
|
||||
key := []byte("hello-rosenpass")
|
||||
require.Equal(t, hashRosenpassKey(key), hashRosenpassKey(key))
|
||||
require.Len(t, hashRosenpassKey(key), 64) // sha256 hex
|
||||
}
|
||||
|
||||
func TestHashRosenpassKey_DifferentInputsDifferOutputs(t *testing.T) {
|
||||
require.NotEqual(t, hashRosenpassKey([]byte("a")), hashRosenpassKey([]byte("b")))
|
||||
}
|
||||
|
||||
func TestGetLogLevel_DefaultWhenUnset(t *testing.T) {
|
||||
// Snapshot + unset to exercise the LookupEnv ok=false branch. t.Setenv
|
||||
// can only set, not delete, so do it manually with restore via t.Cleanup.
|
||||
prev, hadPrev := os.LookupEnv(defaultLogLevelVar)
|
||||
require.NoError(t, os.Unsetenv(defaultLogLevelVar))
|
||||
t.Cleanup(func() {
|
||||
if hadPrev {
|
||||
_ = os.Setenv(defaultLogLevelVar, prev)
|
||||
} else {
|
||||
_ = os.Unsetenv(defaultLogLevelVar)
|
||||
}
|
||||
})
|
||||
require.Equal(t, defaultLog.String(), getLogLevel().String())
|
||||
}
|
||||
|
||||
func TestGetLogLevel_Cases(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"debug": "DEBUG",
|
||||
"info": "INFO",
|
||||
"warn": "WARN",
|
||||
"error": "ERROR",
|
||||
"unknown": "INFO", // default fallback
|
||||
}
|
||||
for input, wantStr := range cases {
|
||||
input, wantStr := input, wantStr
|
||||
t.Run(input, func(t *testing.T) {
|
||||
t.Setenv(defaultLogLevelVar, input)
|
||||
require.Equal(t, wantStr, getLogLevel().String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindRandomAvailableUDPPort(t *testing.T) {
|
||||
port, err := findRandomAvailableUDPPort()
|
||||
require.NoError(t, err)
|
||||
require.Greater(t, port, 0)
|
||||
require.LessOrEqual(t, port, 65535)
|
||||
}
|
||||
|
||||
// --- addPeer ---------------------------------------------------------------
|
||||
|
||||
func TestAddPeer_HigherLocalPubkey_SetsEndpoint(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv) // local spk lexicographically larger
|
||||
|
||||
remotePubKey := make([]byte, 32) // remote spk = all zeros (smaller)
|
||||
err := m.addPeer(remotePubKey, "rosenpass-host:7000", "100.1.1.1", validWGKey(t, 1))
|
||||
require.NoError(t, err)
|
||||
require.Len(t, srv.addCalls, 1)
|
||||
|
||||
ep := srv.addCalls[0].cfg.Endpoint
|
||||
require.NotNil(t, ep, "initiator side must set Endpoint")
|
||||
require.Equal(t, 7000, ep.Port)
|
||||
require.Equal(t, "100.1.1.1", ep.IP.String())
|
||||
}
|
||||
|
||||
func TestAddPeer_HigherLocalPubkey_EndpointIPIsIPv4Mapped(t *testing.T) {
|
||||
// Regression guard for the EDESTADDRREQ fix: Endpoint.IP must be 16-byte
|
||||
// (IPv4-mapped IPv6) so it matches the AF_INET6 listening socket family.
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||
require.NoError(t, err)
|
||||
|
||||
ep := srv.addCalls[0].cfg.Endpoint
|
||||
require.NotNil(t, ep)
|
||||
require.Len(t, ep.IP, 16, "IPv4 endpoint must be normalized to 16-byte v4-mapped form")
|
||||
require.True(t, ep.IP.To4() != nil, "Endpoint must still be detected as IPv4")
|
||||
}
|
||||
|
||||
func TestAddPeer_LowerLocalPubkey_LeavesEndpointNil(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0x00, srv) // local spk smaller
|
||||
|
||||
remotePubKey := make([]byte, 32)
|
||||
remotePubKey[0] = 0xFF
|
||||
err := m.addPeer(remotePubKey, "rp:5000", "100.1.1.1", validWGKey(t, 2))
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Nil(t, srv.addCalls[0].cfg.Endpoint, "responder side must NOT set Endpoint")
|
||||
}
|
||||
|
||||
func TestAddPeer_PresharedKeyPropagated(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
psk := &wgtypes.Key{0x42}
|
||||
m := newTestManager(0xFF, srv)
|
||||
m.preSharedKey = (*[32]byte)(psk)
|
||||
|
||||
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 3))
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, [32]byte(*psk), [32]byte(srv.addCalls[0].cfg.PresharedKey))
|
||||
}
|
||||
|
||||
func TestAddPeer_InvalidRosenpassAddr_ReturnsError(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv) // initiator path → parses rosenpassAddr
|
||||
|
||||
err := m.addPeer(make([]byte, 32), "not-a-host-port", "100.1.1.1", validWGKey(t, 1))
|
||||
require.Error(t, err)
|
||||
require.Empty(t, srv.addCalls, "server.AddPeer must not run when address parse fails")
|
||||
}
|
||||
|
||||
func TestAddPeer_InvalidWireGuardPubKey_ReturnsError(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", "not-a-valid-key")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestAddPeer_ServerError_Propagates(t *testing.T) {
|
||||
srv := &mockServer{addErr: errors.New("boom")}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// Regression guard for issue #4341 (Android crash). If Run() has not completed
|
||||
// before OnConnected fires, m.rpWgHandler or m.server may be nil. Without the
|
||||
// nil guards, m.rpWgHandler.AddPeer panics on nil receiver.
|
||||
func TestAddPeer_NilHandler_ReturnsErrorNoCrash(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
m.rpWgHandler = nil // simulate Run() not yet completed
|
||||
|
||||
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "wg handler not initialized")
|
||||
}
|
||||
|
||||
func TestAddPeer_NilServer_ReturnsErrorNoCrash(t *testing.T) {
|
||||
m := newTestManager(0xFF, nil)
|
||||
m.server = nil // simulate Run() not yet completed
|
||||
|
||||
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", validWGKey(t, 1))
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "server not initialized")
|
||||
}
|
||||
|
||||
// NewManager must pre-initialize rpWgHandler so the nil-receiver crash from
|
||||
// issue #4341 cannot occur in the window between NewManager and Run().
|
||||
func TestNewManager_PreInitializesHandler(t *testing.T) {
|
||||
psk := wgtypes.Key{}
|
||||
m, err := NewManager(&psk, "wt0")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, m.rpWgHandler, "rpWgHandler must be initialized in NewManager")
|
||||
}
|
||||
|
||||
func TestAddPeer_RecordsPeerID(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
wgKey := validWGKey(t, 5)
|
||||
err := m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||
}
|
||||
|
||||
// --- OnConnected / OnDisconnected ------------------------------------------
|
||||
|
||||
func TestOnConnected_NilRemotePubKey_NoAddPeer(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
m.OnConnected(validWGKey(t, 1), nil, "100.1.1.1", "rp:5000")
|
||||
require.Empty(t, srv.addCalls, "nil remote rosenpass pubkey must skip AddPeer")
|
||||
require.Empty(t, m.rpPeerIDs)
|
||||
}
|
||||
|
||||
func TestOnConnected_ValidPubKey_CallsAddPeer(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
wgKey := validWGKey(t, 1)
|
||||
m.OnConnected(wgKey, make([]byte, 32), "100.1.1.1", "rp:5000")
|
||||
require.Len(t, srv.addCalls, 1)
|
||||
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||
}
|
||||
|
||||
func TestOnDisconnected_UnknownPeer_NoOp(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
m.OnDisconnected(validWGKey(t, 99))
|
||||
require.Empty(t, srv.removed, "unknown peer key must not call RemovePeer")
|
||||
}
|
||||
|
||||
func TestOnDisconnected_KnownPeer_CallsRemoveAndForgets(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
wgKey := validWGKey(t, 1)
|
||||
require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
|
||||
require.Contains(t, m.rpPeerIDs, wgKey)
|
||||
|
||||
m.OnDisconnected(wgKey)
|
||||
require.Len(t, srv.removed, 1)
|
||||
require.NotContains(t, m.rpPeerIDs, wgKey, "peer must be forgotten after disconnect")
|
||||
}
|
||||
|
||||
// --- IsPresharedKeyInitialized ---------------------------------------------
|
||||
|
||||
func TestIsPresharedKeyInitialized_UnknownPeer_ReturnsFalse(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
require.False(t, m.IsPresharedKeyInitialized(validWGKey(t, 1)))
|
||||
}
|
||||
|
||||
func TestIsPresharedKeyInitialized_AddedButNotHandshaken_ReturnsFalse(t *testing.T) {
|
||||
srv := &mockServer{}
|
||||
m := newTestManager(0xFF, srv)
|
||||
|
||||
wgKey := validWGKey(t, 2)
|
||||
require.NoError(t, m.addPeer(make([]byte, 32), "rp:5000", "100.1.1.1", wgKey))
|
||||
require.False(t, m.IsPresharedKeyInitialized(wgKey))
|
||||
}
|
||||
|
||||
// --- NetbirdHandler.outputKey ----------------------------------------------
|
||||
|
||||
func TestHandler_OutputKey_FirstCallUsesUpdateOnlyFalse(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface)
|
||||
|
||||
pid := rp.PeerID{0x01}
|
||||
wgKey := wgtypes.Key{0xAA}
|
||||
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
||||
|
||||
psk := rp.Key{0xBB}
|
||||
h.HandshakeCompleted(pid, psk)
|
||||
|
||||
require.Len(t, iface.calls, 1)
|
||||
require.False(t, iface.calls[0].updateOnly, "first PSK rotation must use updateOnly=false")
|
||||
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||
}
|
||||
|
||||
func TestHandler_OutputKey_SubsequentCallsUseUpdateOnlyTrue(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface)
|
||||
|
||||
pid := rp.PeerID{0x02}
|
||||
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xCC}))
|
||||
|
||||
h.HandshakeCompleted(pid, rp.Key{0x01}) // first
|
||||
h.HandshakeCompleted(pid, rp.Key{0x02}) // second
|
||||
|
||||
require.Len(t, iface.calls, 2)
|
||||
require.False(t, iface.calls[0].updateOnly)
|
||||
require.True(t, iface.calls[1].updateOnly, "subsequent rotations must use updateOnly=true")
|
||||
}
|
||||
|
||||
func TestHandler_OutputKey_NilInterface_NoCrashNoCall(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
// no SetInterface — iface remains nil
|
||||
pid := rp.PeerID{0x03}
|
||||
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{}))
|
||||
|
||||
// Must not panic.
|
||||
h.HandshakeCompleted(pid, rp.Key{})
|
||||
}
|
||||
|
||||
func TestHandler_OutputKey_UnknownPeer_NoCall(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface)
|
||||
|
||||
h.HandshakeCompleted(rp.PeerID{0xFF}, rp.Key{})
|
||||
require.Empty(t, iface.calls, "unknown peer id must not trigger SetPresharedKey")
|
||||
}
|
||||
|
||||
func TestHandler_RemovePeer_ClearsInitializedState(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface)
|
||||
|
||||
pid := rp.PeerID{0x04}
|
||||
h.AddPeer(pid, "wt0", rp.Key(wgtypes.Key{0xDD}))
|
||||
h.HandshakeCompleted(pid, rp.Key{0x01})
|
||||
require.True(t, h.IsPeerInitialized(pid))
|
||||
|
||||
h.RemovePeer(pid)
|
||||
require.False(t, h.IsPeerInitialized(pid), "RemovePeer must clear initialized flag")
|
||||
}
|
||||
|
||||
func TestHandler_SetInterfaceAfterAddPeer_StillReceivesKey(t *testing.T) {
|
||||
h := NewNetbirdHandler()
|
||||
pid := rp.PeerID{0x05}
|
||||
wgKey := wgtypes.Key{0xEE}
|
||||
h.AddPeer(pid, "wt0", rp.Key(wgKey))
|
||||
|
||||
iface := &mockIface{}
|
||||
h.SetInterface(iface) // set after AddPeer
|
||||
|
||||
h.HandshakeCompleted(pid, rp.Key{0x42})
|
||||
require.Len(t, iface.calls, 1)
|
||||
require.Equal(t, wgKey.String(), iface.calls[0].peerKey)
|
||||
}
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
package rosenpass
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
|
||||
)
|
||||
|
||||
// DeterministicSeedKey derives a 32-byte WireGuard preshared key from a pair
|
||||
// of peer public keys. Both peers, given the same key pair, produce the same
|
||||
// output regardless of which side runs the function: the inputs are ordered
|
||||
// lexicographically before concatenation.
|
||||
//
|
||||
// NetBird uses this value as the initial Rosenpass-side preshared key when no
|
||||
// explicit account-level PSK is configured, so both peers converge on the same
|
||||
// PSK before the first post-quantum handshake completes.
|
||||
//
|
||||
// The resulting key MUST NOT be treated as quantum-safe: it is deterministic
|
||||
// from public keys and exists only to seed WireGuard until Rosenpass rotates
|
||||
// in a real post-quantum PSK.
|
||||
func DeterministicSeedKey(localKey, remoteKey string) (*wgtypes.Key, error) {
|
||||
lk := []byte(localKey)
|
||||
rk := []byte(remoteKey)
|
||||
if len(lk) < 16 || len(rk) < 16 {
|
||||
return nil, fmt.Errorf("rosenpass: peer keys must be at least 16 bytes (got local=%d, remote=%d)", len(lk), len(rk))
|
||||
}
|
||||
|
||||
var keyInput []byte
|
||||
if localKey > remoteKey {
|
||||
keyInput = append(keyInput, lk[:16]...)
|
||||
keyInput = append(keyInput, rk[:16]...)
|
||||
} else {
|
||||
keyInput = append(keyInput, rk[:16]...)
|
||||
keyInput = append(keyInput, lk[:16]...)
|
||||
}
|
||||
|
||||
key, err := wgtypes.NewKey(keyInput)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("rosenpass: deterministic seed key: %w", err)
|
||||
}
|
||||
return &key, nil
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
package rosenpass
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestDeterministicSeedKey_SameForBothSides(t *testing.T) {
|
||||
// Peer A and peer B must derive the same PSK regardless of which side
|
||||
// computes it: the function orders inputs internally.
|
||||
a := strings.Repeat("a", 32)
|
||||
b := strings.Repeat("b", 32)
|
||||
|
||||
keyAB, err := DeterministicSeedKey(a, b)
|
||||
require.NoError(t, err)
|
||||
keyBA, err := DeterministicSeedKey(b, a)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, keyAB.String(), keyBA.String(), "swapping arguments must yield identical key")
|
||||
}
|
||||
|
||||
func TestDeterministicSeedKey_ChangesWithKeys(t *testing.T) {
|
||||
a := strings.Repeat("a", 32)
|
||||
b := strings.Repeat("b", 32)
|
||||
c := strings.Repeat("c", 32)
|
||||
|
||||
keyAB, err := DeterministicSeedKey(a, b)
|
||||
require.NoError(t, err)
|
||||
keyAC, err := DeterministicSeedKey(a, c)
|
||||
require.NoError(t, err)
|
||||
require.NotEqual(t, keyAB.String(), keyAC.String(), "different peer pair must yield different key")
|
||||
}
|
||||
|
||||
func TestDeterministicSeedKey_TooShortKey_ReturnsError(t *testing.T) {
|
||||
short := "short" // < 16 bytes
|
||||
long := strings.Repeat("x", 32)
|
||||
|
||||
_, err := DeterministicSeedKey(short, long)
|
||||
require.Error(t, err)
|
||||
_, err = DeterministicSeedKey(long, short)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build dragonfly || freebsd || netbsd || openbsd
|
||||
|
||||
package systemops
|
||||
|
||||
// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
|
||||
// given flags should be ignored by the network monitor.
|
||||
func IgnoreAddedDefaultRoute(flags int) bool {
|
||||
return filterRoutesByFlags(flags)
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
//go:build darwin
|
||||
|
||||
package systemops
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// IgnoreAddedDefaultRoute reports whether an RTM_ADD default route with the
|
||||
// given flags should be ignored by the network monitor. Scoped routes
|
||||
// (RTF_IFSCOPE) are tied to a specific interface index and cannot replace the
|
||||
// unscoped default the kernel uses for general egress, so flapping ones (e.g.
|
||||
// Wi-Fi calling IMS tunnels on ipsec0, Docker bridges, scoped utun defaults)
|
||||
// must not trigger an engine restart.
|
||||
func IgnoreAddedDefaultRoute(flags int) bool {
|
||||
if filterRoutesByFlags(flags) {
|
||||
return true
|
||||
}
|
||||
if flags&unix.RTF_IFSCOPE != 0 {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -188,9 +188,7 @@ func (d *Detector) triggerCallback(event EventType, cb func(event EventType), do
|
||||
}
|
||||
|
||||
doneChan := make(chan struct{})
|
||||
// macOS forces sleep ~30s after kIOMessageSystemWillSleep, so block long
|
||||
// enough for teardown to finish while staying under that deadline.
|
||||
timeout := time.NewTimer(20 * time.Second)
|
||||
timeout := time.NewTimer(500 * time.Millisecond)
|
||||
defer timeout.Stop()
|
||||
|
||||
go func() {
|
||||
|
||||
@@ -74,6 +74,14 @@ func New(filePath string) *Manager {
|
||||
}
|
||||
}
|
||||
|
||||
// FilePath returns the path of the underlying state file.
|
||||
func (m *Manager) FilePath() string {
|
||||
if m == nil {
|
||||
return ""
|
||||
}
|
||||
return m.filePath
|
||||
}
|
||||
|
||||
// Start starts the state manager periodic save routine
|
||||
func (m *Manager) Start() {
|
||||
if m == nil {
|
||||
@@ -96,19 +104,17 @@ func (m *Manager) Stop(ctx context.Context) error {
|
||||
}
|
||||
|
||||
m.mu.Lock()
|
||||
cancel := m.cancel
|
||||
done := m.done
|
||||
m.mu.Unlock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if cancel == nil {
|
||||
if m.cancel == nil {
|
||||
return nil
|
||||
}
|
||||
cancel()
|
||||
m.cancel()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-done:
|
||||
case <-m.done:
|
||||
}
|
||||
|
||||
return nil
|
||||
|
||||
@@ -64,6 +64,13 @@
|
||||
<RegistryValue Name="InstalledByMSI" Type="integer" Value="1" KeyPath="yes" />
|
||||
</RegistryKey>
|
||||
</Component>
|
||||
<!-- Drop the HKCU Run\Netbird value written by legacy NSIS installers. -->
|
||||
<Component Id="NetbirdLegacyHKCUCleanup" Guid="*">
|
||||
<RegistryValue Root="HKCU" Key="Software\NetBird GmbH\Installer"
|
||||
Name="LegacyHKCUCleanup" Type="integer" Value="1" KeyPath="yes" />
|
||||
<RemoveRegistryValue Root="HKCU"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="Netbird" />
|
||||
</Component>
|
||||
</StandardDirectory>
|
||||
|
||||
<StandardDirectory Id="CommonAppDataFolder">
|
||||
@@ -76,10 +83,28 @@
|
||||
</Directory>
|
||||
</StandardDirectory>
|
||||
|
||||
<!-- Drop Run, App Paths and Uninstall entries written by legacy NSIS
|
||||
installers into the 32-bit registry view (HKLM\Software\Wow6432Node). -->
|
||||
<Component Id="NetbirdLegacyWow6432Cleanup" Directory="NetbirdInstallDir"
|
||||
Guid="bda5d628-16bd-4086-b2c1-5099d8d51763" Bitness="always32">
|
||||
<RegistryValue Root="HKLM" Key="Software\NetBird GmbH\Installer"
|
||||
Name="LegacyWow6432Cleanup" Type="integer" Value="1" KeyPath="yes" />
|
||||
<RemoveRegistryValue Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\Run" Name="Netbird" />
|
||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\Netbird" />
|
||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\App Paths\Netbird-ui" />
|
||||
<RemoveRegistryKey Action="removeOnInstall" Root="HKLM"
|
||||
Key="Software\Microsoft\Windows\CurrentVersion\Uninstall\Netbird" />
|
||||
</Component>
|
||||
|
||||
<ComponentGroup Id="NetbirdFilesComponent">
|
||||
<ComponentRef Id="NetbirdFiles" />
|
||||
<ComponentRef Id="NetbirdAumidRegistry" />
|
||||
<ComponentRef Id="NetbirdAutoStart" />
|
||||
<ComponentRef Id="NetbirdLegacyHKCUCleanup" />
|
||||
<ComponentRef Id="NetbirdLegacyWow6432Cleanup" />
|
||||
</ComponentGroup>
|
||||
|
||||
<util:CloseApplication Id="CloseNetBird" CloseMessage="no" Target="netbird.exe" RebootPrompt="no" />
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -205,6 +205,8 @@ message LoginRequest {
|
||||
optional bool disableSSHAuth = 38;
|
||||
optional int32 sshJWTCacheTTL = 39;
|
||||
optional bool disable_ipv6 = 40;
|
||||
|
||||
optional bool serverVNCAllowed = 41;
|
||||
}
|
||||
|
||||
message LoginResponse {
|
||||
@@ -314,6 +316,8 @@ message GetConfigResponse {
|
||||
int32 sshJWTCacheTTL = 26;
|
||||
|
||||
bool disable_ipv6 = 27;
|
||||
|
||||
bool serverVNCAllowed = 28;
|
||||
}
|
||||
|
||||
// PeerState contains the latest state of a peer
|
||||
@@ -394,6 +398,20 @@ message SSHServerState {
|
||||
repeated SSHSessionInfo sessions = 2;
|
||||
}
|
||||
|
||||
// VNCSessionInfo contains information about an active VNC session
|
||||
message VNCSessionInfo {
|
||||
string remoteAddress = 1;
|
||||
string mode = 2;
|
||||
string username = 3;
|
||||
string jwtUsername = 4;
|
||||
}
|
||||
|
||||
// VNCServerState contains the latest state of the VNC server
|
||||
message VNCServerState {
|
||||
bool enabled = 1;
|
||||
repeated VNCSessionInfo sessions = 2;
|
||||
}
|
||||
|
||||
// FullStatus contains the full state held by the Status instance
|
||||
message FullStatus {
|
||||
ManagementState managementState = 1;
|
||||
@@ -408,6 +426,7 @@ message FullStatus {
|
||||
|
||||
bool lazyConnectionEnabled = 9;
|
||||
SSHServerState sshServerState = 10;
|
||||
VNCServerState vncServerState = 11;
|
||||
}
|
||||
|
||||
// Networks
|
||||
@@ -678,6 +697,8 @@ message SetConfigRequest {
|
||||
optional bool disableSSHAuth = 33;
|
||||
optional int32 sshJWTCacheTTL = 34;
|
||||
optional bool disable_ipv6 = 35;
|
||||
|
||||
optional bool serverVNCAllowed = 36;
|
||||
}
|
||||
|
||||
message SetConfigResponse{}
|
||||
|
||||
@@ -376,6 +376,7 @@ func (s *Server) SetConfig(callerCtx context.Context, msg *proto.SetConfigReques
|
||||
config.RosenpassPermissive = msg.RosenpassPermissive
|
||||
config.DisableAutoConnect = msg.DisableAutoConnect
|
||||
config.ServerSSHAllowed = msg.ServerSSHAllowed
|
||||
config.ServerVNCAllowed = msg.ServerVNCAllowed
|
||||
config.NetworkMonitor = msg.NetworkMonitor
|
||||
config.DisableClientRoutes = msg.DisableClientRoutes
|
||||
config.DisableServerRoutes = msg.DisableServerRoutes
|
||||
@@ -1136,6 +1137,7 @@ func (s *Server) Status(
|
||||
pbFullStatus := fullStatus.ToProto()
|
||||
pbFullStatus.Events = s.statusRecorder.GetEventHistory()
|
||||
pbFullStatus.SshServerState = s.getSSHServerState()
|
||||
pbFullStatus.VncServerState = s.getVNCServerState()
|
||||
statusResponse.FullStatus = pbFullStatus
|
||||
}
|
||||
|
||||
@@ -1175,6 +1177,37 @@ func (s *Server) getSSHServerState() *proto.SSHServerState {
|
||||
return sshServerState
|
||||
}
|
||||
|
||||
// getVNCServerState retrieves the current VNC server state.
|
||||
func (s *Server) getVNCServerState() *proto.VNCServerState {
|
||||
s.mutex.Lock()
|
||||
connectClient := s.connectClient
|
||||
s.mutex.Unlock()
|
||||
|
||||
if connectClient == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
engine := connectClient.Engine()
|
||||
if engine == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
enabled, sessions := engine.GetVNCServerStatus()
|
||||
pbSessions := make([]*proto.VNCSessionInfo, 0, len(sessions))
|
||||
for _, sess := range sessions {
|
||||
pbSessions = append(pbSessions, &proto.VNCSessionInfo{
|
||||
RemoteAddress: sess.RemoteAddress,
|
||||
Mode: sess.Mode,
|
||||
Username: sess.Username,
|
||||
JwtUsername: sess.JWTUsername,
|
||||
})
|
||||
}
|
||||
return &proto.VNCServerState{
|
||||
Enabled: enabled,
|
||||
Sessions: pbSessions,
|
||||
}
|
||||
}
|
||||
|
||||
// GetPeerSSHHostKey retrieves SSH host key for a specific peer
|
||||
func (s *Server) GetPeerSSHHostKey(
|
||||
ctx context.Context,
|
||||
@@ -1531,6 +1564,7 @@ func (s *Server) GetConfig(ctx context.Context, req *proto.GetConfigRequest) (*p
|
||||
Mtu: int64(cfg.MTU),
|
||||
DisableAutoConnect: cfg.DisableAutoConnect,
|
||||
ServerSSHAllowed: *cfg.ServerSSHAllowed,
|
||||
ServerVNCAllowed: cfg.ServerVNCAllowed != nil && *cfg.ServerVNCAllowed,
|
||||
RosenpassEnabled: cfg.RosenpassEnabled,
|
||||
RosenpassPermissive: cfg.RosenpassPermissive,
|
||||
LazyConnectionEnabled: cfg.LazyConnectionEnabled,
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"github.com/netbirdio/netbird/management/server/integrations/integrated_validator/validator"
|
||||
"github.com/netbirdio/management-integrations/integrations"
|
||||
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/controller"
|
||||
"github.com/netbirdio/netbird/management/internals/controllers/network_map/update_channel"
|
||||
@@ -315,7 +315,7 @@ func startManagement(t *testing.T, signalAddr string, counter *int) (*grpc.Serve
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
ia, _ := validator.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
|
||||
ia, _ := integrations.NewIntegratedValidator(context.Background(), peersManager, settingsManagerMock, eventStore, cacheStore)
|
||||
|
||||
metrics, err := telemetry.NewDefaultAppMetrics(context.Background())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -58,6 +58,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
rosenpassEnabled := true
|
||||
rosenpassPermissive := true
|
||||
serverSSHAllowed := true
|
||||
serverVNCAllowed := true
|
||||
interfaceName := "utun100"
|
||||
wireguardPort := int64(51820)
|
||||
preSharedKey := "test-psk"
|
||||
@@ -83,6 +84,7 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
RosenpassPermissive: &rosenpassPermissive,
|
||||
ServerSSHAllowed: &serverSSHAllowed,
|
||||
ServerVNCAllowed: &serverVNCAllowed,
|
||||
InterfaceName: &interfaceName,
|
||||
WireguardPort: &wireguardPort,
|
||||
OptionalPreSharedKey: &preSharedKey,
|
||||
@@ -127,6 +129,8 @@ func TestSetConfig_AllFieldsSaved(t *testing.T) {
|
||||
require.Equal(t, rosenpassPermissive, cfg.RosenpassPermissive)
|
||||
require.NotNil(t, cfg.ServerSSHAllowed)
|
||||
require.Equal(t, serverSSHAllowed, *cfg.ServerSSHAllowed)
|
||||
require.NotNil(t, cfg.ServerVNCAllowed)
|
||||
require.Equal(t, serverVNCAllowed, *cfg.ServerVNCAllowed)
|
||||
require.Equal(t, interfaceName, cfg.WgIface)
|
||||
require.Equal(t, int(wireguardPort), cfg.WgPort)
|
||||
require.Equal(t, preSharedKey, cfg.PreSharedKey)
|
||||
@@ -179,6 +183,7 @@ func verifyAllFieldsCovered(t *testing.T, req *proto.SetConfigRequest) {
|
||||
"RosenpassEnabled": true,
|
||||
"RosenpassPermissive": true,
|
||||
"ServerSSHAllowed": true,
|
||||
"ServerVNCAllowed": true,
|
||||
"InterfaceName": true,
|
||||
"WireguardPort": true,
|
||||
"OptionalPreSharedKey": true,
|
||||
@@ -240,6 +245,7 @@ func TestCLIFlags_MappedToSetConfig(t *testing.T) {
|
||||
"enable-rosenpass": "RosenpassEnabled",
|
||||
"rosenpass-permissive": "RosenpassPermissive",
|
||||
"allow-server-ssh": "ServerSSHAllowed",
|
||||
"allow-server-vnc": "ServerVNCAllowed",
|
||||
"interface-name": "InterfaceName",
|
||||
"wireguard-port": "WireguardPort",
|
||||
"preshared-key": "OptionalPreSharedKey",
|
||||
|
||||
@@ -200,8 +200,8 @@ func newLsaString(s string) lsaString {
|
||||
}
|
||||
}
|
||||
|
||||
// generateS4UUserToken creates a Windows token using S4U authentication
|
||||
// This is the exact approach OpenSSH for Windows uses for public key authentication
|
||||
// generateS4UUserToken creates a Windows token using S4U authentication.
|
||||
// This is the same approach OpenSSH for Windows uses for public key authentication.
|
||||
func generateS4UUserToken(logger *log.Entry, username, domain string) (windows.Handle, error) {
|
||||
userCpn := buildUserCpn(username, domain)
|
||||
|
||||
|
||||
@@ -551,27 +551,7 @@ func (s *Server) checkTokenAge(token *gojwt.Token, jwtConfig *JWTConfig) error {
|
||||
maxTokenAge = DefaultJWTMaxTokenAge
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(gojwt.MapClaims)
|
||||
if !ok {
|
||||
userID := extractUserID(token)
|
||||
return fmt.Errorf("token has invalid claims format (user=%s)", userID)
|
||||
}
|
||||
|
||||
iat, ok := claims["iat"].(float64)
|
||||
if !ok {
|
||||
userID := extractUserID(token)
|
||||
return fmt.Errorf("token missing iat claim (user=%s)", userID)
|
||||
}
|
||||
|
||||
issuedAt := time.Unix(int64(iat), 0)
|
||||
tokenAge := time.Since(issuedAt)
|
||||
maxAge := time.Duration(maxTokenAge) * time.Second
|
||||
if tokenAge > maxAge {
|
||||
userID := getUserIDFromClaims(claims)
|
||||
return fmt.Errorf("token expired for user=%s: age=%v, max=%v", userID, tokenAge, maxAge)
|
||||
}
|
||||
|
||||
return nil
|
||||
return jwt.CheckTokenAge(token, time.Duration(maxTokenAge)*time.Second)
|
||||
}
|
||||
|
||||
func (s *Server) extractAndValidateUser(token *gojwt.Token) (*auth.UserAuth, error) {
|
||||
@@ -602,27 +582,7 @@ func (s *Server) hasSSHAccess(userAuth *auth.UserAuth) bool {
|
||||
}
|
||||
|
||||
func extractUserID(token *gojwt.Token) string {
|
||||
if token == nil {
|
||||
return "unknown"
|
||||
}
|
||||
claims, ok := token.Claims.(gojwt.MapClaims)
|
||||
if !ok {
|
||||
return "unknown"
|
||||
}
|
||||
return getUserIDFromClaims(claims)
|
||||
}
|
||||
|
||||
func getUserIDFromClaims(claims gojwt.MapClaims) string {
|
||||
if sub, ok := claims["sub"].(string); ok && sub != "" {
|
||||
return sub
|
||||
}
|
||||
if userID, ok := claims["user_id"].(string); ok && userID != "" {
|
||||
return userID
|
||||
}
|
||||
if email, ok := claims["email"].(string); ok && email != "" {
|
||||
return email
|
||||
}
|
||||
return "unknown"
|
||||
return jwt.UserIDFromToken(token)
|
||||
}
|
||||
|
||||
func (s *Server) parseTokenWithoutValidation(tokenString string) (map[string]interface{}, error) {
|
||||
|
||||
@@ -131,6 +131,18 @@ type SSHServerStateOutput struct {
|
||||
Sessions []SSHSessionOutput `json:"sessions" yaml:"sessions"`
|
||||
}
|
||||
|
||||
type VNCSessionOutput struct {
|
||||
RemoteAddress string `json:"remoteAddress" yaml:"remoteAddress"`
|
||||
Mode string `json:"mode" yaml:"mode"`
|
||||
Username string `json:"username,omitempty" yaml:"username,omitempty"`
|
||||
JWTUsername string `json:"jwtUsername,omitempty" yaml:"jwtUsername,omitempty"`
|
||||
}
|
||||
|
||||
type VNCServerStateOutput struct {
|
||||
Enabled bool `json:"enabled" yaml:"enabled"`
|
||||
Sessions []VNCSessionOutput `json:"sessions" yaml:"sessions"`
|
||||
}
|
||||
|
||||
type OutputOverview struct {
|
||||
Peers PeersStateOutput `json:"peers" yaml:"peers"`
|
||||
CliVersion string `json:"cliVersion" yaml:"cliVersion"`
|
||||
@@ -153,6 +165,7 @@ type OutputOverview struct {
|
||||
LazyConnectionEnabled bool `json:"lazyConnectionEnabled" yaml:"lazyConnectionEnabled"`
|
||||
ProfileName string `json:"profileName" yaml:"profileName"`
|
||||
SSHServerState SSHServerStateOutput `json:"sshServer" yaml:"sshServer"`
|
||||
VNCServerState VNCServerStateOutput `json:"vncServer" yaml:"vncServer"`
|
||||
}
|
||||
|
||||
// ConvertToStatusOutputOverview converts protobuf status to the output overview.
|
||||
@@ -173,6 +186,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
|
||||
relayOverview := mapRelays(pbFullStatus.GetRelays())
|
||||
sshServerOverview := mapSSHServer(pbFullStatus.GetSshServerState())
|
||||
vncServerOverview := mapVNCServer(pbFullStatus.GetVncServerState())
|
||||
peersOverview := mapPeers(pbFullStatus.GetPeers(), opts.StatusFilter, opts.PrefixNamesFilter, opts.PrefixNamesFilterMap, opts.IPsFilter, opts.ConnectionTypeFilter)
|
||||
|
||||
overview := OutputOverview{
|
||||
@@ -197,6 +211,7 @@ func ConvertToStatusOutputOverview(pbFullStatus *proto.FullStatus, opts ConvertO
|
||||
LazyConnectionEnabled: pbFullStatus.GetLazyConnectionEnabled(),
|
||||
ProfileName: opts.ProfileName,
|
||||
SSHServerState: sshServerOverview,
|
||||
VNCServerState: vncServerOverview,
|
||||
}
|
||||
|
||||
if opts.Anonymize {
|
||||
@@ -271,6 +286,25 @@ func mapSSHServer(sshServerState *proto.SSHServerState) SSHServerStateOutput {
|
||||
}
|
||||
}
|
||||
|
||||
func mapVNCServer(state *proto.VNCServerState) VNCServerStateOutput {
|
||||
if state == nil {
|
||||
return VNCServerStateOutput{Sessions: []VNCSessionOutput{}}
|
||||
}
|
||||
sessions := make([]VNCSessionOutput, 0, len(state.GetSessions()))
|
||||
for _, sess := range state.GetSessions() {
|
||||
sessions = append(sessions, VNCSessionOutput{
|
||||
RemoteAddress: sess.GetRemoteAddress(),
|
||||
Mode: sess.GetMode(),
|
||||
Username: sess.GetUsername(),
|
||||
JWTUsername: sess.GetJwtUsername(),
|
||||
})
|
||||
}
|
||||
return VNCServerStateOutput{
|
||||
Enabled: state.GetEnabled(),
|
||||
Sessions: sessions,
|
||||
}
|
||||
}
|
||||
|
||||
func mapPeers(
|
||||
peers []*proto.PeerState,
|
||||
statusFilter string,
|
||||
@@ -533,6 +567,34 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
}
|
||||
}
|
||||
|
||||
vncServerStatus := "Disabled"
|
||||
if o.VNCServerState.Enabled {
|
||||
vncSessionCount := len(o.VNCServerState.Sessions)
|
||||
if vncSessionCount > 0 {
|
||||
sessionWord := "session"
|
||||
if vncSessionCount > 1 {
|
||||
sessionWord = "sessions"
|
||||
}
|
||||
vncServerStatus = fmt.Sprintf("Enabled (%d active %s)", vncSessionCount, sessionWord)
|
||||
} else {
|
||||
vncServerStatus = "Enabled"
|
||||
}
|
||||
|
||||
if showSSHSessions && vncSessionCount > 0 {
|
||||
for _, sess := range o.VNCServerState.Sessions {
|
||||
var line string
|
||||
if sess.JWTUsername != "" {
|
||||
line = fmt.Sprintf("[%s@%s -> %s] mode=%s",
|
||||
sess.JWTUsername, sess.RemoteAddress, sess.Username, sess.Mode)
|
||||
} else {
|
||||
line = fmt.Sprintf("[%s] mode=%s user=%s",
|
||||
sess.RemoteAddress, sess.Mode, sess.Username)
|
||||
}
|
||||
vncServerStatus += "\n " + line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
peersCountString := fmt.Sprintf("%d/%d Connected", o.Peers.Connected, o.Peers.Total)
|
||||
|
||||
var forwardingRulesString string
|
||||
@@ -563,6 +625,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
"Quantum resistance: %s\n"+
|
||||
"Lazy connection: %s\n"+
|
||||
"SSH Server: %s\n"+
|
||||
"VNC Server: %s\n"+
|
||||
"Networks: %s\n"+
|
||||
"%s"+
|
||||
"Peers count: %s\n",
|
||||
@@ -581,6 +644,7 @@ func (o *OutputOverview) GeneralSummary(showURL bool, showRelays bool, showNameS
|
||||
rosenpassEnabledStatus,
|
||||
lazyConnectionEnabledStatus,
|
||||
sshServerStatus,
|
||||
vncServerStatus,
|
||||
networks,
|
||||
forwardingRulesString,
|
||||
peersCountString,
|
||||
@@ -960,6 +1024,19 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
overview.Relays.Details[i] = detail
|
||||
}
|
||||
|
||||
anonymizeNSServerGroups(a, overview)
|
||||
|
||||
for i, route := range overview.Networks {
|
||||
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||
}
|
||||
|
||||
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||
|
||||
anonymizeEvents(a, overview)
|
||||
anonymizeServerSessions(a, overview)
|
||||
}
|
||||
|
||||
func anonymizeNSServerGroups(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, nsGroup := range overview.NSServerGroups {
|
||||
for j, domain := range nsGroup.Domains {
|
||||
overview.NSServerGroups[i].Domains[j] = a.AnonymizeDomain(domain)
|
||||
@@ -971,13 +1048,9 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i, route := range overview.Networks {
|
||||
overview.Networks[i] = a.AnonymizeRoute(route)
|
||||
}
|
||||
|
||||
overview.FQDN = a.AnonymizeDomain(overview.FQDN)
|
||||
|
||||
func anonymizeEvents(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, event := range overview.Events {
|
||||
overview.Events[i].Message = a.AnonymizeString(event.Message)
|
||||
overview.Events[i].UserMessage = a.AnonymizeString(event.UserMessage)
|
||||
@@ -986,13 +1059,21 @@ func anonymizeOverview(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
event.Metadata[k] = a.AnonymizeString(v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func anonymizeRemoteAddress(a *anonymize.Anonymizer, addr string) string {
|
||||
if host, port, err := net.SplitHostPort(addr); err == nil {
|
||||
return fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port)
|
||||
}
|
||||
return a.AnonymizeIPString(addr)
|
||||
}
|
||||
|
||||
func anonymizeServerSessions(a *anonymize.Anonymizer, overview *OutputOverview) {
|
||||
for i, session := range overview.SSHServerState.Sessions {
|
||||
if host, port, err := net.SplitHostPort(session.RemoteAddress); err == nil {
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = fmt.Sprintf("%s:%s", a.AnonymizeIPString(host), port)
|
||||
} else {
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = a.AnonymizeIPString(session.RemoteAddress)
|
||||
}
|
||||
overview.SSHServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, session.RemoteAddress)
|
||||
overview.SSHServerState.Sessions[i].Command = a.AnonymizeString(session.Command)
|
||||
}
|
||||
for i, sess := range overview.VNCServerState.Sessions {
|
||||
overview.VNCServerState.Sessions[i].RemoteAddress = anonymizeRemoteAddress(a, sess.RemoteAddress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +240,10 @@ var overview = OutputOverview{
|
||||
Enabled: false,
|
||||
Sessions: []SSHSessionOutput{},
|
||||
},
|
||||
VNCServerState: VNCServerStateOutput{
|
||||
Enabled: false,
|
||||
Sessions: []VNCSessionOutput{},
|
||||
},
|
||||
}
|
||||
|
||||
func TestConversionFromFullStatusToOutputOverview(t *testing.T) {
|
||||
@@ -404,6 +408,10 @@ func TestParsingToJSON(t *testing.T) {
|
||||
"sshServer":{
|
||||
"enabled":false,
|
||||
"sessions":[]
|
||||
},
|
||||
"vncServer":{
|
||||
"enabled":false,
|
||||
"sessions":[]
|
||||
}
|
||||
}`
|
||||
// @formatter:on
|
||||
@@ -513,6 +521,9 @@ profileName: ""
|
||||
sshServer:
|
||||
enabled: false
|
||||
sessions: []
|
||||
vncServer:
|
||||
enabled: false
|
||||
sessions: []
|
||||
`
|
||||
|
||||
assert.Equal(t, expectedYAML, yaml)
|
||||
@@ -582,6 +593,7 @@ Interface type: Kernel
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
VNC Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`, lastConnectionUpdate1, lastHandshake1, lastConnectionUpdate2, lastHandshake2, runtime.GOOS, runtime.GOARCH, overview.CliVersion)
|
||||
@@ -607,6 +619,7 @@ Interface type: Kernel
|
||||
Quantum resistance: false
|
||||
Lazy connection: false
|
||||
SSH Server: Disabled
|
||||
VNC Server: Disabled
|
||||
Networks: 10.10.0.0/24
|
||||
Peers count: 2/2 Connected
|
||||
`
|
||||
|
||||
@@ -62,6 +62,7 @@ type Info struct {
|
||||
RosenpassEnabled bool
|
||||
RosenpassPermissive bool
|
||||
ServerSSHAllowed bool
|
||||
ServerVNCAllowed bool
|
||||
|
||||
DisableClientRoutes bool
|
||||
DisableServerRoutes bool
|
||||
@@ -83,6 +84,7 @@ type Info struct {
|
||||
func (i *Info) SetFlags(
|
||||
rosenpassEnabled, rosenpassPermissive bool,
|
||||
serverSSHAllowed *bool,
|
||||
serverVNCAllowed *bool,
|
||||
disableClientRoutes, disableServerRoutes,
|
||||
disableDNS, disableFirewall, blockLANAccess, blockInbound, disableIPv6, lazyConnectionEnabled bool,
|
||||
enableSSHRoot, enableSSHSFTP, enableSSHLocalPortForwarding, enableSSHRemotePortForwarding *bool,
|
||||
@@ -93,6 +95,9 @@ func (i *Info) SetFlags(
|
||||
if serverSSHAllowed != nil {
|
||||
i.ServerSSHAllowed = *serverSSHAllowed
|
||||
}
|
||||
if serverVNCAllowed != nil {
|
||||
i.ServerVNCAllowed = *serverVNCAllowed
|
||||
}
|
||||
|
||||
i.DisableClientRoutes = disableClientRoutes
|
||||
i.DisableServerRoutes = disableServerRoutes
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
package system
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/unix"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/zcalusic/sysinfo"
|
||||
|
||||
@@ -28,11 +29,19 @@ func UpdateStaticInfoAsync() {
|
||||
|
||||
// GetInfo retrieves and parses the system information
|
||||
func GetInfo(ctx context.Context) *Info {
|
||||
kernelName, kernelVersion, kernelPlatform := kernelInfo()
|
||||
info := _getInfo()
|
||||
for strings.Contains(info, "broken pipe") {
|
||||
info = _getInfo()
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
|
||||
osStr := strings.ReplaceAll(info, "\n", "")
|
||||
osStr = strings.ReplaceAll(osStr, "\r\n", "")
|
||||
osInfo := strings.Split(osStr, " ")
|
||||
|
||||
osName, osVersion := readOsReleaseFile()
|
||||
if osName == "" {
|
||||
osName = kernelName
|
||||
osName = osInfo[3]
|
||||
}
|
||||
|
||||
systemHostname, _ := os.Hostname()
|
||||
@@ -49,8 +58,8 @@ func GetInfo(ctx context.Context) *Info {
|
||||
}
|
||||
|
||||
gio := &Info{
|
||||
Kernel: kernelName,
|
||||
Platform: kernelPlatform,
|
||||
Kernel: osInfo[0],
|
||||
Platform: osInfo[2],
|
||||
OS: osName,
|
||||
OSVersion: osVersion,
|
||||
Hostname: extractDeviceName(ctx, systemHostname),
|
||||
@@ -58,7 +67,7 @@ func GetInfo(ctx context.Context) *Info {
|
||||
CPUs: runtime.NumCPU(),
|
||||
NetbirdVersion: version.NetbirdVersion(),
|
||||
UIVersion: extractUserAgent(ctx),
|
||||
KernelVersion: kernelVersion,
|
||||
KernelVersion: osInfo[1],
|
||||
NetworkAddresses: addrs,
|
||||
SystemSerialNumber: si.SystemSerialNumber,
|
||||
SystemProductName: si.SystemProductName,
|
||||
@@ -69,12 +78,18 @@ func GetInfo(ctx context.Context) *Info {
|
||||
return gio
|
||||
}
|
||||
|
||||
func kernelInfo() (string, string, string) {
|
||||
var uts unix.Utsname
|
||||
if err := unix.Uname(&uts); err != nil {
|
||||
return "", "", ""
|
||||
func _getInfo() string {
|
||||
cmd := exec.Command("uname", "-srio")
|
||||
cmd.Stdin = strings.NewReader("some")
|
||||
var out bytes.Buffer
|
||||
var stderr bytes.Buffer
|
||||
cmd.Stdout = &out
|
||||
cmd.Stderr = &stderr
|
||||
err := cmd.Run()
|
||||
if err != nil {
|
||||
log.Warnf("getInfo: %s", err)
|
||||
}
|
||||
return unix.ByteSliceToString(uts.Sysname[:]), unix.ByteSliceToString(uts.Release[:]), unix.ByteSliceToString(uts.Machine[:])
|
||||
return out.String()
|
||||
}
|
||||
|
||||
func sysInfo() (string, string, string) {
|
||||
|
||||
@@ -249,6 +249,7 @@ type serviceClient struct {
|
||||
mQuit *systray.MenuItem
|
||||
mNetworks *systray.MenuItem
|
||||
mAllowSSH *systray.MenuItem
|
||||
mAllowVNC *systray.MenuItem
|
||||
mAutoConnect *systray.MenuItem
|
||||
mEnableRosenpass *systray.MenuItem
|
||||
mLazyConnEnabled *systray.MenuItem
|
||||
@@ -1045,6 +1046,7 @@ func (s *serviceClient) onTrayReady() {
|
||||
|
||||
s.mSettings = systray.AddMenuItem("Settings", disabledMenuDescr)
|
||||
s.mAllowSSH = s.mSettings.AddSubMenuItemCheckbox("Allow SSH", allowSSHMenuDescr, false)
|
||||
s.mAllowVNC = s.mSettings.AddSubMenuItemCheckbox("Allow VNC", allowVNCMenuDescr, false)
|
||||
s.mAutoConnect = s.mSettings.AddSubMenuItemCheckbox("Connect on Startup", autoConnectMenuDescr, false)
|
||||
s.mEnableRosenpass = s.mSettings.AddSubMenuItemCheckbox("Enable Quantum-Resistance", quantumResistanceMenuDescr, false)
|
||||
s.mLazyConnEnabled = s.mSettings.AddSubMenuItemCheckbox("Enable Lazy Connections", lazyConnMenuDescr, false)
|
||||
@@ -1452,6 +1454,7 @@ func protoConfigToConfig(cfg *proto.GetConfigResponse) *profilemanager.Config {
|
||||
|
||||
config.DisableAutoConnect = cfg.DisableAutoConnect
|
||||
config.ServerSSHAllowed = &cfg.ServerSSHAllowed
|
||||
config.ServerVNCAllowed = &cfg.ServerVNCAllowed
|
||||
config.RosenpassEnabled = cfg.RosenpassEnabled
|
||||
config.RosenpassPermissive = cfg.RosenpassPermissive
|
||||
config.DisableNotifications = &cfg.DisableNotifications
|
||||
@@ -1547,6 +1550,12 @@ func (s *serviceClient) loadSettings() {
|
||||
s.mAllowSSH.Uncheck()
|
||||
}
|
||||
|
||||
if cfg.ServerVNCAllowed {
|
||||
s.mAllowVNC.Check()
|
||||
} else {
|
||||
s.mAllowVNC.Uncheck()
|
||||
}
|
||||
|
||||
if cfg.DisableAutoConnect {
|
||||
s.mAutoConnect.Uncheck()
|
||||
} else {
|
||||
@@ -1586,6 +1595,7 @@ func (s *serviceClient) loadSettings() {
|
||||
func (s *serviceClient) updateConfig() error {
|
||||
disableAutoStart := !s.mAutoConnect.Checked()
|
||||
sshAllowed := s.mAllowSSH.Checked()
|
||||
vncAllowed := s.mAllowVNC.Checked()
|
||||
rosenpassEnabled := s.mEnableRosenpass.Checked()
|
||||
lazyConnectionEnabled := s.mLazyConnEnabled.Checked()
|
||||
blockInbound := s.mBlockInbound.Checked()
|
||||
@@ -1614,6 +1624,7 @@ func (s *serviceClient) updateConfig() error {
|
||||
Username: currUser.Username,
|
||||
DisableAutoConnect: &disableAutoStart,
|
||||
ServerSSHAllowed: &sshAllowed,
|
||||
ServerVNCAllowed: &vncAllowed,
|
||||
RosenpassEnabled: &rosenpassEnabled,
|
||||
LazyConnectionEnabled: &lazyConnectionEnabled,
|
||||
BlockInbound: &blockInbound,
|
||||
|
||||
@@ -2,6 +2,7 @@ package main
|
||||
|
||||
const (
|
||||
allowSSHMenuDescr = "Allow SSH connections"
|
||||
allowVNCMenuDescr = "Allow embedded VNC server"
|
||||
autoConnectMenuDescr = "Connect automatically when the service starts"
|
||||
quantumResistanceMenuDescr = "Enable post-quantum security via Rosenpass"
|
||||
lazyConnMenuDescr = "[Experimental] Enable lazy connections"
|
||||
|
||||
@@ -39,6 +39,8 @@ func (h *eventHandler) listen(ctx context.Context) {
|
||||
h.handleDisconnectClick()
|
||||
case <-h.client.mAllowSSH.ClickedCh:
|
||||
h.handleAllowSSHClick()
|
||||
case <-h.client.mAllowVNC.ClickedCh:
|
||||
h.handleAllowVNCClick()
|
||||
case <-h.client.mAutoConnect.ClickedCh:
|
||||
h.handleAutoConnectClick()
|
||||
case <-h.client.mEnableRosenpass.ClickedCh:
|
||||
@@ -134,6 +136,15 @@ func (h *eventHandler) handleAllowSSHClick() {
|
||||
|
||||
}
|
||||
|
||||
func (h *eventHandler) handleAllowVNCClick() {
|
||||
h.toggleCheckbox(h.client.mAllowVNC)
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
h.toggleCheckbox(h.client.mAllowVNC) // revert checkbox state on error
|
||||
log.Errorf("failed to update config: %v", err)
|
||||
h.client.notifier.Send("Error", "Failed to update VNC settings")
|
||||
}
|
||||
}
|
||||
|
||||
func (h *eventHandler) handleAutoConnectClick() {
|
||||
h.toggleCheckbox(h.client.mAutoConnect)
|
||||
if err := h.updateConfigWithErr(); err != nil {
|
||||
|
||||
810
client/vnc/server/agent_windows.go
Normal file
810
client/vnc/server/agent_windows.go
Normal file
@@ -0,0 +1,810 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
crand "crypto/rand"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
const (
|
||||
agentPort = "15900"
|
||||
|
||||
// agentTokenLen is the length of the random authentication token
|
||||
// used to verify that connections to the agent come from the service.
|
||||
agentTokenLen = 32
|
||||
|
||||
stillActive = 259
|
||||
|
||||
tokenPrimary = 1
|
||||
securityImpersonation = 2
|
||||
tokenSessionID = 12
|
||||
|
||||
createUnicodeEnvironment = 0x00000400
|
||||
createNoWindow = 0x08000000
|
||||
createSuspended = 0x00000004
|
||||
createBreakawayFromJob = 0x01000000
|
||||
)
|
||||
|
||||
var (
|
||||
kernel32 = windows.NewLazySystemDLL("kernel32.dll")
|
||||
advapi32 = windows.NewLazySystemDLL("advapi32.dll")
|
||||
userenv = windows.NewLazySystemDLL("userenv.dll")
|
||||
|
||||
procWTSGetActiveConsoleSessionId = kernel32.NewProc("WTSGetActiveConsoleSessionId")
|
||||
procCreateJobObjectW = kernel32.NewProc("CreateJobObjectW")
|
||||
procSetInformationJobObject = kernel32.NewProc("SetInformationJobObject")
|
||||
procAssignProcessToJobObject = kernel32.NewProc("AssignProcessToJobObject")
|
||||
procSetTokenInformation = advapi32.NewProc("SetTokenInformation")
|
||||
procCreateEnvironmentBlock = userenv.NewProc("CreateEnvironmentBlock")
|
||||
procDestroyEnvironmentBlock = userenv.NewProc("DestroyEnvironmentBlock")
|
||||
|
||||
wtsapi32 = windows.NewLazySystemDLL("wtsapi32.dll")
|
||||
procWTSEnumerateSessionsW = wtsapi32.NewProc("WTSEnumerateSessionsW")
|
||||
procWTSFreeMemory = wtsapi32.NewProc("WTSFreeMemory")
|
||||
procWTSQuerySessionInformation = wtsapi32.NewProc("WTSQuerySessionInformationW")
|
||||
|
||||
iphlpapi = windows.NewLazySystemDLL("iphlpapi.dll")
|
||||
procGetExtendedTcpTable = iphlpapi.NewProc("GetExtendedTcpTable")
|
||||
)
|
||||
|
||||
// GetCurrentSessionID returns the session ID of the current process.
|
||||
func GetCurrentSessionID() uint32 {
|
||||
var token windows.Token
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(),
|
||||
windows.TOKEN_QUERY, &token); err != nil {
|
||||
return 0
|
||||
}
|
||||
defer token.Close()
|
||||
var id uint32
|
||||
var ret uint32
|
||||
_ = windows.GetTokenInformation(token, windows.TokenSessionId,
|
||||
(*byte)(unsafe.Pointer(&id)), 4, &ret)
|
||||
return id
|
||||
}
|
||||
|
||||
func getConsoleSessionID() uint32 {
|
||||
r, _, _ := procWTSGetActiveConsoleSessionId.Call()
|
||||
return uint32(r)
|
||||
}
|
||||
|
||||
const (
|
||||
wtsActive = 0
|
||||
wtsConnected = 1
|
||||
wtsDisconnected = 4
|
||||
)
|
||||
|
||||
// getActiveSessionID returns the session ID of the best session to attach to.
|
||||
// On a Windows Server with no console display attached, session 1 still
|
||||
// reports WTSActive (login screen "owns" the console), so a naive
|
||||
// first-active-wins pick lands on a session with no actual rendering.
|
||||
// Preference order:
|
||||
// 1. Active session with a user logged in (RDP user in session ≥2)
|
||||
// 2. Active session without a user (console at login screen)
|
||||
// 3. Console session ID
|
||||
func getActiveSessionID() uint32 {
|
||||
var sessionInfo uintptr
|
||||
var count uint32
|
||||
|
||||
r, _, _ := procWTSEnumerateSessionsW.Call(
|
||||
0, // WTS_CURRENT_SERVER_HANDLE
|
||||
0, // reserved
|
||||
1, // version
|
||||
uintptr(unsafe.Pointer(&sessionInfo)),
|
||||
uintptr(unsafe.Pointer(&count)),
|
||||
)
|
||||
if r == 0 || count == 0 {
|
||||
return getConsoleSessionID()
|
||||
}
|
||||
defer func() { _, _, _ = procWTSFreeMemory.Call(sessionInfo) }()
|
||||
|
||||
type wtsSession struct {
|
||||
SessionID uint32
|
||||
Station *uint16
|
||||
State uint32
|
||||
}
|
||||
sessions := unsafe.Slice((*wtsSession)(unsafe.Pointer(sessionInfo)), count)
|
||||
|
||||
var withUser uint32
|
||||
var withUserFound bool
|
||||
var anyActive uint32
|
||||
var anyActiveFound bool
|
||||
for _, s := range sessions {
|
||||
if s.SessionID == 0 {
|
||||
continue
|
||||
}
|
||||
if s.State != wtsActive {
|
||||
continue
|
||||
}
|
||||
if !anyActiveFound {
|
||||
anyActive = s.SessionID
|
||||
anyActiveFound = true
|
||||
}
|
||||
if !withUserFound && wtsSessionHasUser(s.SessionID) {
|
||||
withUser = s.SessionID
|
||||
withUserFound = true
|
||||
}
|
||||
}
|
||||
if withUserFound {
|
||||
return withUser
|
||||
}
|
||||
if anyActiveFound {
|
||||
return anyActive
|
||||
}
|
||||
return getConsoleSessionID()
|
||||
}
|
||||
|
||||
// reapOrphanOnPort finds any process listening on 127.0.0.1:portStr and,
|
||||
// if it's a netbird vnc-agent left over from a previous service instance,
|
||||
// terminates it. Verified by image-name match so we never kill an
|
||||
// unrelated process that happens to use the same port.
|
||||
func reapOrphanOnPort(portStr string) {
|
||||
port64, err := strconv.ParseUint(portStr, 10, 16)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
port := uint16(port64)
|
||||
pid := tcpListenerPID(port)
|
||||
if pid == 0 || pid == uint32(windows.GetCurrentProcessId()) {
|
||||
return
|
||||
}
|
||||
h, err := windows.OpenProcess(windows.PROCESS_QUERY_LIMITED_INFORMATION|windows.PROCESS_TERMINATE|windows.SYNCHRONIZE, false, pid)
|
||||
if err != nil {
|
||||
log.Warnf("reap on port %d: open PID=%d: %v", port, pid, err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = windows.CloseHandle(h) }()
|
||||
if !isOurAgentProcess(h) {
|
||||
log.Warnf("reap on port %d: PID=%d is not a netbird vnc-agent, leaving it alone", port, pid)
|
||||
return
|
||||
}
|
||||
if err := windows.TerminateProcess(h, 0); err != nil {
|
||||
log.Warnf("reap on port %d: terminate PID=%d: %v", port, pid, err)
|
||||
return
|
||||
}
|
||||
log.Infof("reaped orphan vnc-agent PID=%d holding port %d", pid, port)
|
||||
}
|
||||
|
||||
// isOurAgentProcess returns true if the given process handle points at a
|
||||
// netbird.exe binary at the same path as the current process. We compare
|
||||
// full paths (case-insensitive on Windows) so co-installed netbird binaries
|
||||
// from a different install dir or unrelated apps named netbird.exe don't
|
||||
// get killed.
|
||||
func isOurAgentProcess(h windows.Handle) bool {
|
||||
var size uint32 = windows.MAX_PATH
|
||||
buf := make([]uint16, size)
|
||||
if err := windows.QueryFullProcessImageName(h, 0, &buf[0], &size); err != nil {
|
||||
return false
|
||||
}
|
||||
target := strings.ToLower(windows.UTF16ToString(buf[:size]))
|
||||
selfExe, err := os.Executable()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return target == strings.ToLower(selfExe)
|
||||
}
|
||||
|
||||
// tcpListenerPID returns the PID of the process listening on 127.0.0.1:port,
|
||||
// or 0 if none. Uses GetExtendedTcpTable with TCP_TABLE_OWNER_PID_LISTENER.
|
||||
func tcpListenerPID(port uint16) uint32 {
|
||||
const tcpTableOwnerPidListener = 3
|
||||
const afInet = 2
|
||||
|
||||
// MIB_TCPROW_OWNER_PID layout: state(4) + localAddr(4) + localPort(4) +
|
||||
// remoteAddr(4) + remotePort(4) + owningPid(4) = 24 bytes.
|
||||
const rowSize = 24
|
||||
|
||||
var size uint32
|
||||
_, _, _ = procGetExtendedTcpTable.Call(0, uintptr(unsafe.Pointer(&size)), 0, afInet, tcpTableOwnerPidListener, 0)
|
||||
if size == 0 {
|
||||
return 0
|
||||
}
|
||||
buf := make([]byte, size)
|
||||
r, _, _ := procGetExtendedTcpTable.Call(
|
||||
uintptr(unsafe.Pointer(&buf[0])),
|
||||
uintptr(unsafe.Pointer(&size)),
|
||||
0, afInet, tcpTableOwnerPidListener, 0,
|
||||
)
|
||||
if r != 0 {
|
||||
return 0
|
||||
}
|
||||
count := binary.LittleEndian.Uint32(buf[:4])
|
||||
for i := uint32(0); i < count; i++ {
|
||||
off := 4 + int(i)*rowSize
|
||||
if off+rowSize > len(buf) {
|
||||
break
|
||||
}
|
||||
// localPort is stored big-endian in the high 16 bits of a 32-bit field.
|
||||
localPort := uint16(buf[off+8])<<8 | uint16(buf[off+9])
|
||||
if localPort != port {
|
||||
continue
|
||||
}
|
||||
localAddr := binary.LittleEndian.Uint32(buf[off+4 : off+8])
|
||||
// 0x0100007f == 127.0.0.1 in network byte order on little-endian.
|
||||
// We accept 0.0.0.0 too in case the orphan bound to all interfaces.
|
||||
if localAddr != 0x0100007f && localAddr != 0 {
|
||||
continue
|
||||
}
|
||||
return binary.LittleEndian.Uint32(buf[off+20 : off+24])
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// wtsSessionHasUser returns true if the session has a non-empty user name,
|
||||
// i.e. someone is logged in (vs. the login/Welcome screen). The console
|
||||
// session at the lock screen has WTSUserName == "".
|
||||
const wtsUserName = 5
|
||||
|
||||
func wtsSessionHasUser(sessionID uint32) bool {
|
||||
var buf uintptr
|
||||
var bytesReturned uint32
|
||||
r, _, _ := procWTSQuerySessionInformation.Call(
|
||||
0, // WTS_CURRENT_SERVER_HANDLE
|
||||
uintptr(sessionID),
|
||||
uintptr(wtsUserName),
|
||||
uintptr(unsafe.Pointer(&buf)),
|
||||
uintptr(unsafe.Pointer(&bytesReturned)),
|
||||
)
|
||||
if r == 0 || buf == 0 {
|
||||
return false
|
||||
}
|
||||
defer func() { _, _, _ = procWTSFreeMemory.Call(buf) }()
|
||||
// First UTF-16 code unit non-zero ⇒ non-empty username.
|
||||
return *(*uint16)(unsafe.Pointer(buf)) != 0
|
||||
}
|
||||
|
||||
// getSystemTokenForSession duplicates the current SYSTEM token and sets its
|
||||
// session ID so the spawned process runs in the target session. Using a SYSTEM
|
||||
// token gives access to both Default and Winlogon desktops plus UIPI bypass.
|
||||
func getSystemTokenForSession(sessionID uint32) (windows.Token, error) {
|
||||
var cur windows.Token
|
||||
if err := windows.OpenProcessToken(windows.CurrentProcess(),
|
||||
windows.MAXIMUM_ALLOWED, &cur); err != nil {
|
||||
return 0, fmt.Errorf("OpenProcessToken: %w", err)
|
||||
}
|
||||
defer cur.Close()
|
||||
|
||||
var dup windows.Token
|
||||
if err := windows.DuplicateTokenEx(cur, windows.MAXIMUM_ALLOWED, nil,
|
||||
securityImpersonation, tokenPrimary, &dup); err != nil {
|
||||
return 0, fmt.Errorf("DuplicateTokenEx: %w", err)
|
||||
}
|
||||
|
||||
sid := sessionID
|
||||
r, _, err := procSetTokenInformation.Call(
|
||||
uintptr(dup),
|
||||
uintptr(tokenSessionID),
|
||||
uintptr(unsafe.Pointer(&sid)),
|
||||
unsafe.Sizeof(sid),
|
||||
)
|
||||
if r == 0 {
|
||||
dup.Close()
|
||||
return 0, fmt.Errorf("SetTokenInformation(SessionId=%d): %w", sessionID, err)
|
||||
}
|
||||
return dup, nil
|
||||
}
|
||||
|
||||
const agentTokenEnvVar = "NB_VNC_AGENT_TOKEN" // #nosec G101 -- env var name, not a credential
|
||||
|
||||
// injectEnvVar appends a KEY=VALUE entry to a Unicode environment block.
|
||||
// The block is a sequence of null-terminated UTF-16 strings, terminated by
|
||||
// an extra null. Returns the new []uint16 backing slice; the caller must
|
||||
// hold the returned slice alive until CreateProcessAsUser completes.
|
||||
func injectEnvVar(envBlock uintptr, key, value string) []uint16 {
|
||||
entry := key + "=" + value
|
||||
|
||||
// Walk the existing block to find its total length.
|
||||
ptr := (*uint16)(unsafe.Pointer(envBlock))
|
||||
var totalChars int
|
||||
for {
|
||||
ch := *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(totalChars)*2))
|
||||
if ch == 0 {
|
||||
// Check for double-null terminator.
|
||||
next := *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(totalChars+1)*2))
|
||||
totalChars++
|
||||
if next == 0 {
|
||||
// End of block (don't count the final null yet, we'll rebuild).
|
||||
break
|
||||
}
|
||||
} else {
|
||||
totalChars++
|
||||
}
|
||||
}
|
||||
|
||||
entryUTF16, _ := windows.UTF16FromString(entry)
|
||||
// New block: existing entries + new entry (null-terminated) + final null.
|
||||
newLen := totalChars + len(entryUTF16) + 1
|
||||
newBlock := make([]uint16, newLen)
|
||||
// Copy existing entries (up to but not including the final null).
|
||||
for i := range totalChars {
|
||||
newBlock[i] = *(*uint16)(unsafe.Pointer(uintptr(unsafe.Pointer(ptr)) + uintptr(i)*2))
|
||||
}
|
||||
copy(newBlock[totalChars:], entryUTF16)
|
||||
newBlock[newLen-1] = 0 // final null terminator
|
||||
|
||||
return newBlock
|
||||
}
|
||||
|
||||
func spawnAgentInSession(sessionID uint32, port string, authToken string, jobHandle windows.Handle) (windows.Handle, error) {
|
||||
token, err := getSystemTokenForSession(sessionID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get SYSTEM token for session %d: %w", sessionID, err)
|
||||
}
|
||||
defer token.Close()
|
||||
|
||||
var envBlock uintptr
|
||||
r, _, e := procCreateEnvironmentBlock.Call(
|
||||
uintptr(unsafe.Pointer(&envBlock)),
|
||||
uintptr(token),
|
||||
0,
|
||||
)
|
||||
if r == 0 {
|
||||
// Without an environment block we cannot inject NB_VNC_AGENT_TOKEN;
|
||||
// the agent would start unauthenticated. Abort instead of launching.
|
||||
return 0, fmt.Errorf("CreateEnvironmentBlock: %w", e)
|
||||
}
|
||||
defer func() { _, _, _ = procDestroyEnvironmentBlock.Call(envBlock) }()
|
||||
|
||||
// Inject the auth token into the environment block so it doesn't appear
|
||||
// in the process command line (visible via tasklist/wmic). injectedBlock
|
||||
// must stay alive until CreateProcessAsUser returns.
|
||||
injectedBlock := injectEnvVar(envBlock, agentTokenEnvVar, authToken)
|
||||
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get executable path: %w", err)
|
||||
}
|
||||
|
||||
cmdLine := fmt.Sprintf(`"%s" vnc-agent --port %s`, exePath, port)
|
||||
cmdLineW, err := windows.UTF16PtrFromString(cmdLine)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("UTF16 cmdline: %w", err)
|
||||
}
|
||||
|
||||
// Create an inheritable pipe for the agent's stderr so we can relog
|
||||
// its output in the service process.
|
||||
var sa windows.SecurityAttributes
|
||||
sa.Length = uint32(unsafe.Sizeof(sa))
|
||||
sa.InheritHandle = 1
|
||||
|
||||
var stderrRead, stderrWrite windows.Handle
|
||||
if err := windows.CreatePipe(&stderrRead, &stderrWrite, &sa, 0); err != nil {
|
||||
return 0, fmt.Errorf("create stderr pipe: %w", err)
|
||||
}
|
||||
// The read end must NOT be inherited by the child.
|
||||
_ = windows.SetHandleInformation(stderrRead, windows.HANDLE_FLAG_INHERIT, 0)
|
||||
|
||||
desktop, _ := windows.UTF16PtrFromString(`WinSta0\Default`)
|
||||
si := windows.StartupInfo{
|
||||
Cb: uint32(unsafe.Sizeof(windows.StartupInfo{})),
|
||||
Desktop: desktop,
|
||||
Flags: windows.STARTF_USESHOWWINDOW | windows.STARTF_USESTDHANDLES,
|
||||
ShowWindow: 0,
|
||||
StdErr: stderrWrite,
|
||||
StdOutput: stderrWrite,
|
||||
}
|
||||
var pi windows.ProcessInformation
|
||||
|
||||
var envPtr *uint16
|
||||
if len(injectedBlock) > 0 {
|
||||
envPtr = &injectedBlock[0]
|
||||
} else if envBlock != 0 {
|
||||
envPtr = (*uint16)(unsafe.Pointer(envBlock))
|
||||
}
|
||||
|
||||
// CREATE_SUSPENDED so we can assign the process to our Job Object
|
||||
// before it executes. Without this the agent could spawn its own child
|
||||
// processes and have them inherit the SCM service-job (not ours), or
|
||||
// briefly listen on the agent port before we tear it down on rollback.
|
||||
// CREATE_BREAKAWAY_FROM_JOB lets the child leave the SCM-managed
|
||||
// service job; harmless if that job allows breakaway, and is required
|
||||
// before AssignProcessToJobObject can succeed in the no-nested-jobs case.
|
||||
err = windows.CreateProcessAsUser(
|
||||
token, nil, cmdLineW,
|
||||
nil, nil, true, // inheritHandles=true for the pipe
|
||||
createUnicodeEnvironment|createNoWindow|createSuspended|createBreakawayFromJob,
|
||||
envPtr, nil, &si, &pi,
|
||||
)
|
||||
runtime.KeepAlive(injectedBlock)
|
||||
// Close the write end in the parent so reads will get EOF when the child exits.
|
||||
_ = windows.CloseHandle(stderrWrite)
|
||||
if err != nil {
|
||||
_ = windows.CloseHandle(stderrRead)
|
||||
return 0, fmt.Errorf("CreateProcessAsUser: %w", err)
|
||||
}
|
||||
|
||||
if jobHandle != 0 {
|
||||
r, _, e := procAssignProcessToJobObject.Call(uintptr(jobHandle), uintptr(pi.Process))
|
||||
if r == 0 {
|
||||
log.Warnf("assign agent to job object: %v (orphan possible on service crash)", e)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := windows.ResumeThread(pi.Thread); err != nil {
|
||||
log.Warnf("resume agent main thread: %v", err)
|
||||
}
|
||||
_ = windows.CloseHandle(pi.Thread)
|
||||
|
||||
// Relog agent output in the service with a [vnc-agent] prefix.
|
||||
go relogAgentOutput(stderrRead)
|
||||
|
||||
log.Infof("spawned agent PID=%d in session %d on port %s", pi.ProcessId, sessionID, port)
|
||||
return pi.Process, nil
|
||||
}
|
||||
|
||||
// sessionManager monitors the active console session and ensures a VNC agent
|
||||
// process is running in it. When the session changes (e.g., user switch, RDP
|
||||
// connect/disconnect), it kills the old agent and spawns a new one.
|
||||
type sessionManager struct {
|
||||
port string
|
||||
mu sync.Mutex
|
||||
agentProc windows.Handle
|
||||
everSpawned bool
|
||||
agentStartedAt time.Time
|
||||
spawnFailures int
|
||||
nextSpawnAt time.Time
|
||||
sessionID uint32
|
||||
authToken string
|
||||
done chan struct{}
|
||||
// jobHandle owns the agent processes via a Windows Job Object with
|
||||
// JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE. When the service exits or crashes,
|
||||
// the OS closes the handle and terminates every assigned agent: no
|
||||
// orphaned listeners holding the agent port across restarts.
|
||||
jobHandle windows.Handle
|
||||
}
|
||||
|
||||
func newSessionManager(port string) *sessionManager {
|
||||
m := &sessionManager{port: port, sessionID: ^uint32(0), done: make(chan struct{})}
|
||||
if h, err := createKillOnCloseJob(); err != nil {
|
||||
log.Warnf("create job object for vnc-agent (orphan agents possible after crash): %v", err)
|
||||
} else {
|
||||
m.jobHandle = h
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// createKillOnCloseJob returns a Job Object configured so that closing its
|
||||
// handle (process exit or explicit Close) terminates every process assigned
|
||||
// to it. Used to keep orphaned vnc-agent processes from outliving the service.
|
||||
func createKillOnCloseJob() (windows.Handle, error) {
|
||||
r, _, e := procCreateJobObjectW.Call(0, 0)
|
||||
if r == 0 {
|
||||
return 0, fmt.Errorf("CreateJobObject: %w", e)
|
||||
}
|
||||
job := windows.Handle(r)
|
||||
|
||||
// JOBOBJECT_EXTENDED_LIMIT_INFORMATION on amd64 = 144 bytes.
|
||||
//
|
||||
// JOBOBJECT_BASIC_LIMIT_INFORMATION (64 bytes with alignment padding)
|
||||
// PerProcessUserTimeLimit LARGE_INTEGER off 0
|
||||
// PerJobUserTimeLimit LARGE_INTEGER off 8
|
||||
// LimitFlags DWORD off 16
|
||||
// [4 byte pad to align SIZE_T]
|
||||
// MinimumWorkingSetSize SIZE_T off 24
|
||||
// MaximumWorkingSetSize SIZE_T off 32
|
||||
// ActiveProcessLimit DWORD off 40
|
||||
// [4 byte pad to align ULONG_PTR]
|
||||
// Affinity ULONG_PTR off 48
|
||||
// PriorityClass DWORD off 56
|
||||
// SchedulingClass DWORD off 60
|
||||
// IO_COUNTERS (48) + 4 * SIZE_T (32) = 144 total.
|
||||
//
|
||||
// We only set LimitFlags; the rest stays zero.
|
||||
const sizeofExtended = 144
|
||||
const offsetLimitFlags = 16
|
||||
const jobObjectExtendedLimitInformation = 9
|
||||
const jobObjectLimitKillOnJobClose = 0x00002000
|
||||
|
||||
var info [sizeofExtended]byte
|
||||
binary.LittleEndian.PutUint32(info[offsetLimitFlags:offsetLimitFlags+4], jobObjectLimitKillOnJobClose)
|
||||
|
||||
r, _, e = procSetInformationJobObject.Call(
|
||||
uintptr(job),
|
||||
uintptr(jobObjectExtendedLimitInformation),
|
||||
uintptr(unsafe.Pointer(&info[0])),
|
||||
uintptr(sizeofExtended),
|
||||
)
|
||||
if r == 0 {
|
||||
_ = windows.CloseHandle(job)
|
||||
return 0, fmt.Errorf("SetInformationJobObject(KILL_ON_JOB_CLOSE): %w", e)
|
||||
}
|
||||
return job, nil
|
||||
}
|
||||
|
||||
// generateAuthToken creates a new random hex token for agent authentication.
|
||||
func generateAuthToken() string {
|
||||
b := make([]byte, agentTokenLen)
|
||||
if _, err := crand.Read(b); err != nil {
|
||||
log.Warnf("generate agent auth token: %v", err)
|
||||
return ""
|
||||
}
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
// AuthToken returns the current agent authentication token.
|
||||
func (m *sessionManager) AuthToken() string {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
return m.authToken
|
||||
}
|
||||
|
||||
// Stop signals the session manager to exit its polling loop and closes the
|
||||
// Job Object handle, which Windows uses as the trigger to terminate every
|
||||
// agent process this manager spawned.
|
||||
func (m *sessionManager) Stop() {
|
||||
select {
|
||||
case <-m.done:
|
||||
default:
|
||||
close(m.done)
|
||||
}
|
||||
m.mu.Lock()
|
||||
if m.jobHandle != 0 {
|
||||
_ = windows.CloseHandle(m.jobHandle)
|
||||
m.jobHandle = 0
|
||||
}
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
func (m *sessionManager) run() {
|
||||
ticker := time.NewTicker(2 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
if !m.tick() {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case <-m.done:
|
||||
m.mu.Lock()
|
||||
m.killAgent()
|
||||
m.mu.Unlock()
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// tick performs one session/agent-state update. Returns false if the manager
|
||||
// should permanently stop (e.g. missing SYSTEM privileges).
|
||||
func (m *sessionManager) tick() bool {
|
||||
sid := getActiveSessionID()
|
||||
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.handleSessionChange(sid)
|
||||
m.reapExitedAgent()
|
||||
return m.maybeSpawnAgent(sid)
|
||||
}
|
||||
|
||||
func (m *sessionManager) handleSessionChange(sid uint32) {
|
||||
if sid == m.sessionID {
|
||||
return
|
||||
}
|
||||
log.Infof("active session changed: %d -> %d", m.sessionID, sid)
|
||||
m.killAgent()
|
||||
m.sessionID = sid
|
||||
}
|
||||
|
||||
func (m *sessionManager) reapExitedAgent() {
|
||||
if m.agentProc == 0 {
|
||||
return
|
||||
}
|
||||
var code uint32
|
||||
if err := windows.GetExitCodeProcess(m.agentProc, &code); err != nil {
|
||||
log.Debugf("GetExitCodeProcess: %v", err)
|
||||
return
|
||||
}
|
||||
if code == stillActive {
|
||||
return
|
||||
}
|
||||
m.scheduleNextSpawn(code, time.Since(m.agentStartedAt))
|
||||
if err := windows.CloseHandle(m.agentProc); err != nil {
|
||||
log.Debugf("close agent handle: %v", err)
|
||||
}
|
||||
m.agentProc = 0
|
||||
}
|
||||
|
||||
// scheduleNextSpawn applies an exponential backoff on fast crashes (<5s) and
|
||||
// resets immediately otherwise.
|
||||
func (m *sessionManager) scheduleNextSpawn(exitCode uint32, lifetime time.Duration) {
|
||||
if lifetime < 5*time.Second {
|
||||
m.spawnFailures++
|
||||
backoff := time.Duration(1<<min(m.spawnFailures, 5)) * time.Second
|
||||
if backoff > 30*time.Second {
|
||||
backoff = 30 * time.Second
|
||||
}
|
||||
m.nextSpawnAt = time.Now().Add(backoff)
|
||||
log.Warnf("agent exited (code=%d) after %v, retrying in %v (failures=%d)", exitCode, lifetime.Round(time.Millisecond), backoff, m.spawnFailures)
|
||||
return
|
||||
}
|
||||
m.spawnFailures = 0
|
||||
m.nextSpawnAt = time.Time{}
|
||||
log.Infof("agent exited (code=%d) after %v, respawning", exitCode, lifetime.Round(time.Second))
|
||||
}
|
||||
|
||||
// maybeSpawnAgent spawns a new agent if there's no current one and the backoff
|
||||
// window has elapsed. Returns false to permanently stop the manager when the
|
||||
// service lacks the privileges needed to spawn cross-session.
|
||||
func (m *sessionManager) maybeSpawnAgent(sid uint32) bool {
|
||||
if m.agentProc != 0 || sid == 0xFFFFFFFF || !time.Now().After(m.nextSpawnAt) {
|
||||
return true
|
||||
}
|
||||
// Reap any orphan still holding the agent port from a previous
|
||||
// service instance, only on our very first spawn. Once we own
|
||||
// an agent, we manage its lifecycle ourselves and never need to
|
||||
// kill an unknown listener; if a kill+respawn races on port
|
||||
// release, the spawn-failure backoff handles it without forcing
|
||||
// a synchronous wait or duplicate kill.
|
||||
if !m.everSpawned {
|
||||
reapOrphanOnPort(m.port)
|
||||
}
|
||||
m.authToken = generateAuthToken()
|
||||
h, err := spawnAgentInSession(sid, m.port, m.authToken, m.jobHandle)
|
||||
if err != nil {
|
||||
m.authToken = ""
|
||||
if errors.Is(err, windows.ERROR_PRIVILEGE_NOT_HELD) {
|
||||
// SE_TCB_NAME (token-impersonation across sessions) is only
|
||||
// granted to SYSTEM. Without it spawnAgent will fail every 2
|
||||
// seconds forever: log once and give up.
|
||||
log.Warnf("VNC service mode disabled: agent spawn requires SYSTEM privileges (got: %v)", err)
|
||||
return false
|
||||
}
|
||||
log.Warnf("spawn agent in session %d: %v", sid, err)
|
||||
return true
|
||||
}
|
||||
m.agentProc = h
|
||||
m.agentStartedAt = time.Now()
|
||||
m.everSpawned = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (m *sessionManager) killAgent() {
|
||||
if m.agentProc == 0 {
|
||||
return
|
||||
}
|
||||
_ = windows.TerminateProcess(m.agentProc, 0)
|
||||
_ = windows.CloseHandle(m.agentProc)
|
||||
m.agentProc = 0
|
||||
log.Info("killed old agent")
|
||||
}
|
||||
|
||||
// relogAgentOutput reads log lines from the agent's stderr pipe and
|
||||
// relogs them with the service's formatter. Each line is tried as JSON
|
||||
// first (the agent's normal log format); plain-text lines (e.g. cobra
|
||||
// error output, panic stack traces) are forwarded verbatim so failures
|
||||
// during early agent startup remain visible.
|
||||
func relogAgentOutput(pipe windows.Handle) {
|
||||
defer func() { _ = windows.CloseHandle(pipe) }()
|
||||
f := os.NewFile(uintptr(pipe), "vnc-agent-stderr")
|
||||
defer f.Close()
|
||||
|
||||
entry := log.WithField("component", "vnc-agent")
|
||||
scanner := bufio.NewScanner(f)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
if len(line) == 0 {
|
||||
continue
|
||||
}
|
||||
if line[0] != '{' {
|
||||
entry.Warn(string(line))
|
||||
continue
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(line, &m); err != nil {
|
||||
entry.Warn(string(line))
|
||||
continue
|
||||
}
|
||||
msg, _ := m["msg"].(string)
|
||||
if msg == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
fields := make(log.Fields)
|
||||
for k, v := range m {
|
||||
switch k {
|
||||
case "msg", "level", "time", "func":
|
||||
continue
|
||||
case "caller":
|
||||
fields["source"] = v
|
||||
default:
|
||||
fields[k] = v
|
||||
}
|
||||
}
|
||||
e := entry.WithFields(fields)
|
||||
|
||||
switch m["level"] {
|
||||
case "error":
|
||||
e.Error(msg)
|
||||
case "warning":
|
||||
e.Warn(msg)
|
||||
case "debug":
|
||||
e.Debug(msg)
|
||||
case "trace":
|
||||
e.Trace(msg)
|
||||
default:
|
||||
e.Info(msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// proxyToAgent connects to the agent, sends the auth token, then proxies
|
||||
// the VNC client connection bidirectionally.
|
||||
func proxyToAgent(client net.Conn, port string, authToken string) {
|
||||
defer client.Close()
|
||||
|
||||
addr := "127.0.0.1:" + port
|
||||
var agentConn net.Conn
|
||||
var err error
|
||||
for range 50 {
|
||||
agentConn, err = net.DialTimeout("tcp", addr, time.Second)
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
if err != nil {
|
||||
log.Warnf("proxy cannot reach agent at %s: %v", addr, err)
|
||||
return
|
||||
}
|
||||
defer agentConn.Close()
|
||||
|
||||
// Send the auth token so the agent can verify this connection
|
||||
// comes from the trusted service process.
|
||||
tokenBytes, _ := hex.DecodeString(authToken)
|
||||
if _, err := agentConn.Write(tokenBytes); err != nil {
|
||||
log.Warnf("send auth token to agent: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("proxy connected to agent, starting bidirectional copy")
|
||||
|
||||
done := make(chan struct{}, 2)
|
||||
cp := func(label string, dst, src net.Conn) {
|
||||
n, err := io.Copy(dst, src)
|
||||
log.Debugf("proxy %s: %d bytes, err=%v", label, n, err)
|
||||
done <- struct{}{}
|
||||
}
|
||||
go cp("client→agent", agentConn, client)
|
||||
go cp("agent→client", client, agentConn)
|
||||
<-done
|
||||
}
|
||||
|
||||
// logCleanupCall invokes a Windows syscall used solely as a cleanup primitive
|
||||
// (CloseClipboard, ReleaseDC, etc.) and logs failures at trace level. The
|
||||
// indirection lets us satisfy errcheck without scattering ignored returns at
|
||||
// each call site, while still capturing diagnostic info when the OS reports
|
||||
// a failure.
|
||||
func logCleanupCall(name string, proc *windows.LazyProc) {
|
||||
r, _, err := proc.Call()
|
||||
if r == 0 && err != nil && err != windows.NTE_OP_OK {
|
||||
log.Tracef("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
|
||||
// logCleanupCallArgs is logCleanupCall with one argument; common pattern for
|
||||
// release-by-handle syscalls.
|
||||
func logCleanupCallArgs(name string, proc *windows.LazyProc, args ...uintptr) {
|
||||
r, _, err := proc.Call(args...)
|
||||
if r == 0 && err != nil && err != windows.NTE_OP_OK {
|
||||
log.Tracef("%s: %v", name, err)
|
||||
}
|
||||
}
|
||||
601
client/vnc/server/capture_darwin.go
Normal file
601
client/vnc/server/capture_darwin.go
Normal file
@@ -0,0 +1,601 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/maphash"
|
||||
"image"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
var darwinCaptureOnce sync.Once
|
||||
|
||||
var (
|
||||
cgMainDisplayID func() uint32
|
||||
cgDisplayPixelsWide func(uint32) uintptr
|
||||
cgDisplayPixelsHigh func(uint32) uintptr
|
||||
cgDisplayCreateImage func(uint32) uintptr
|
||||
cgImageGetWidth func(uintptr) uintptr
|
||||
cgImageGetHeight func(uintptr) uintptr
|
||||
cgImageGetBytesPerRow func(uintptr) uintptr
|
||||
cgImageGetBitsPerPixel func(uintptr) uintptr
|
||||
cgImageGetDataProvider func(uintptr) uintptr
|
||||
cgDataProviderCopyData func(uintptr) uintptr
|
||||
cgImageRelease func(uintptr)
|
||||
cfDataGetLength func(uintptr) int64
|
||||
cfDataGetBytePtr func(uintptr) uintptr
|
||||
cfRelease func(uintptr)
|
||||
cgPreflightScreenCaptureAccess func() bool
|
||||
cgRequestScreenCaptureAccess func() bool
|
||||
darwinCaptureReady bool
|
||||
)
|
||||
|
||||
func initDarwinCapture() {
|
||||
darwinCaptureOnce.Do(func() {
|
||||
cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreGraphics: %v", err)
|
||||
return
|
||||
}
|
||||
cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreFoundation: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
purego.RegisterLibFunc(&cgMainDisplayID, cg, "CGMainDisplayID")
|
||||
purego.RegisterLibFunc(&cgDisplayPixelsWide, cg, "CGDisplayPixelsWide")
|
||||
purego.RegisterLibFunc(&cgDisplayPixelsHigh, cg, "CGDisplayPixelsHigh")
|
||||
purego.RegisterLibFunc(&cgDisplayCreateImage, cg, "CGDisplayCreateImage")
|
||||
purego.RegisterLibFunc(&cgImageGetWidth, cg, "CGImageGetWidth")
|
||||
purego.RegisterLibFunc(&cgImageGetHeight, cg, "CGImageGetHeight")
|
||||
purego.RegisterLibFunc(&cgImageGetBytesPerRow, cg, "CGImageGetBytesPerRow")
|
||||
purego.RegisterLibFunc(&cgImageGetBitsPerPixel, cg, "CGImageGetBitsPerPixel")
|
||||
purego.RegisterLibFunc(&cgImageGetDataProvider, cg, "CGImageGetDataProvider")
|
||||
purego.RegisterLibFunc(&cgDataProviderCopyData, cg, "CGDataProviderCopyData")
|
||||
purego.RegisterLibFunc(&cgImageRelease, cg, "CGImageRelease")
|
||||
purego.RegisterLibFunc(&cfDataGetLength, cf, "CFDataGetLength")
|
||||
purego.RegisterLibFunc(&cfDataGetBytePtr, cf, "CFDataGetBytePtr")
|
||||
purego.RegisterLibFunc(&cfRelease, cf, "CFRelease")
|
||||
|
||||
// Screen capture permission APIs (macOS 11+). Might not exist on older versions.
|
||||
if sym, err := purego.Dlsym(cg, "CGPreflightScreenCaptureAccess"); err == nil {
|
||||
purego.RegisterFunc(&cgPreflightScreenCaptureAccess, sym)
|
||||
}
|
||||
if sym, err := purego.Dlsym(cg, "CGRequestScreenCaptureAccess"); err == nil {
|
||||
purego.RegisterFunc(&cgRequestScreenCaptureAccess, sym)
|
||||
}
|
||||
|
||||
darwinCaptureReady = true
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
// CGCapturer captures the macOS main display using Core Graphics.
|
||||
type CGCapturer struct {
|
||||
displayID uint32
|
||||
w, h int
|
||||
// downscale is 1 for pixel-perfect, 2 for Retina 2:1 box-filter downscale.
|
||||
downscale int
|
||||
hashSeed maphash.Seed
|
||||
lastHash uint64
|
||||
hasHash bool
|
||||
}
|
||||
|
||||
// NewCGCapturer creates a screen capturer for the main display.
|
||||
func NewCGCapturer() (*CGCapturer, error) {
|
||||
initDarwinCapture()
|
||||
if !darwinCaptureReady {
|
||||
return nil, fmt.Errorf("CoreGraphics not available")
|
||||
}
|
||||
|
||||
// Request Screen Recording permission (shows system dialog on macOS 11+).
|
||||
if cgPreflightScreenCaptureAccess != nil && !cgPreflightScreenCaptureAccess() {
|
||||
if cgRequestScreenCaptureAccess != nil {
|
||||
cgRequestScreenCaptureAccess()
|
||||
}
|
||||
openPrivacyPane("Privacy_ScreenCapture")
|
||||
log.Warn("Screen Recording permission not granted. " +
|
||||
"Opened System Settings > Privacy & Security > Screen Recording; enable netbird and restart.")
|
||||
}
|
||||
|
||||
displayID := cgMainDisplayID()
|
||||
c := &CGCapturer{displayID: displayID, downscale: 1, hashSeed: maphash.MakeSeed()}
|
||||
|
||||
// Probe actual pixel dimensions via a test capture. CGDisplayPixelsWide/High
|
||||
// returns logical points on Retina, but CGDisplayCreateImage produces native
|
||||
// pixels (often 2x), so probing the image is the only reliable source.
|
||||
img, err := c.Capture()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("probe capture: %w", err)
|
||||
}
|
||||
nativeW := img.Rect.Dx()
|
||||
nativeH := img.Rect.Dy()
|
||||
c.hasHash = false
|
||||
if nativeW == 0 || nativeH == 0 {
|
||||
return nil, errors.New("display dimensions are zero")
|
||||
}
|
||||
|
||||
logicalW := int(cgDisplayPixelsWide(displayID))
|
||||
logicalH := int(cgDisplayPixelsHigh(displayID))
|
||||
|
||||
// Enable 2:1 downscale on Retina unless explicitly disabled. Cuts pixel
|
||||
// count 4x, shrinking convert, diff, and wire data proportionally.
|
||||
if !retinaDownscaleDisabled() && nativeW >= 2*logicalW && nativeH >= 2*logicalH && nativeW%2 == 0 && nativeH%2 == 0 {
|
||||
c.downscale = 2
|
||||
}
|
||||
c.w = nativeW / c.downscale
|
||||
c.h = nativeH / c.downscale
|
||||
|
||||
log.Infof("macOS capturer ready: %dx%d (native %dx%d, logical %dx%d, downscale=%d, display=%d)",
|
||||
c.w, c.h, nativeW, nativeH, logicalW, logicalH, c.downscale, displayID)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func retinaDownscaleDisabled() bool {
|
||||
v := os.Getenv(EnvVNCDisableDownscale)
|
||||
if v == "" {
|
||||
return false
|
||||
}
|
||||
disabled, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
log.Warnf("parse %s: %v", EnvVNCDisableDownscale, err)
|
||||
return false
|
||||
}
|
||||
return disabled
|
||||
}
|
||||
|
||||
// Width returns the screen width.
|
||||
func (c *CGCapturer) Width() int { return c.w }
|
||||
|
||||
// Height returns the screen height.
|
||||
func (c *CGCapturer) Height() int { return c.h }
|
||||
|
||||
// Capture returns the current screen as an RGBA image.
|
||||
// CaptureInto writes a fresh frame directly into dst, skipping the
|
||||
// per-frame image.RGBA allocation that Capture() does. Returns
|
||||
// errFrameUnchanged when the screen hash matches the prior call.
|
||||
func (c *CGCapturer) CaptureInto(dst *image.RGBA) error {
|
||||
cgImage := cgDisplayCreateImage(c.displayID)
|
||||
if cgImage == 0 {
|
||||
return fmt.Errorf("CGDisplayCreateImage returned nil (screen recording permission?)")
|
||||
}
|
||||
defer cgImageRelease(cgImage)
|
||||
w := int(cgImageGetWidth(cgImage))
|
||||
h := int(cgImageGetHeight(cgImage))
|
||||
bytesPerRow := int(cgImageGetBytesPerRow(cgImage))
|
||||
bpp := int(cgImageGetBitsPerPixel(cgImage))
|
||||
provider := cgImageGetDataProvider(cgImage)
|
||||
if provider == 0 {
|
||||
return fmt.Errorf("CGImageGetDataProvider returned nil")
|
||||
}
|
||||
cfData := cgDataProviderCopyData(provider)
|
||||
if cfData == 0 {
|
||||
return fmt.Errorf("CGDataProviderCopyData returned nil")
|
||||
}
|
||||
defer cfRelease(cfData)
|
||||
dataLen := int(cfDataGetLength(cfData))
|
||||
dataPtr := cfDataGetBytePtr(cfData)
|
||||
if dataPtr == 0 || dataLen == 0 {
|
||||
return fmt.Errorf("empty image data")
|
||||
}
|
||||
src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen)
|
||||
hash := maphash.Bytes(c.hashSeed, src)
|
||||
if c.hasHash && hash == c.lastHash {
|
||||
return errFrameUnchanged
|
||||
}
|
||||
c.lastHash = hash
|
||||
c.hasHash = true
|
||||
|
||||
ds := c.downscale
|
||||
if ds < 1 {
|
||||
ds = 1
|
||||
}
|
||||
outW := w / ds
|
||||
outH := h / ds
|
||||
if dst.Rect.Dx() != outW || dst.Rect.Dy() != outH {
|
||||
return fmt.Errorf("dst size mismatch: dst=%dx%d capturer=%dx%d",
|
||||
dst.Rect.Dx(), dst.Rect.Dy(), outW, outH)
|
||||
}
|
||||
bytesPerPixel := bpp / 8
|
||||
if bytesPerPixel == 4 && ds == 1 {
|
||||
convertBGRAToRGBA(dst.Pix, dst.Stride, src, bytesPerRow, w, h)
|
||||
return nil
|
||||
}
|
||||
if bytesPerPixel == 4 && ds == 2 {
|
||||
convertBGRAToRGBADownscale2(dst.Pix, dst.Stride, src, bytesPerRow, outW, outH)
|
||||
return nil
|
||||
}
|
||||
for row := 0; row < outH; row++ {
|
||||
srcOff := row * ds * bytesPerRow
|
||||
dstOff := row * dst.Stride
|
||||
for col := 0; col < outW; col++ {
|
||||
si := srcOff + col*ds*bytesPerPixel
|
||||
di := dstOff + col*4
|
||||
dst.Pix[di+0] = src[si+2]
|
||||
dst.Pix[di+1] = src[si+1]
|
||||
dst.Pix[di+2] = src[si+0]
|
||||
dst.Pix[di+3] = 0xff
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *CGCapturer) Capture() (*image.RGBA, error) {
|
||||
cgImage := cgDisplayCreateImage(c.displayID)
|
||||
if cgImage == 0 {
|
||||
return nil, fmt.Errorf("CGDisplayCreateImage returned nil (screen recording permission?)")
|
||||
}
|
||||
defer cgImageRelease(cgImage)
|
||||
|
||||
w := int(cgImageGetWidth(cgImage))
|
||||
h := int(cgImageGetHeight(cgImage))
|
||||
bytesPerRow := int(cgImageGetBytesPerRow(cgImage))
|
||||
bpp := int(cgImageGetBitsPerPixel(cgImage))
|
||||
|
||||
provider := cgImageGetDataProvider(cgImage)
|
||||
if provider == 0 {
|
||||
return nil, fmt.Errorf("CGImageGetDataProvider returned nil")
|
||||
}
|
||||
|
||||
cfData := cgDataProviderCopyData(provider)
|
||||
if cfData == 0 {
|
||||
return nil, fmt.Errorf("CGDataProviderCopyData returned nil")
|
||||
}
|
||||
defer cfRelease(cfData)
|
||||
|
||||
dataLen := int(cfDataGetLength(cfData))
|
||||
dataPtr := cfDataGetBytePtr(cfData)
|
||||
if dataPtr == 0 || dataLen == 0 {
|
||||
return nil, fmt.Errorf("empty image data")
|
||||
}
|
||||
|
||||
src := unsafe.Slice((*byte)(unsafe.Pointer(dataPtr)), dataLen)
|
||||
|
||||
hash := maphash.Bytes(c.hashSeed, src)
|
||||
if c.hasHash && hash == c.lastHash {
|
||||
return nil, errFrameUnchanged
|
||||
}
|
||||
c.lastHash = hash
|
||||
c.hasHash = true
|
||||
|
||||
ds := c.downscale
|
||||
if ds < 1 {
|
||||
ds = 1
|
||||
}
|
||||
outW := w / ds
|
||||
outH := h / ds
|
||||
img := image.NewRGBA(image.Rect(0, 0, outW, outH))
|
||||
|
||||
bytesPerPixel := bpp / 8
|
||||
switch {
|
||||
case bytesPerPixel == 4 && ds == 1:
|
||||
convertBGRAToRGBA(img.Pix, img.Stride, src, bytesPerRow, w, h)
|
||||
case bytesPerPixel == 4 && ds == 2:
|
||||
convertBGRAToRGBADownscale2(img.Pix, img.Stride, src, bytesPerRow, outW, outH)
|
||||
default:
|
||||
convertBGRAToRGBAGeneric(img.Pix, img.Stride, src, bytesPerRow, bgraDownscaleParams{outW: outW, outH: outH, bytesPerPixel: bytesPerPixel, ds: ds})
|
||||
}
|
||||
|
||||
return img, nil
|
||||
}
|
||||
|
||||
type bgraDownscaleParams struct {
|
||||
outW, outH, bytesPerPixel, ds int
|
||||
}
|
||||
|
||||
// convertBGRAToRGBAGeneric is the slow per-pixel fallback for non-4-bytes
|
||||
// or non-1/2 downscale formats. Always available regardless of the source
|
||||
// format quirks the fast paths optimize for.
|
||||
func convertBGRAToRGBAGeneric(dst []byte, dstStride int, src []byte, srcStride int, p bgraDownscaleParams) {
|
||||
for row := 0; row < p.outH; row++ {
|
||||
srcOff := row * p.ds * srcStride
|
||||
dstOff := row * dstStride
|
||||
for col := 0; col < p.outW; col++ {
|
||||
si := srcOff + col*p.ds*p.bytesPerPixel
|
||||
di := dstOff + col*4
|
||||
dst[di+0] = src[si+2]
|
||||
dst[di+1] = src[si+1]
|
||||
dst[di+2] = src[si+0]
|
||||
dst[di+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// convertBGRAToRGBADownscale2 averages every 2x2 BGRA block into one RGBA
|
||||
// output pixel, parallelised across GOMAXPROCS cores. outW and outH are the
|
||||
// destination dimensions (source is 2*outW by 2*outH).
|
||||
func convertBGRAToRGBADownscale2(dst []byte, dstStride int, src []byte, srcStride, outW, outH int) {
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > outH {
|
||||
workers = outH
|
||||
}
|
||||
if workers < 1 || outH < 32 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
convertRows := func(y0, y1 int) {
|
||||
for row := y0; row < y1; row++ {
|
||||
srcRow0 := 2 * row * srcStride
|
||||
srcRow1 := srcRow0 + srcStride
|
||||
dstOff := row * dstStride
|
||||
for col := 0; col < outW; col++ {
|
||||
s0 := srcRow0 + col*8
|
||||
s1 := srcRow1 + col*8
|
||||
b := (uint32(src[s0]) + uint32(src[s0+4]) + uint32(src[s1]) + uint32(src[s1+4])) >> 2
|
||||
g := (uint32(src[s0+1]) + uint32(src[s0+5]) + uint32(src[s1+1]) + uint32(src[s1+5])) >> 2
|
||||
r := (uint32(src[s0+2]) + uint32(src[s0+6]) + uint32(src[s1+2]) + uint32(src[s1+6])) >> 2
|
||||
di := dstOff + col*4
|
||||
dst[di+0] = byte(r)
|
||||
dst[di+1] = byte(g)
|
||||
dst[di+2] = byte(b)
|
||||
dst[di+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if workers == 1 {
|
||||
convertRows(0, outH)
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
chunk := (outH + workers - 1) / workers
|
||||
for i := 0; i < workers; i++ {
|
||||
y0 := i * chunk
|
||||
y1 := y0 + chunk
|
||||
if y1 > outH {
|
||||
y1 = outH
|
||||
}
|
||||
if y0 >= y1 {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(y0, y1 int) {
|
||||
defer wg.Done()
|
||||
convertRows(y0, y1)
|
||||
}(y0, y1)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// convertBGRAToRGBA swaps R/B channels using uint32 word operations, and
|
||||
// parallelises across GOMAXPROCS cores for large images.
|
||||
func convertBGRAToRGBA(dst []byte, dstStride int, src []byte, srcStride, w, h int) {
|
||||
workers := runtime.GOMAXPROCS(0)
|
||||
if workers > h {
|
||||
workers = h
|
||||
}
|
||||
if workers < 1 || h < 64 {
|
||||
workers = 1
|
||||
}
|
||||
|
||||
convertRows := func(y0, y1 int) {
|
||||
rowBytes := w * 4
|
||||
for row := y0; row < y1; row++ {
|
||||
dstRow := dst[row*dstStride : row*dstStride+rowBytes]
|
||||
srcRow := src[row*srcStride : row*srcStride+rowBytes]
|
||||
dstU := unsafe.Slice((*uint32)(unsafe.Pointer(&dstRow[0])), w)
|
||||
srcU := unsafe.Slice((*uint32)(unsafe.Pointer(&srcRow[0])), w)
|
||||
for i, p := range srcU {
|
||||
dstU[i] = (p & 0xff00ff00) | ((p & 0x000000ff) << 16) | ((p & 0x00ff0000) >> 16) | 0xff000000
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if workers == 1 {
|
||||
convertRows(0, h)
|
||||
return
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
chunk := (h + workers - 1) / workers
|
||||
for i := 0; i < workers; i++ {
|
||||
y0 := i * chunk
|
||||
y1 := y0 + chunk
|
||||
if y1 > h {
|
||||
y1 = h
|
||||
}
|
||||
if y0 >= y1 {
|
||||
break
|
||||
}
|
||||
wg.Add(1)
|
||||
go func(y0, y1 int) {
|
||||
defer wg.Done()
|
||||
convertRows(y0, y1)
|
||||
}(y0, y1)
|
||||
}
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// MacPoller wraps CGCapturer with a staleness-cached on-demand Capture:
|
||||
// sessions drive captures themselves from their encoder goroutine, so we
|
||||
// don't need a background ticker. The last result is cached for a short
|
||||
// window so concurrent sessions coalesce into one capture.
|
||||
//
|
||||
// The capturer is allocated lazily on first use and released when all
|
||||
// clients disconnect. Init is retried with backoff because the user may
|
||||
// grant Screen Recording permission while the server is already running.
|
||||
type MacPoller struct {
|
||||
mu sync.Mutex
|
||||
|
||||
capturer *CGCapturer
|
||||
w, h int
|
||||
|
||||
lastFrame *image.RGBA
|
||||
lastAt time.Time
|
||||
|
||||
clients atomic.Int32
|
||||
initFails int
|
||||
initBackoffUntil time.Time
|
||||
closed bool
|
||||
}
|
||||
|
||||
// macInitRetryBackoffFor returns the delay we wait between init attempts
|
||||
// after consecutive failures. Screen Recording permission is a one-shot
|
||||
// user grant, so after several failures we back off aggressively.
|
||||
func macInitRetryBackoffFor(fails int) time.Duration {
|
||||
switch {
|
||||
case fails > 15:
|
||||
return 30 * time.Second
|
||||
case fails > 5:
|
||||
return 10 * time.Second
|
||||
default:
|
||||
return 2 * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
// NewMacPoller creates a lazy on-demand capturer for the macOS display.
|
||||
func NewMacPoller() *MacPoller {
|
||||
return &MacPoller{}
|
||||
}
|
||||
|
||||
// Wake is a no-op retained for API compatibility. With on-demand capture
|
||||
// there is no background retry loop to kick: init happens on the next
|
||||
// Capture/ClientConnect call.
|
||||
func (p *MacPoller) Wake() {
|
||||
// intentional no-op
|
||||
}
|
||||
|
||||
// ClientConnect increments the active client count and eagerly initialises
|
||||
// the capturer so the first FBUpdateRequest doesn't pay the init cost.
|
||||
func (p *MacPoller) ClientConnect() {
|
||||
if p.clients.Add(1) == 1 {
|
||||
p.mu.Lock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnect decrements the active client count. On the last
|
||||
// disconnect the capturer is released.
|
||||
func (p *MacPoller) ClientDisconnect() {
|
||||
if p.clients.Add(-1) == 0 {
|
||||
p.mu.Lock()
|
||||
p.capturer = nil
|
||||
p.lastFrame = nil
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases all resources.
|
||||
func (p *MacPoller) Close() {
|
||||
p.mu.Lock()
|
||||
p.closed = true
|
||||
p.capturer = nil
|
||||
p.lastFrame = nil
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
// Width returns the screen width. Triggers lazy init if needed.
|
||||
func (p *MacPoller) Width() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.w
|
||||
}
|
||||
|
||||
// Height returns the screen height. Triggers lazy init if needed.
|
||||
func (p *MacPoller) Height() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.h
|
||||
}
|
||||
|
||||
// CaptureInto fills dst directly via the underlying capturer, bypassing
|
||||
// the freshness cache.
|
||||
func (p *MacPoller) CaptureInto(dst *image.RGBA) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
err := p.capturer.CaptureInto(dst)
|
||||
if errors.Is(err, errFrameUnchanged) {
|
||||
// Caller (session) treats this as "no change"; the dst buffer
|
||||
// keeps its prior contents from the previous capture cycle so
|
||||
// the diff stays meaningful.
|
||||
return err
|
||||
}
|
||||
if err != nil {
|
||||
p.capturer = nil
|
||||
return fmt.Errorf("macos capture: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Capture returns a fresh frame, serving from the short-lived cache if a
|
||||
// previous caller captured within freshWindow. Handles the
|
||||
// errFrameUnchanged return from CGCapturer by reusing the cached frame.
|
||||
func (p *MacPoller) Capture() (*image.RGBA, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.lastFrame != nil && time.Since(p.lastAt) < freshWindow {
|
||||
return p.lastFrame, nil
|
||||
}
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, err := p.capturer.Capture()
|
||||
if errors.Is(err, errFrameUnchanged) {
|
||||
if p.lastFrame != nil {
|
||||
p.lastAt = time.Now()
|
||||
return p.lastFrame, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if err != nil {
|
||||
// Drop the capturer so the next call retries init; the display stream
|
||||
// can die if the session changes or permissions are revoked.
|
||||
p.capturer = nil
|
||||
return nil, fmt.Errorf("macos capture: %w", err)
|
||||
}
|
||||
p.lastFrame = img
|
||||
p.lastAt = time.Now()
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// ensureCapturerLocked initialises the underlying CGCapturer if needed.
|
||||
// Caller must hold p.mu.
|
||||
func (p *MacPoller) ensureCapturerLocked() error {
|
||||
if p.closed {
|
||||
return fmt.Errorf("poller closed")
|
||||
}
|
||||
if p.capturer != nil {
|
||||
return nil
|
||||
}
|
||||
if time.Now().Before(p.initBackoffUntil) {
|
||||
return fmt.Errorf("macOS capturer unavailable (retry scheduled)")
|
||||
}
|
||||
c, err := NewCGCapturer()
|
||||
if err != nil {
|
||||
p.initFails++
|
||||
p.initBackoffUntil = time.Now().Add(macInitRetryBackoffFor(p.initFails))
|
||||
if p.initFails == 1 || p.initFails%10 == 0 {
|
||||
log.Warnf("macOS capturer: %v (attempt %d)", err, p.initFails)
|
||||
} else {
|
||||
log.Debugf("macOS capturer: %v (attempt %d)", err, p.initFails)
|
||||
}
|
||||
return err
|
||||
}
|
||||
p.initFails = 0
|
||||
p.capturer = c
|
||||
p.w, p.h = c.Width(), c.Height()
|
||||
return nil
|
||||
}
|
||||
|
||||
var _ ScreenCapturer = (*MacPoller)(nil)
|
||||
99
client/vnc/server/capture_dxgi_windows.go
Normal file
99
client/vnc/server/capture_dxgi_windows.go
Normal file
@@ -0,0 +1,99 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/kirides/go-d3d/d3d11"
|
||||
"github.com/kirides/go-d3d/outputduplication"
|
||||
)
|
||||
|
||||
// dxgiCapturer captures the desktop using DXGI Desktop Duplication.
|
||||
// Provides GPU-accelerated capture with native dirty rect tracking.
|
||||
// Only works from the interactive user session, not Session 0.
|
||||
//
|
||||
// Uses a double-buffer: DXGI writes into img, then we copy to the current
|
||||
// output buffer and hand it out. Alternating between two output buffers
|
||||
// avoids allocating a new image.RGBA per frame (~8MB at 1080p, 30fps).
|
||||
type dxgiCapturer struct {
|
||||
dup *outputduplication.OutputDuplicator
|
||||
device *d3d11.ID3D11Device
|
||||
ctx *d3d11.ID3D11DeviceContext
|
||||
img *image.RGBA
|
||||
out [2]*image.RGBA
|
||||
outIdx int
|
||||
width int
|
||||
height int
|
||||
}
|
||||
|
||||
func newDXGICapturer() (*dxgiCapturer, error) {
|
||||
device, deviceCtx, err := d3d11.NewD3D11Device()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create D3D11 device: %w", err)
|
||||
}
|
||||
|
||||
dup, err := outputduplication.NewIDXGIOutputDuplication(device, deviceCtx, 0)
|
||||
if err != nil {
|
||||
device.Release()
|
||||
deviceCtx.Release()
|
||||
return nil, fmt.Errorf("create output duplication: %w", err)
|
||||
}
|
||||
|
||||
w, h := screenSize()
|
||||
if w == 0 || h == 0 {
|
||||
dup.Release()
|
||||
device.Release()
|
||||
deviceCtx.Release()
|
||||
return nil, fmt.Errorf("screen dimensions are zero")
|
||||
}
|
||||
|
||||
rect := image.Rect(0, 0, w, h)
|
||||
c := &dxgiCapturer{
|
||||
dup: dup,
|
||||
device: device,
|
||||
ctx: deviceCtx,
|
||||
img: image.NewRGBA(rect),
|
||||
out: [2]*image.RGBA{image.NewRGBA(rect), image.NewRGBA(rect)},
|
||||
width: w,
|
||||
height: h,
|
||||
}
|
||||
|
||||
// Grab the initial frame with a longer timeout to ensure we have
|
||||
// a valid image before returning.
|
||||
_ = dup.GetImage(c.img, 2000)
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (c *dxgiCapturer) capture() (*image.RGBA, error) {
|
||||
err := c.dup.GetImage(c.img, 100)
|
||||
if err != nil && !errors.Is(err, outputduplication.ErrNoImageYet) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Copy into the next output buffer. The DesktopCapturer hands out the
|
||||
// returned pointer to VNC sessions that read pixels concurrently, so we
|
||||
// alternate between two pre-allocated buffers instead of allocating per frame.
|
||||
out := c.out[c.outIdx]
|
||||
c.outIdx ^= 1
|
||||
copy(out.Pix, c.img.Pix)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *dxgiCapturer) close() {
|
||||
if c.dup != nil {
|
||||
c.dup.Release()
|
||||
c.dup = nil
|
||||
}
|
||||
if c.ctx != nil {
|
||||
c.ctx.Release()
|
||||
c.ctx = nil
|
||||
}
|
||||
if c.device != nil {
|
||||
c.device.Release()
|
||||
c.device = nil
|
||||
}
|
||||
}
|
||||
148
client/vnc/server/capture_fb_freebsd.go
Normal file
148
client/vnc/server/capture_fb_freebsd.go
Normal file
@@ -0,0 +1,148 @@
|
||||
//go:build freebsd
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// FreeBSD vt(4) framebuffer ioctl numbers from sys/fbio.h.
|
||||
//
|
||||
// #define FBIOGTYPE _IOR('F', 0, struct fbtype)
|
||||
//
|
||||
// _IOR(g, n, t) on FreeBSD: dir=2 (read) <<30 | (sizeof(t) & 0x1fff)<<16
|
||||
// | (g<<8) | n. sizeof(struct fbtype)=24 → 0x40184600.
|
||||
const fbioGType = 0x40184600
|
||||
|
||||
func defaultFBPath() string { return "/dev/ttyv0" }
|
||||
|
||||
// fbType mirrors FreeBSD's struct fbtype.
|
||||
type fbType struct {
|
||||
FbType int32
|
||||
FbHeight int32
|
||||
FbWidth int32
|
||||
FbDepth int32
|
||||
FbCMSize int32
|
||||
FbSize int32
|
||||
}
|
||||
|
||||
// FBCapturer reads pixels from FreeBSD's vt(4) framebuffer device. The
|
||||
// vt(4) console exposes the active framebuffer via ttyv0 with FBIOGTYPE
|
||||
// for geometry and mmap for backing memory. Pixel layout is assumed to
|
||||
// be 32bpp BGRA (the common case for KMS-backed vt); fbtype doesn't
|
||||
// expose channel offsets, so we don't try to handle exotic layouts here.
|
||||
type FBCapturer struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
fd int
|
||||
mmap []byte
|
||||
w, h int
|
||||
bpp int
|
||||
stride int
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewFBCapturer opens the given vt(4) device and queries its geometry.
|
||||
func NewFBCapturer(path string) (*FBCapturer, error) {
|
||||
if path == "" {
|
||||
path = defaultFBPath()
|
||||
}
|
||||
fd, err := unix.Open(path, unix.O_RDWR, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
|
||||
var fbt fbType
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGType, uintptr(unsafe.Pointer(&fbt))); e != 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("FBIOGTYPE: %v", e)
|
||||
}
|
||||
if fbt.FbDepth != 16 && fbt.FbDepth != 24 && fbt.FbDepth != 32 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("unsupported framebuffer depth: %d", fbt.FbDepth)
|
||||
}
|
||||
if fbt.FbWidth <= 0 || fbt.FbHeight <= 0 || fbt.FbSize <= 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("invalid framebuffer geometry: %dx%d size=%d", fbt.FbWidth, fbt.FbHeight, fbt.FbSize)
|
||||
}
|
||||
|
||||
mm, err := unix.Mmap(fd, 0, int(fbt.FbSize), unix.PROT_READ, unix.MAP_SHARED)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("mmap %s: %w (vt may not support mmap on this driver, e.g. virtio_gpu)", path, err)
|
||||
}
|
||||
|
||||
bpp := int(fbt.FbDepth)
|
||||
stride := int(fbt.FbWidth) * (bpp / 8)
|
||||
c := &FBCapturer{
|
||||
path: path,
|
||||
fd: fd, // valid fd >= 0; we use -1 as the closed sentinel
|
||||
mmap: mm,
|
||||
w: int(fbt.FbWidth),
|
||||
h: int(fbt.FbHeight),
|
||||
bpp: bpp,
|
||||
stride: stride,
|
||||
}
|
||||
log.Infof("framebuffer capturer ready: %s %dx%d bpp=%d (freebsd vt)", path, c.w, c.h, c.bpp)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Width returns the framebuffer width.
|
||||
func (c *FBCapturer) Width() int { return c.w }
|
||||
|
||||
// Height returns the framebuffer height.
|
||||
func (c *FBCapturer) Height() int { return c.h }
|
||||
|
||||
// Capture allocates a fresh image and fills it with the current
|
||||
// framebuffer contents.
|
||||
func (c *FBCapturer) Capture() (*image.RGBA, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, c.w, c.h))
|
||||
if err := c.CaptureInto(img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// CaptureInto reads the framebuffer directly into dst.Pix. Assumes BGRA
|
||||
// for 32bpp; the FreeBSD fbtype struct doesn't expose channel offsets.
|
||||
func (c *FBCapturer) CaptureInto(dst *image.RGBA) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h {
|
||||
return fmt.Errorf("dst size mismatch: dst=%dx%d fb=%dx%d",
|
||||
dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h)
|
||||
}
|
||||
switch c.bpp {
|
||||
case 32:
|
||||
// vt(4) on KMS framebuffers is BGRA: byte 0=B, 1=G, 2=R.
|
||||
swizzleBGRAtoRGBA(dst.Pix, c.mmap[:c.h*c.stride])
|
||||
case 24:
|
||||
swizzleFB24(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h)
|
||||
case 16:
|
||||
swizzleFB16RGB565(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the framebuffer mmap and file descriptor. Serialized with
|
||||
// CaptureInto via c.mu so an in-flight capture can't read freed memory.
|
||||
func (c *FBCapturer) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.mmap != nil {
|
||||
_ = unix.Munmap(c.mmap)
|
||||
c.mmap = nil
|
||||
}
|
||||
if c.fd >= 0 {
|
||||
_ = unix.Close(c.fd)
|
||||
c.fd = -1
|
||||
}
|
||||
})
|
||||
}
|
||||
230
client/vnc/server/capture_fb_linux.go
Normal file
230
client/vnc/server/capture_fb_linux.go
Normal file
@@ -0,0 +1,230 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// Linux framebuffer ioctls (linux/fb.h).
|
||||
const (
|
||||
fbioGetVScreenInfo = 0x4600
|
||||
fbioGetFScreenInfo = 0x4602
|
||||
)
|
||||
|
||||
func defaultFBPath() string { return "/dev/fb0" }
|
||||
|
||||
// fbVarScreenInfo mirrors the kernel's fb_var_screeninfo. Only the
|
||||
// fields we use are mapped; the rest are absorbed into _padN.
|
||||
type fbVarScreenInfo struct {
|
||||
Xres, Yres uint32
|
||||
XresVirtual, YresVirtual uint32
|
||||
XOffset, YOffset uint32
|
||||
BitsPerPixel uint32
|
||||
Grayscale uint32
|
||||
RedOffset, RedLen, RedMSBR uint32
|
||||
GreenOffset, GreenLen, GreenMSBR uint32
|
||||
BlueOffset, BlueLen, BlueMSBR uint32
|
||||
TranspOffset, TranspLen, TranspM uint32
|
||||
NonStd uint32
|
||||
Activate uint32
|
||||
Height, Width uint32
|
||||
AccelFlags uint32
|
||||
PixClock uint32
|
||||
LeftMargin, RightMargin uint32
|
||||
UpperMargin, LowerMargin uint32
|
||||
HsyncLen, VsyncLen uint32
|
||||
Sync uint32
|
||||
Vmode uint32
|
||||
Rotate uint32
|
||||
Colorspace uint32
|
||||
_pad [4]uint32
|
||||
}
|
||||
|
||||
// fbFixScreenInfo mirrors fb_fix_screeninfo. We only need LineLength.
|
||||
type fbFixScreenInfo struct {
|
||||
IDStr [16]byte
|
||||
SmemStart uint64
|
||||
SmemLen uint32
|
||||
Type uint32
|
||||
TypeAux uint32
|
||||
Visual uint32
|
||||
XPanStep uint16
|
||||
YPanStep uint16
|
||||
YWrapStep uint16
|
||||
_pad0 uint16
|
||||
LineLength uint32
|
||||
MmioStart uint64
|
||||
MmioLen uint32
|
||||
Accel uint32
|
||||
Capabilities uint16
|
||||
_reserved [2]uint16
|
||||
}
|
||||
|
||||
// FBCapturer reads pixels straight from the Linux framebuffer device.
|
||||
// Used as a fallback when X11 isn't available, e.g. on a headless box at
|
||||
// the kernel console or the display manager's pre-login screen on machines
|
||||
// without an Xorg server. The framebuffer must be mmap()-able under our
|
||||
// process privileges (typically the netbird service runs as root).
|
||||
type FBCapturer struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
fd int
|
||||
mmap []byte
|
||||
w, h int
|
||||
bpp int
|
||||
stride int
|
||||
rOff uint32
|
||||
gOff uint32
|
||||
bOff uint32
|
||||
rLen uint32
|
||||
gLen uint32
|
||||
bLen uint32
|
||||
closeOnce sync.Once
|
||||
}
|
||||
|
||||
// NewFBCapturer opens the given framebuffer device (/dev/fbN) and
|
||||
// queries its current geometry + pixel format.
|
||||
func NewFBCapturer(path string) (*FBCapturer, error) {
|
||||
if path == "" {
|
||||
path = "/dev/fb0"
|
||||
}
|
||||
fd, err := unix.Open(path, unix.O_RDONLY, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
|
||||
var vinfo fbVarScreenInfo
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGetVScreenInfo, uintptr(unsafe.Pointer(&vinfo))); e != 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("FBIOGET_VSCREENINFO: %v", e)
|
||||
}
|
||||
var finfo fbFixScreenInfo
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), fbioGetFScreenInfo, uintptr(unsafe.Pointer(&finfo))); e != 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("FBIOGET_FSCREENINFO: %v", e)
|
||||
}
|
||||
|
||||
bpp := int(vinfo.BitsPerPixel)
|
||||
if bpp != 16 && bpp != 24 && bpp != 32 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("unsupported framebuffer bpp: %d", bpp)
|
||||
}
|
||||
|
||||
size := int(finfo.LineLength) * int(vinfo.Yres)
|
||||
if size <= 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("invalid framebuffer dimensions: stride=%d h=%d", finfo.LineLength, vinfo.Yres)
|
||||
}
|
||||
|
||||
mm, err := unix.Mmap(fd, 0, size, unix.PROT_READ, unix.MAP_SHARED)
|
||||
if err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("mmap %s: %w", path, err)
|
||||
}
|
||||
|
||||
c := &FBCapturer{
|
||||
path: path,
|
||||
fd: fd,
|
||||
mmap: mm,
|
||||
w: int(vinfo.Xres),
|
||||
h: int(vinfo.Yres),
|
||||
bpp: bpp,
|
||||
stride: int(finfo.LineLength),
|
||||
rOff: vinfo.RedOffset,
|
||||
gOff: vinfo.GreenOffset,
|
||||
bOff: vinfo.BlueOffset,
|
||||
rLen: vinfo.RedLen,
|
||||
gLen: vinfo.GreenLen,
|
||||
bLen: vinfo.BlueLen,
|
||||
}
|
||||
log.Infof("framebuffer capturer ready: %s %dx%d bpp=%d r=%d/%d g=%d/%d b=%d/%d",
|
||||
path, c.w, c.h, c.bpp, c.rOff, c.rLen, c.gOff, c.gLen, c.bOff, c.bLen)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Width returns the framebuffer width in pixels.
|
||||
func (c *FBCapturer) Width() int { return c.w }
|
||||
|
||||
// Height returns the framebuffer height in pixels.
|
||||
func (c *FBCapturer) Height() int { return c.h }
|
||||
|
||||
// Capture allocates a fresh image and fills it with the current
|
||||
// framebuffer contents.
|
||||
func (c *FBCapturer) Capture() (*image.RGBA, error) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, c.w, c.h))
|
||||
if err := c.CaptureInto(img); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// CaptureInto reads the framebuffer directly into dst.Pix.
|
||||
func (c *FBCapturer) CaptureInto(dst *image.RGBA) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h {
|
||||
return fmt.Errorf("dst size mismatch: dst=%dx%d fb=%dx%d",
|
||||
dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h)
|
||||
}
|
||||
|
||||
switch c.bpp {
|
||||
case 32:
|
||||
swizzleFB32(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h, channelShifts{R: c.rOff, G: c.gOff, B: c.bOff})
|
||||
case 24:
|
||||
swizzleFB24(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h)
|
||||
case 16:
|
||||
swizzleFB16RGB565(dst.Pix, dst.Stride, c.mmap, c.stride, c.w, c.h)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close releases the framebuffer mmap and file descriptor. Serialized with
|
||||
// CaptureInto via c.mu so an in-flight capture can't read freed memory.
|
||||
func (c *FBCapturer) Close() {
|
||||
c.closeOnce.Do(func() {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.mmap != nil {
|
||||
_ = unix.Munmap(c.mmap)
|
||||
c.mmap = nil
|
||||
}
|
||||
if c.fd >= 0 {
|
||||
_ = unix.Close(c.fd)
|
||||
c.fd = -1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// channelShifts groups the bit offsets for the R/G/B channels in a packed
|
||||
// uint32 framebuffer pixel. Bundling avoids drowning per-row callers in a
|
||||
// 9-parameter signature.
|
||||
type channelShifts struct {
|
||||
R, G, B uint32
|
||||
}
|
||||
|
||||
// swizzleFB32 handles 32-bit framebuffers with arbitrary R/G/B channel
|
||||
// offsets. Pulls one pixel per uint32, then masks each channel into the
|
||||
// destination RGBA byte order.
|
||||
func swizzleFB32(dst []byte, dstStride int, src []byte, srcStride, w, h int, shifts channelShifts) {
|
||||
for y := 0; y < h; y++ {
|
||||
srcRow := src[y*srcStride : y*srcStride+w*4]
|
||||
dstRow := dst[y*dstStride:]
|
||||
for x := 0; x < w; x++ {
|
||||
pix := binary.LittleEndian.Uint32(srcRow[x*4 : x*4+4])
|
||||
dstRow[x*4+0] = byte(pix >> shifts.R)
|
||||
dstRow[x*4+1] = byte(pix >> shifts.G)
|
||||
dstRow[x*4+2] = byte(pix >> shifts.B)
|
||||
dstRow[x*4+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
150
client/vnc/server/capture_fb_unix.go
Normal file
150
client/vnc/server/capture_fb_unix.go
Normal file
@@ -0,0 +1,150 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"image"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// FBPoller wraps FBCapturer with the same lifecycle (ClientConnect /
|
||||
// ClientDisconnect, lazy init) as X11Poller, so it slots into the same
|
||||
// session plumbing without code changes upstream. The concrete
|
||||
// FBCapturer is platform-specific (capture_fb_linux.go / _freebsd.go);
|
||||
// this file owns the cross-platform glue.
|
||||
type FBPoller struct {
|
||||
mu sync.Mutex
|
||||
path string
|
||||
capturer *FBCapturer
|
||||
w, h int
|
||||
clients int32
|
||||
}
|
||||
|
||||
// NewFBPoller returns a poller that opens path on first use. Empty path
|
||||
// defaults to /dev/fb0 on Linux and /dev/ttyv0 on FreeBSD.
|
||||
func NewFBPoller(path string) *FBPoller {
|
||||
if path == "" {
|
||||
path = defaultFBPath()
|
||||
}
|
||||
return &FBPoller{path: path}
|
||||
}
|
||||
|
||||
// ClientConnect eagerly initialises the capturer on first connect.
|
||||
func (p *FBPoller) ClientConnect() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.clients++
|
||||
if p.clients == 1 {
|
||||
_ = p.ensureCapturerLocked()
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnect closes the capturer when the last client leaves.
|
||||
func (p *FBPoller) ClientDisconnect() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.clients--
|
||||
if p.clients <= 0 && p.capturer != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the framebuffer width, doing lazy init if needed.
|
||||
func (p *FBPoller) Width() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.w
|
||||
}
|
||||
|
||||
// Height returns the framebuffer height, doing lazy init if needed.
|
||||
func (p *FBPoller) Height() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.h
|
||||
}
|
||||
|
||||
// Capture takes a fresh frame.
|
||||
func (p *FBPoller) Capture() (*image.RGBA, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return p.capturer.Capture()
|
||||
}
|
||||
|
||||
// CaptureInto fills dst directly.
|
||||
func (p *FBPoller) CaptureInto(dst *image.RGBA) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.capturer.CaptureInto(dst)
|
||||
}
|
||||
|
||||
// Close releases all framebuffer resources.
|
||||
func (p *FBPoller) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.capturer != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
}
|
||||
}
|
||||
|
||||
func (p *FBPoller) ensureCapturerLocked() error {
|
||||
if p.capturer != nil {
|
||||
return nil
|
||||
}
|
||||
c, err := NewFBCapturer(p.path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.capturer = c
|
||||
p.w, p.h = c.Width(), c.Height()
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
var _ ScreenCapturer = (*FBPoller)(nil)
|
||||
var _ captureIntoer = (*FBPoller)(nil)
|
||||
|
||||
// swizzleFB24 handles 24-bit packed framebuffers (B,G,R triplets).
|
||||
// Shared between Linux and FreeBSD framebuffer paths.
|
||||
func swizzleFB24(dst []byte, dstStride int, src []byte, srcStride, w, h int) {
|
||||
for y := 0; y < h; y++ {
|
||||
srcRow := src[y*srcStride : y*srcStride+w*3]
|
||||
dstRow := dst[y*dstStride:]
|
||||
for x := 0; x < w; x++ {
|
||||
b := srcRow[x*3+0]
|
||||
g := srcRow[x*3+1]
|
||||
r := srcRow[x*3+2]
|
||||
dstRow[x*4+0] = r
|
||||
dstRow[x*4+1] = g
|
||||
dstRow[x*4+2] = b
|
||||
dstRow[x*4+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// swizzleFB16RGB565 handles 16bpp RGB 565 framebuffers.
|
||||
func swizzleFB16RGB565(dst []byte, dstStride int, src []byte, srcStride, w, h int) {
|
||||
for y := 0; y < h; y++ {
|
||||
srcRow := src[y*srcStride : y*srcStride+w*2]
|
||||
dstRow := dst[y*dstStride:]
|
||||
for x := 0; x < w; x++ {
|
||||
pix := uint16(srcRow[x*2]) | uint16(srcRow[x*2+1])<<8
|
||||
r := byte((pix >> 11) & 0x1f)
|
||||
g := byte((pix >> 5) & 0x3f)
|
||||
b := byte(pix & 0x1f)
|
||||
dstRow[x*4+0] = (r << 3) | (r >> 2)
|
||||
dstRow[x*4+1] = (g << 2) | (g >> 4)
|
||||
dstRow[x*4+2] = (b << 3) | (b >> 2)
|
||||
dstRow[x*4+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
545
client/vnc/server/capture_windows.go
Normal file
545
client/vnc/server/capture_windows.go
Normal file
@@ -0,0 +1,545 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"runtime"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
gdi32 = windows.NewLazySystemDLL("gdi32.dll")
|
||||
user32 = windows.NewLazySystemDLL("user32.dll")
|
||||
|
||||
procGetDC = user32.NewProc("GetDC")
|
||||
procReleaseDC = user32.NewProc("ReleaseDC")
|
||||
procCreateCompatDC = gdi32.NewProc("CreateCompatibleDC")
|
||||
procCreateDIBSection = gdi32.NewProc("CreateDIBSection")
|
||||
procSelectObject = gdi32.NewProc("SelectObject")
|
||||
procDeleteObject = gdi32.NewProc("DeleteObject")
|
||||
procDeleteDC = gdi32.NewProc("DeleteDC")
|
||||
procBitBlt = gdi32.NewProc("BitBlt")
|
||||
procGetSystemMetrics = user32.NewProc("GetSystemMetrics")
|
||||
|
||||
// Desktop switching for service/Session 0 capture.
|
||||
procOpenInputDesktop = user32.NewProc("OpenInputDesktop")
|
||||
procSetThreadDesktop = user32.NewProc("SetThreadDesktop")
|
||||
procCloseDesktop = user32.NewProc("CloseDesktop")
|
||||
procOpenWindowStation = user32.NewProc("OpenWindowStationW")
|
||||
procSetProcessWindowStation = user32.NewProc("SetProcessWindowStation")
|
||||
procCloseWindowStation = user32.NewProc("CloseWindowStation")
|
||||
procGetUserObjectInformationW = user32.NewProc("GetUserObjectInformationW")
|
||||
)
|
||||
|
||||
const uoiName = 2
|
||||
|
||||
const (
|
||||
smCxScreen = 0
|
||||
smCyScreen = 1
|
||||
srccopy = 0x00CC0020
|
||||
captureBlt = 0x40000000
|
||||
dibRgbColors = 0
|
||||
)
|
||||
|
||||
type bitmapInfoHeader struct {
|
||||
Size uint32
|
||||
Width int32
|
||||
Height int32
|
||||
Planes uint16
|
||||
BitCount uint16
|
||||
Compression uint32
|
||||
SizeImage uint32
|
||||
XPelsPerMeter int32
|
||||
YPelsPerMeter int32
|
||||
ClrUsed uint32
|
||||
ClrImportant uint32
|
||||
}
|
||||
|
||||
type bitmapInfo struct {
|
||||
Header bitmapInfoHeader
|
||||
}
|
||||
|
||||
// setupInteractiveWindowStation associates the current process with WinSta0,
|
||||
// the interactive window station. This is required for a SYSTEM service in
|
||||
// Session 0 to call OpenInputDesktop for screen capture and input injection.
|
||||
func setupInteractiveWindowStation() error {
|
||||
name, err := windows.UTF16PtrFromString("WinSta0")
|
||||
if err != nil {
|
||||
return fmt.Errorf("UTF16 WinSta0: %w", err)
|
||||
}
|
||||
hWinSta, _, err := procOpenWindowStation.Call(
|
||||
uintptr(unsafe.Pointer(name)),
|
||||
0,
|
||||
uintptr(windows.MAXIMUM_ALLOWED),
|
||||
)
|
||||
if hWinSta == 0 {
|
||||
return fmt.Errorf("OpenWindowStation(WinSta0): %w", err)
|
||||
}
|
||||
r, _, err := procSetProcessWindowStation.Call(hWinSta)
|
||||
if r == 0 {
|
||||
_, _, _ = procCloseWindowStation.Call(hWinSta)
|
||||
return fmt.Errorf("SetProcessWindowStation: %w", err)
|
||||
}
|
||||
log.Info("process window station set to WinSta0 (interactive)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func screenSize() (int, int) {
|
||||
w, _, _ := procGetSystemMetrics.Call(uintptr(smCxScreen))
|
||||
h, _, _ := procGetSystemMetrics.Call(uintptr(smCyScreen))
|
||||
return int(w), int(h)
|
||||
}
|
||||
|
||||
func getDesktopName(hDesk uintptr) string {
|
||||
var buf [256]uint16
|
||||
var needed uint32
|
||||
_, _, _ = procGetUserObjectInformationW.Call(hDesk, uoiName,
|
||||
uintptr(unsafe.Pointer(&buf[0])), 512,
|
||||
uintptr(unsafe.Pointer(&needed)))
|
||||
return windows.UTF16ToString(buf[:])
|
||||
}
|
||||
|
||||
// switchToInputDesktop opens the desktop currently receiving user input
|
||||
// and sets it as the calling OS thread's desktop. Must be called from a
|
||||
// goroutine locked to its OS thread via runtime.LockOSThread().
|
||||
func switchToInputDesktop() (bool, string) {
|
||||
hDesk, _, _ := procOpenInputDesktop.Call(0, 0, uintptr(windows.MAXIMUM_ALLOWED))
|
||||
if hDesk == 0 {
|
||||
return false, ""
|
||||
}
|
||||
name := getDesktopName(hDesk)
|
||||
ret, _, _ := procSetThreadDesktop.Call(hDesk)
|
||||
_, _, _ = procCloseDesktop.Call(hDesk)
|
||||
return ret != 0, name
|
||||
}
|
||||
|
||||
// gdiCapturer captures the desktop screen using GDI BitBlt.
|
||||
// GDI objects (DC, DIBSection) are allocated once and reused across frames.
|
||||
type gdiCapturer struct {
|
||||
mu sync.Mutex
|
||||
width int
|
||||
height int
|
||||
|
||||
// Pre-allocated GDI resources, reused across captures.
|
||||
memDC uintptr
|
||||
bmp uintptr
|
||||
bits uintptr
|
||||
}
|
||||
|
||||
func newGDICapturer() (*gdiCapturer, error) {
|
||||
w, h := screenSize()
|
||||
if w == 0 || h == 0 {
|
||||
return nil, fmt.Errorf("screen dimensions are zero")
|
||||
}
|
||||
c := &gdiCapturer{width: w, height: h}
|
||||
if err := c.allocGDI(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// allocGDI pre-allocates the compatible DC and DIB section for reuse.
|
||||
func (c *gdiCapturer) allocGDI() error {
|
||||
screenDC, _, _ := procGetDC.Call(0)
|
||||
if screenDC == 0 {
|
||||
return fmt.Errorf("GetDC returned 0")
|
||||
}
|
||||
defer func() { _, _, _ = procReleaseDC.Call(0, screenDC) }()
|
||||
|
||||
memDC, _, _ := procCreateCompatDC.Call(screenDC)
|
||||
if memDC == 0 {
|
||||
return fmt.Errorf("CreateCompatibleDC returned 0")
|
||||
}
|
||||
|
||||
bi := bitmapInfo{
|
||||
Header: bitmapInfoHeader{
|
||||
Size: uint32(unsafe.Sizeof(bitmapInfoHeader{})),
|
||||
Width: int32(c.width),
|
||||
Height: -int32(c.height), // negative = top-down DIB
|
||||
Planes: 1,
|
||||
BitCount: 32,
|
||||
},
|
||||
}
|
||||
|
||||
var bits uintptr
|
||||
bmp, _, _ := procCreateDIBSection.Call(
|
||||
screenDC,
|
||||
uintptr(unsafe.Pointer(&bi)),
|
||||
dibRgbColors,
|
||||
uintptr(unsafe.Pointer(&bits)),
|
||||
0, 0,
|
||||
)
|
||||
if bmp == 0 || bits == 0 {
|
||||
_, _, _ = procDeleteDC.Call(memDC)
|
||||
return fmt.Errorf("CreateDIBSection returned 0")
|
||||
}
|
||||
|
||||
_, _, _ = procSelectObject.Call(memDC, bmp)
|
||||
|
||||
c.memDC = memDC
|
||||
c.bmp = bmp
|
||||
c.bits = bits
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *gdiCapturer) close() { c.freeGDI() }
|
||||
|
||||
// freeGDI releases pre-allocated GDI resources.
|
||||
func (c *gdiCapturer) freeGDI() {
|
||||
if c.bmp != 0 {
|
||||
_, _, _ = procDeleteObject.Call(c.bmp)
|
||||
c.bmp = 0
|
||||
}
|
||||
if c.memDC != 0 {
|
||||
_, _, _ = procDeleteDC.Call(c.memDC)
|
||||
c.memDC = 0
|
||||
}
|
||||
c.bits = 0
|
||||
}
|
||||
|
||||
func (c *gdiCapturer) capture() (*image.RGBA, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.memDC == 0 {
|
||||
return nil, fmt.Errorf("GDI resources not allocated")
|
||||
}
|
||||
|
||||
screenDC, _, _ := procGetDC.Call(0)
|
||||
if screenDC == 0 {
|
||||
return nil, fmt.Errorf("GetDC returned 0")
|
||||
}
|
||||
defer func() { _, _, _ = procReleaseDC.Call(0, screenDC) }()
|
||||
|
||||
// SRCCOPY|CAPTUREBLT: CAPTUREBLT forces inclusion of layered/topmost
|
||||
// windows in the capture and is required for GDI BitBlt to return live
|
||||
// pixels when the session is rendered through RDP / DWM-composited
|
||||
// surfaces. Without it BitBlt reads the backing-store DIB which is
|
||||
// often empty (all-black) on RDP and headless sessions.
|
||||
ret, _, _ := procBitBlt.Call(c.memDC, 0, 0, uintptr(c.width), uintptr(c.height),
|
||||
screenDC, 0, 0, srccopy|captureBlt)
|
||||
if ret == 0 {
|
||||
return nil, fmt.Errorf("BitBlt returned 0")
|
||||
}
|
||||
|
||||
n := c.width * c.height * 4
|
||||
raw := unsafe.Slice((*byte)(unsafe.Pointer(c.bits)), n)
|
||||
|
||||
// GDI gives BGRA, the RFB encoder expects RGBA (img.Pix layout).
|
||||
// Swap R and B in bulk using uint32 operations (one load + mask + shift
|
||||
// per pixel instead of three separate byte assignments).
|
||||
img := image.NewRGBA(image.Rect(0, 0, c.width, c.height))
|
||||
swizzleBGRAtoRGBA(img.Pix, raw)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// DesktopCapturer captures the interactive desktop, handling desktop transitions
|
||||
// (login screen, UAC prompts). A dedicated OS-locked goroutine continuously
|
||||
// captures frames on demand via a dedicated OS-locked goroutine (required
|
||||
// because DXGI's D3D11 device context is not thread-safe). Sessions drive
|
||||
// timing by calling Capture(); a short staleness cache coalesces concurrent
|
||||
// requests. Capture pauses automatically when no clients are connected.
|
||||
type DesktopCapturer struct {
|
||||
mu sync.Mutex
|
||||
w, h int
|
||||
|
||||
// lastFrame/lastAt implement a small staleness cache so multiple
|
||||
// near-simultaneous Capture calls share one DXGI round-trip.
|
||||
lastFrame *image.RGBA
|
||||
lastAt time.Time
|
||||
|
||||
// clients tracks the number of active VNC sessions. When zero, the
|
||||
// worker goroutine releases the underlying capturer.
|
||||
clients atomic.Int32
|
||||
|
||||
// reqCh carries capture requests from sessions to the OS-locked worker.
|
||||
reqCh chan captureReq
|
||||
// wake is signaled when a client connects and the worker should resume.
|
||||
wake chan struct{}
|
||||
// done is closed when Close is called, terminating the worker.
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// captureReq is a single capture request awaiting a reply. Reply channel is
|
||||
// buffered to size 1 so the worker never blocks on a sender that's gone.
|
||||
type captureReq struct {
|
||||
reply chan captureReply
|
||||
}
|
||||
|
||||
type captureReply struct {
|
||||
img *image.RGBA
|
||||
err error
|
||||
}
|
||||
|
||||
// NewDesktopCapturer creates an on-demand capturer for the active desktop.
|
||||
func NewDesktopCapturer() *DesktopCapturer {
|
||||
c := &DesktopCapturer{
|
||||
wake: make(chan struct{}, 1),
|
||||
done: make(chan struct{}),
|
||||
reqCh: make(chan captureReq),
|
||||
}
|
||||
go c.worker()
|
||||
return c
|
||||
}
|
||||
|
||||
// ClientConnect increments the active client count, resuming capture if needed.
|
||||
func (c *DesktopCapturer) ClientConnect() {
|
||||
c.clients.Add(1)
|
||||
select {
|
||||
case c.wake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnect decrements the active client count.
|
||||
func (c *DesktopCapturer) ClientDisconnect() {
|
||||
c.clients.Add(-1)
|
||||
}
|
||||
|
||||
// Close stops the capture loop and releases resources.
|
||||
func (c *DesktopCapturer) Close() {
|
||||
select {
|
||||
case <-c.done:
|
||||
default:
|
||||
close(c.done)
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the current screen width, triggering a capture if the
|
||||
// worker hasn't initialised yet. validateCapturer depends on Width/Height
|
||||
// becoming non-zero promptly after ClientConnect so it doesn't reject
|
||||
// brand-new sessions.
|
||||
func (c *DesktopCapturer) Width() int {
|
||||
c.mu.Lock()
|
||||
w := c.w
|
||||
c.mu.Unlock()
|
||||
if w == 0 && c.clients.Load() > 0 {
|
||||
_, _ = c.Capture()
|
||||
c.mu.Lock()
|
||||
w = c.w
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return w
|
||||
}
|
||||
|
||||
// Height returns the current screen height, triggering a capture if the
|
||||
// worker hasn't initialised yet (see Width). Returns 0 while no client is
|
||||
// connected so callers don't deadlock against a parked worker.
|
||||
func (c *DesktopCapturer) Height() int {
|
||||
c.mu.Lock()
|
||||
h := c.h
|
||||
c.mu.Unlock()
|
||||
if h == 0 && c.clients.Load() > 0 {
|
||||
_, _ = c.Capture()
|
||||
c.mu.Lock()
|
||||
h = c.h
|
||||
c.mu.Unlock()
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// Capture returns a freshly captured frame, serving from a short staleness
|
||||
// cache when multiple sessions ask within freshWindow of each other. All
|
||||
// real DXGI/GDI work happens on the OS-locked worker goroutine.
|
||||
func (c *DesktopCapturer) Capture() (*image.RGBA, error) {
|
||||
c.mu.Lock()
|
||||
if c.lastFrame != nil && time.Since(c.lastAt) < freshWindow {
|
||||
img := c.lastFrame
|
||||
c.mu.Unlock()
|
||||
return img, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
reply := make(chan captureReply, 1)
|
||||
select {
|
||||
case c.reqCh <- captureReq{reply: reply}:
|
||||
case <-c.done:
|
||||
return nil, fmt.Errorf("capturer closed")
|
||||
}
|
||||
select {
|
||||
case r := <-reply:
|
||||
if r.err != nil {
|
||||
return nil, r.err
|
||||
}
|
||||
c.mu.Lock()
|
||||
c.lastFrame = r.img
|
||||
c.lastAt = time.Now()
|
||||
c.mu.Unlock()
|
||||
return r.img, nil
|
||||
case <-c.done:
|
||||
return nil, fmt.Errorf("capturer closed")
|
||||
}
|
||||
}
|
||||
|
||||
// waitForClient blocks until a client connects or the capturer is closed.
|
||||
func (c *DesktopCapturer) waitForClient() bool {
|
||||
if c.clients.Load() > 0 {
|
||||
return true
|
||||
}
|
||||
select {
|
||||
case <-c.wake:
|
||||
return true
|
||||
case <-c.done:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// worker owns DXGI/GDI state on its OS-locked thread and services capture
|
||||
// requests from sessions. No background ticker: a capture happens only when
|
||||
// a session asks for one (throttled by Capture()'s staleness cache).
|
||||
func (c *DesktopCapturer) worker() {
|
||||
runtime.LockOSThread()
|
||||
|
||||
// When running as a Windows service (Session 0), we need to attach to the
|
||||
// interactive window station before OpenInputDesktop will succeed.
|
||||
if err := setupInteractiveWindowStation(); err != nil {
|
||||
log.Warnf("attach to interactive window station: %v", err)
|
||||
}
|
||||
|
||||
w := &captureWorker{c: c}
|
||||
defer w.closeCapturer()
|
||||
|
||||
for {
|
||||
if !c.waitForClient() {
|
||||
return
|
||||
}
|
||||
// Drop the capturer when all clients have disconnected so we don't
|
||||
// hold the DXGI duplication or GDI DC on an idle peer.
|
||||
if c.clients.Load() <= 0 {
|
||||
w.closeCapturer()
|
||||
continue
|
||||
}
|
||||
if !w.handleNextRequest() {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// frameCapturer is the per-backend interface used by the worker. DXGI and
|
||||
// GDI implementations both satisfy it.
|
||||
type frameCapturer interface {
|
||||
capture() (*image.RGBA, error)
|
||||
close()
|
||||
}
|
||||
|
||||
// captureWorker owns the worker goroutine's mutable state. Extracted into a
|
||||
// struct so the request/desktop/init logic can live on small methods and the
|
||||
// outer worker() stays a thin loop.
|
||||
type captureWorker struct {
|
||||
c *DesktopCapturer
|
||||
cap frameCapturer
|
||||
desktopFails int
|
||||
lastDesktop string
|
||||
nextInitRetry time.Time
|
||||
}
|
||||
|
||||
// handleNextRequest waits for either shutdown or a capture request and runs
|
||||
// the request through prepCapturer/capture. Returns false when the worker
|
||||
// should exit.
|
||||
func (w *captureWorker) handleNextRequest() bool {
|
||||
select {
|
||||
case <-w.c.done:
|
||||
return false
|
||||
case req := <-w.c.reqCh:
|
||||
w.serveRequest(req)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func (w *captureWorker) serveRequest(req captureReq) {
|
||||
fc, err := w.prepCapturer()
|
||||
if err != nil {
|
||||
req.reply <- captureReply{err: err}
|
||||
return
|
||||
}
|
||||
img, err := fc.capture()
|
||||
if err != nil {
|
||||
log.Debugf("capture: %v", err)
|
||||
w.closeCapturer()
|
||||
w.nextInitRetry = time.Now().Add(100 * time.Millisecond)
|
||||
req.reply <- captureReply{err: err}
|
||||
return
|
||||
}
|
||||
req.reply <- captureReply{img: img}
|
||||
}
|
||||
|
||||
// prepCapturer switches to the input desktop, handles desktop-change
|
||||
// teardown, and creates the underlying capturer on demand. Backoff state is
|
||||
// tracked across calls via w.nextInitRetry.
|
||||
func (w *captureWorker) prepCapturer() (frameCapturer, error) {
|
||||
if err := w.refreshDesktop(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w.cap != nil {
|
||||
return w.cap, nil
|
||||
}
|
||||
if time.Now().Before(w.nextInitRetry) {
|
||||
return nil, fmt.Errorf("capturer init backing off")
|
||||
}
|
||||
fc, err := w.createCapturer()
|
||||
if err != nil {
|
||||
w.nextInitRetry = time.Now().Add(500 * time.Millisecond)
|
||||
return nil, err
|
||||
}
|
||||
w.cap = fc
|
||||
sw, sh := screenSize()
|
||||
w.c.mu.Lock()
|
||||
w.c.w, w.c.h = sw, sh
|
||||
w.c.mu.Unlock()
|
||||
log.Infof("screen capturer ready: %dx%d", sw, sh)
|
||||
return w.cap, nil
|
||||
}
|
||||
|
||||
// refreshDesktop tracks the active input desktop. When it changes (lock
|
||||
// screen, fast-user-switch) the existing capturer is dropped so the next
|
||||
// call rebuilds one against the new desktop.
|
||||
func (w *captureWorker) refreshDesktop() error {
|
||||
ok, desk := switchToInputDesktop()
|
||||
if !ok {
|
||||
w.desktopFails++
|
||||
if w.desktopFails == 1 || w.desktopFails%100 == 0 {
|
||||
log.Warnf("switchToInputDesktop failed (count=%d), no interactive desktop session?", w.desktopFails)
|
||||
}
|
||||
return fmt.Errorf("no interactive desktop")
|
||||
}
|
||||
if w.desktopFails > 0 {
|
||||
log.Infof("switchToInputDesktop recovered after %d failures, desktop=%q", w.desktopFails, desk)
|
||||
w.desktopFails = 0
|
||||
}
|
||||
if desk != w.lastDesktop {
|
||||
log.Infof("desktop changed: %q -> %q", w.lastDesktop, desk)
|
||||
w.lastDesktop = desk
|
||||
w.closeCapturer()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (w *captureWorker) createCapturer() (frameCapturer, error) {
|
||||
dc, err := newDXGICapturer()
|
||||
if err == nil {
|
||||
log.Info("using DXGI Desktop Duplication for capture")
|
||||
return dc, nil
|
||||
}
|
||||
log.Debugf("DXGI unavailable (%v), falling back to GDI", err)
|
||||
gc, err := newGDICapturer()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
log.Info("using GDI BitBlt for capture")
|
||||
return gc, nil
|
||||
}
|
||||
|
||||
func (w *captureWorker) closeCapturer() {
|
||||
if w.cap != nil {
|
||||
w.cap.close()
|
||||
w.cap = nil
|
||||
}
|
||||
}
|
||||
479
client/vnc/server/capture_x11.go
Normal file
479
client/vnc/server/capture_x11.go
Normal file
@@ -0,0 +1,479 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/jezek/xgb"
|
||||
"github.com/jezek/xgb/xproto"
|
||||
)
|
||||
|
||||
// X11Capturer captures the screen from an X11 display using the MIT-SHM extension.
|
||||
type X11Capturer struct {
|
||||
mu sync.Mutex
|
||||
conn *xgb.Conn
|
||||
screen *xproto.ScreenInfo
|
||||
w, h int
|
||||
shmID int
|
||||
shmAddr []byte
|
||||
shmSeg uint32 // shm.Seg
|
||||
useSHM bool
|
||||
// bufs double-buffers output images so the X11Poller's capture loop can
|
||||
// overwrite one while the session is still encoding the other. Before
|
||||
// this, a single reused buffer would race with the reader. Allocation
|
||||
// happens on first use and on geometry change.
|
||||
bufs [2]*image.RGBA
|
||||
cur int
|
||||
}
|
||||
|
||||
// detectX11Display finds the active X11 display and sets DISPLAY/XAUTHORITY
|
||||
// environment variables if needed. This is required when running as a system
|
||||
// service where these vars aren't set.
|
||||
func detectX11Display() {
|
||||
if os.Getenv("DISPLAY") != "" {
|
||||
return
|
||||
}
|
||||
|
||||
// Try /proc first (Linux), then ps fallback (FreeBSD and others).
|
||||
if detectX11FromProc() {
|
||||
return
|
||||
}
|
||||
if detectX11FromSockets() {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// detectX11FromProc scans /proc/*/cmdline for Xorg (Linux).
|
||||
func detectX11FromProc() bool {
|
||||
entries, err := os.ReadDir("/proc")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
for _, e := range entries {
|
||||
if !e.IsDir() {
|
||||
continue
|
||||
}
|
||||
cmdline, err := os.ReadFile("/proc/" + e.Name() + "/cmdline")
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if display, auth := parseXorgArgs(splitCmdline(cmdline)); display != "" {
|
||||
setDisplayEnv(display, auth)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// detectX11FromSockets checks /tmp/.X11-unix/ for X sockets and uses ps
|
||||
// to find the auth file. Works on FreeBSD and other systems without /proc.
|
||||
func detectX11FromSockets() bool {
|
||||
entries, err := os.ReadDir("/tmp/.X11-unix")
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
|
||||
// Find the lowest display number.
|
||||
for _, e := range entries {
|
||||
name := e.Name()
|
||||
if len(name) < 2 || name[0] != 'X' {
|
||||
continue
|
||||
}
|
||||
display := ":" + name[1:]
|
||||
os.Setenv("DISPLAY", display)
|
||||
log.Infof("auto-detected DISPLAY=%s (from socket)", display)
|
||||
|
||||
// Try to find -auth from ps output.
|
||||
if auth := findXorgAuthFromPS(); auth != "" {
|
||||
os.Setenv("XAUTHORITY", auth)
|
||||
log.Infof("auto-detected XAUTHORITY=%s (from ps)", auth)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// findXorgAuthFromPS runs ps to find Xorg and extract its -auth argument.
|
||||
func findXorgAuthFromPS() string {
|
||||
out, err := exec.Command("ps", "auxww").Output()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
for _, line := range strings.Split(string(out), "\n") {
|
||||
if !strings.Contains(line, "Xorg") && !strings.Contains(line, "/X ") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
for i, f := range fields {
|
||||
if f == "-auth" && i+1 < len(fields) {
|
||||
return fields[i+1]
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func parseXorgArgs(args []string) (display, auth string) {
|
||||
if len(args) == 0 {
|
||||
return "", ""
|
||||
}
|
||||
base := args[0]
|
||||
if !(base == "Xorg" || base == "X" || len(base) > 0 && base[len(base)-1] == 'X' ||
|
||||
strings.Contains(base, "/Xorg") || strings.Contains(base, "/X")) {
|
||||
return "", ""
|
||||
}
|
||||
for i, arg := range args[1:] {
|
||||
if len(arg) > 0 && arg[0] == ':' {
|
||||
display = arg
|
||||
}
|
||||
if arg == "-auth" && i+2 < len(args) {
|
||||
auth = args[i+2]
|
||||
}
|
||||
}
|
||||
return display, auth
|
||||
}
|
||||
|
||||
func setDisplayEnv(display, auth string) {
|
||||
os.Setenv("DISPLAY", display)
|
||||
log.Infof("auto-detected DISPLAY=%s", display)
|
||||
if auth != "" {
|
||||
os.Setenv("XAUTHORITY", auth)
|
||||
log.Infof("auto-detected XAUTHORITY=%s", auth)
|
||||
}
|
||||
}
|
||||
|
||||
func splitCmdline(data []byte) []string {
|
||||
var args []string
|
||||
for _, b := range splitNull(data) {
|
||||
if len(b) > 0 {
|
||||
args = append(args, string(b))
|
||||
}
|
||||
}
|
||||
return args
|
||||
}
|
||||
|
||||
func splitNull(data []byte) [][]byte {
|
||||
var parts [][]byte
|
||||
start := 0
|
||||
for i, b := range data {
|
||||
if b == 0 {
|
||||
parts = append(parts, data[start:i])
|
||||
start = i + 1
|
||||
}
|
||||
}
|
||||
if start < len(data) {
|
||||
parts = append(parts, data[start:])
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
// NewX11Capturer connects to the X11 display and sets up shared memory capture.
|
||||
func NewX11Capturer(display string) (*X11Capturer, error) {
|
||||
if display == "" {
|
||||
detectX11Display()
|
||||
display = os.Getenv("DISPLAY")
|
||||
}
|
||||
if display == "" {
|
||||
return nil, fmt.Errorf("DISPLAY not set and no Xorg process found")
|
||||
}
|
||||
|
||||
conn, err := xgb.NewConnDisplay(display)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to X11 display %s: %w", display, err)
|
||||
}
|
||||
|
||||
setup := xproto.Setup(conn)
|
||||
if len(setup.Roots) == 0 {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("no X11 screens")
|
||||
}
|
||||
screen := setup.Roots[0]
|
||||
|
||||
c := &X11Capturer{
|
||||
conn: conn,
|
||||
screen: &screen,
|
||||
w: int(screen.WidthInPixels),
|
||||
h: int(screen.HeightInPixels),
|
||||
}
|
||||
|
||||
if err := c.initSHM(); err != nil {
|
||||
log.Debugf("X11 SHM not available, using slow GetImage: %v", err)
|
||||
}
|
||||
|
||||
log.Infof("X11 capturer ready: %dx%d (display=%s, shm=%v)", c.w, c.h, display, c.useSHM)
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// initSHM is implemented in capture_x11_shm_linux.go (requires SysV SHM).
|
||||
// On platforms without SysV SHM (FreeBSD), a stub returns an error and
|
||||
// the capturer falls back to GetImage.
|
||||
|
||||
// Width returns the screen width.
|
||||
func (c *X11Capturer) Width() int { return c.w }
|
||||
|
||||
// Height returns the screen height.
|
||||
func (c *X11Capturer) Height() int { return c.h }
|
||||
|
||||
// Capture returns the current screen as an RGBA image.
|
||||
func (c *X11Capturer) Capture() (*image.RGBA, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
if c.useSHM {
|
||||
return c.captureSHM()
|
||||
}
|
||||
return c.captureGetImage()
|
||||
}
|
||||
|
||||
// CaptureInto fills the caller's destination buffer in one pass. The
|
||||
// source path (SHM or fallback GetImage) writes directly into dst.Pix
|
||||
// instead of going through the X11Capturer's internal double-buffer,
|
||||
// saving one full-frame memcpy per capture.
|
||||
func (c *X11Capturer) CaptureInto(dst *image.RGBA) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if dst.Rect.Dx() != c.w || dst.Rect.Dy() != c.h {
|
||||
return fmt.Errorf("dst size mismatch: dst=%dx%d capturer=%dx%d",
|
||||
dst.Rect.Dx(), dst.Rect.Dy(), c.w, c.h)
|
||||
}
|
||||
if c.useSHM {
|
||||
return c.captureSHMInto(dst)
|
||||
}
|
||||
return c.captureGetImageInto(dst)
|
||||
}
|
||||
|
||||
func (c *X11Capturer) captureGetImageInto(dst *image.RGBA) error {
|
||||
cookie := xproto.GetImage(c.conn, xproto.ImageFormatZPixmap,
|
||||
xproto.Drawable(c.screen.Root),
|
||||
0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF)
|
||||
reply, err := cookie.Reply()
|
||||
if err != nil {
|
||||
return fmt.Errorf("GetImage: %w", err)
|
||||
}
|
||||
n := c.w * c.h * 4
|
||||
if len(reply.Data) < n {
|
||||
return fmt.Errorf("GetImage returned %d bytes, expected %d", len(reply.Data), n)
|
||||
}
|
||||
swizzleBGRAtoRGBA(dst.Pix, reply.Data)
|
||||
return nil
|
||||
}
|
||||
|
||||
// captureSHM is implemented in capture_x11_shm_linux.go.
|
||||
|
||||
func (c *X11Capturer) captureGetImage() (*image.RGBA, error) {
|
||||
cookie := xproto.GetImage(c.conn, xproto.ImageFormatZPixmap,
|
||||
xproto.Drawable(c.screen.Root),
|
||||
0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF)
|
||||
|
||||
reply, err := cookie.Reply()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("GetImage: %w", err)
|
||||
}
|
||||
|
||||
data := reply.Data
|
||||
n := c.w * c.h * 4
|
||||
if len(data) < n {
|
||||
return nil, fmt.Errorf("GetImage returned %d bytes, expected %d", len(data), n)
|
||||
}
|
||||
|
||||
img := c.nextBuffer()
|
||||
swizzleBGRAtoRGBA(img.Pix, data)
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// nextBuffer returns the *image.RGBA the next capture should fill, advancing
|
||||
// the double-buffer index. Reallocates on geometry change.
|
||||
func (c *X11Capturer) nextBuffer() *image.RGBA {
|
||||
c.cur ^= 1
|
||||
b := c.bufs[c.cur]
|
||||
if b == nil || b.Rect.Dx() != c.w || b.Rect.Dy() != c.h {
|
||||
b = image.NewRGBA(image.Rect(0, 0, c.w, c.h))
|
||||
c.bufs[c.cur] = b
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// Close releases X11 resources.
|
||||
func (c *X11Capturer) Close() {
|
||||
c.closeSHM()
|
||||
c.conn.Close()
|
||||
}
|
||||
|
||||
// closeSHM is implemented in capture_x11_shm_linux.go.
|
||||
|
||||
// X11Poller wraps X11Capturer with a staleness-cached on-demand Capture:
|
||||
// sessions drive captures themselves through the encoder goroutine, so we
|
||||
// don't need a background ticker. The last result is cached for a short
|
||||
// window so concurrent sessions coalesce into one capture.
|
||||
//
|
||||
// The capturer is allocated lazily on first use and released when all
|
||||
// clients disconnect, so an idle peer holds no X connection or SHM segment.
|
||||
type X11Poller struct {
|
||||
mu sync.Mutex
|
||||
|
||||
capturer *X11Capturer
|
||||
w, h int
|
||||
// closed at Close so callers can stop waiting on retry backoff.
|
||||
done chan struct{}
|
||||
|
||||
// lastFrame/lastAt implement a small cache: multiple near-simultaneous
|
||||
// Capture calls (multi-client, or input-coalesced) return the same
|
||||
// frame instead of hammering the X server.
|
||||
lastFrame *image.RGBA
|
||||
lastAt time.Time
|
||||
|
||||
// initBackoffUntil throttles capturer re-init when the X server is
|
||||
// unavailable or flapping.
|
||||
initBackoffUntil time.Time
|
||||
|
||||
clients atomic.Int32
|
||||
display string
|
||||
}
|
||||
|
||||
// initRetryBackoff gates capturer re-init attempts after a failure so we
|
||||
// don't spin on X server errors.
|
||||
const initRetryBackoff = 2 * time.Second
|
||||
|
||||
// NewX11Poller creates a lazy on-demand capturer for the given X display.
|
||||
func NewX11Poller(display string) *X11Poller {
|
||||
return &X11Poller{
|
||||
display: display,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// ClientConnect increments the active client count. The first client triggers
|
||||
// eager capturer initialisation so that the first FBUpdateRequest doesn't
|
||||
// pay the X11 connect + SHM attach latency.
|
||||
func (p *X11Poller) ClientConnect() {
|
||||
if p.clients.Add(1) == 1 {
|
||||
p.mu.Lock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// ClientDisconnect decrements the active client count. On the last
|
||||
// disconnect we close the underlying capturer so idle peers cost nothing.
|
||||
func (p *X11Poller) ClientDisconnect() {
|
||||
if p.clients.Add(-1) == 0 {
|
||||
p.mu.Lock()
|
||||
if p.capturer != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
p.lastFrame = nil
|
||||
}
|
||||
p.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases all resources. Subsequent Capture calls will fail.
|
||||
func (p *X11Poller) Close() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
select {
|
||||
case <-p.done:
|
||||
default:
|
||||
close(p.done)
|
||||
}
|
||||
if p.capturer != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Width returns the screen width. Triggers lazy init if needed.
|
||||
func (p *X11Poller) Width() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.w
|
||||
}
|
||||
|
||||
// Height returns the screen height. Triggers lazy init if needed.
|
||||
func (p *X11Poller) Height() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
_ = p.ensureCapturerLocked()
|
||||
return p.h
|
||||
}
|
||||
|
||||
// Capture returns a fresh frame, serving from the short-lived cache if a
|
||||
// previous caller captured within freshWindow.
|
||||
func (p *X11Poller) Capture() (*image.RGBA, error) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if p.lastFrame != nil && time.Since(p.lastAt) < freshWindow {
|
||||
return p.lastFrame, nil
|
||||
}
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img, err := p.capturer.Capture()
|
||||
if err != nil {
|
||||
// Drop the capturer so the next call re-inits; the X connection may
|
||||
// have died (e.g. Xorg restart).
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
p.initBackoffUntil = time.Now().Add(initRetryBackoff)
|
||||
return nil, fmt.Errorf("x11 capture: %w", err)
|
||||
}
|
||||
p.lastFrame = img
|
||||
p.lastAt = time.Now()
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// CaptureInto fills dst directly via the underlying capturer, bypassing
|
||||
// the freshness cache. The session's prevFrame/curFrame swap means each
|
||||
// session needs its own buffer anyway, so caching wouldn't help.
|
||||
func (p *X11Poller) CaptureInto(dst *image.RGBA) error {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
if err := p.ensureCapturerLocked(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := p.capturer.CaptureInto(dst); err != nil {
|
||||
p.capturer.Close()
|
||||
p.capturer = nil
|
||||
p.initBackoffUntil = time.Now().Add(initRetryBackoff)
|
||||
return fmt.Errorf("x11 capture: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ensureCapturerLocked initialises the underlying X11Capturer if not
|
||||
// already open. Caller must hold p.mu.
|
||||
func (p *X11Poller) ensureCapturerLocked() error {
|
||||
if p.capturer != nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-p.done:
|
||||
return fmt.Errorf("x11 capturer closed")
|
||||
default:
|
||||
}
|
||||
if time.Now().Before(p.initBackoffUntil) {
|
||||
return fmt.Errorf("x11 capturer unavailable (retry scheduled)")
|
||||
}
|
||||
c, err := NewX11Capturer(p.display)
|
||||
if err != nil {
|
||||
p.initBackoffUntil = time.Now().Add(initRetryBackoff)
|
||||
log.Debugf("X11 capturer: %v", err)
|
||||
return err
|
||||
}
|
||||
p.capturer = c
|
||||
p.w, p.h = c.Width(), c.Height()
|
||||
return nil
|
||||
}
|
||||
96
client/vnc/server/capture_x11_shm_linux.go
Normal file
96
client/vnc/server/capture_x11_shm_linux.go
Normal file
@@ -0,0 +1,96 @@
|
||||
//go:build linux && !android
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
|
||||
"github.com/jezek/xgb/shm"
|
||||
"github.com/jezek/xgb/xproto"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
func (c *X11Capturer) initSHM() error {
|
||||
if err := shm.Init(c.conn); err != nil {
|
||||
return fmt.Errorf("init SHM extension: %w", err)
|
||||
}
|
||||
|
||||
size := c.w * c.h * 4
|
||||
id, err := unix.SysvShmGet(unix.IPC_PRIVATE, size, unix.IPC_CREAT|0600)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shmget: %w", err)
|
||||
}
|
||||
|
||||
addr, err := unix.SysvShmAttach(id, 0, 0)
|
||||
if err != nil {
|
||||
if _, ctlErr := unix.SysvShmCtl(id, unix.IPC_RMID, nil); ctlErr != nil {
|
||||
log.Debugf("shmctl IPC_RMID on attach failure: %v", ctlErr)
|
||||
}
|
||||
return fmt.Errorf("shmat: %w", err)
|
||||
}
|
||||
|
||||
if _, err := unix.SysvShmCtl(id, unix.IPC_RMID, nil); err != nil {
|
||||
log.Debugf("shmctl IPC_RMID: %v", err)
|
||||
}
|
||||
|
||||
seg, err := shm.NewSegId(c.conn)
|
||||
if err != nil {
|
||||
if detachErr := unix.SysvShmDetach(addr); detachErr != nil {
|
||||
log.Debugf("shmdt on new-seg failure: %v", detachErr)
|
||||
}
|
||||
return fmt.Errorf("new SHM seg: %w", err)
|
||||
}
|
||||
|
||||
if err := shm.AttachChecked(c.conn, seg, uint32(id), false).Check(); err != nil {
|
||||
if detachErr := unix.SysvShmDetach(addr); detachErr != nil {
|
||||
log.Debugf("shmdt on attach-checked failure: %v", detachErr)
|
||||
}
|
||||
return fmt.Errorf("SHM attach to X: %w", err)
|
||||
}
|
||||
|
||||
c.shmID = id
|
||||
c.shmAddr = addr
|
||||
c.shmSeg = uint32(seg)
|
||||
c.useSHM = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *X11Capturer) captureSHM() (*image.RGBA, error) {
|
||||
if err := c.fillSHM(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
img := c.nextBuffer()
|
||||
swizzleBGRAtoRGBA(img.Pix, c.shmAddr[:c.w*c.h*4])
|
||||
return img, nil
|
||||
}
|
||||
|
||||
// captureSHMInto runs a single SHM GetImage and swizzles directly into the
|
||||
// caller-provided destination, skipping the internal double-buffer.
|
||||
func (c *X11Capturer) captureSHMInto(dst *image.RGBA) error {
|
||||
if err := c.fillSHM(); err != nil {
|
||||
return err
|
||||
}
|
||||
swizzleBGRAtoRGBA(dst.Pix, c.shmAddr[:c.w*c.h*4])
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *X11Capturer) fillSHM() error {
|
||||
cookie := shm.GetImage(c.conn, xproto.Drawable(c.screen.Root),
|
||||
0, 0, uint16(c.w), uint16(c.h), 0xFFFFFFFF,
|
||||
xproto.ImageFormatZPixmap, shm.Seg(c.shmSeg), 0)
|
||||
if _, err := cookie.Reply(); err != nil {
|
||||
return fmt.Errorf("SHM GetImage: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *X11Capturer) closeSHM() {
|
||||
if c.useSHM {
|
||||
shm.Detach(c.conn, shm.Seg(c.shmSeg))
|
||||
if err := unix.SysvShmDetach(c.shmAddr); err != nil {
|
||||
log.Debugf("shmdt on close: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
client/vnc/server/capture_x11_shm_stub.go
Normal file
24
client/vnc/server/capture_x11_shm_stub.go
Normal file
@@ -0,0 +1,24 @@
|
||||
//go:build freebsd
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
)
|
||||
|
||||
func (c *X11Capturer) initSHM() error {
|
||||
return fmt.Errorf("SysV SHM not available on this platform")
|
||||
}
|
||||
|
||||
func (c *X11Capturer) captureSHM() (*image.RGBA, error) {
|
||||
return nil, fmt.Errorf("SHM capture not available on this platform")
|
||||
}
|
||||
|
||||
func (c *X11Capturer) captureSHMInto(_ *image.RGBA) error {
|
||||
return fmt.Errorf("SHM capture not available on this platform")
|
||||
}
|
||||
|
||||
func (c *X11Capturer) closeSHM() {
|
||||
// no SHM to close on this platform
|
||||
}
|
||||
75
client/vnc/server/coalesce_test.go
Normal file
75
client/vnc/server/coalesce_test.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCoalesceRects(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
in [][4]int
|
||||
want [][4]int
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
in: nil,
|
||||
want: nil,
|
||||
},
|
||||
{
|
||||
name: "single",
|
||||
in: [][4]int{{0, 0, 64, 64}},
|
||||
want: [][4]int{{0, 0, 64, 64}},
|
||||
},
|
||||
{
|
||||
name: "horizontal_run",
|
||||
in: [][4]int{{0, 0, 64, 64}, {64, 0, 64, 64}, {128, 0, 64, 64}},
|
||||
want: [][4]int{{0, 0, 192, 64}},
|
||||
},
|
||||
{
|
||||
name: "vertical_run",
|
||||
in: [][4]int{{0, 0, 64, 64}, {0, 64, 64, 64}, {0, 128, 64, 64}},
|
||||
want: [][4]int{{0, 0, 64, 192}},
|
||||
},
|
||||
{
|
||||
name: "block_2x2",
|
||||
in: [][4]int{
|
||||
{0, 0, 64, 64}, {64, 0, 64, 64},
|
||||
{0, 64, 64, 64}, {64, 64, 64, 64},
|
||||
},
|
||||
want: [][4]int{{0, 0, 128, 128}},
|
||||
},
|
||||
{
|
||||
name: "no_merge_gap",
|
||||
in: [][4]int{{0, 0, 64, 64}, {192, 0, 64, 64}},
|
||||
want: [][4]int{{0, 0, 64, 64}, {192, 0, 64, 64}},
|
||||
},
|
||||
{
|
||||
name: "two_disjoint_columns",
|
||||
in: [][4]int{
|
||||
{0, 0, 64, 64}, {192, 0, 64, 64},
|
||||
{0, 64, 64, 64}, {192, 64, 64, 64},
|
||||
},
|
||||
want: [][4]int{{0, 0, 64, 128}, {192, 0, 64, 128}},
|
||||
},
|
||||
{
|
||||
name: "misaligned_widths_no_vertical_merge",
|
||||
in: [][4]int{
|
||||
{0, 0, 128, 64},
|
||||
{0, 64, 64, 64},
|
||||
},
|
||||
want: [][4]int{{0, 0, 128, 64}, {0, 64, 64, 64}},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := coalesceRects(tc.in)
|
||||
if len(got) == 0 && len(tc.want) == 0 {
|
||||
return
|
||||
}
|
||||
if !reflect.DeepEqual(got, tc.want) {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
188
client/vnc/server/hextile_test.go
Normal file
188
client/vnc/server/hextile_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"image"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// roundTrip decodes an encoded Hextile rect back into pixels and checks it
|
||||
// matches the source. Implements just enough of the noVNC Hextile decoder
|
||||
// to validate our encoder.
|
||||
func decodeHextile(t *testing.T, buf []byte, pf clientPixelFormat) *image.RGBA {
|
||||
t.Helper()
|
||||
if len(buf) < 12 {
|
||||
t.Fatalf("buf too short: %d", len(buf))
|
||||
}
|
||||
x := int(uint16(buf[0])<<8 | uint16(buf[1]))
|
||||
y := int(uint16(buf[2])<<8 | uint16(buf[3]))
|
||||
w := int(uint16(buf[4])<<8 | uint16(buf[5]))
|
||||
h := int(uint16(buf[6])<<8 | uint16(buf[7]))
|
||||
enc := uint32(buf[8])<<24 | uint32(buf[9])<<16 | uint32(buf[10])<<8 | uint32(buf[11])
|
||||
if enc != encHextile {
|
||||
t.Fatalf("not hextile: %d", enc)
|
||||
}
|
||||
body := buf[12:]
|
||||
bytesPerPixel := max(int(pf.bpp)/8, 1)
|
||||
out := image.NewRGBA(image.Rect(x, y, x+w, y+h))
|
||||
|
||||
var bg, fg [3]byte
|
||||
pos := 0
|
||||
readPixel := func() [3]byte {
|
||||
var v uint32
|
||||
if pf.bigEndian != 0 {
|
||||
for i := 0; i < bytesPerPixel; i++ {
|
||||
v |= uint32(body[pos+i]) << (8 * (bytesPerPixel - 1 - i))
|
||||
}
|
||||
} else {
|
||||
for i := 0; i < bytesPerPixel; i++ {
|
||||
v |= uint32(body[pos+i]) << (8 * i)
|
||||
}
|
||||
}
|
||||
pos += bytesPerPixel
|
||||
r := byte((v >> pf.rShift) & uint32(pf.rMax))
|
||||
g := byte((v >> pf.gShift) & uint32(pf.gMax))
|
||||
b := byte((v >> pf.bShift) & uint32(pf.bMax))
|
||||
return [3]byte{r, g, b}
|
||||
}
|
||||
for sy := 0; sy < h; sy += hextileSubSize {
|
||||
sh := min(hextileSubSize, h-sy)
|
||||
for sx := 0; sx < w; sx += hextileSubSize {
|
||||
sw := min(hextileSubSize, w-sx)
|
||||
flags := body[pos]
|
||||
pos++
|
||||
if flags&hextileRaw != 0 {
|
||||
for ry := 0; ry < sh; ry++ {
|
||||
for rx := 0; rx < sw; rx++ {
|
||||
px := readPixel()
|
||||
i := (sy+ry)*out.Stride + (sx+rx)*4
|
||||
out.Pix[i+0] = px[0]
|
||||
out.Pix[i+1] = px[1]
|
||||
out.Pix[i+2] = px[2]
|
||||
out.Pix[i+3] = 0xff
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
if flags&hextileBackgroundSpecified != 0 {
|
||||
bg = readPixel()
|
||||
}
|
||||
if flags&hextileForegroundSpecified != 0 {
|
||||
fg = readPixel()
|
||||
}
|
||||
// Fill sub-tile with bg.
|
||||
for ry := 0; ry < sh; ry++ {
|
||||
for rx := 0; rx < sw; rx++ {
|
||||
i := (sy+ry)*out.Stride + (sx+rx)*4
|
||||
out.Pix[i+0] = bg[0]
|
||||
out.Pix[i+1] = bg[1]
|
||||
out.Pix[i+2] = bg[2]
|
||||
out.Pix[i+3] = 0xff
|
||||
}
|
||||
}
|
||||
if flags&hextileAnySubrects == 0 {
|
||||
continue
|
||||
}
|
||||
n := int(body[pos])
|
||||
pos++
|
||||
for k := 0; k < n; k++ {
|
||||
color := fg
|
||||
if flags&hextileSubrectsColoured != 0 {
|
||||
color = readPixel()
|
||||
}
|
||||
xy := body[pos]
|
||||
wh := body[pos+1]
|
||||
pos += 2
|
||||
rxr := int(xy >> 4)
|
||||
ryr := int(xy & 0x0f)
|
||||
rwr := int(wh>>4) + 1
|
||||
rhr := int(wh&0x0f) + 1
|
||||
for ry := 0; ry < rhr; ry++ {
|
||||
for rx := 0; rx < rwr; rx++ {
|
||||
i := (sy+ryr+ry)*out.Stride + (sx+rxr+rx)*4
|
||||
out.Pix[i+0] = color[0]
|
||||
out.Pix[i+1] = color[1]
|
||||
out.Pix[i+2] = color[2]
|
||||
out.Pix[i+3] = 0xff
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func makeUniformImage(w, h int, r, g, b byte) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
for i := 0; i < len(img.Pix); i += 4 {
|
||||
img.Pix[i+0] = r
|
||||
img.Pix[i+1] = g
|
||||
img.Pix[i+2] = b
|
||||
img.Pix[i+3] = 0xff
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func makeTwoColorImage(w, h int) *image.RGBA {
|
||||
img := makeUniformImage(w, h, 0x10, 0x20, 0x30)
|
||||
// Draw a vertical bar of fg in the middle.
|
||||
fg := [3]byte{0xa0, 0xb0, 0xc0}
|
||||
for y := 0; y < h; y++ {
|
||||
for x := w / 4; x < w/2; x++ {
|
||||
i := y*img.Stride + x*4
|
||||
img.Pix[i+0] = fg[0]
|
||||
img.Pix[i+1] = fg[1]
|
||||
img.Pix[i+2] = fg[2]
|
||||
}
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func compareImages(t *testing.T, want, got *image.RGBA) {
|
||||
t.Helper()
|
||||
if want.Rect != got.Rect {
|
||||
t.Fatalf("rect mismatch: %v vs %v", want.Rect, got.Rect)
|
||||
}
|
||||
w, h := want.Rect.Dx(), want.Rect.Dy()
|
||||
for y := 0; y < h; y++ {
|
||||
for x := 0; x < w; x++ {
|
||||
i := y*want.Stride + x*4
|
||||
j := y*got.Stride + x*4
|
||||
if want.Pix[i] != got.Pix[j] || want.Pix[i+1] != got.Pix[j+1] || want.Pix[i+2] != got.Pix[j+2] {
|
||||
t.Fatalf("pixel mismatch at (%d,%d): want %v got %v",
|
||||
x, y, want.Pix[i:i+3], got.Pix[j:j+3])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodeHextileRect_Uniform(t *testing.T) {
|
||||
pf := defaultClientPixelFormat()
|
||||
img := makeUniformImage(64, 64, 0x33, 0x66, 0x99)
|
||||
buf := encodeHextileRect(img, pf, 0, 0, 64, 64)
|
||||
got := decodeHextile(t, buf, pf)
|
||||
compareImages(t, img, got)
|
||||
}
|
||||
|
||||
func TestEncodeHextileRect_TwoColor(t *testing.T) {
|
||||
pf := defaultClientPixelFormat()
|
||||
img := makeTwoColorImage(64, 64)
|
||||
buf := encodeHextileRect(img, pf, 0, 0, 64, 64)
|
||||
got := decodeHextile(t, buf, pf)
|
||||
compareImages(t, img, got)
|
||||
}
|
||||
|
||||
func TestEncodeHextileRect_Multicolor(t *testing.T) {
|
||||
pf := defaultClientPixelFormat()
|
||||
img := makeBenchImage(64, 64, 42)
|
||||
buf := encodeHextileRect(img, pf, 0, 0, 64, 64)
|
||||
got := decodeHextile(t, buf, pf)
|
||||
compareImages(t, img, got)
|
||||
}
|
||||
|
||||
func TestEncodeHextileRect_NonAligned(t *testing.T) {
|
||||
pf := defaultClientPixelFormat()
|
||||
img := makeTwoColorImage(50, 33) // not a multiple of 16
|
||||
buf := encodeHextileRect(img, pf, 0, 0, 50, 33)
|
||||
got := decodeHextile(t, buf, pf)
|
||||
compareImages(t, img, got)
|
||||
}
|
||||
613
client/vnc/server/input_darwin.go
Normal file
613
client/vnc/server/input_darwin.go
Normal file
@@ -0,0 +1,613 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/ebitengine/purego"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// Core Graphics event constants.
|
||||
const (
|
||||
kCGEventSourceStateCombinedSessionState int32 = 0
|
||||
|
||||
kCGEventLeftMouseDown int32 = 1
|
||||
kCGEventLeftMouseUp int32 = 2
|
||||
kCGEventRightMouseDown int32 = 3
|
||||
kCGEventRightMouseUp int32 = 4
|
||||
kCGEventMouseMoved int32 = 5
|
||||
kCGEventLeftMouseDragged int32 = 6
|
||||
kCGEventRightMouseDragged int32 = 7
|
||||
kCGEventKeyDown int32 = 10
|
||||
kCGEventKeyUp int32 = 11
|
||||
kCGEventOtherMouseDown int32 = 25
|
||||
kCGEventOtherMouseUp int32 = 26
|
||||
|
||||
kCGMouseButtonLeft int32 = 0
|
||||
kCGMouseButtonRight int32 = 1
|
||||
kCGMouseButtonCenter int32 = 2
|
||||
|
||||
kCGHIDEventTap int32 = 0
|
||||
|
||||
// IOKit power management constants.
|
||||
kIOPMUserActiveLocal int32 = 0
|
||||
kIOPMAssertionLevelOn uint32 = 255
|
||||
kCFStringEncodingUTF8 uint32 = 0x08000100
|
||||
)
|
||||
|
||||
var darwinInputOnce sync.Once
|
||||
|
||||
var (
|
||||
cgEventSourceCreate func(int32) uintptr
|
||||
cgEventCreateKeyboardEvent func(uintptr, uint16, bool) uintptr
|
||||
// CGEventCreateMouseEvent takes CGPoint as two separate float64 args.
|
||||
// purego can't handle array/struct types but individual float64s work.
|
||||
cgEventCreateMouseEvent func(uintptr, int32, float64, float64, int32) uintptr
|
||||
cgEventPost func(int32, uintptr)
|
||||
|
||||
// CGEventCreateScrollWheelEvent is variadic, call via SyscallN.
|
||||
cgEventCreateScrollWheelEventAddr uintptr
|
||||
|
||||
axIsProcessTrusted func() bool
|
||||
|
||||
// IOKit power-management bindings used to wake the display and inhibit
|
||||
// idle sleep while a VNC client is driving input.
|
||||
iopmAssertionDeclareUserActivity func(uintptr, int32, *uint32) int32
|
||||
iopmAssertionCreateWithName func(uintptr, uint32, uintptr, *uint32) int32
|
||||
iopmAssertionRelease func(uint32) int32
|
||||
cfStringCreateWithCString func(uintptr, string, uint32) uintptr
|
||||
|
||||
// Cached CFStrings for assertion name and idle-sleep type.
|
||||
pmAssertionNameCFStr uintptr
|
||||
pmPreventIdleDisplayCFStr uintptr
|
||||
|
||||
// Assertion IDs. userActivityID is reused across input events so repeated
|
||||
// calls refresh the same assertion rather than create new ones.
|
||||
pmMu sync.Mutex
|
||||
userActivityID uint32
|
||||
preventSleepID uint32
|
||||
preventSleepHeld bool
|
||||
preventSleepRef int // refcount across concurrent injectors/sessions
|
||||
|
||||
darwinInputReady bool
|
||||
darwinEventSource uintptr
|
||||
)
|
||||
|
||||
func initDarwinInput() {
|
||||
darwinInputOnce.Do(func() {
|
||||
cg, err := purego.Dlopen("/System/Library/Frameworks/CoreGraphics.framework/CoreGraphics", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreGraphics for input: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
purego.RegisterLibFunc(&cgEventSourceCreate, cg, "CGEventSourceCreate")
|
||||
purego.RegisterLibFunc(&cgEventCreateKeyboardEvent, cg, "CGEventCreateKeyboardEvent")
|
||||
purego.RegisterLibFunc(&cgEventCreateMouseEvent, cg, "CGEventCreateMouseEvent")
|
||||
purego.RegisterLibFunc(&cgEventPost, cg, "CGEventPost")
|
||||
|
||||
sym, err := purego.Dlsym(cg, "CGEventCreateScrollWheelEvent")
|
||||
if err == nil {
|
||||
cgEventCreateScrollWheelEventAddr = sym
|
||||
}
|
||||
|
||||
if ax, err := purego.Dlopen("/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices", purego.RTLD_NOW|purego.RTLD_GLOBAL); err == nil {
|
||||
if sym, err := purego.Dlsym(ax, "AXIsProcessTrusted"); err == nil {
|
||||
purego.RegisterFunc(&axIsProcessTrusted, sym)
|
||||
}
|
||||
}
|
||||
|
||||
initPowerAssertions()
|
||||
|
||||
darwinInputReady = true
|
||||
})
|
||||
}
|
||||
|
||||
func initPowerAssertions() {
|
||||
iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load IOKit: %v", err)
|
||||
return
|
||||
}
|
||||
cf, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_GLOBAL)
|
||||
if err != nil {
|
||||
log.Debugf("load CoreFoundation for power assertions: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
purego.RegisterLibFunc(&cfStringCreateWithCString, cf, "CFStringCreateWithCString")
|
||||
purego.RegisterLibFunc(&iopmAssertionDeclareUserActivity, iokit, "IOPMAssertionDeclareUserActivity")
|
||||
purego.RegisterLibFunc(&iopmAssertionCreateWithName, iokit, "IOPMAssertionCreateWithName")
|
||||
purego.RegisterLibFunc(&iopmAssertionRelease, iokit, "IOPMAssertionRelease")
|
||||
|
||||
pmAssertionNameCFStr = cfStringCreateWithCString(0, "NetBird VNC input", kCFStringEncodingUTF8)
|
||||
pmPreventIdleDisplayCFStr = cfStringCreateWithCString(0, "PreventUserIdleDisplaySleep", kCFStringEncodingUTF8)
|
||||
}
|
||||
|
||||
// wakeDisplay declares user activity so macOS treats the synthesized input as
|
||||
// real HID activity, waking the display if it is asleep. Called on every key
|
||||
// and pointer event; the kernel coalesces repeated calls cheaply.
|
||||
func wakeDisplay() {
|
||||
if iopmAssertionDeclareUserActivity == nil || pmAssertionNameCFStr == 0 {
|
||||
return
|
||||
}
|
||||
pmMu.Lock()
|
||||
defer pmMu.Unlock()
|
||||
id := userActivityID
|
||||
r := iopmAssertionDeclareUserActivity(pmAssertionNameCFStr, kIOPMUserActiveLocal, &id)
|
||||
if r != 0 {
|
||||
log.Tracef("IOPMAssertionDeclareUserActivity returned %d", r)
|
||||
return
|
||||
}
|
||||
userActivityID = id
|
||||
}
|
||||
|
||||
// holdPreventIdleSleep creates an assertion that keeps the display from going
|
||||
// idle-to-sleep while a VNC session is active. Reference-counted so multiple
|
||||
// concurrent sessions don't yank the assertion when one of them releases.
|
||||
func holdPreventIdleSleep() {
|
||||
if iopmAssertionCreateWithName == nil || pmPreventIdleDisplayCFStr == 0 || pmAssertionNameCFStr == 0 {
|
||||
return
|
||||
}
|
||||
pmMu.Lock()
|
||||
defer pmMu.Unlock()
|
||||
preventSleepRef++
|
||||
if preventSleepRef > 1 {
|
||||
return
|
||||
}
|
||||
var id uint32
|
||||
r := iopmAssertionCreateWithName(pmPreventIdleDisplayCFStr, kIOPMAssertionLevelOn, pmAssertionNameCFStr, &id)
|
||||
if r != 0 {
|
||||
log.Debugf("IOPMAssertionCreateWithName returned %d", r)
|
||||
// Reset the refcount on failure so a later successful hold can take it.
|
||||
preventSleepRef = 0
|
||||
return
|
||||
}
|
||||
preventSleepID = id
|
||||
preventSleepHeld = true
|
||||
}
|
||||
|
||||
// releasePreventIdleSleep decrements the assertion refcount and only drops
|
||||
// the actual IOKit assertion on the final release.
|
||||
func releasePreventIdleSleep() {
|
||||
if iopmAssertionRelease == nil {
|
||||
return
|
||||
}
|
||||
pmMu.Lock()
|
||||
defer pmMu.Unlock()
|
||||
if !preventSleepHeld || preventSleepRef == 0 {
|
||||
return
|
||||
}
|
||||
preventSleepRef--
|
||||
if preventSleepRef > 0 {
|
||||
return
|
||||
}
|
||||
if r := iopmAssertionRelease(preventSleepID); r != 0 {
|
||||
log.Debugf("IOPMAssertionRelease returned %d", r)
|
||||
}
|
||||
preventSleepHeld = false
|
||||
preventSleepID = 0
|
||||
}
|
||||
|
||||
func ensureEventSource() uintptr {
|
||||
if darwinEventSource != 0 {
|
||||
return darwinEventSource
|
||||
}
|
||||
darwinEventSource = cgEventSourceCreate(kCGEventSourceStateCombinedSessionState)
|
||||
return darwinEventSource
|
||||
}
|
||||
|
||||
// MacInputInjector injects keyboard and mouse events via Core Graphics.
|
||||
type MacInputInjector struct {
|
||||
lastButtons uint8
|
||||
pbcopyPath string
|
||||
pbpastePath string
|
||||
}
|
||||
|
||||
// NewMacInputInjector creates a macOS input injector.
|
||||
func NewMacInputInjector() (*MacInputInjector, error) {
|
||||
initDarwinInput()
|
||||
if !darwinInputReady {
|
||||
return nil, fmt.Errorf("CoreGraphics not available for input injection")
|
||||
}
|
||||
checkMacPermissions()
|
||||
|
||||
m := &MacInputInjector{}
|
||||
if path, err := exec.LookPath("pbcopy"); err == nil {
|
||||
m.pbcopyPath = path
|
||||
}
|
||||
if path, err := exec.LookPath("pbpaste"); err == nil {
|
||||
m.pbpastePath = path
|
||||
}
|
||||
if m.pbcopyPath == "" || m.pbpastePath == "" {
|
||||
log.Debugf("clipboard tools not found (pbcopy=%q, pbpaste=%q)", m.pbcopyPath, m.pbpastePath)
|
||||
}
|
||||
|
||||
holdPreventIdleSleep()
|
||||
|
||||
log.Info("macOS input injector ready")
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// checkMacPermissions warns and opens the Privacy pane if Accessibility is
|
||||
// missing. Uses AXIsProcessTrusted which returns immediately; the previous
|
||||
// osascript probe blocked for 120s (AppleEvent timeout) when access was
|
||||
// denied, which delayed VNC server startup past client deadlines.
|
||||
func checkMacPermissions() {
|
||||
if axIsProcessTrusted != nil && !axIsProcessTrusted() {
|
||||
openPrivacyPane("Privacy_Accessibility")
|
||||
log.Warn("Accessibility permission not granted. Input injection will not work. " +
|
||||
"Opened System Settings > Privacy & Security > Accessibility; enable netbird.")
|
||||
}
|
||||
|
||||
log.Info("Screen Recording permission is required for screen capture. " +
|
||||
"If the screen appears black, grant in System Settings > Privacy & Security > Screen Recording.")
|
||||
}
|
||||
|
||||
// openPrivacyPane opens the given Privacy pane in System Settings so the user
|
||||
// can toggle the permission without navigating manually.
|
||||
func openPrivacyPane(pane string) {
|
||||
url := "x-apple.systempreferences:com.apple.preference.security?" + pane
|
||||
if err := exec.Command("open", url).Start(); err != nil {
|
||||
log.Debugf("open privacy pane %s: %v", pane, err)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectKey simulates a key press or release.
|
||||
func (m *MacInputInjector) InjectKey(keysym uint32, down bool) {
|
||||
wakeDisplay()
|
||||
src := ensureEventSource()
|
||||
if src == 0 {
|
||||
return
|
||||
}
|
||||
keycode := keysymToMacKeycode(keysym)
|
||||
if keycode == 0xFFFF {
|
||||
return
|
||||
}
|
||||
event := cgEventCreateKeyboardEvent(src, keycode, down)
|
||||
if event == 0 {
|
||||
return
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, event)
|
||||
cfRelease(event)
|
||||
}
|
||||
|
||||
// InjectPointer simulates mouse movement and button events.
|
||||
func (m *MacInputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serverH int) {
|
||||
wakeDisplay()
|
||||
if serverW == 0 || serverH == 0 {
|
||||
return
|
||||
}
|
||||
src := ensureEventSource()
|
||||
if src == 0 {
|
||||
return
|
||||
}
|
||||
x, y := scalePxToLogical(px, py, serverW, serverH)
|
||||
m.dispatchPointer(src, buttonMask, x, y)
|
||||
m.lastButtons = buttonMask
|
||||
}
|
||||
|
||||
// scalePxToLogical converts framebuffer coordinates (physical pixels) into
|
||||
// the logical points CGEventCreateMouseEvent expects. Falls back to a 1:1
|
||||
// mapping if the display API is unavailable.
|
||||
func scalePxToLogical(px, py, serverW, serverH int) (float64, float64) {
|
||||
x, y := float64(px), float64(py)
|
||||
if cgDisplayPixelsWide == nil || cgMainDisplayID == nil {
|
||||
return x, y
|
||||
}
|
||||
displayID := cgMainDisplayID()
|
||||
logicalW := int(cgDisplayPixelsWide(displayID))
|
||||
logicalH := int(cgDisplayPixelsHigh(displayID))
|
||||
if logicalW <= 0 || logicalH <= 0 {
|
||||
return x, y
|
||||
}
|
||||
return float64(px) * float64(logicalW) / float64(serverW),
|
||||
float64(py) * float64(logicalH) / float64(serverH)
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) dispatchPointer(src uintptr, buttonMask uint8, x, y float64) {
|
||||
leftDown := buttonMask&0x01 != 0
|
||||
rightDown := buttonMask&0x04 != 0
|
||||
middleDown := buttonMask&0x02 != 0
|
||||
m.postMoveOrDrag(src, leftDown, rightDown, x, y)
|
||||
m.postButtonTransitions(src, buttonMask, x, y)
|
||||
m.postScrollWheel(src, buttonMask)
|
||||
_ = middleDown
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) postMoveOrDrag(src uintptr, leftDown, rightDown bool, x, y float64) {
|
||||
switch {
|
||||
case leftDown:
|
||||
m.postMouse(src, kCGEventLeftMouseDragged, x, y, kCGMouseButtonLeft)
|
||||
case rightDown:
|
||||
m.postMouse(src, kCGEventRightMouseDragged, x, y, kCGMouseButtonRight)
|
||||
default:
|
||||
m.postMouse(src, kCGEventMouseMoved, x, y, kCGMouseButtonLeft)
|
||||
}
|
||||
}
|
||||
|
||||
// postButtonTransitions emits the up/down events for each button whose state
|
||||
// changed against m.lastButtons.
|
||||
func (m *MacInputInjector) postButtonTransitions(src uintptr, buttonMask uint8, x, y float64) {
|
||||
emit := func(curBit, prevBit uint8, down, up int32, button int32) {
|
||||
cur := buttonMask&curBit != 0
|
||||
prev := m.lastButtons&prevBit != 0
|
||||
if cur && !prev {
|
||||
m.postMouse(src, down, x, y, button)
|
||||
} else if !cur && prev {
|
||||
m.postMouse(src, up, x, y, button)
|
||||
}
|
||||
}
|
||||
emit(0x01, 0x01, kCGEventLeftMouseDown, kCGEventLeftMouseUp, kCGMouseButtonLeft)
|
||||
emit(0x04, 0x04, kCGEventRightMouseDown, kCGEventRightMouseUp, kCGMouseButtonRight)
|
||||
emit(0x02, 0x02, kCGEventOtherMouseDown, kCGEventOtherMouseUp, kCGMouseButtonCenter)
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) postScrollWheel(src uintptr, buttonMask uint8) {
|
||||
if buttonMask&0x08 != 0 {
|
||||
m.postScroll(src, 3)
|
||||
}
|
||||
if buttonMask&0x10 != 0 {
|
||||
m.postScroll(src, -3)
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) postMouse(src uintptr, eventType int32, x, y float64, button int32) {
|
||||
if cgEventCreateMouseEvent == nil {
|
||||
return
|
||||
}
|
||||
event := cgEventCreateMouseEvent(src, eventType, x, y, button)
|
||||
if event == 0 {
|
||||
return
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, event)
|
||||
cfRelease(event)
|
||||
}
|
||||
|
||||
func (m *MacInputInjector) postScroll(src uintptr, deltaY int32) {
|
||||
if cgEventCreateScrollWheelEventAddr == 0 {
|
||||
return
|
||||
}
|
||||
// CGEventCreateScrollWheelEvent(source, units, wheelCount, wheel1delta)
|
||||
// units=0 (pixel), wheelCount=1, wheel1delta=deltaY
|
||||
// Variadic C function: pass args as uintptr via SyscallN.
|
||||
r1, _, _ := purego.SyscallN(cgEventCreateScrollWheelEventAddr,
|
||||
src, 0, 1, uintptr(uint32(deltaY)))
|
||||
if r1 == 0 {
|
||||
return
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, r1)
|
||||
cfRelease(r1)
|
||||
}
|
||||
|
||||
// SetClipboard sets the macOS clipboard using pbcopy.
|
||||
func (m *MacInputInjector) SetClipboard(text string) {
|
||||
if m.pbcopyPath == "" {
|
||||
return
|
||||
}
|
||||
cmd := exec.Command(m.pbcopyPath)
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Tracef("set clipboard via pbcopy: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TypeText synthesizes the given text as keystrokes via Core Graphics.
|
||||
// Used by the dashboard's Paste button so the host clipboard reaches
|
||||
// the focused remote app even when the app doesn't honor pbpaste-style
|
||||
// clipboard sync (e.g. login screens, locked-down apps). ASCII printable
|
||||
// runes only; others are skipped.
|
||||
func (m *MacInputInjector) TypeText(text string) {
|
||||
wakeDisplay()
|
||||
src := ensureEventSource()
|
||||
if src == 0 {
|
||||
return
|
||||
}
|
||||
const maxChars = 4096
|
||||
count := 0
|
||||
for _, r := range text {
|
||||
if count >= maxChars {
|
||||
break
|
||||
}
|
||||
count++
|
||||
typeRune(src, r)
|
||||
}
|
||||
}
|
||||
|
||||
// typeRune emits the press/release events for a single ASCII rune, framing
|
||||
// the keystroke with Shift-down/up when required by the keysym.
|
||||
func typeRune(src uintptr, r rune) {
|
||||
const shiftKey = uint16(0x38) // kVK_Shift
|
||||
keysym, shift, ok := keysymForASCIIRune(r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
keycode := keysymToMacKeycode(keysym)
|
||||
if keycode == 0xFFFF {
|
||||
return
|
||||
}
|
||||
if shift {
|
||||
postKey(src, shiftKey, true)
|
||||
}
|
||||
postKey(src, keycode, true)
|
||||
postKey(src, keycode, false)
|
||||
if shift {
|
||||
postKey(src, shiftKey, false)
|
||||
}
|
||||
}
|
||||
|
||||
func postKey(src uintptr, keycode uint16, down bool) {
|
||||
e := cgEventCreateKeyboardEvent(src, keycode, down)
|
||||
if e == 0 {
|
||||
return
|
||||
}
|
||||
cgEventPost(kCGHIDEventTap, e)
|
||||
cfRelease(e)
|
||||
}
|
||||
|
||||
// GetClipboard reads the macOS clipboard using pbpaste.
|
||||
func (m *MacInputInjector) GetClipboard() string {
|
||||
if m.pbpastePath == "" {
|
||||
return ""
|
||||
}
|
||||
out, err := exec.Command(m.pbpastePath).Output()
|
||||
if err != nil {
|
||||
log.Tracef("get clipboard via pbpaste: %v", err)
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// Close releases the idle-sleep assertion held for the injector's lifetime.
|
||||
func (m *MacInputInjector) Close() {
|
||||
releasePreventIdleSleep()
|
||||
}
|
||||
|
||||
func keysymToMacKeycode(keysym uint32) uint16 {
|
||||
if keysym >= 0x61 && keysym <= 0x7a {
|
||||
return asciiToMacKey[keysym-0x61]
|
||||
}
|
||||
if keysym >= 0x41 && keysym <= 0x5a {
|
||||
return asciiToMacKey[keysym-0x41]
|
||||
}
|
||||
if keysym >= 0x30 && keysym <= 0x39 {
|
||||
return digitToMacKey[keysym-0x30]
|
||||
}
|
||||
if code, ok := specialKeyMap[keysym]; ok {
|
||||
return code
|
||||
}
|
||||
return 0xFFFF
|
||||
}
|
||||
|
||||
var asciiToMacKey = [26]uint16{
|
||||
0x00, 0x0B, 0x08, 0x02, 0x0E, 0x03, 0x05, 0x04,
|
||||
0x22, 0x26, 0x28, 0x25, 0x2E, 0x2D, 0x1F, 0x23,
|
||||
0x0C, 0x0F, 0x01, 0x11, 0x20, 0x09, 0x0D, 0x07,
|
||||
0x10, 0x06,
|
||||
}
|
||||
|
||||
var digitToMacKey = [10]uint16{
|
||||
0x1D, 0x12, 0x13, 0x14, 0x15, 0x17, 0x16, 0x1A, 0x1C, 0x19,
|
||||
}
|
||||
|
||||
var specialKeyMap = map[uint32]uint16{
|
||||
// Whitespace and editing
|
||||
0x0020: 0x31, // space
|
||||
0xff08: 0x33, // BackSpace
|
||||
0xff09: 0x30, // Tab
|
||||
0xff0d: 0x24, // Return
|
||||
0xff1b: 0x35, // Escape
|
||||
0xffff: 0x75, // Delete (forward)
|
||||
|
||||
// Navigation
|
||||
0xff50: 0x73, // Home
|
||||
0xff51: 0x7B, // Left
|
||||
0xff52: 0x7E, // Up
|
||||
0xff53: 0x7C, // Right
|
||||
0xff54: 0x7D, // Down
|
||||
0xff55: 0x74, // Page_Up
|
||||
0xff56: 0x79, // Page_Down
|
||||
0xff57: 0x77, // End
|
||||
0xff63: 0x72, // Insert (Help on Mac)
|
||||
|
||||
// Modifiers
|
||||
0xffe1: 0x38, // Shift_L
|
||||
0xffe2: 0x3C, // Shift_R
|
||||
0xffe3: 0x3B, // Control_L
|
||||
0xffe4: 0x3E, // Control_R
|
||||
0xffe5: 0x39, // Caps_Lock
|
||||
0xffe9: 0x3A, // Alt_L (Option)
|
||||
0xffea: 0x3D, // Alt_R (Option)
|
||||
0xffe7: 0x37, // Meta_L (Command)
|
||||
0xffe8: 0x36, // Meta_R (Command)
|
||||
0xffeb: 0x37, // Super_L (Command) - noVNC sends this
|
||||
0xffec: 0x36, // Super_R (Command)
|
||||
|
||||
// Mode_switch / ISO_Level3_Shift (sent by noVNC for macOS Option remap)
|
||||
0xff7e: 0x3A, // Mode_switch -> Option
|
||||
0xfe03: 0x3D, // ISO_Level3_Shift -> Right Option
|
||||
|
||||
// Function keys
|
||||
0xffbe: 0x7A, // F1
|
||||
0xffbf: 0x78, // F2
|
||||
0xffc0: 0x63, // F3
|
||||
0xffc1: 0x76, // F4
|
||||
0xffc2: 0x60, // F5
|
||||
0xffc3: 0x61, // F6
|
||||
0xffc4: 0x62, // F7
|
||||
0xffc5: 0x64, // F8
|
||||
0xffc6: 0x65, // F9
|
||||
0xffc7: 0x6D, // F10
|
||||
0xffc8: 0x67, // F11
|
||||
0xffc9: 0x6F, // F12
|
||||
0xffca: 0x69, // F13
|
||||
0xffcb: 0x6B, // F14
|
||||
0xffcc: 0x71, // F15
|
||||
0xffcd: 0x6A, // F16
|
||||
0xffce: 0x40, // F17
|
||||
0xffcf: 0x4F, // F18
|
||||
0xffd0: 0x50, // F19
|
||||
0xffd1: 0x5A, // F20
|
||||
|
||||
// Punctuation (US keyboard layout, keysym = ASCII code)
|
||||
0x002d: 0x1B, // minus -
|
||||
0x003d: 0x18, // equal =
|
||||
0x005b: 0x21, // bracketleft [
|
||||
0x005d: 0x1E, // bracketright ]
|
||||
0x005c: 0x2A, // backslash
|
||||
0x003b: 0x29, // semicolon ;
|
||||
0x0027: 0x27, // apostrophe '
|
||||
0x0060: 0x32, // grave `
|
||||
0x002c: 0x2B, // comma ,
|
||||
0x002e: 0x2F, // period .
|
||||
0x002f: 0x2C, // slash /
|
||||
|
||||
// Shifted punctuation (noVNC sends these as separate keysyms)
|
||||
0x005f: 0x1B, // underscore _ (shift+minus)
|
||||
0x002b: 0x18, // plus + (shift+equal)
|
||||
0x007b: 0x21, // braceleft { (shift+[)
|
||||
0x007d: 0x1E, // braceright } (shift+])
|
||||
0x007c: 0x2A, // bar | (shift+\)
|
||||
0x003a: 0x29, // colon : (shift+;)
|
||||
0x0022: 0x27, // quotedbl " (shift+')
|
||||
0x007e: 0x32, // tilde ~ (shift+`)
|
||||
0x003c: 0x2B, // less < (shift+,)
|
||||
0x003e: 0x2F, // greater > (shift+.)
|
||||
0x003f: 0x2C, // question ? (shift+/)
|
||||
0x0021: 0x12, // exclam ! (shift+1)
|
||||
0x0040: 0x13, // at @ (shift+2)
|
||||
0x0023: 0x14, // numbersign # (shift+3)
|
||||
0x0024: 0x15, // dollar $ (shift+4)
|
||||
0x0025: 0x17, // percent % (shift+5)
|
||||
0x005e: 0x16, // asciicircum ^ (shift+6)
|
||||
0x0026: 0x1A, // ampersand & (shift+7)
|
||||
0x002a: 0x1C, // asterisk * (shift+8)
|
||||
0x0028: 0x19, // parenleft ( (shift+9)
|
||||
0x0029: 0x1D, // parenright ) (shift+0)
|
||||
|
||||
// Numpad
|
||||
0xffb0: 0x52, // KP_0
|
||||
0xffb1: 0x53, // KP_1
|
||||
0xffb2: 0x54, // KP_2
|
||||
0xffb3: 0x55, // KP_3
|
||||
0xffb4: 0x56, // KP_4
|
||||
0xffb5: 0x57, // KP_5
|
||||
0xffb6: 0x58, // KP_6
|
||||
0xffb7: 0x59, // KP_7
|
||||
0xffb8: 0x5B, // KP_8
|
||||
0xffb9: 0x5C, // KP_9
|
||||
0xffae: 0x41, // KP_Decimal
|
||||
0xffaa: 0x43, // KP_Multiply
|
||||
0xffab: 0x45, // KP_Add
|
||||
0xffad: 0x4E, // KP_Subtract
|
||||
0xffaf: 0x4B, // KP_Divide
|
||||
0xff8d: 0x4C, // KP_Enter
|
||||
0xffbd: 0x51, // KP_Equal
|
||||
}
|
||||
|
||||
var _ InputInjector = (*MacInputInjector)(nil)
|
||||
500
client/vnc/server/input_uinput_unix.go
Normal file
500
client/vnc/server/input_uinput_unix.go
Normal file
@@ -0,0 +1,500 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/unix"
|
||||
)
|
||||
|
||||
// /dev/uinput ioctl numbers. Computed from the kernel _IO/_IOW macros so
|
||||
// we don't depend on cgo. UINPUT_IOCTL_BASE = 'U' = 0x55.
|
||||
const (
|
||||
uiDevCreate = 0x5501
|
||||
uiDevDestroy = 0x5502
|
||||
// _IOW('U', 3, struct uinput_setup); uinput_setup is 92 bytes on amd64.
|
||||
uiDevSetup = (1 << 30) | (92 << 16) | (0x55 << 8) | 3
|
||||
uiSetEvBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 100
|
||||
uiSetKeyBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 101
|
||||
uiSetAbsBit = (1 << 30) | (4 << 16) | (0x55 << 8) | 103
|
||||
uinputAbsSize = 64 // legacy struct uses absmin/absmax/absfuzz/absflat[64].
|
||||
)
|
||||
|
||||
// Linux input event types and key codes (linux/input-event-codes.h).
|
||||
const (
|
||||
evSyn = 0x00
|
||||
evKey = 0x01
|
||||
evAbs = 0x03
|
||||
evRep = 0x14
|
||||
|
||||
synReport = 0
|
||||
|
||||
absX = 0x00
|
||||
absY = 0x01
|
||||
|
||||
btnLeft = 0x110
|
||||
btnRight = 0x111
|
||||
btnMiddle = 0x112
|
||||
)
|
||||
|
||||
// inputEvent matches struct input_event for x86_64 (timeval is 16 bytes).
|
||||
// Total size 24 bytes; Go's natural alignment matches the kernel layout.
|
||||
type inputEvent struct {
|
||||
TvSec int64
|
||||
TvUsec int64
|
||||
Type uint16
|
||||
Code uint16
|
||||
Value int32
|
||||
}
|
||||
|
||||
// UInputInjector synthesizes keyboard and mouse events via /dev/uinput.
|
||||
// Used as a fallback when X11 isn't running, e.g. at the kernel console
|
||||
// or pre-login screen on a server without X. Requires root or
|
||||
// CAP_SYS_ADMIN, which the netbird service has.
|
||||
type UInputInjector struct {
|
||||
mu sync.Mutex
|
||||
fd int
|
||||
closeOnce sync.Once
|
||||
keysymToKey map[uint32]uint16
|
||||
prevButtons uint8
|
||||
screenW int
|
||||
screenH int
|
||||
}
|
||||
|
||||
// NewUInputInjector opens /dev/uinput and registers a virtual keyboard +
|
||||
// absolute pointer device sized to (w, h). The dimensions are needed
|
||||
// because uinput's ABS axes don't autoscale; we always send absolute
|
||||
// coordinates and let the kernel route them to the right monitor.
|
||||
func NewUInputInjector(w, h int) (*UInputInjector, error) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return nil, fmt.Errorf("invalid screen size: %dx%d", w, h)
|
||||
}
|
||||
fd, err := unix.Open("/dev/uinput", unix.O_WRONLY|unix.O_NONBLOCK, 0)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open /dev/uinput: %w", err)
|
||||
}
|
||||
|
||||
if err := setBit(fd, uiSetEvBit, evKey); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if err := setBit(fd, uiSetEvBit, evAbs); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if err := setBit(fd, uiSetEvBit, evSyn); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
// Advertise key auto-repeat so the kernel input core repeats held
|
||||
// keys at the configured rate (default ~250 ms delay, ~33 ms period).
|
||||
// Without this, holding Backspace etc. only deletes one character.
|
||||
if err := setBit(fd, uiSetEvBit, evRep); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
keymap := buildUInputKeymap()
|
||||
for _, key := range keymap {
|
||||
if err := setBit(fd, uiSetKeyBit, uint32(key)); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("UI_SET_KEYBIT %d: %w", key, err)
|
||||
}
|
||||
}
|
||||
for _, btn := range []uint16{btnLeft, btnRight, btnMiddle} {
|
||||
if err := setBit(fd, uiSetKeyBit, uint32(btn)); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("UI_SET_KEYBIT btn %d: %w", btn, err)
|
||||
}
|
||||
}
|
||||
if err := setBit(fd, uiSetAbsBit, absX); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if err := setBit(fd, uiSetAbsBit, absY); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := writeUInputUserDev(fd, w, h); err != nil {
|
||||
unix.Close(fd)
|
||||
return nil, err
|
||||
}
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), uiDevCreate, 0); e != 0 {
|
||||
unix.Close(fd)
|
||||
return nil, fmt.Errorf("UI_DEV_CREATE: %v", e)
|
||||
}
|
||||
// Give udev a moment to settle before sending events.
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
inj := &UInputInjector{
|
||||
fd: fd,
|
||||
keysymToKey: keymapByKeysym(keymap),
|
||||
screenW: w,
|
||||
screenH: h,
|
||||
}
|
||||
log.Infof("uinput injector ready: %dx%d, %d keys", w, h, len(inj.keysymToKey))
|
||||
return inj, nil
|
||||
}
|
||||
|
||||
func setBit(fd int, op uintptr, code uint32) error {
|
||||
if _, _, e := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), op, uintptr(code)); e != 0 {
|
||||
return fmt.Errorf("ioctl 0x%x %d: %v", op, code, e)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeUInputUserDev uses the legacy uinput_user_dev path (write the
|
||||
// whole struct then UI_DEV_CREATE) which is universally supported on
|
||||
// older and current kernels alike. uinput_user_dev is name(80) + id(8) +
|
||||
// ff_effects_max(4) + absmax/absmin/absfuzz/absflat[64] = 92 + 4*64*4 =
|
||||
// 1116 bytes total.
|
||||
func writeUInputUserDev(fd, w, h int) error {
|
||||
const sz = 80 + 8 + 4 + uinputAbsSize*4*4
|
||||
buf := make([]byte, sz)
|
||||
copy(buf[0:80], []byte("netbird-vnc-uinput"))
|
||||
// id: BUS_VIRTUAL=0x06, vendor=0x0001, product=0x0001, version=1.
|
||||
binary.LittleEndian.PutUint16(buf[80:82], 0x06)
|
||||
binary.LittleEndian.PutUint16(buf[82:84], 0x0001)
|
||||
binary.LittleEndian.PutUint16(buf[84:86], 0x0001)
|
||||
binary.LittleEndian.PutUint16(buf[86:88], 0x0001)
|
||||
// ff_effects_max(4) at 88..92 stays zero.
|
||||
// absmax[64] at 92..348: set absX/absY.
|
||||
absmaxOff := 80 + 8 + 4
|
||||
absminOff := absmaxOff + uinputAbsSize*4
|
||||
binary.LittleEndian.PutUint32(buf[absmaxOff+absX*4:], uint32(w-1))
|
||||
binary.LittleEndian.PutUint32(buf[absmaxOff+absY*4:], uint32(h-1))
|
||||
binary.LittleEndian.PutUint32(buf[absminOff+absX*4:], 0)
|
||||
binary.LittleEndian.PutUint32(buf[absminOff+absY*4:], 0)
|
||||
if _, err := unix.Write(fd, buf); err != nil {
|
||||
return fmt.Errorf("write uinput_user_dev: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// emit writes a single input_event to the device. Caller-locked.
|
||||
func (u *UInputInjector) emit(typ, code uint16, value int32) error {
|
||||
ev := inputEvent{Type: typ, Code: code, Value: value}
|
||||
buf := (*[unsafe.Sizeof(inputEvent{})]byte)(unsafe.Pointer(&ev))[:]
|
||||
_, err := unix.Write(u.fd, buf)
|
||||
return err
|
||||
}
|
||||
|
||||
func (u *UInputInjector) sync() {
|
||||
_ = u.emit(evSyn, synReport, 0)
|
||||
}
|
||||
|
||||
// InjectKey synthesizes a press or release for the given X11 keysym.
|
||||
func (u *UInputInjector) InjectKey(keysym uint32, down bool) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
code, ok := u.keysymToKey[keysym]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
value := int32(0)
|
||||
if down {
|
||||
value = 1
|
||||
}
|
||||
if err := u.emit(evKey, code, value); err != nil {
|
||||
log.Tracef("uinput emit key: %v", err)
|
||||
return
|
||||
}
|
||||
u.sync()
|
||||
}
|
||||
|
||||
// InjectPointer moves the absolute pointer and presses/releases buttons
|
||||
// based on the RFB button mask delta against the previous mask.
|
||||
func (u *UInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH int) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if serverW <= 1 || serverH <= 1 {
|
||||
return
|
||||
}
|
||||
absXVal := int32(x * (u.screenW - 1) / (serverW - 1))
|
||||
absYVal := int32(y * (u.screenH - 1) / (serverH - 1))
|
||||
_ = u.emit(evAbs, absX, absXVal)
|
||||
_ = u.emit(evAbs, absY, absYVal)
|
||||
|
||||
type btnMap struct {
|
||||
bit uint8
|
||||
key uint16
|
||||
}
|
||||
for _, b := range []btnMap{
|
||||
{0x01, btnLeft},
|
||||
{0x02, btnMiddle},
|
||||
{0x04, btnRight},
|
||||
} {
|
||||
pressed := buttonMask&b.bit != 0
|
||||
was := u.prevButtons&b.bit != 0
|
||||
if pressed && !was {
|
||||
_ = u.emit(evKey, b.key, 1)
|
||||
} else if !pressed && was {
|
||||
_ = u.emit(evKey, b.key, 0)
|
||||
}
|
||||
}
|
||||
u.prevButtons = buttonMask
|
||||
u.sync()
|
||||
}
|
||||
|
||||
// SetClipboard is a no-op on the framebuffer console: there is no system
|
||||
// clipboard daemon. Use TypeText (Paste button) to deliver host text.
|
||||
func (u *UInputInjector) SetClipboard(_ string) {
|
||||
// no system clipboard daemon on framebuffer console
|
||||
}
|
||||
|
||||
// GetClipboard returns empty: no clipboard outside X11/Wayland.
|
||||
func (u *UInputInjector) GetClipboard() string { return "" }
|
||||
|
||||
// TypeText synthesizes the given UTF-8 text as keystrokes. Only ASCII
|
||||
// printable characters and newline are typed; other runes are skipped.
|
||||
// This drives the "paste" button: with no console clipboard available,
|
||||
// keystroke-by-keystroke entry is the only way to deliver a password to
|
||||
// a TTY login prompt.
|
||||
func (u *UInputInjector) TypeText(text string) {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
const maxChars = 4096
|
||||
count := 0
|
||||
for _, r := range text {
|
||||
if count >= maxChars {
|
||||
break
|
||||
}
|
||||
count++
|
||||
code, shift, ok := keyForRune(r)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if shift {
|
||||
_ = u.emit(evKey, keyLeftShift, 1)
|
||||
}
|
||||
_ = u.emit(evKey, code, 1)
|
||||
_ = u.emit(evKey, code, 0)
|
||||
if shift {
|
||||
_ = u.emit(evKey, keyLeftShift, 0)
|
||||
}
|
||||
u.sync()
|
||||
}
|
||||
}
|
||||
|
||||
// Close destroys the virtual uinput device and closes the file descriptor.
|
||||
func (u *UInputInjector) Close() {
|
||||
u.closeOnce.Do(func() {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
if u.fd >= 0 {
|
||||
_, _, _ = unix.Syscall(unix.SYS_IOCTL, uintptr(u.fd), uiDevDestroy, 0)
|
||||
_ = unix.Close(u.fd)
|
||||
u.fd = -1
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Linux KEY_* codes for the small set we care about.
|
||||
const (
|
||||
keyEsc = 1
|
||||
keyMinus = 12
|
||||
keyEqual = 13
|
||||
keyBackspace = 14
|
||||
keyTab = 15
|
||||
keyEnter = 28
|
||||
keyLeftCtrl = 29
|
||||
keySemicolon = 39
|
||||
keyApostrophe = 40
|
||||
keyGrave = 41
|
||||
keyLeftShift = 42
|
||||
keyBackslash = 43
|
||||
keyComma = 51
|
||||
keyDot = 52
|
||||
keySlash = 53
|
||||
keyRightShift = 54
|
||||
keyLeftAlt = 56
|
||||
keySpace = 57
|
||||
keyCapsLock = 58
|
||||
keyF1 = 59
|
||||
keyLeftBracket = 26
|
||||
keyRightBracket = 27
|
||||
keyHome = 102
|
||||
keyUp = 103
|
||||
keyPageUp = 104
|
||||
keyLeft = 105
|
||||
keyRight = 106
|
||||
keyEnd = 107
|
||||
keyDown = 108
|
||||
keyPageDown = 109
|
||||
keyInsert = 110
|
||||
keyDelete = 111
|
||||
keyRightCtrl = 97
|
||||
keyRightAlt = 100
|
||||
keyLeftMeta = 125
|
||||
keyRightMeta = 126
|
||||
)
|
||||
|
||||
// buildUInputKeymap returns every linux KEY_ code we want the virtual
|
||||
// device to advertise during UI_SET_KEYBIT. Order doesn't matter.
|
||||
func buildUInputKeymap() []uint16 {
|
||||
out := make([]uint16, 0, 128)
|
||||
// Letters: KEY_A=30, KEY_B=48, etc; not a clean range. The kernel's
|
||||
// row-by-row layout is qwertyuiop / asdfghjkl / zxcvbnm.
|
||||
letters := []uint16{
|
||||
30, 48, 46, 32, 18, 33, 34, 35, 23, 36, 37, 38, 50, // a..m
|
||||
49, 24, 25, 16, 19, 31, 20, 22, 47, 17, 45, 21, 44, // n..z
|
||||
}
|
||||
out = append(out, letters...)
|
||||
// Top-row digits: KEY_1..KEY_0 = 2..11.
|
||||
for i := uint16(2); i <= 11; i++ {
|
||||
out = append(out, i)
|
||||
}
|
||||
// Function keys F1..F12 = 59..68 + 87, 88. We only register F1..F12
|
||||
// which the kernel header enumerates as a contiguous block.
|
||||
for i := uint16(59); i <= 68; i++ {
|
||||
out = append(out, i)
|
||||
}
|
||||
out = append(out, 87, 88)
|
||||
out = append(out, []uint16{
|
||||
keyEsc, keyMinus, keyEqual, keyBackspace, keyTab, keyEnter,
|
||||
keyLeftCtrl, keyRightCtrl, keyLeftShift, keyRightShift,
|
||||
keyLeftAlt, keyRightAlt, keyLeftMeta, keyRightMeta,
|
||||
keySpace, keyCapsLock,
|
||||
keyLeftBracket, keyRightBracket, keyBackslash,
|
||||
keySemicolon, keyApostrophe, keyGrave,
|
||||
keyComma, keyDot, keySlash,
|
||||
keyHome, keyEnd, keyPageUp, keyPageDown,
|
||||
keyUp, keyDown, keyLeft, keyRight,
|
||||
keyInsert, keyDelete,
|
||||
}...)
|
||||
return out
|
||||
}
|
||||
|
||||
// keymapByKeysym maps X11 keysyms (the values our session receives over
|
||||
// RFB) onto Linux KEY_ codes. Shifted ASCII keysyms (uppercase letters,
|
||||
// "!@#..." etc.) map to the same scan code as their unshifted twin: the
|
||||
// client also sends a separate Shift keysym (0xffe1), so the kernel
|
||||
// composes the final character from the held modifier + scan code.
|
||||
func keymapByKeysym(_ []uint16) map[uint32]uint16 {
|
||||
letters := map[rune]uint16{
|
||||
'a': 30, 'b': 48, 'c': 46, 'd': 32, 'e': 18, 'f': 33, 'g': 34,
|
||||
'h': 35, 'i': 23, 'j': 36, 'k': 37, 'l': 38, 'm': 50,
|
||||
'n': 49, 'o': 24, 'p': 25, 'q': 16, 'r': 19, 's': 31, 't': 20,
|
||||
'u': 22, 'v': 47, 'w': 17, 'x': 45, 'y': 21, 'z': 44,
|
||||
}
|
||||
m := map[uint32]uint16{
|
||||
// Digits.
|
||||
'0': 11, '1': 2, '2': 3, '3': 4, '4': 5, '5': 6, '6': 7,
|
||||
'7': 8, '8': 9, '9': 10,
|
||||
// Shifted digits (US layout).
|
||||
')': 11, '!': 2, '@': 3, '#': 4, '$': 5, '%': 6, '^': 7,
|
||||
'&': 8, '*': 9, '(': 10,
|
||||
// Punctuation (US layout) and shifted twins.
|
||||
' ': keySpace,
|
||||
'-': keyMinus, '_': keyMinus,
|
||||
'=': keyEqual, '+': keyEqual,
|
||||
'[': keyLeftBracket, '{': keyLeftBracket,
|
||||
']': keyRightBracket, '}': keyRightBracket,
|
||||
'\\': keyBackslash, '|': keyBackslash,
|
||||
';': keySemicolon, ':': keySemicolon,
|
||||
'\'': keyApostrophe, '"': keyApostrophe,
|
||||
'`': keyGrave, '~': keyGrave,
|
||||
',': keyComma, '<': keyComma,
|
||||
'.': keyDot, '>': keyDot,
|
||||
'/': keySlash, '?': keySlash,
|
||||
// Special keys (X11 keysyms).
|
||||
0xff08: keyBackspace, 0xff09: keyTab, 0xff0d: keyEnter,
|
||||
0xff1b: keyEsc, 0xffff: keyDelete,
|
||||
0xff50: keyHome, 0xff57: keyEnd,
|
||||
0xff51: keyLeft, 0xff52: keyUp, 0xff53: keyRight, 0xff54: keyDown,
|
||||
0xff55: keyPageUp, 0xff56: keyPageDown, 0xff63: keyInsert,
|
||||
0xffe1: keyLeftShift, 0xffe2: keyRightShift,
|
||||
0xffe3: keyLeftCtrl, 0xffe4: keyRightCtrl,
|
||||
0xffe9: keyLeftAlt, 0xffea: keyRightAlt,
|
||||
0xffeb: keyLeftMeta, 0xffec: keyRightMeta,
|
||||
}
|
||||
// Letters: register both lowercase and uppercase keysyms onto the same
|
||||
// KEY_ code. The client sends Shift separately for uppercase.
|
||||
for r, code := range letters {
|
||||
m[uint32(r)] = code
|
||||
m[uint32(r-'a'+'A')] = code
|
||||
}
|
||||
// Function keys F1..F12 (X11 keysyms 0xffbe..0xffc9 → KEY_F1..KEY_F12).
|
||||
xF := uint32(0xffbe)
|
||||
codes := []uint16{59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 87, 88}
|
||||
for i, c := range codes {
|
||||
m[xF+uint32(i)] = c
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// keyForRune maps a printable rune to (keycode, needsShift). Used by
|
||||
// TypeText to synthesize keystrokes for a paste payload.
|
||||
func keyForRune(r rune) (uint16, bool, bool) {
|
||||
if r >= 'a' && r <= 'z' {
|
||||
m := map[rune]uint16{
|
||||
'a': 30, 'b': 48, 'c': 46, 'd': 32, 'e': 18, 'f': 33, 'g': 34,
|
||||
'h': 35, 'i': 23, 'j': 36, 'k': 37, 'l': 38, 'm': 50,
|
||||
'n': 49, 'o': 24, 'p': 25, 'q': 16, 'r': 19, 's': 31, 't': 20,
|
||||
'u': 22, 'v': 47, 'w': 17, 'x': 45, 'y': 21, 'z': 44,
|
||||
}
|
||||
return m[r], false, true
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
c, _, ok := keyForRune(unicode.ToLower(r))
|
||||
return c, true, ok
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
nums := []uint16{11, 2, 3, 4, 5, 6, 7, 8, 9, 10}
|
||||
idx := int(r - '0')
|
||||
if idx < 0 || idx >= len(nums) { //nolint:gosec // explicit bound disarms G602
|
||||
return 0, false, false
|
||||
}
|
||||
return nums[idx], false, true
|
||||
}
|
||||
if r == '\n' || r == '\r' {
|
||||
return keyEnter, false, true
|
||||
}
|
||||
if k, ok := punctUnshifted[r]; ok {
|
||||
return k, false, true
|
||||
}
|
||||
if k, ok := punctShifted[r]; ok {
|
||||
return k, true, true
|
||||
}
|
||||
return 0, false, false
|
||||
}
|
||||
|
||||
// punctUnshifted maps ASCII punctuation that needs no Shift to its uinput
|
||||
// KEY_* code. Split out of keyForRune's switch to keep the function's
|
||||
// cognitive complexity below Sonar's threshold.
|
||||
var punctUnshifted = map[rune]uint16{
|
||||
' ': keySpace,
|
||||
'\t': keyTab,
|
||||
'-': keyMinus,
|
||||
'=': keyEqual,
|
||||
'[': keyLeftBracket,
|
||||
']': keyRightBracket,
|
||||
'\\': keyBackslash,
|
||||
';': keySemicolon,
|
||||
'\'': keyApostrophe,
|
||||
'`': keyGrave,
|
||||
',': keyComma,
|
||||
'.': keyDot,
|
||||
'/': keySlash,
|
||||
}
|
||||
|
||||
// punctShifted maps ASCII punctuation that requires Shift to its base KEY_*
|
||||
// code; the caller adds the shift modifier itself.
|
||||
var punctShifted = map[rune]uint16{
|
||||
'!': 2, '@': 3, '#': 4, '$': 5, '%': 6, '^': 7, '&': 8, '*': 9,
|
||||
'(': 10, ')': 11,
|
||||
'_': keyMinus, '+': keyEqual,
|
||||
'{': keyLeftBracket, '}': keyRightBracket, '|': keyBackslash,
|
||||
':': keySemicolon, '"': keyApostrophe, '~': keyGrave,
|
||||
'<': keyComma, '>': keyDot, '?': keySlash,
|
||||
}
|
||||
|
||||
var _ InputInjector = (*UInputInjector)(nil)
|
||||
495
client/vnc/server/input_windows.go
Normal file
495
client/vnc/server/input_windows.go
Normal file
@@ -0,0 +1,495 @@
|
||||
//go:build windows
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"runtime"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
var (
|
||||
procOpenEventW = kernel32.NewProc("OpenEventW")
|
||||
procSendInput = user32.NewProc("SendInput")
|
||||
procVkKeyScanA = user32.NewProc("VkKeyScanA")
|
||||
)
|
||||
|
||||
const eventModifyState = 0x0002
|
||||
|
||||
const (
|
||||
inputMouse = 0
|
||||
inputKeyboard = 1
|
||||
|
||||
mouseeventfMove = 0x0001
|
||||
mouseeventfLeftDown = 0x0002
|
||||
mouseeventfLeftUp = 0x0004
|
||||
mouseeventfRightDown = 0x0008
|
||||
mouseeventfRightUp = 0x0010
|
||||
mouseeventfMiddleDown = 0x0020
|
||||
mouseeventfMiddleUp = 0x0040
|
||||
mouseeventfWheel = 0x0800
|
||||
mouseeventfAbsolute = 0x8000
|
||||
|
||||
wheelDelta = 120
|
||||
|
||||
keyeventfExtendedKey = 0x0001
|
||||
keyeventfKeyUp = 0x0002
|
||||
keyeventfUnicode = 0x0004
|
||||
keyeventfScanCode = 0x0008
|
||||
)
|
||||
|
||||
|
||||
// maxTypedClipboardChars caps the number of characters we will synthesize as
|
||||
// keystrokes when falling back on the Winlogon desktop. Passwords are short;
|
||||
// a huge clipboard getting typed into the login screen would be surprising.
|
||||
const maxTypedClipboardChars = 4096
|
||||
|
||||
type mouseInput struct {
|
||||
Dx int32
|
||||
Dy int32
|
||||
MouseData uint32
|
||||
DwFlags uint32
|
||||
Time uint32
|
||||
DwExtraInfo uintptr
|
||||
}
|
||||
|
||||
type keybdInput struct {
|
||||
WVk uint16
|
||||
WScan uint16
|
||||
DwFlags uint32
|
||||
Time uint32
|
||||
DwExtraInfo uintptr
|
||||
_ [8]byte
|
||||
}
|
||||
|
||||
type inputUnion [32]byte
|
||||
|
||||
type winInput struct {
|
||||
Type uint32
|
||||
_ [4]byte
|
||||
Data inputUnion
|
||||
}
|
||||
|
||||
func sendMouseInput(flags uint32, dx, dy int32, mouseData uint32) {
|
||||
mi := mouseInput{
|
||||
Dx: dx,
|
||||
Dy: dy,
|
||||
MouseData: mouseData,
|
||||
DwFlags: flags,
|
||||
}
|
||||
inp := winInput{Type: inputMouse}
|
||||
copy(inp.Data[:], (*[unsafe.Sizeof(mi)]byte)(unsafe.Pointer(&mi))[:])
|
||||
r, _, err := procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp))
|
||||
if r == 0 {
|
||||
log.Tracef("SendInput(mouse flags=0x%x): %v", flags, err)
|
||||
}
|
||||
}
|
||||
|
||||
func sendKeyInput(vk uint16, scanCode uint16, flags uint32) {
|
||||
ki := keybdInput{
|
||||
WVk: vk,
|
||||
WScan: scanCode,
|
||||
DwFlags: flags,
|
||||
}
|
||||
inp := winInput{Type: inputKeyboard}
|
||||
copy(inp.Data[:], (*[unsafe.Sizeof(ki)]byte)(unsafe.Pointer(&ki))[:])
|
||||
r, _, err := procSendInput.Call(1, uintptr(unsafe.Pointer(&inp)), unsafe.Sizeof(inp))
|
||||
if r == 0 {
|
||||
log.Tracef("SendInput(key vk=0x%x): %v", vk, err)
|
||||
}
|
||||
}
|
||||
|
||||
const sasEventName = `Global\NetBirdVNC_SAS`
|
||||
|
||||
type inputCmd struct {
|
||||
isKey bool
|
||||
isClipboard bool
|
||||
isType bool
|
||||
keysym uint32
|
||||
down bool
|
||||
buttonMask uint8
|
||||
x, y int
|
||||
serverW int
|
||||
serverH int
|
||||
clipText string
|
||||
}
|
||||
|
||||
// WindowsInputInjector delivers input events from a dedicated OS thread that
|
||||
// calls switchToInputDesktop before each injection. SendInput targets the
|
||||
// calling thread's desktop, so the injection thread must be on the same
|
||||
// desktop the user sees.
|
||||
type WindowsInputInjector struct {
|
||||
ch chan inputCmd
|
||||
closed chan struct{}
|
||||
closeOnce sync.Once
|
||||
prevButtonMask uint8
|
||||
ctrlDown bool
|
||||
altDown bool
|
||||
}
|
||||
|
||||
// NewWindowsInputInjector creates a desktop-aware input injector.
|
||||
func NewWindowsInputInjector() *WindowsInputInjector {
|
||||
w := &WindowsInputInjector{
|
||||
ch: make(chan inputCmd, 64),
|
||||
closed: make(chan struct{}),
|
||||
}
|
||||
go w.loop()
|
||||
return w
|
||||
}
|
||||
|
||||
// Close stops the injector loop. Safe to call multiple times. Subsequent
|
||||
// Inject*/SetClipboard/TypeText calls become no-ops; we use a separate
|
||||
// signal channel rather than closing ch so late senders can't panic.
|
||||
func (w *WindowsInputInjector) Close() {
|
||||
w.closeOnce.Do(func() {
|
||||
close(w.closed)
|
||||
})
|
||||
}
|
||||
|
||||
// tryEnqueue posts a command unless the injector is closed or the channel is
|
||||
// full. Non-blocking so callers (RFB read loop) never stall.
|
||||
func (w *WindowsInputInjector) tryEnqueue(cmd inputCmd) {
|
||||
select {
|
||||
case <-w.closed:
|
||||
return
|
||||
default:
|
||||
}
|
||||
select {
|
||||
case w.ch <- cmd:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) loop() {
|
||||
runtime.LockOSThread()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-w.closed:
|
||||
return
|
||||
case cmd := <-w.ch:
|
||||
w.dispatch(cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) dispatch(cmd inputCmd) {
|
||||
// Switch to the current input desktop so SendInput and the clipboard
|
||||
// API target the desktop the user sees. The returned name tells us
|
||||
// whether we are on the secure Winlogon desktop.
|
||||
_, _ = switchToInputDesktop()
|
||||
|
||||
switch {
|
||||
case cmd.isClipboard:
|
||||
w.doSetClipboard(cmd.clipText)
|
||||
case cmd.isType:
|
||||
w.typeUnicodeText(cmd.clipText)
|
||||
case cmd.isKey:
|
||||
w.doInjectKey(cmd.keysym, cmd.down)
|
||||
default:
|
||||
w.doInjectPointer(cmd.buttonMask, cmd.x, cmd.y, cmd.serverW, cmd.serverH)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectKey queues a key event for injection on the input desktop thread.
|
||||
func (w *WindowsInputInjector) InjectKey(keysym uint32, down bool) {
|
||||
w.tryEnqueue(inputCmd{isKey: true, keysym: keysym, down: down})
|
||||
}
|
||||
|
||||
// InjectPointer queues a pointer event for injection on the input desktop
|
||||
// thread. Pointer events coalesce: when the channel is full (slow desktop
|
||||
// switch, hung SendInput), drop the new sample so the read loop never
|
||||
// blocks. The next mouse event carries fresher position anyway.
|
||||
func (w *WindowsInputInjector) InjectPointer(buttonMask uint8, x, y, serverW, serverH int) {
|
||||
w.tryEnqueue(inputCmd{buttonMask: buttonMask, x: x, y: y, serverW: serverW, serverH: serverH})
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) doInjectKey(keysym uint32, down bool) {
|
||||
switch keysym {
|
||||
case 0xffe3, 0xffe4:
|
||||
w.ctrlDown = down
|
||||
case 0xffe9, 0xffea:
|
||||
w.altDown = down
|
||||
}
|
||||
|
||||
if (keysym == 0xff9f || keysym == 0xffff) && w.ctrlDown && w.altDown && down {
|
||||
signalSAS()
|
||||
return
|
||||
}
|
||||
|
||||
vk, _, extended := keysym2VK(keysym)
|
||||
if vk == 0 {
|
||||
return
|
||||
}
|
||||
var flags uint32
|
||||
if !down {
|
||||
flags |= keyeventfKeyUp
|
||||
}
|
||||
if extended {
|
||||
flags |= keyeventfExtendedKey
|
||||
}
|
||||
sendKeyInput(vk, 0, flags)
|
||||
}
|
||||
|
||||
// signalSAS signals the SAS named event. A listener in Session 0
|
||||
// (startSASListener) calls SendSAS to trigger the Secure Attention Sequence.
|
||||
func signalSAS() {
|
||||
namePtr, err := windows.UTF16PtrFromString(sasEventName)
|
||||
if err != nil {
|
||||
log.Warnf("SAS UTF16: %v", err)
|
||||
return
|
||||
}
|
||||
h, _, lerr := procOpenEventW.Call(
|
||||
uintptr(eventModifyState),
|
||||
0,
|
||||
uintptr(unsafe.Pointer(namePtr)),
|
||||
)
|
||||
if h == 0 {
|
||||
log.Warnf("OpenEvent(%s): %v", sasEventName, lerr)
|
||||
return
|
||||
}
|
||||
ev := windows.Handle(h)
|
||||
defer func() { _ = windows.CloseHandle(ev) }()
|
||||
if err := windows.SetEvent(ev); err != nil {
|
||||
log.Warnf("SetEvent SAS: %v", err)
|
||||
} else {
|
||||
log.Info("SAS event signaled")
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) doInjectPointer(buttonMask uint8, x, y, serverW, serverH int) {
|
||||
if serverW == 0 || serverH == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
absX := int32(x * 65535 / serverW)
|
||||
absY := int32(y * 65535 / serverH)
|
||||
|
||||
sendMouseInput(mouseeventfMove|mouseeventfAbsolute, absX, absY, 0)
|
||||
|
||||
changed := buttonMask ^ w.prevButtonMask
|
||||
w.prevButtonMask = buttonMask
|
||||
|
||||
type btnMap struct {
|
||||
bit uint8
|
||||
down uint32
|
||||
up uint32
|
||||
}
|
||||
buttons := [...]btnMap{
|
||||
{0x01, mouseeventfLeftDown, mouseeventfLeftUp},
|
||||
{0x02, mouseeventfMiddleDown, mouseeventfMiddleUp},
|
||||
{0x04, mouseeventfRightDown, mouseeventfRightUp},
|
||||
}
|
||||
for _, b := range buttons {
|
||||
if changed&b.bit == 0 {
|
||||
continue
|
||||
}
|
||||
var flags uint32
|
||||
if buttonMask&b.bit != 0 {
|
||||
flags = b.down
|
||||
} else {
|
||||
flags = b.up
|
||||
}
|
||||
sendMouseInput(flags|mouseeventfAbsolute, absX, absY, 0)
|
||||
}
|
||||
|
||||
negWheelDelta := ^uint32(wheelDelta - 1)
|
||||
if changed&0x08 != 0 && buttonMask&0x08 != 0 {
|
||||
sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, wheelDelta)
|
||||
}
|
||||
if changed&0x10 != 0 && buttonMask&0x10 != 0 {
|
||||
sendMouseInput(mouseeventfWheel|mouseeventfAbsolute, absX, absY, negWheelDelta)
|
||||
}
|
||||
}
|
||||
|
||||
// keysym2VK converts an X11 keysym to a Windows virtual key code.
|
||||
func keysym2VK(keysym uint32) (vk uint16, scan uint16, extended bool) {
|
||||
if keysym >= 0x20 && keysym <= 0x7e {
|
||||
r, _, _ := procVkKeyScanA.Call(uintptr(keysym))
|
||||
vk = uint16(r & 0xff)
|
||||
return
|
||||
}
|
||||
|
||||
if keysym >= 0xffbe && keysym <= 0xffc9 {
|
||||
vk = uint16(0x70 + keysym - 0xffbe)
|
||||
return
|
||||
}
|
||||
|
||||
switch keysym {
|
||||
case 0xff08:
|
||||
vk = 0x08 // Backspace
|
||||
case 0xff09:
|
||||
vk = 0x09 // Tab
|
||||
case 0xff0d:
|
||||
vk = 0x0d // Return
|
||||
case 0xff1b:
|
||||
vk = 0x1b // Escape
|
||||
case 0xff63:
|
||||
vk, extended = 0x2d, true // Insert
|
||||
case 0xff9f, 0xffff:
|
||||
vk, extended = 0x2e, true // Delete
|
||||
case 0xff50:
|
||||
vk, extended = 0x24, true // Home
|
||||
case 0xff57:
|
||||
vk, extended = 0x23, true // End
|
||||
case 0xff55:
|
||||
vk, extended = 0x21, true // PageUp
|
||||
case 0xff56:
|
||||
vk, extended = 0x22, true // PageDown
|
||||
case 0xff51:
|
||||
vk, extended = 0x25, true // Left
|
||||
case 0xff52:
|
||||
vk, extended = 0x26, true // Up
|
||||
case 0xff53:
|
||||
vk, extended = 0x27, true // Right
|
||||
case 0xff54:
|
||||
vk, extended = 0x28, true // Down
|
||||
case 0xffe1, 0xffe2:
|
||||
vk = 0x10 // Shift
|
||||
case 0xffe3, 0xffe4:
|
||||
vk = 0x11 // Control
|
||||
case 0xffe9, 0xffea:
|
||||
vk = 0x12 // Alt
|
||||
case 0xffe5:
|
||||
vk = 0x14 // CapsLock
|
||||
case 0xffe7, 0xffeb:
|
||||
vk, extended = 0x5B, true // Meta_L / Super_L -> Left Windows
|
||||
case 0xffe8, 0xffec:
|
||||
vk, extended = 0x5C, true // Meta_R / Super_R -> Right Windows
|
||||
case 0xff61:
|
||||
vk = 0x2c // PrintScreen
|
||||
case 0xff13:
|
||||
vk = 0x13 // Pause
|
||||
case 0xff14:
|
||||
vk = 0x91 // ScrollLock
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
procOpenClipboard = user32.NewProc("OpenClipboard")
|
||||
procCloseClipboard = user32.NewProc("CloseClipboard")
|
||||
procEmptyClipboard = user32.NewProc("EmptyClipboard")
|
||||
procSetClipboardData = user32.NewProc("SetClipboardData")
|
||||
procGetClipboardData = user32.NewProc("GetClipboardData")
|
||||
procIsClipboardFormatAvailable = user32.NewProc("IsClipboardFormatAvailable")
|
||||
|
||||
procGlobalAlloc = kernel32.NewProc("GlobalAlloc")
|
||||
procGlobalLock = kernel32.NewProc("GlobalLock")
|
||||
procGlobalUnlock = kernel32.NewProc("GlobalUnlock")
|
||||
)
|
||||
|
||||
const (
|
||||
cfUnicodeText = 13
|
||||
gmemMoveable = 0x0002
|
||||
)
|
||||
|
||||
// SetClipboard queues a request to update the Windows clipboard with the
|
||||
// given UTF-8 text. The work runs on the input thread so it follows the
|
||||
// current input desktop. Secure desktops (Winlogon, UAC) have isolated
|
||||
// clipboards we cannot reach, so the call is a no-op there; use TypeText
|
||||
// to enter text into a secure desktop instead.
|
||||
func (w *WindowsInputInjector) SetClipboard(text string) {
|
||||
w.tryEnqueue(inputCmd{isClipboard: true, clipText: text})
|
||||
}
|
||||
|
||||
// TypeText queues a request to synthesize the given text as Unicode
|
||||
// keystrokes on the current input desktop. Targets the secure desktop
|
||||
// when the user is on Winlogon/UAC, where the clipboard is unreachable.
|
||||
func (w *WindowsInputInjector) TypeText(text string) {
|
||||
w.tryEnqueue(inputCmd{isType: true, clipText: text})
|
||||
}
|
||||
|
||||
func (w *WindowsInputInjector) doSetClipboard(text string) {
|
||||
utf16, err := windows.UTF16FromString(text)
|
||||
if err != nil {
|
||||
log.Tracef("clipboard UTF16 encode: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
size := uintptr(len(utf16) * 2)
|
||||
hMem, _, _ := procGlobalAlloc.Call(gmemMoveable, size)
|
||||
if hMem == 0 {
|
||||
log.Tracef("GlobalAlloc for clipboard: allocation returned nil")
|
||||
return
|
||||
}
|
||||
|
||||
ptr, _, _ := procGlobalLock.Call(hMem)
|
||||
if ptr == 0 {
|
||||
log.Tracef("GlobalLock for clipboard: lock returned nil")
|
||||
return
|
||||
}
|
||||
copy(unsafe.Slice((*uint16)(unsafe.Pointer(ptr)), len(utf16)), utf16)
|
||||
_, _, _ = procGlobalUnlock.Call(hMem)
|
||||
|
||||
r, _, lerr := procOpenClipboard.Call(0)
|
||||
if r == 0 {
|
||||
log.Tracef("OpenClipboard: %v", lerr)
|
||||
return
|
||||
}
|
||||
defer logCleanupCall("CloseClipboard", procCloseClipboard)
|
||||
|
||||
_, _, _ = procEmptyClipboard.Call()
|
||||
r, _, lerr = procSetClipboardData.Call(cfUnicodeText, hMem)
|
||||
if r == 0 {
|
||||
log.Tracef("SetClipboardData: %v", lerr)
|
||||
}
|
||||
}
|
||||
|
||||
// typeUnicodeText synthesizes the given text as Unicode keystrokes via
|
||||
// SendInput+KEYEVENTF_UNICODE. Used on the Winlogon secure desktop where the
|
||||
// clipboard is isolated: this lets a VNC client paste a password into the
|
||||
// login or credential prompt by sending ClientCutText.
|
||||
func (w *WindowsInputInjector) typeUnicodeText(text string) {
|
||||
utf16, err := windows.UTF16FromString(text)
|
||||
if err != nil {
|
||||
log.Tracef("clipboard UTF16 encode: %v", err)
|
||||
return
|
||||
}
|
||||
if len(utf16) > 0 && utf16[len(utf16)-1] == 0 {
|
||||
utf16 = utf16[:len(utf16)-1]
|
||||
}
|
||||
if len(utf16) > maxTypedClipboardChars {
|
||||
log.Warnf("clipboard paste on Winlogon truncated to %d chars", maxTypedClipboardChars)
|
||||
utf16 = utf16[:maxTypedClipboardChars]
|
||||
}
|
||||
for _, c := range utf16 {
|
||||
sendKeyInput(0, c, keyeventfUnicode)
|
||||
sendKeyInput(0, c, keyeventfUnicode|keyeventfKeyUp)
|
||||
}
|
||||
}
|
||||
|
||||
// GetClipboard reads the Windows clipboard as UTF-8 text.
|
||||
func (w *WindowsInputInjector) GetClipboard() string {
|
||||
r, _, _ := procIsClipboardFormatAvailable.Call(cfUnicodeText)
|
||||
if r == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
r, _, lerr := procOpenClipboard.Call(0)
|
||||
if r == 0 {
|
||||
log.Tracef("OpenClipboard for read: %v", lerr)
|
||||
return ""
|
||||
}
|
||||
defer logCleanupCall("CloseClipboard", procCloseClipboard)
|
||||
|
||||
hData, _, _ := procGetClipboardData.Call(cfUnicodeText)
|
||||
if hData == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
ptr, _, _ := procGlobalLock.Call(hData)
|
||||
if ptr == 0 {
|
||||
return ""
|
||||
}
|
||||
defer logCleanupCallArgs("GlobalUnlock", procGlobalUnlock, hData)
|
||||
|
||||
return windows.UTF16PtrToString((*uint16)(unsafe.Pointer(ptr)))
|
||||
}
|
||||
|
||||
var _ InputInjector = (*WindowsInputInjector)(nil)
|
||||
|
||||
var _ ScreenCapturer = (*DesktopCapturer)(nil)
|
||||
283
client/vnc/server/input_x11.go
Normal file
283
client/vnc/server/input_x11.go
Normal file
@@ -0,0 +1,283 @@
|
||||
//go:build (linux && !android) || freebsd
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/jezek/xgb"
|
||||
"github.com/jezek/xgb/xproto"
|
||||
"github.com/jezek/xgb/xtest"
|
||||
)
|
||||
|
||||
// X11InputInjector injects keyboard and mouse events via the XTest extension.
|
||||
type X11InputInjector struct {
|
||||
conn *xgb.Conn
|
||||
root xproto.Window
|
||||
screen *xproto.ScreenInfo
|
||||
display string
|
||||
keysymMap map[uint32]byte
|
||||
lastButtons uint8
|
||||
clipboardTool string
|
||||
clipboardToolName string
|
||||
}
|
||||
|
||||
// NewX11InputInjector connects to the X11 display and initializes XTest.
|
||||
func NewX11InputInjector(display string) (*X11InputInjector, error) {
|
||||
detectX11Display()
|
||||
|
||||
if display == "" {
|
||||
display = os.Getenv("DISPLAY")
|
||||
}
|
||||
if display == "" {
|
||||
return nil, fmt.Errorf("DISPLAY not set and no Xorg process found")
|
||||
}
|
||||
|
||||
conn, err := xgb.NewConnDisplay(display)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect to X11 display %s: %w", display, err)
|
||||
}
|
||||
|
||||
if err := xtest.Init(conn); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("init XTest extension: %w", err)
|
||||
}
|
||||
|
||||
setup := xproto.Setup(conn)
|
||||
if len(setup.Roots) == 0 {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("no X11 screens")
|
||||
}
|
||||
screen := setup.Roots[0]
|
||||
|
||||
inj := &X11InputInjector{
|
||||
conn: conn,
|
||||
root: screen.Root,
|
||||
screen: &screen,
|
||||
display: display,
|
||||
}
|
||||
inj.cacheKeyboardMapping()
|
||||
inj.resolveClipboardTool()
|
||||
|
||||
log.Infof("X11 input injector ready (display=%s)", display)
|
||||
return inj, nil
|
||||
}
|
||||
|
||||
// InjectKey simulates a key press or release. keysym is an X11 KeySym.
|
||||
func (x *X11InputInjector) InjectKey(keysym uint32, down bool) {
|
||||
keycode := x.keysymToKeycode(keysym)
|
||||
if keycode == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
var eventType byte
|
||||
if down {
|
||||
eventType = xproto.KeyPress
|
||||
} else {
|
||||
eventType = xproto.KeyRelease
|
||||
}
|
||||
|
||||
xtest.FakeInput(x.conn, eventType, keycode, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
|
||||
// InjectPointer simulates mouse movement and button events.
|
||||
func (x *X11InputInjector) InjectPointer(buttonMask uint8, px, py, serverW, serverH int) {
|
||||
if serverW == 0 || serverH == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
// Scale to actual screen coordinates.
|
||||
screenW := int(x.screen.WidthInPixels)
|
||||
screenH := int(x.screen.HeightInPixels)
|
||||
absX := px * screenW / serverW
|
||||
absY := py * screenH / serverH
|
||||
|
||||
// Move pointer.
|
||||
xtest.FakeInput(x.conn, xproto.MotionNotify, 0, 0, x.root, int16(absX), int16(absY), 0)
|
||||
|
||||
// Handle button events. RFB button mask: bit0=left, bit1=middle, bit2=right,
|
||||
// bit3=scrollUp, bit4=scrollDown. X11 buttons: 1=left, 2=middle, 3=right,
|
||||
// 4=scrollUp, 5=scrollDown.
|
||||
type btnMap struct {
|
||||
rfbBit uint8
|
||||
x11Btn byte
|
||||
}
|
||||
buttons := [...]btnMap{
|
||||
{0x01, 1}, // left
|
||||
{0x02, 2}, // middle
|
||||
{0x04, 3}, // right
|
||||
{0x08, 4}, // scroll up
|
||||
{0x10, 5}, // scroll down
|
||||
}
|
||||
|
||||
for _, b := range buttons {
|
||||
pressed := buttonMask&b.rfbBit != 0
|
||||
wasPressed := x.lastButtons&b.rfbBit != 0
|
||||
if b.x11Btn >= 4 {
|
||||
// Scroll: send press+release on each scroll event.
|
||||
if pressed {
|
||||
xtest.FakeInput(x.conn, xproto.ButtonPress, b.x11Btn, 0, x.root, 0, 0, 0)
|
||||
xtest.FakeInput(x.conn, xproto.ButtonRelease, b.x11Btn, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
} else {
|
||||
if pressed && !wasPressed {
|
||||
xtest.FakeInput(x.conn, xproto.ButtonPress, b.x11Btn, 0, x.root, 0, 0, 0)
|
||||
} else if !pressed && wasPressed {
|
||||
xtest.FakeInput(x.conn, xproto.ButtonRelease, b.x11Btn, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
x.lastButtons = buttonMask
|
||||
}
|
||||
|
||||
// cacheKeyboardMapping fetches the X11 keyboard mapping once and stores it
|
||||
// as a keysym-to-keycode map, avoiding a round-trip per keystroke.
|
||||
func (x *X11InputInjector) cacheKeyboardMapping() {
|
||||
setup := xproto.Setup(x.conn)
|
||||
minKeycode := setup.MinKeycode
|
||||
maxKeycode := setup.MaxKeycode
|
||||
|
||||
reply, err := xproto.GetKeyboardMapping(x.conn, minKeycode,
|
||||
byte(maxKeycode-minKeycode+1)).Reply()
|
||||
if err != nil {
|
||||
log.Debugf("cache keyboard mapping: %v", err)
|
||||
x.keysymMap = make(map[uint32]byte)
|
||||
return
|
||||
}
|
||||
|
||||
m := make(map[uint32]byte, int(maxKeycode-minKeycode+1)*int(reply.KeysymsPerKeycode))
|
||||
keysymsPerKeycode := int(reply.KeysymsPerKeycode)
|
||||
for i := int(minKeycode); i <= int(maxKeycode); i++ {
|
||||
offset := (i - int(minKeycode)) * keysymsPerKeycode
|
||||
for j := 0; j < keysymsPerKeycode; j++ {
|
||||
ks := uint32(reply.Keysyms[offset+j])
|
||||
if ks != 0 {
|
||||
if _, exists := m[ks]; !exists {
|
||||
m[ks] = byte(i)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
x.keysymMap = m
|
||||
}
|
||||
|
||||
// keysymToKeycode looks up a cached keysym-to-keycode mapping.
|
||||
// Returns 0 if the keysym is not mapped.
|
||||
func (x *X11InputInjector) keysymToKeycode(keysym uint32) byte {
|
||||
return x.keysymMap[keysym]
|
||||
}
|
||||
|
||||
// SetClipboard sets the X11 clipboard using xclip or xsel.
|
||||
func (x *X11InputInjector) SetClipboard(text string) {
|
||||
if x.clipboardTool == "" {
|
||||
return
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if x.clipboardToolName == "xclip" {
|
||||
cmd = exec.Command(x.clipboardTool, "-selection", "clipboard")
|
||||
} else {
|
||||
cmd = exec.Command(x.clipboardTool, "--clipboard", "--input")
|
||||
}
|
||||
cmd.Env = x.clipboardEnv()
|
||||
cmd.Stdin = strings.NewReader(text)
|
||||
if err := cmd.Run(); err != nil {
|
||||
log.Debugf("set clipboard via %s: %v", x.clipboardToolName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TypeText synthesizes the given text as keystrokes via XTest. We can
|
||||
// no longer just stuff the host clipboard with xclip and expect Ctrl+V
|
||||
// to do the rest, because the Paste button is also used at places where
|
||||
// the focused application isn't a clipboard-aware one (e.g. a TTY login
|
||||
// in an X11 session, an SDDM/GDM password field that ignores XSelection,
|
||||
// or a kiosk app). Typing keystrokes covers all of those.
|
||||
//
|
||||
// Limitation: only ASCII printable characters are typed. Non-ASCII runes
|
||||
// are skipped: a paste workflow for them needs Wayland-aware text input
|
||||
// or layout introspection that we don't have.
|
||||
func (x *X11InputInjector) TypeText(text string) {
|
||||
const maxChars = 4096
|
||||
count := 0
|
||||
for _, r := range text {
|
||||
if count >= maxChars {
|
||||
break
|
||||
}
|
||||
count++
|
||||
keysym, shift, ok := keysymForASCIIRune(r)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
keycode := x.keysymToKeycode(keysym)
|
||||
if keycode == 0 {
|
||||
continue
|
||||
}
|
||||
var shiftCode byte
|
||||
if shift {
|
||||
shiftCode = x.keysymToKeycode(0xffe1) // Shift_L
|
||||
if shiftCode != 0 {
|
||||
xtest.FakeInput(x.conn, xproto.KeyPress, shiftCode, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
xtest.FakeInput(x.conn, xproto.KeyPress, keycode, 0, x.root, 0, 0, 0)
|
||||
xtest.FakeInput(x.conn, xproto.KeyRelease, keycode, 0, x.root, 0, 0, 0)
|
||||
if shift && shiftCode != 0 {
|
||||
xtest.FakeInput(x.conn, xproto.KeyRelease, shiftCode, 0, x.root, 0, 0, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (x *X11InputInjector) resolveClipboardTool() {
|
||||
for _, name := range []string{"xclip", "xsel"} {
|
||||
path, err := exec.LookPath(name)
|
||||
if err == nil {
|
||||
x.clipboardTool = path
|
||||
x.clipboardToolName = name
|
||||
log.Debugf("clipboard tool resolved to %s", path)
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Debugf("no clipboard tool (xclip/xsel) found, clipboard sync disabled")
|
||||
}
|
||||
|
||||
// GetClipboard reads the X11 clipboard using xclip or xsel.
|
||||
func (x *X11InputInjector) GetClipboard() string {
|
||||
if x.clipboardTool == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
var cmd *exec.Cmd
|
||||
if x.clipboardToolName == "xclip" {
|
||||
cmd = exec.Command(x.clipboardTool, "-selection", "clipboard", "-o")
|
||||
} else {
|
||||
cmd = exec.Command(x.clipboardTool, "--clipboard", "--output")
|
||||
}
|
||||
cmd.Env = x.clipboardEnv()
|
||||
out, err := cmd.Output()
|
||||
if err != nil {
|
||||
log.Tracef("get clipboard via %s: %v", x.clipboardToolName, err)
|
||||
return ""
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
func (x *X11InputInjector) clipboardEnv() []string {
|
||||
env := []string{"DISPLAY=" + x.display}
|
||||
if auth := os.Getenv("XAUTHORITY"); auth != "" {
|
||||
env = append(env, "XAUTHORITY="+auth)
|
||||
}
|
||||
return env
|
||||
}
|
||||
|
||||
// Close releases X11 resources.
|
||||
func (x *X11InputInjector) Close() {
|
||||
x.conn.Close()
|
||||
}
|
||||
|
||||
var _ InputInjector = (*X11InputInjector)(nil)
|
||||
var _ ScreenCapturer = (*X11Poller)(nil)
|
||||
73
client/vnc/server/keysym_typetext.go
Normal file
73
client/vnc/server/keysym_typetext.go
Normal file
@@ -0,0 +1,73 @@
|
||||
//go:build !windows
|
||||
|
||||
package server
|
||||
|
||||
// keysymForASCIIRune maps an ASCII rune to (X11 keysym for the unshifted
|
||||
// version, needsShift). Used by TypeText implementations on each platform
|
||||
// so the caller can explicitly press Shift instead of relying on the
|
||||
// server-side modifier state. Returns ok=false for runes outside the
|
||||
// supported set; non-ASCII text is dropped by TypeText.
|
||||
func keysymForASCIIRune(r rune) (uint32, bool, bool) {
|
||||
if r >= 'a' && r <= 'z' {
|
||||
return uint32(r), false, true
|
||||
}
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
return uint32(r - 'A' + 'a'), true, true
|
||||
}
|
||||
if r >= '0' && r <= '9' {
|
||||
return uint32(r), false, true
|
||||
}
|
||||
switch r {
|
||||
case ' ':
|
||||
return 0x20, false, true
|
||||
case '\n', '\r':
|
||||
return 0xff0d, false, true // Return
|
||||
case '\t':
|
||||
return 0xff09, false, true // Tab
|
||||
case '-', '=', '[', ']', '\\', ';', '\'', '`', ',', '.', '/':
|
||||
return uint32(r), false, true
|
||||
case '!':
|
||||
return '1', true, true
|
||||
case '@':
|
||||
return '2', true, true
|
||||
case '#':
|
||||
return '3', true, true
|
||||
case '$':
|
||||
return '4', true, true
|
||||
case '%':
|
||||
return '5', true, true
|
||||
case '^':
|
||||
return '6', true, true
|
||||
case '&':
|
||||
return '7', true, true
|
||||
case '*':
|
||||
return '8', true, true
|
||||
case '(':
|
||||
return '9', true, true
|
||||
case ')':
|
||||
return '0', true, true
|
||||
case '_':
|
||||
return '-', true, true
|
||||
case '+':
|
||||
return '=', true, true
|
||||
case '{':
|
||||
return '[', true, true
|
||||
case '}':
|
||||
return ']', true, true
|
||||
case '|':
|
||||
return '\\', true, true
|
||||
case ':':
|
||||
return ';', true, true
|
||||
case '"':
|
||||
return '\'', true, true
|
||||
case '~':
|
||||
return '`', true, true
|
||||
case '<':
|
||||
return ',', true, true
|
||||
case '>':
|
||||
return '.', true, true
|
||||
case '?':
|
||||
return '/', true, true
|
||||
}
|
||||
return 0, false, false
|
||||
}
|
||||
905
client/vnc/server/rfb.go
Normal file
905
client/vnc/server/rfb.go
Normal file
@@ -0,0 +1,905 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/zlib"
|
||||
"crypto/des" //nolint:gosec // RFB protocol-defined DES challenge/response; not used for confidentiality
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"image"
|
||||
"image/jpeg"
|
||||
"unsafe"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// rect describes a rectangle on the framebuffer in pixels.
|
||||
type rect struct {
|
||||
x, y, w, h int
|
||||
}
|
||||
|
||||
const (
|
||||
rfbProtocolVersion = "RFB 003.008\n"
|
||||
|
||||
secNone = 1
|
||||
secVNCAuth = 2
|
||||
|
||||
// Client message types.
|
||||
clientSetPixelFormat = 0
|
||||
clientSetEncodings = 2
|
||||
clientFramebufferUpdateRequest = 3
|
||||
clientKeyEvent = 4
|
||||
clientPointerEvent = 5
|
||||
clientCutText = 6
|
||||
|
||||
// clientNetbirdTypeText is a NetBird-specific message that asks the
|
||||
// server to synthesize the given text as keystrokes regardless of the
|
||||
// active desktop. Used by the dashboard's Paste button to push host
|
||||
// clipboard content into a Windows secure desktop (Winlogon, UAC),
|
||||
// where the OS clipboard is isolated. Format mirrors clientCutText:
|
||||
// 1-byte message type + 3-byte padding + 4-byte length + text bytes.
|
||||
// The opcode is in the vendor-specific range (>=128).
|
||||
clientNetbirdTypeText = 250
|
||||
|
||||
// Server message types.
|
||||
serverFramebufferUpdate = 0
|
||||
serverCutText = 3
|
||||
|
||||
// Encoding types.
|
||||
encRaw = 0
|
||||
encHextile = 5
|
||||
encZlib = 6
|
||||
encTight = 7
|
||||
|
||||
// Tight compression-control byte top nibble. Stream-reset bits 0-3
|
||||
// (one per zlib stream) are unused while we run a single stream.
|
||||
tightFillSubenc = 0x80
|
||||
tightJPEGSubenc = 0x90
|
||||
tightBasicFilter = 0x40 // Bit 6 set = explicit filter byte follows.
|
||||
tightFilterCopy = 0x00 // No-op filter, raw pixel stream.
|
||||
|
||||
// JPEG quality used by the Tight encoder. 70 is a reasonable speed/
|
||||
// quality knee; bandwidth roughly halves vs raw RGB while staying
|
||||
// visually clean for typical desktop content. Large rects (e.g. a
|
||||
// fullscreen video region) drop to a lower quality so the encoder
|
||||
// keeps up at 30+ fps; the visual hit is small for moving content.
|
||||
tightJPEGQuality = 70
|
||||
tightJPEGQualityMedium = 55
|
||||
tightJPEGQualityLarge = 40
|
||||
tightJPEGMediumPixels = 800 * 600 // ≈ SVGA, applies medium tier
|
||||
tightJPEGLargePixels = 1280 * 720 // ≈ 720p, applies large tier
|
||||
// Minimum rect area before we consider JPEG. Below this, header
|
||||
// overhead dominates and Basic+zlib wins.
|
||||
tightJPEGMinArea = 4096 // 64×64 ≈ 1 tile
|
||||
// Distinct-colour cap below which we still prefer Basic+zlib (text,
|
||||
// UI). Sampled, not exhaustive: cheap to compute, good enough.
|
||||
tightJPEGMinColors = 64
|
||||
|
||||
// Hextile subencoding flags (a bitmask in the first byte of each sub-tile).
|
||||
hextileRaw = 0x01
|
||||
hextileBackgroundSpecified = 0x02
|
||||
hextileForegroundSpecified = 0x04
|
||||
hextileAnySubrects = 0x08
|
||||
hextileSubrectsColoured = 0x10
|
||||
|
||||
// Hextile sub-tile size per RFB spec.
|
||||
hextileSubSize = 16
|
||||
)
|
||||
|
||||
// serverPixelFormat is the default pixel format advertised by the server:
|
||||
// 32bpp RGBA, big-endian, true-colour, 8 bits per channel.
|
||||
var serverPixelFormat = [16]byte{
|
||||
32, // bits-per-pixel
|
||||
24, // depth
|
||||
1, // big-endian-flag
|
||||
1, // true-colour-flag
|
||||
0, 255, // red-max
|
||||
0, 255, // green-max
|
||||
0, 255, // blue-max
|
||||
16, // red-shift
|
||||
8, // green-shift
|
||||
0, // blue-shift
|
||||
0, 0, 0, // padding
|
||||
}
|
||||
|
||||
// clientPixelFormat holds the negotiated pixel format from the client.
|
||||
type clientPixelFormat struct {
|
||||
bpp uint8
|
||||
bigEndian uint8
|
||||
rMax uint16
|
||||
gMax uint16
|
||||
bMax uint16
|
||||
rShift uint8
|
||||
gShift uint8
|
||||
bShift uint8
|
||||
}
|
||||
|
||||
func defaultClientPixelFormat() clientPixelFormat {
|
||||
return clientPixelFormat{
|
||||
bpp: serverPixelFormat[0],
|
||||
bigEndian: serverPixelFormat[2],
|
||||
rMax: binary.BigEndian.Uint16(serverPixelFormat[4:6]),
|
||||
gMax: binary.BigEndian.Uint16(serverPixelFormat[6:8]),
|
||||
bMax: binary.BigEndian.Uint16(serverPixelFormat[8:10]),
|
||||
rShift: serverPixelFormat[10],
|
||||
gShift: serverPixelFormat[11],
|
||||
bShift: serverPixelFormat[12],
|
||||
}
|
||||
}
|
||||
|
||||
func parsePixelFormat(pf []byte) clientPixelFormat {
|
||||
return clientPixelFormat{
|
||||
bpp: pf[0],
|
||||
bigEndian: pf[2],
|
||||
rMax: binary.BigEndian.Uint16(pf[4:6]),
|
||||
gMax: binary.BigEndian.Uint16(pf[6:8]),
|
||||
bMax: binary.BigEndian.Uint16(pf[8:10]),
|
||||
rShift: pf[10],
|
||||
gShift: pf[11],
|
||||
bShift: pf[12],
|
||||
}
|
||||
}
|
||||
|
||||
// encodeRawRect encodes a framebuffer region as a raw RFB rectangle.
|
||||
// The returned buffer includes the FramebufferUpdate header (1 rectangle).
|
||||
func encodeRawRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte {
|
||||
bytesPerPixel := max(int(pf.bpp)/8, 1)
|
||||
|
||||
pixelBytes := w * h * bytesPerPixel
|
||||
buf := make([]byte, 4+12+pixelBytes)
|
||||
|
||||
// FramebufferUpdate header.
|
||||
buf[0] = serverFramebufferUpdate
|
||||
buf[1] = 0 // padding
|
||||
binary.BigEndian.PutUint16(buf[2:4], 1)
|
||||
|
||||
// Rectangle header.
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(x))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(y))
|
||||
binary.BigEndian.PutUint16(buf[8:10], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[10:12], uint16(h))
|
||||
binary.BigEndian.PutUint32(buf[12:16], uint32(encRaw))
|
||||
|
||||
writePixels(buf[16:], img, pf, rect{x, y, w, h}, bytesPerPixel)
|
||||
return buf
|
||||
}
|
||||
|
||||
// writePixels writes a rectangle of img into dst in the client's requested
|
||||
// pixel format. It fast-paths the common case (32bpp, full 8-bit channels)
|
||||
// with a tight loop that skips the per-channel *max/255 arithmetic and emits
|
||||
// a single uint32 per pixel; the general path handles arbitrary formats.
|
||||
func writePixels(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect, bytesPerPixel int) {
|
||||
if bytesPerPixel == 4 && pf.rMax == 255 && pf.gMax == 255 && pf.bMax == 255 {
|
||||
writePixelsFast32(dst, img, pf, r)
|
||||
return
|
||||
}
|
||||
writePixelsGeneric(dst, img, pf, r, bytesPerPixel)
|
||||
}
|
||||
|
||||
func writePixelsFast32(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect) {
|
||||
stride := img.Stride
|
||||
rShift, gShift, bShift := pf.rShift, pf.gShift, pf.bShift
|
||||
bigEndian := pf.bigEndian != 0
|
||||
off := 0
|
||||
for row := r.y; row < r.y+r.h; row++ {
|
||||
p := row*stride + r.x*4
|
||||
for col := 0; col < r.w; col++ {
|
||||
pixel := (uint32(img.Pix[p]) << rShift) |
|
||||
(uint32(img.Pix[p+1]) << gShift) |
|
||||
(uint32(img.Pix[p+2]) << bShift)
|
||||
if bigEndian {
|
||||
binary.BigEndian.PutUint32(dst[off:off+4], pixel)
|
||||
} else {
|
||||
binary.LittleEndian.PutUint32(dst[off:off+4], pixel)
|
||||
}
|
||||
p += 4
|
||||
off += 4
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writePixelsGeneric(dst []byte, img *image.RGBA, pf clientPixelFormat, r rect, bytesPerPixel int) {
|
||||
stride := img.Stride
|
||||
off := 0
|
||||
for row := r.y; row < r.y+r.h; row++ {
|
||||
for col := r.x; col < r.x+r.w; col++ {
|
||||
p := row*stride + col*4
|
||||
rv := uint32(img.Pix[p]) * uint32(pf.rMax) / 255
|
||||
gv := uint32(img.Pix[p+1]) * uint32(pf.gMax) / 255
|
||||
bv := uint32(img.Pix[p+2]) * uint32(pf.bMax) / 255
|
||||
pixel := (rv << pf.rShift) | (gv << pf.gShift) | (bv << pf.bShift)
|
||||
emitPixelBytes(dst[off:off+bytesPerPixel], pixel, bytesPerPixel, pf.bigEndian != 0)
|
||||
off += bytesPerPixel
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func emitPixelBytes(dst []byte, pixel uint32, bytesPerPixel int, bigEndian bool) {
|
||||
if bigEndian {
|
||||
for i := range bytesPerPixel {
|
||||
dst[i] = byte(pixel >> uint((bytesPerPixel-1-i)*8))
|
||||
}
|
||||
return
|
||||
}
|
||||
for i := range bytesPerPixel {
|
||||
dst[i] = byte(pixel >> uint(i*8))
|
||||
}
|
||||
}
|
||||
|
||||
// vncAuthEncrypt encrypts a 16-byte challenge using the VNC DES scheme.
|
||||
func vncAuthEncrypt(challenge []byte, password string) ([]byte, error) {
|
||||
key := make([]byte, 8)
|
||||
pw := []byte(password)
|
||||
n := len(pw)
|
||||
if n > 8 {
|
||||
n = 8
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
key[i] = reverseBits(pw[i])
|
||||
}
|
||||
block, err := des.NewCipher(key) //nolint:gosec // RFB protocol-defined DES challenge/response; not a confidentiality cipher
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("des.NewCipher: %w", err)
|
||||
}
|
||||
if len(challenge) < 16 { //nolint:gosec // explicit length check disarms G602
|
||||
return nil, fmt.Errorf("vnc auth challenge too short: %d", len(challenge))
|
||||
}
|
||||
out := make([]byte, 16)
|
||||
block.Encrypt(out[:8], challenge[:8])
|
||||
block.Encrypt(out[8:], challenge[8:])
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func reverseBits(b byte) byte {
|
||||
var r byte
|
||||
for range 8 {
|
||||
r = (r << 1) | (b & 1)
|
||||
b >>= 1
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// encodeZlibRect encodes a framebuffer region using Zlib compression.
|
||||
// The zlib stream is continuous for the entire VNC session: noVNC creates
|
||||
// one inflate context at startup and reuses it for all zlib-encoded rects.
|
||||
// We must NOT reset the zlib writer between calls.
|
||||
func encodeZlibRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, z *zlibState) []byte {
|
||||
bytesPerPixel := max(int(pf.bpp)/8, 1)
|
||||
zw, zbuf := z.w, z.buf
|
||||
|
||||
// Clear the output buffer but keep the deflate dictionary intact.
|
||||
zbuf.Reset()
|
||||
|
||||
// Encode the full rect pixel stream into the session-lived scratch buffer
|
||||
// and feed zlib one row at a time. Row-granular writes amortise the per-
|
||||
// Write overhead that used to dominate this function when it wrote one
|
||||
// byte slice per pixel.
|
||||
rowBytes := w * bytesPerPixel
|
||||
total := rowBytes * h
|
||||
if cap(z.scratch) < total {
|
||||
z.scratch = make([]byte, total)
|
||||
}
|
||||
scratch := z.scratch[:total]
|
||||
writePixels(scratch, img, pf, rect{x, y, w, h}, bytesPerPixel)
|
||||
for row := 0; row < h; row++ {
|
||||
if _, err := zw.Write(scratch[row*rowBytes : (row+1)*rowBytes]); err != nil {
|
||||
log.Debugf("zlib write row %d: %v", row, err)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
if err := zw.Flush(); err != nil {
|
||||
log.Debugf("zlib flush: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
compressed := zbuf.Bytes()
|
||||
|
||||
// Build the FramebufferUpdate message.
|
||||
buf := make([]byte, 4+12+4+len(compressed))
|
||||
buf[0] = serverFramebufferUpdate
|
||||
buf[1] = 0
|
||||
binary.BigEndian.PutUint16(buf[2:4], 1) // 1 rectangle
|
||||
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(x))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(y))
|
||||
binary.BigEndian.PutUint16(buf[8:10], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[10:12], uint16(h))
|
||||
binary.BigEndian.PutUint32(buf[12:16], uint32(encZlib))
|
||||
binary.BigEndian.PutUint32(buf[16:20], uint32(len(compressed)))
|
||||
copy(buf[20:], compressed)
|
||||
|
||||
return buf
|
||||
}
|
||||
|
||||
// diffRects compares two RGBA images and returns a list of dirty rectangles.
|
||||
// Divides the screen into tiles and checks each for changes.
|
||||
func diffRects(prev, cur *image.RGBA, w, h, tileSize int) [][4]int {
|
||||
if prev == nil {
|
||||
return [][4]int{{0, 0, w, h}}
|
||||
}
|
||||
|
||||
var rects [][4]int
|
||||
for ty := 0; ty < h; ty += tileSize {
|
||||
th := min(tileSize, h-ty)
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := min(tileSize, w-tx)
|
||||
if tileChanged(prev, cur, tx, ty, tw, th) {
|
||||
rects = append(rects, [4]int{tx, ty, tw, th})
|
||||
}
|
||||
}
|
||||
}
|
||||
return coalesceRects(rects)
|
||||
}
|
||||
|
||||
// coalesceRects merges adjacent dirty tiles into larger rectangles to cut
|
||||
// per-rect framing overhead. Input must be tile-ordered (top-to-bottom rows,
|
||||
// left-to-right within each row), as produced by diffRects. Two passes:
|
||||
// 1. Horizontal: within a row, merge tiles whose x-extents touch.
|
||||
// 2. Vertical: merge a row's run with the run directly above it when they
|
||||
// share the same [x, x+w] extent and are vertically adjacent.
|
||||
//
|
||||
// Larger merged rects still encode correctly: Hextile-solid and Zlib paths
|
||||
// both work on arbitrary sizes, and uniform-tile detection still fires when
|
||||
// the merged region happens to be a single colour.
|
||||
func coalesceRects(in [][4]int) [][4]int {
|
||||
if len(in) < 2 {
|
||||
return in
|
||||
}
|
||||
c := newRectCoalescer(len(in))
|
||||
c.curY = in[0][1]
|
||||
for _, r := range in {
|
||||
c.consume(r)
|
||||
}
|
||||
c.flushCurrentRow()
|
||||
return c.out
|
||||
}
|
||||
|
||||
// rectCoalescer is the working state for coalesceRects, lifted out so the
|
||||
// algorithm can be split across small methods without long parameter lists
|
||||
// and to keep each method's cognitive complexity below Sonar's threshold.
|
||||
type rectCoalescer struct {
|
||||
out [][4]int
|
||||
prevRowStart, prevRowEnd int
|
||||
curRowStart int
|
||||
curY int
|
||||
}
|
||||
|
||||
func newRectCoalescer(capacity int) *rectCoalescer {
|
||||
return &rectCoalescer{out: make([][4]int, 0, capacity)}
|
||||
}
|
||||
|
||||
// consume processes one rect from the (row-ordered) input.
|
||||
func (c *rectCoalescer) consume(r [4]int) {
|
||||
if r[1] != c.curY {
|
||||
c.flushCurrentRow()
|
||||
c.prevRowEnd = len(c.out)
|
||||
c.curRowStart = len(c.out)
|
||||
c.curY = r[1]
|
||||
}
|
||||
if c.tryHorizontalMerge(r) {
|
||||
return
|
||||
}
|
||||
c.out = append(c.out, r)
|
||||
}
|
||||
|
||||
// tryHorizontalMerge extends the last run in the current row when r is
|
||||
// vertically aligned and horizontally adjacent to it.
|
||||
func (c *rectCoalescer) tryHorizontalMerge(r [4]int) bool {
|
||||
if len(c.out) <= c.curRowStart {
|
||||
return false
|
||||
}
|
||||
last := &c.out[len(c.out)-1]
|
||||
if last[1] == r[1] && last[3] == r[3] && last[0]+last[2] == r[0] {
|
||||
last[2] += r[2]
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// flushCurrentRow merges each run in the current row with any run from the
|
||||
// previous row that has identical x extent and is vertically adjacent.
|
||||
func (c *rectCoalescer) flushCurrentRow() {
|
||||
i := c.curRowStart
|
||||
for i < len(c.out) {
|
||||
if c.mergeWithPrevRow(i) {
|
||||
continue
|
||||
}
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// mergeWithPrevRow tries to extend a previous-row run downward to absorb
|
||||
// out[i]. Returns true and removes out[i] from the slice on success.
|
||||
func (c *rectCoalescer) mergeWithPrevRow(i int) bool {
|
||||
for j := c.prevRowStart; j < c.prevRowEnd; j++ {
|
||||
if c.out[j][0] == c.out[i][0] &&
|
||||
c.out[j][2] == c.out[i][2] &&
|
||||
c.out[j][1]+c.out[j][3] == c.out[i][1] {
|
||||
c.out[j][3] += c.out[i][3]
|
||||
copy(c.out[i:], c.out[i+1:])
|
||||
c.out = c.out[:len(c.out)-1]
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func tileChanged(prev, cur *image.RGBA, x, y, w, h int) bool {
|
||||
stride := prev.Stride
|
||||
for row := y; row < y+h; row++ {
|
||||
off := row*stride + x*4
|
||||
end := off + w*4
|
||||
prevRow := prev.Pix[off:end]
|
||||
curRow := cur.Pix[off:end]
|
||||
if !bytes.Equal(prevRow, curRow) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// tileIsUniform reports whether every pixel in the given rectangle of img is
|
||||
// the same RGBA value, and returns that pixel packed as 0xRRGGBBAA when so.
|
||||
// Uses uint32 comparisons across rows; returns early on the first mismatch.
|
||||
func tileIsUniform(img *image.RGBA, x, y, w, h int) (uint32, bool) {
|
||||
if w <= 0 || h <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
stride := img.Stride
|
||||
base := y*stride + x*4
|
||||
first := *(*uint32)(unsafe.Pointer(&img.Pix[base]))
|
||||
rowBytes := w * 4
|
||||
for row := 0; row < h; row++ {
|
||||
p := base + row*stride
|
||||
for col := 0; col < rowBytes; col += 4 {
|
||||
if *(*uint32)(unsafe.Pointer(&img.Pix[p+col])) != first {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
}
|
||||
return first, true
|
||||
}
|
||||
|
||||
// encodePixel packs an RGBA byte triple into the client's requested pixel
|
||||
// format, honouring bpp, channel maxes, shifts and endianness. Returns the
|
||||
// number of bytes written to dst (1..4).
|
||||
func encodePixel(dst []byte, pf clientPixelFormat, r, g, b byte) int {
|
||||
bytesPerPixel := max(int(pf.bpp)/8, 1)
|
||||
var val uint32
|
||||
if pf.rMax == 255 && pf.gMax == 255 && pf.bMax == 255 {
|
||||
val = (uint32(r) << pf.rShift) | (uint32(g) << pf.gShift) | (uint32(b) << pf.bShift)
|
||||
} else {
|
||||
rv := uint32(r) * uint32(pf.rMax) / 255
|
||||
gv := uint32(g) * uint32(pf.gMax) / 255
|
||||
bv := uint32(b) * uint32(pf.bMax) / 255
|
||||
val = (rv << pf.rShift) | (gv << pf.gShift) | (bv << pf.bShift)
|
||||
}
|
||||
if pf.bigEndian != 0 {
|
||||
for i := range bytesPerPixel {
|
||||
dst[i] = byte(val >> uint((bytesPerPixel-1-i)*8))
|
||||
}
|
||||
} else {
|
||||
for i := range bytesPerPixel {
|
||||
dst[i] = byte(val >> uint(i*8))
|
||||
}
|
||||
}
|
||||
return bytesPerPixel
|
||||
}
|
||||
|
||||
// encodeHextileSolidRect emits a Hextile-encoded rectangle whose every pixel
|
||||
// is the same color. All sub-tiles after the first inherit the background
|
||||
// via a zero subencoding byte, collapsing a uniform 64×64 tile from ~16 KB
|
||||
// raw (or ~1-2 KB zlib) down to ~20 bytes on the wire.
|
||||
//
|
||||
// The returned buffer starts with the 12-byte rect header + the hextile
|
||||
// body. Callers assembling a multi-rect FramebufferUpdate append this after
|
||||
// their own message header.
|
||||
func encodeHextileSolidRect(r, g, b byte, pf clientPixelFormat, rc rect) []byte {
|
||||
bytesPerPixel := max(int(pf.bpp)/8, 1)
|
||||
|
||||
// Count sub-tiles. Right/bottom sub-tiles may be smaller than 16.
|
||||
cols := (rc.w + hextileSubSize - 1) / hextileSubSize
|
||||
rows := (rc.h + hextileSubSize - 1) / hextileSubSize
|
||||
subs := cols * rows
|
||||
|
||||
// Body: first sub-tile carries (subenc 0x02 + bg pixel); the rest are
|
||||
// subenc 0x00 (inherit the previously-emitted background).
|
||||
bodySize := 1 + bytesPerPixel + (subs - 1)
|
||||
buf := make([]byte, 12+bodySize)
|
||||
|
||||
binary.BigEndian.PutUint16(buf[0:2], uint16(rc.x))
|
||||
binary.BigEndian.PutUint16(buf[2:4], uint16(rc.y))
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(rc.w))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(rc.h))
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(encHextile))
|
||||
|
||||
buf[12] = hextileBackgroundSpecified
|
||||
encodePixel(buf[13:13+bytesPerPixel], pf, r, g, b)
|
||||
// Remaining sub-tiles are already zero-valued from make(): "same as
|
||||
// previous background", no pixel bytes.
|
||||
_ = subs
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeHextileRect emits a full Hextile-encoded rectangle. Each 16×16
|
||||
// sub-tile is classified as 1-color (background only), 2-color (background
|
||||
// + foreground subrects), or raw. The 1-color and 2-color paths are
|
||||
// significantly cheaper than zlib on UI content (text, icons, flat
|
||||
// backgrounds) and avoid the persistent zlib stream's inter-rect
|
||||
// serialization point, so they parallelize trivially.
|
||||
//
|
||||
// The returned buffer starts with the 12-byte rect header + hextile body.
|
||||
func encodeHextileRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int) []byte {
|
||||
bytesPerPixel := max(int(pf.bpp)/8, 1)
|
||||
|
||||
// Pre-size: worst case is every sub-tile raw → 1 header byte + raw
|
||||
// pixels per sub-tile.
|
||||
maxBody := 0
|
||||
for sy := 0; sy < h; sy += hextileSubSize {
|
||||
sh := min(hextileSubSize, h-sy)
|
||||
for sx := 0; sx < w; sx += hextileSubSize {
|
||||
sw := min(hextileSubSize, w-sx)
|
||||
maxBody += 1 + sw*sh*bytesPerPixel
|
||||
}
|
||||
}
|
||||
buf := make([]byte, 12, 12+maxBody)
|
||||
|
||||
binary.BigEndian.PutUint16(buf[0:2], uint16(x))
|
||||
binary.BigEndian.PutUint16(buf[2:4], uint16(y))
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(h))
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(encHextile))
|
||||
|
||||
var state hextileBgState
|
||||
|
||||
for sy := 0; sy < h; sy += hextileSubSize {
|
||||
sh := min(hextileSubSize, h-sy)
|
||||
for sx := 0; sx < w; sx += hextileSubSize {
|
||||
sw := min(hextileSubSize, w-sx)
|
||||
buf = appendHextileSubtile(buf, img, pf, rect{x + sx, y + sy, sw, sh}, &state, bytesPerPixel)
|
||||
}
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// hextileBgState carries the running background across sub-tile encodes so
|
||||
// we can omit the BackgroundSpecified flag when it hasn't changed.
|
||||
type hextileBgState struct {
|
||||
prev uint32
|
||||
valid bool
|
||||
}
|
||||
|
||||
// appendHextileSubtile encodes a single 16×16 (or smaller edge) sub-tile
|
||||
// onto buf.
|
||||
func appendHextileSubtile(buf []byte, img *image.RGBA, pf clientPixelFormat, rc rect, state *hextileBgState, bytesPerPixel int) []byte {
|
||||
x, y, w, h := rc.x, rc.y, rc.w, rc.h
|
||||
c0, c1, only2, c0Count, c1Count := classifySubtile(img, x, y, w, h)
|
||||
|
||||
if !only2 {
|
||||
// >2 distinct colours: raw fallback.
|
||||
buf = append(buf, hextileRaw)
|
||||
buf = appendRawPixels(buf, img, pf, rc, bytesPerPixel)
|
||||
state.valid = false
|
||||
return buf
|
||||
}
|
||||
|
||||
if c1Count == 0 {
|
||||
// Single colour. Background only.
|
||||
if state.valid && state.prev == c0 {
|
||||
return append(buf, 0)
|
||||
}
|
||||
buf = append(buf, hextileBackgroundSpecified)
|
||||
buf = appendPackedPixelFromRGBA(buf, pf, c0, bytesPerPixel)
|
||||
state.prev = c0
|
||||
state.valid = true
|
||||
return buf
|
||||
}
|
||||
|
||||
// Two colours. Background = majority; foreground = minority,
|
||||
// emitted as 1-row subrects of fg runs.
|
||||
bg, fg := c0, c1
|
||||
if c1Count > c0Count {
|
||||
bg, fg = c1, c0
|
||||
}
|
||||
subrects := collectFgSubrects(img, x, y, w, h, bg)
|
||||
// Cap at 255 (the count is a uint8). On overflow fall through to
|
||||
// raw: that's the simplest correct fallback.
|
||||
if len(subrects) <= 255 {
|
||||
flags := byte(hextileForegroundSpecified | hextileAnySubrects)
|
||||
emitBg := !state.valid || state.prev != bg
|
||||
if emitBg {
|
||||
flags |= hextileBackgroundSpecified
|
||||
}
|
||||
buf = append(buf, flags)
|
||||
if emitBg {
|
||||
buf = appendPackedPixelFromRGBA(buf, pf, bg, bytesPerPixel)
|
||||
state.prev = bg
|
||||
state.valid = true
|
||||
}
|
||||
buf = appendPackedPixelFromRGBA(buf, pf, fg, bytesPerPixel)
|
||||
buf = append(buf, byte(len(subrects)))
|
||||
for _, sr := range subrects {
|
||||
buf = append(buf, byte((sr[0]<<4)|sr[1]), byte(((sr[2]-1)<<4)|(sr[3]-1)))
|
||||
}
|
||||
return buf
|
||||
}
|
||||
|
||||
// Raw fallback.
|
||||
buf = append(buf, hextileRaw)
|
||||
buf = appendRawPixels(buf, img, pf, rc, bytesPerPixel)
|
||||
// Raw sub-tiles invalidate the persistent background.
|
||||
state.valid = false
|
||||
return buf
|
||||
}
|
||||
|
||||
// classifySubtile scans the sub-tile and reports up to two distinct pixel
|
||||
// values plus their counts. only2 is false the moment a third distinct
|
||||
// colour is seen, in which case the caller falls back to raw.
|
||||
func classifySubtile(img *image.RGBA, x, y, w, h int) (c0, c1 uint32, only2 bool, c0Count, c1Count int) {
|
||||
stride := img.Stride
|
||||
base := y*stride + x*4
|
||||
c0 = *(*uint32)(unsafe.Pointer(&img.Pix[base]))
|
||||
only2 = true
|
||||
for row := 0; row < h; row++ {
|
||||
p := base + row*stride
|
||||
for col := 0; col < w; col++ {
|
||||
px := *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4]))
|
||||
switch {
|
||||
case px == c0:
|
||||
c0Count++
|
||||
case c1Count == 0:
|
||||
c1 = px
|
||||
c1Count = 1
|
||||
case px == c1:
|
||||
c1Count++
|
||||
default:
|
||||
return c0, c1, false, 0, 0
|
||||
}
|
||||
}
|
||||
}
|
||||
return c0, c1, only2, c0Count, c1Count
|
||||
}
|
||||
|
||||
// collectFgSubrects walks the sub-tile row by row, emitting one subrect per
|
||||
// horizontal run of pixels not equal to bg. Each subrect is [subX, subY,
|
||||
// width, height] with width/height in 1..16.
|
||||
func collectFgSubrects(img *image.RGBA, x, y, w, h int, bg uint32) [][4]int {
|
||||
stride := img.Stride
|
||||
var out [][4]int
|
||||
for row := 0; row < h; row++ {
|
||||
p := y*stride + x*4 + row*stride
|
||||
col := 0
|
||||
for col < w {
|
||||
if *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) == bg {
|
||||
col++
|
||||
continue
|
||||
}
|
||||
start := col
|
||||
for col < w && *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4])) != bg {
|
||||
col++
|
||||
}
|
||||
out = append(out, [4]int{start, row, col - start, 1})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendPackedPixelFromRGBA(buf []byte, pf clientPixelFormat, px uint32, bytesPerPixel int) []byte {
|
||||
r := byte(px)
|
||||
g := byte(px >> 8)
|
||||
b := byte(px >> 16)
|
||||
var tmp [4]byte
|
||||
encodePixel(tmp[:], pf, r, g, b)
|
||||
return append(buf, tmp[:bytesPerPixel]...)
|
||||
}
|
||||
|
||||
func appendRawPixels(buf []byte, img *image.RGBA, pf clientPixelFormat, rc rect, bytesPerPixel int) []byte {
|
||||
start := len(buf)
|
||||
buf = append(buf, make([]byte, rc.w*rc.h*bytesPerPixel)...)
|
||||
writePixels(buf[start:], img, pf, rc, bytesPerPixel)
|
||||
return buf
|
||||
}
|
||||
|
||||
// tightState holds the per-session JPEG scratch buffer and reused encoders
|
||||
// so per-rect encoding stays alloc-free in the steady state.
|
||||
type tightState struct {
|
||||
jpegBuf *bytes.Buffer
|
||||
zlib *zlibState
|
||||
scratch []byte // RGB-packed pixel scratch for JPEG and Basic paths.
|
||||
// colorSeen is reused by sampledColorCount per rect; cleared via the Go
|
||||
// runtime's map-clear fast path to avoid a fresh allocation each call.
|
||||
colorSeen map[uint32]struct{}
|
||||
}
|
||||
|
||||
func newTightState() *tightState {
|
||||
return &tightState{
|
||||
jpegBuf: &bytes.Buffer{},
|
||||
zlib: newZlibState(),
|
||||
colorSeen: make(map[uint32]struct{}, 64),
|
||||
}
|
||||
}
|
||||
|
||||
// encodeTightRect emits a single Tight-encoded rect. Picks Fill for uniform
|
||||
// content, JPEG for photo-like rects above a size and color-count threshold,
|
||||
// and Basic+zlib otherwise. Returns the rect header + Tight body (no
|
||||
// FramebufferUpdate header).
|
||||
func encodeTightRect(img *image.RGBA, pf clientPixelFormat, x, y, w, h int, t *tightState) []byte {
|
||||
if pixel, uniform := tileIsUniform(img, x, y, w, h); uniform {
|
||||
return encodeTightFill(x, y, w, h, byte(pixel), byte(pixel>>8), byte(pixel>>16))
|
||||
}
|
||||
if w*h >= tightJPEGMinArea && sampledColorCountInto(t.colorSeen, img, x, y, w, h, tightJPEGMinColors) >= tightJPEGMinColors {
|
||||
if buf, ok := encodeTightJPEG(img, x, y, w, h, t); ok {
|
||||
return buf
|
||||
}
|
||||
}
|
||||
return encodeTightBasic(img, x, y, w, h, t)
|
||||
}
|
||||
|
||||
func writeTightRectHeader(buf []byte, x, y, w, h int) {
|
||||
binary.BigEndian.PutUint16(buf[0:2], uint16(x))
|
||||
binary.BigEndian.PutUint16(buf[2:4], uint16(y))
|
||||
binary.BigEndian.PutUint16(buf[4:6], uint16(w))
|
||||
binary.BigEndian.PutUint16(buf[6:8], uint16(h))
|
||||
binary.BigEndian.PutUint32(buf[8:12], uint32(encTight))
|
||||
}
|
||||
|
||||
// appendTightLength encodes a Tight compact length prefix (1, 2, or 3 bytes
|
||||
// LE-ish, top bit of each byte signals continuation).
|
||||
func appendTightLength(buf []byte, n int) []byte {
|
||||
b0 := byte(n & 0x7f)
|
||||
if n <= 0x7f {
|
||||
return append(buf, b0)
|
||||
}
|
||||
b0 |= 0x80
|
||||
b1 := byte((n >> 7) & 0x7f)
|
||||
if n <= 0x3fff {
|
||||
return append(buf, b0, b1)
|
||||
}
|
||||
b1 |= 0x80
|
||||
b2 := byte((n >> 14) & 0xff)
|
||||
return append(buf, b0, b1, b2)
|
||||
}
|
||||
|
||||
// encodeTightFill emits a uniform rect: 12-byte rect header + 1-byte
|
||||
// subenc (0x80) + 3-byte RGB pixel. Tight Fill always uses 24-bit RGB
|
||||
// regardless of the negotiated pixel format.
|
||||
func encodeTightFill(x, y, w, h int, r, g, b byte) []byte {
|
||||
buf := make([]byte, 12+1+3)
|
||||
writeTightRectHeader(buf, x, y, w, h)
|
||||
buf[12] = tightFillSubenc
|
||||
buf[13] = r
|
||||
buf[14] = g
|
||||
buf[15] = b
|
||||
return buf
|
||||
}
|
||||
|
||||
// encodeTightJPEG compresses the rect as a baseline JPEG. Returns ok=false
|
||||
// if the encoder errors so the caller can fall back to Basic.
|
||||
func encodeTightJPEG(img *image.RGBA, x, y, w, h int, t *tightState) ([]byte, bool) {
|
||||
t.jpegBuf.Reset()
|
||||
sub := img.SubImage(image.Rect(img.Rect.Min.X+x, img.Rect.Min.Y+y, img.Rect.Min.X+x+w, img.Rect.Min.Y+y+h))
|
||||
if err := jpeg.Encode(t.jpegBuf, sub, &jpeg.Options{Quality: tightQualityFor(w * h)}); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
jpegBytes := t.jpegBuf.Bytes()
|
||||
buf := make([]byte, 0, 12+1+3+len(jpegBytes))
|
||||
buf = buf[:12]
|
||||
writeTightRectHeader(buf, x, y, w, h)
|
||||
buf = append(buf, tightJPEGSubenc)
|
||||
buf = appendTightLength(buf, len(jpegBytes))
|
||||
buf = append(buf, jpegBytes...)
|
||||
return buf, true
|
||||
}
|
||||
|
||||
// encodeTightBasic emits Basic+zlib with the no-op (CopyFilter) filter.
|
||||
// Pixels are sent as 24-bit RGB ("TPIXEL" format) which most clients
|
||||
// negotiate when the server advertises 32bpp true colour. Streams under
|
||||
// 12 bytes ship uncompressed per RFB Tight spec.
|
||||
func encodeTightBasic(img *image.RGBA, x, y, w, h int, t *tightState) []byte {
|
||||
pixelStream := w * h * 3
|
||||
if cap(t.scratch) < pixelStream {
|
||||
t.scratch = make([]byte, pixelStream)
|
||||
}
|
||||
scratch := t.scratch[:pixelStream]
|
||||
stride := img.Stride
|
||||
off := 0
|
||||
for row := y; row < y+h; row++ {
|
||||
p := row*stride + x*4
|
||||
for col := 0; col < w; col++ {
|
||||
scratch[off+0] = img.Pix[p]
|
||||
scratch[off+1] = img.Pix[p+1]
|
||||
scratch[off+2] = img.Pix[p+2]
|
||||
p += 4
|
||||
off += 3
|
||||
}
|
||||
}
|
||||
|
||||
// Sub-encoding byte: stream 0, no resets, basic encoding (top nibble
|
||||
// = 0x40 = explicit filter follows).
|
||||
subenc := byte(tightBasicFilter)
|
||||
filter := byte(tightFilterCopy)
|
||||
|
||||
if pixelStream < 12 {
|
||||
buf := make([]byte, 0, 12+2+pixelStream)
|
||||
buf = buf[:12]
|
||||
writeTightRectHeader(buf, x, y, w, h)
|
||||
buf = append(buf, subenc, filter)
|
||||
buf = append(buf, scratch...)
|
||||
return buf
|
||||
}
|
||||
|
||||
z := t.zlib
|
||||
z.buf.Reset()
|
||||
if _, err := z.w.Write(scratch); err != nil {
|
||||
log.Debugf("tight zlib write: %v", err)
|
||||
return nil
|
||||
}
|
||||
if err := z.w.Flush(); err != nil {
|
||||
log.Debugf("tight zlib flush: %v", err)
|
||||
return nil
|
||||
}
|
||||
compressed := z.buf.Bytes()
|
||||
|
||||
buf := make([]byte, 0, 12+2+5+len(compressed))
|
||||
buf = buf[:12]
|
||||
writeTightRectHeader(buf, x, y, w, h)
|
||||
buf = append(buf, subenc, filter)
|
||||
buf = appendTightLength(buf, len(compressed))
|
||||
buf = append(buf, compressed...)
|
||||
return buf
|
||||
}
|
||||
|
||||
func tightQualityFor(pixels int) int {
|
||||
switch {
|
||||
case pixels >= tightJPEGLargePixels:
|
||||
return tightJPEGQualityLarge
|
||||
case pixels >= tightJPEGMediumPixels:
|
||||
return tightJPEGQualityMedium
|
||||
default:
|
||||
return tightJPEGQuality
|
||||
}
|
||||
}
|
||||
|
||||
// sampledColorCountInto estimates distinct-colour count by checking up to
|
||||
// maxColors samples. The caller-provided `seen` map is cleared and reused so
|
||||
// per-rect Tight encoding stays alloc-free. Cheap O(maxColors) per call.
|
||||
func sampledColorCountInto(seen map[uint32]struct{}, img *image.RGBA, x, y, w, h, maxColors int) int {
|
||||
clear(seen)
|
||||
stride := img.Stride
|
||||
step := max((w*h)/(maxColors*4), 1)
|
||||
var idx int
|
||||
for row := 0; row < h; row++ {
|
||||
p := (y+row)*stride + x*4
|
||||
for col := 0; col < w; col++ {
|
||||
if idx%step == 0 {
|
||||
px := *(*uint32)(unsafe.Pointer(&img.Pix[p+col*4]))
|
||||
seen[px&0x00ffffff] = struct{}{}
|
||||
if len(seen) > maxColors {
|
||||
return len(seen)
|
||||
}
|
||||
}
|
||||
idx++
|
||||
}
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
// zlibState holds the persistent zlib writer, output buffer, and a scratch
|
||||
// slice reused by encodeZlibRect to stage the packed pixel stream before
|
||||
// handing it to the deflate writer. The scratch grows to the largest rect
|
||||
// we've seen and is kept for the session lifetime.
|
||||
type zlibState struct {
|
||||
buf *bytes.Buffer
|
||||
w *zlib.Writer
|
||||
scratch []byte
|
||||
}
|
||||
|
||||
func newZlibState() *zlibState {
|
||||
buf := &bytes.Buffer{}
|
||||
w, _ := zlib.NewWriterLevel(buf, zlib.BestSpeed)
|
||||
return &zlibState{buf: buf, w: w}
|
||||
}
|
||||
|
||||
func (z *zlibState) Close() error {
|
||||
return z.w.Close()
|
||||
}
|
||||
405
client/vnc/server/rfb_bench_test.go
Normal file
405
client/vnc/server/rfb_bench_test.go
Normal file
@@ -0,0 +1,405 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"image"
|
||||
"math/rand"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Representative frame sizes.
|
||||
var benchRects = []struct {
|
||||
name string
|
||||
w, h int
|
||||
}{
|
||||
{"1080p_full", 1920, 1080},
|
||||
{"720p_full", 1280, 720},
|
||||
{"256x256_tile", 256, 256},
|
||||
{"64x64_tile", 64, 64},
|
||||
}
|
||||
|
||||
func makeBenchImage(w, h int, seed int64) *image.RGBA {
|
||||
img := image.NewRGBA(image.Rect(0, 0, w, h))
|
||||
r := rand.New(rand.NewSource(seed))
|
||||
_, _ = r.Read(img.Pix)
|
||||
// Force alpha byte so the fast path and slow path produce identical output.
|
||||
for i := 3; i < len(img.Pix); i += 4 {
|
||||
img.Pix[i] = 0xff
|
||||
}
|
||||
return img
|
||||
}
|
||||
|
||||
func makeBenchImagePartial(w, h, changedRows int) (*image.RGBA, *image.RGBA) {
|
||||
prev := makeBenchImage(w, h, 1)
|
||||
cur := image.NewRGBA(prev.Rect)
|
||||
copy(cur.Pix, prev.Pix)
|
||||
if changedRows > h {
|
||||
changedRows = h
|
||||
}
|
||||
// Dirty the first `changedRows` rows.
|
||||
r := rand.New(rand.NewSource(2))
|
||||
_, _ = r.Read(cur.Pix[:changedRows*cur.Stride])
|
||||
for i := 3; i < len(cur.Pix); i += 4 {
|
||||
cur.Pix[i] = 0xff
|
||||
}
|
||||
return prev, cur
|
||||
}
|
||||
|
||||
func BenchmarkEncodeRawRect(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
for _, r := range benchRects {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = encodeRawRect(img, pf, 0, 0, r.w, r.h)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkEncodeZlibRect(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
for _, r := range benchRects {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
z := newZlibState()
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = encodeZlibRect(img, pf, 0, 0, r.w, r.h, z)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWritePixels isolates the per-pixel pack loop from the allocation
|
||||
// and FramebufferUpdate-header overhead.
|
||||
func BenchmarkWritePixels(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
for _, r := range benchRects {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
dst := make([]byte, r.w*r.h*4)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
writePixels(dst, img, pf, rect{0, 0, r.w, r.h}, 4)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkWritePixelsScaled forces the general (non-fast) path by using a
|
||||
// pixel format with non-255 channel maxes.
|
||||
func BenchmarkWritePixelsScaled(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
pf.rMax, pf.gMax, pf.bMax = 31, 63, 31 // 16bpp-ish; exercises the divide path
|
||||
pf.bpp = 16
|
||||
for _, r := range benchRects {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
dst := make([]byte, r.w*r.h*2)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
writePixels(dst, img, pf, rect{0, 0, r.w, r.h}, 2)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkSwizzleBGRAtoRGBA(b *testing.B) {
|
||||
for _, r := range benchRects {
|
||||
size := r.w * r.h * 4
|
||||
src := make([]byte, size)
|
||||
dst := make([]byte, size)
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
_, _ = rng.Read(src)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
swizzleBGRAtoRGBA(dst, src)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkSwizzleBGRAtoRGBANaive is the naive byte-by-byte implementation
|
||||
// that the Linux SHM capturer used before the uint32 rewrite, kept here so
|
||||
// we can compare the cost directly.
|
||||
func BenchmarkSwizzleBGRAtoRGBANaive(b *testing.B) {
|
||||
for _, r := range benchRects {
|
||||
size := r.w * r.h * 4
|
||||
src := make([]byte, size)
|
||||
dst := make([]byte, size)
|
||||
rng := rand.New(rand.NewSource(1))
|
||||
_, _ = rng.Read(src)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(size))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
for j := 0; j < size; j += 4 {
|
||||
dst[j+0] = src[j+2]
|
||||
dst[j+1] = src[j+1]
|
||||
dst[j+2] = src[j+0]
|
||||
dst[j+3] = 0xff
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeUniformTile_Zlib measures the cost of sending a uniform
|
||||
// 64×64 dirty tile via zlib (the old path before the Hextile fast path).
|
||||
func BenchmarkEncodeUniformTile_Zlib(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
for i := 0; i < len(img.Pix); i += 4 {
|
||||
img.Pix[i+0] = 0x33
|
||||
img.Pix[i+1] = 0x66
|
||||
img.Pix[i+2] = 0x99
|
||||
img.Pix[i+3] = 0xff
|
||||
}
|
||||
z := newZlibState()
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
out := encodeZlibRect(img, pf, 0, 0, 64, 64, z)
|
||||
bytesOut = len(out)
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
}
|
||||
|
||||
// BenchmarkEncodeUniformTile_Hextile measures the new fast path: uniform
|
||||
// 64×64 tile emitted as Hextile SolidFill.
|
||||
func BenchmarkEncodeUniformTile_Hextile(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
out := encodeHextileSolidRect(0x33, 0x66, 0x99, pf, rect{0, 0, 64, 64})
|
||||
bytesOut = len(out)
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
}
|
||||
|
||||
func BenchmarkTileIsUniform(b *testing.B) {
|
||||
img := image.NewRGBA(image.Rect(0, 0, 64, 64))
|
||||
for i := 0; i < len(img.Pix); i += 4 {
|
||||
img.Pix[i+3] = 0xff
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_, _ = tileIsUniform(img, 0, 0, 64, 64)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeManyTilesVsFullFrame exercises the bandwidth + CPU
|
||||
// trade-off that motivates the full-frame promotion path: encoding a burst
|
||||
// of N dirty 64×64 tiles as separate zlib rects vs emitting one big zlib
|
||||
// rect for the whole frame.
|
||||
func BenchmarkEncodeManyTilesVsFullFrame(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
const w, h = 1920, 1080
|
||||
img := makeBenchImage(w, h, 1)
|
||||
|
||||
// Build the list of every tile in the frame (worst case: entire screen dirty).
|
||||
var tiles [][4]int
|
||||
for ty := 0; ty < h; ty += tileSize {
|
||||
th := tileSize
|
||||
if ty+th > h {
|
||||
th = h - ty
|
||||
}
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := tileSize
|
||||
if tx+tw > w {
|
||||
tw = w - tx
|
||||
}
|
||||
tiles = append(tiles, [4]int{tx, ty, tw, th})
|
||||
}
|
||||
}
|
||||
nTiles := len(tiles)
|
||||
|
||||
b.Run("per_tile_zlib", func(b *testing.B) {
|
||||
z := newZlibState()
|
||||
b.SetBytes(int64(w * h * 4))
|
||||
b.ReportAllocs()
|
||||
var totalOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
totalOut = 0
|
||||
for _, r := range tiles {
|
||||
out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z)
|
||||
totalOut += len(out)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(totalOut), "wire_bytes")
|
||||
b.ReportMetric(float64(nTiles), "tiles")
|
||||
})
|
||||
|
||||
b.Run("full_frame_zlib", func(b *testing.B) {
|
||||
z := newZlibState()
|
||||
b.SetBytes(int64(w * h * 4))
|
||||
b.ReportAllocs()
|
||||
var totalOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
out := encodeZlibRect(img, pf, 0, 0, w, h, z)
|
||||
totalOut = len(out)
|
||||
}
|
||||
b.ReportMetric(float64(totalOut), "wire_bytes")
|
||||
})
|
||||
}
|
||||
|
||||
// BenchmarkShouldPromoteToFullFrame verifies the threshold check itself is
|
||||
// cheap. It runs on every frame, so regressions here hit all workloads.
|
||||
func BenchmarkShouldPromoteToFullFrame(b *testing.B) {
|
||||
const w, h = 1920, 1080
|
||||
s := &session{serverW: w, serverH: h}
|
||||
// Build a worst-case rect list (every tile dirty, 510 entries).
|
||||
var rects [][4]int
|
||||
for ty := 0; ty < h; ty += tileSize {
|
||||
th := tileSize
|
||||
if ty+th > h {
|
||||
th = h - ty
|
||||
}
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := tileSize
|
||||
if tx+tw > w {
|
||||
tw = w - tx
|
||||
}
|
||||
rects = append(rects, [4]int{tx, ty, tw, th})
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = s.shouldPromoteToFullFrame(rects)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeCoalescedVsPerTile compares per-tile encoding vs the
|
||||
// coalesced rect list emitted by diffRects, on a horizontal-band dirty
|
||||
// pattern (e.g. a scrolling status bar) where coalescing pays off.
|
||||
func BenchmarkEncodeCoalescedVsPerTile(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
const w, h = 1920, 1080
|
||||
img := makeBenchImage(w, h, 1)
|
||||
|
||||
// Dirty band: rows 200..264 (one tile-row), full width.
|
||||
var perTile [][4]int
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := tileSize
|
||||
if tx+tw > w {
|
||||
tw = w - tx
|
||||
}
|
||||
perTile = append(perTile, [4]int{tx, 200, tw, tileSize})
|
||||
}
|
||||
coalesced := coalesceRects(append([][4]int(nil), perTile...))
|
||||
|
||||
b.Run("per_tile", func(b *testing.B) {
|
||||
z := newZlibState()
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
bytesOut = 0
|
||||
for _, r := range perTile {
|
||||
out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z)
|
||||
bytesOut += len(out)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
b.ReportMetric(float64(len(perTile)), "rects")
|
||||
})
|
||||
|
||||
b.Run("coalesced", func(b *testing.B) {
|
||||
z := newZlibState()
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
bytesOut = 0
|
||||
for _, r := range coalesced {
|
||||
out := encodeZlibRect(img, pf, r[0], r[1], r[2], r[3], z)
|
||||
bytesOut += len(out)
|
||||
}
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
b.ReportMetric(float64(len(coalesced)), "rects")
|
||||
})
|
||||
}
|
||||
|
||||
func BenchmarkCoalesceRects(b *testing.B) {
|
||||
const w, h = 1920, 1080
|
||||
// Worst case: every tile dirty.
|
||||
var allTiles [][4]int
|
||||
for ty := 0; ty < h; ty += tileSize {
|
||||
th := tileSize
|
||||
if ty+th > h {
|
||||
th = h - ty
|
||||
}
|
||||
for tx := 0; tx < w; tx += tileSize {
|
||||
tw := tileSize
|
||||
if tx+tw > w {
|
||||
tw = w - tx
|
||||
}
|
||||
allTiles = append(allTiles, [4]int{tx, ty, tw, th})
|
||||
}
|
||||
}
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
in := make([][4]int, len(allTiles))
|
||||
copy(in, allTiles)
|
||||
_ = coalesceRects(in)
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkEncodeTightVsZlib_Photo compares Tight (which routes random/
|
||||
// photographic content to JPEG) against the persistent Zlib stream. JPEG
|
||||
// at quality 70 should be 5-15× smaller on this kind of content.
|
||||
func BenchmarkEncodeTightVsZlib_Photo(b *testing.B) {
|
||||
pf := defaultClientPixelFormat()
|
||||
for _, r := range []struct {
|
||||
name string
|
||||
w, h int
|
||||
}{
|
||||
{"256x256", 256, 256},
|
||||
{"512x512", 512, 512},
|
||||
{"1080p", 1920, 1080},
|
||||
} {
|
||||
img := makeBenchImage(r.w, r.h, 1)
|
||||
b.Run(r.name+"/zlib", func(b *testing.B) {
|
||||
z := newZlibState()
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
out := encodeZlibRect(img, pf, 0, 0, r.w, r.h, z)
|
||||
bytesOut = len(out)
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
})
|
||||
b.Run(r.name+"/tight", func(b *testing.B) {
|
||||
t := newTightState()
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
var bytesOut int
|
||||
for i := 0; i < b.N; i++ {
|
||||
out := encodeTightRect(img, pf, 0, 0, r.w, r.h, t)
|
||||
bytesOut = len(out)
|
||||
}
|
||||
b.ReportMetric(float64(bytesOut), "wire_bytes")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkDiffRects(b *testing.B) {
|
||||
for _, r := range benchRects {
|
||||
prev, cur := makeBenchImagePartial(r.w, r.h, 100)
|
||||
b.Run(r.name, func(b *testing.B) {
|
||||
b.SetBytes(int64(r.w * r.h * 4))
|
||||
b.ReportAllocs()
|
||||
for i := 0; i < b.N; i++ {
|
||||
_ = diffRects(prev, cur, r.w, r.h, tileSize)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
814
client/vnc/server/server.go
Normal file
814
client/vnc/server/server.go
Normal file
@@ -0,0 +1,814 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/subtle"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"image"
|
||||
"io"
|
||||
"net"
|
||||
"net/netip"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
gojwt "github.com/golang-jwt/jwt/v5"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.zx2c4.com/wireguard/tun/netstack"
|
||||
|
||||
sshauth "github.com/netbirdio/netbird/client/ssh/auth"
|
||||
nbjwt "github.com/netbirdio/netbird/shared/auth/jwt"
|
||||
)
|
||||
|
||||
// Connection modes sent by the client in the session header.
|
||||
const (
|
||||
ModeAttach byte = 0 // Capture current display
|
||||
ModeSession byte = 1 // Virtual session as specified user
|
||||
)
|
||||
|
||||
// RFB security-failure reason codes sent to the client. These prefixes are
|
||||
// stable so dashboard/noVNC integrations can branch on them without parsing
|
||||
// free text. Format: "CODE: human message".
|
||||
const (
|
||||
RejectCodeJWTMissing = "AUTH_JWT_MISSING"
|
||||
RejectCodeJWTExpired = "AUTH_JWT_EXPIRED"
|
||||
RejectCodeJWTInvalid = "AUTH_JWT_INVALID"
|
||||
RejectCodeAuthForbidden = "AUTH_FORBIDDEN"
|
||||
RejectCodeAuthConfig = "AUTH_CONFIG"
|
||||
RejectCodeSessionError = "SESSION_ERROR"
|
||||
RejectCodeCapturerError = "CAPTURER_ERROR"
|
||||
RejectCodeUnsupportedOS = "UNSUPPORTED"
|
||||
RejectCodeBadRequest = "BAD_REQUEST"
|
||||
)
|
||||
|
||||
// EnvVNCDisableDownscale disables any platform-specific framebuffer
|
||||
// downscaling (e.g. Retina 2:1). Set to 1/true to send the native resolution.
|
||||
const EnvVNCDisableDownscale = "NB_VNC_DISABLE_DOWNSCALE"
|
||||
|
||||
// freshWindow is how long an on-demand capturer may reuse its last result
|
||||
// before triggering a new capture. Short enough to feel responsive, long
|
||||
// enough to coalesce bursty multi-session requests. 16 ms ~= 60 fps.
|
||||
const freshWindow = 16 * time.Millisecond
|
||||
|
||||
// ScreenCapturer grabs desktop frames for the VNC server.
|
||||
type ScreenCapturer interface {
|
||||
// Width returns the current screen width in pixels.
|
||||
Width() int
|
||||
// Height returns the current screen height in pixels.
|
||||
Height() int
|
||||
// Capture returns the current desktop as an RGBA image.
|
||||
Capture() (*image.RGBA, error)
|
||||
}
|
||||
|
||||
// captureIntoer is implemented by capturers that can write directly into a
|
||||
// caller-provided buffer, skipping the per-frame snapshot copy that the
|
||||
// session would otherwise need to make. Linux and macOS implement this.
|
||||
type captureIntoer interface {
|
||||
CaptureInto(dst *image.RGBA) error
|
||||
}
|
||||
|
||||
// errFrameUnchanged is returned by capturers that hash the raw source
|
||||
// bytes (currently macOS) when the new frame is byte-identical to the
|
||||
// last one, so the encoder can short-circuit to an empty update.
|
||||
var errFrameUnchanged = errors.New("frame unchanged")
|
||||
|
||||
// InputInjector delivers keyboard and mouse events to the OS.
|
||||
type InputInjector interface {
|
||||
// InjectKey simulates a key press or release. keysym is an X11 KeySym.
|
||||
InjectKey(keysym uint32, down bool)
|
||||
// InjectPointer simulates mouse movement and button state.
|
||||
InjectPointer(buttonMask uint8, x, y, serverW, serverH int)
|
||||
// SetClipboard sets the system clipboard to the given text.
|
||||
SetClipboard(text string)
|
||||
// GetClipboard returns the current system clipboard text.
|
||||
GetClipboard() string
|
||||
// TypeText synthesizes the given text as keystrokes on the active
|
||||
// desktop. Used by the dashboard's Paste button to push host clipboard
|
||||
// content into a secure desktop (Winlogon/UAC) where the clipboard is
|
||||
// isolated. On platforms or sessions without keystroke synthesis it
|
||||
// may be a no-op.
|
||||
TypeText(text string)
|
||||
}
|
||||
|
||||
// JWTConfig holds JWT validation configuration for VNC auth.
|
||||
type JWTConfig struct {
|
||||
Issuer string
|
||||
KeysLocation string
|
||||
MaxTokenAge int64
|
||||
Audiences []string
|
||||
}
|
||||
|
||||
// connectionHeader is sent by the client before the RFB handshake to specify
|
||||
// the VNC session mode and authenticate.
|
||||
type connectionHeader struct {
|
||||
mode byte
|
||||
username string
|
||||
jwt string
|
||||
sessionID uint32 // Windows session ID (0 = console/auto)
|
||||
// width and height request the virtual display geometry for session mode.
|
||||
// Zero means use the default.
|
||||
width uint16
|
||||
height uint16
|
||||
}
|
||||
|
||||
// Server is the embedded VNC server that listens on the WireGuard interface.
|
||||
// It supports two operating modes:
|
||||
// - Direct mode: captures the screen and handles VNC sessions in-process.
|
||||
// Used when running in a user session with desktop access.
|
||||
// - Service mode: proxies VNC connections to an agent process spawned in
|
||||
// the active console session. Used when running as a Windows service in
|
||||
// Session 0.
|
||||
//
|
||||
// Within direct mode, each connection can request one of two session modes
|
||||
// via the connection header:
|
||||
// - Attach: capture the current physical display.
|
||||
// - Session: start a virtual Xvfb display as the requested user.
|
||||
type Server struct {
|
||||
capturer ScreenCapturer
|
||||
injector InputInjector
|
||||
password string
|
||||
serviceMode bool
|
||||
disableAuth bool
|
||||
localAddr netip.Addr // NetBird WireGuard IP this server is bound to
|
||||
network netip.Prefix // NetBird overlay network
|
||||
log *log.Entry
|
||||
|
||||
mu sync.Mutex
|
||||
listener net.Listener
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
vmgr virtualSessionManager
|
||||
jwtConfig *JWTConfig
|
||||
jwtValidator *nbjwt.Validator
|
||||
jwtExtractor *nbjwt.ClaimsExtractor
|
||||
authorizer *sshauth.Authorizer
|
||||
netstackNet *netstack.Net
|
||||
agentToken []byte // raw token bytes for agent-mode auth
|
||||
|
||||
sessionsMu sync.Mutex
|
||||
sessionSeq uint64
|
||||
sessions map[uint64]ActiveSessionInfo
|
||||
}
|
||||
|
||||
// ActiveSessionInfo describes a currently connected VNC client.
|
||||
type ActiveSessionInfo struct {
|
||||
RemoteAddress string
|
||||
Mode string
|
||||
Username string
|
||||
JWTUsername string
|
||||
}
|
||||
|
||||
// vncSession provides capturer and injector for a virtual display session.
|
||||
type vncSession interface {
|
||||
Capturer() ScreenCapturer
|
||||
Injector() InputInjector
|
||||
Display() string
|
||||
ClientConnect()
|
||||
ClientDisconnect()
|
||||
}
|
||||
|
||||
// virtualSessionManager is implemented by sessionManager on Linux.
|
||||
type virtualSessionManager interface {
|
||||
// GetOrCreate returns an existing session for the user or starts a new one
|
||||
// with the requested geometry. width/height of 0 means use the default.
|
||||
GetOrCreate(username string, width, height uint16) (vncSession, error)
|
||||
StopAll()
|
||||
}
|
||||
|
||||
// New creates a VNC server with the given screen capturer and input injector.
|
||||
func New(capturer ScreenCapturer, injector InputInjector, password string) *Server {
|
||||
return &Server{
|
||||
capturer: capturer,
|
||||
injector: injector,
|
||||
password: password,
|
||||
authorizer: sshauth.NewAuthorizer(),
|
||||
log: log.WithField("component", "vnc-server"),
|
||||
sessions: make(map[uint64]ActiveSessionInfo),
|
||||
}
|
||||
}
|
||||
|
||||
// ActiveSessions returns a snapshot of currently connected VNC clients.
|
||||
func (s *Server) ActiveSessions() []ActiveSessionInfo {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
out := make([]ActiveSessionInfo, 0, len(s.sessions))
|
||||
for _, info := range s.sessions {
|
||||
out = append(out, info)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) addSession(info ActiveSessionInfo) uint64 {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
s.sessionSeq++
|
||||
id := s.sessionSeq
|
||||
s.sessions[id] = info
|
||||
return id
|
||||
}
|
||||
|
||||
func (s *Server) removeSession(id uint64) {
|
||||
s.sessionsMu.Lock()
|
||||
defer s.sessionsMu.Unlock()
|
||||
delete(s.sessions, id)
|
||||
}
|
||||
|
||||
// SetServiceMode enables proxy-to-agent mode for Windows service operation.
|
||||
func (s *Server) SetServiceMode(enabled bool) {
|
||||
s.serviceMode = enabled
|
||||
}
|
||||
|
||||
// SetJWTConfig configures JWT authentication for VNC connections.
|
||||
// Pass nil to disable JWT (public mode).
|
||||
func (s *Server) SetJWTConfig(config *JWTConfig) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.jwtConfig = config
|
||||
s.jwtValidator = nil
|
||||
s.jwtExtractor = nil
|
||||
}
|
||||
|
||||
// SetDisableAuth disables authentication entirely.
|
||||
func (s *Server) SetDisableAuth(disable bool) {
|
||||
s.disableAuth = disable
|
||||
}
|
||||
|
||||
// SetAgentToken sets a hex-encoded token that must be presented by incoming
|
||||
// connections before any VNC data. Used in agent mode to verify that only the
|
||||
// trusted service process connects.
|
||||
func (s *Server) SetAgentToken(hexToken string) {
|
||||
if hexToken == "" {
|
||||
return
|
||||
}
|
||||
b, err := hex.DecodeString(hexToken)
|
||||
if err != nil {
|
||||
s.log.Warnf("invalid agent token: %v", err)
|
||||
return
|
||||
}
|
||||
s.agentToken = b
|
||||
}
|
||||
|
||||
// SetNetstackNet sets the netstack network for userspace-only listening.
|
||||
// When set, the VNC server listens via netstack instead of a real OS socket.
|
||||
func (s *Server) SetNetstackNet(n *netstack.Net) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.netstackNet = n
|
||||
}
|
||||
|
||||
// UpdateVNCAuth updates the fine-grained authorization configuration.
|
||||
func (s *Server) UpdateVNCAuth(config *sshauth.Config) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.jwtValidator = nil
|
||||
s.jwtExtractor = nil
|
||||
s.authorizer.Update(config)
|
||||
}
|
||||
|
||||
// Start begins listening for VNC connections on the given address.
|
||||
// network is the NetBird overlay prefix used to validate connection sources.
|
||||
func (s *Server) Start(ctx context.Context, addr netip.AddrPort, network netip.Prefix) error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.listener != nil {
|
||||
return fmt.Errorf("server already running")
|
||||
}
|
||||
|
||||
if !network.IsValid() {
|
||||
return fmt.Errorf("invalid overlay network prefix")
|
||||
}
|
||||
|
||||
s.ctx, s.cancel = context.WithCancel(ctx)
|
||||
s.vmgr = s.platformSessionManager()
|
||||
s.localAddr = addr.Addr()
|
||||
s.network = network
|
||||
|
||||
var listener net.Listener
|
||||
var listenDesc string
|
||||
if s.netstackNet != nil {
|
||||
ln, err := s.netstackNet.ListenTCPAddrPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on netstack %s: %w", addr, err)
|
||||
}
|
||||
listener = ln
|
||||
listenDesc = fmt.Sprintf("netstack %s", addr)
|
||||
} else {
|
||||
tcpAddr := net.TCPAddrFromAddrPort(addr)
|
||||
ln, err := net.ListenTCP("tcp", tcpAddr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen on %s: %w", addr, err)
|
||||
}
|
||||
listener = ln
|
||||
listenDesc = addr.String()
|
||||
}
|
||||
s.listener = listener
|
||||
|
||||
if s.serviceMode {
|
||||
s.platformInit()
|
||||
}
|
||||
|
||||
if s.serviceMode {
|
||||
go s.serviceAcceptLoop()
|
||||
} else {
|
||||
go s.acceptLoop()
|
||||
}
|
||||
|
||||
s.log.Infof("started on %s (service_mode=%v)", listenDesc, s.serviceMode)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Stop shuts down the server and closes all connections.
|
||||
func (s *Server) Stop() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.cancel != nil {
|
||||
s.cancel()
|
||||
s.cancel = nil
|
||||
}
|
||||
|
||||
if s.vmgr != nil {
|
||||
s.vmgr.StopAll()
|
||||
}
|
||||
|
||||
if s.serviceMode {
|
||||
s.platformShutdown()
|
||||
}
|
||||
|
||||
if c, ok := s.capturer.(interface{ Close() }); ok {
|
||||
c.Close()
|
||||
}
|
||||
|
||||
if s.listener != nil {
|
||||
err := s.listener.Close()
|
||||
s.listener = nil
|
||||
if err != nil {
|
||||
return fmt.Errorf("close VNC listener: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
s.log.Info("stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
// acceptLoop handles VNC connections directly (user session mode).
|
||||
func (s *Server) acceptLoop() {
|
||||
for {
|
||||
conn, err := s.listener.Accept()
|
||||
if err != nil {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
s.log.Debugf("accept VNC connection: %v", err)
|
||||
continue
|
||||
}
|
||||
|
||||
go s.handleConnection(conn)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) validateCapturer(capturer ScreenCapturer) error {
|
||||
// Quick check first: if already ready, return immediately.
|
||||
if capturer.Width() > 0 && capturer.Height() > 0 {
|
||||
return nil
|
||||
}
|
||||
// Capturer not ready: poke any retry loop that supports it so it doesn't
|
||||
// wait out its full backoff (e.g. macOS waiting for Screen Recording).
|
||||
if w, ok := capturer.(interface{ Wake() }); ok {
|
||||
w.Wake()
|
||||
}
|
||||
// Wait up to 5s for the capturer to become ready.
|
||||
for range 50 {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
if capturer.Width() > 0 && capturer.Height() > 0 {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New("no display available (check X11 / framebuffer on Linux/FreeBSD or Screen Recording permission on macOS)")
|
||||
}
|
||||
|
||||
// isAllowedSource rejects connections from outside the NetBird overlay network
|
||||
// and from the local WireGuard IP (prevents local privilege escalation).
|
||||
// Matches the SSH server's connectionValidator logic.
|
||||
func (s *Server) isAllowedSource(addr net.Addr) bool {
|
||||
tcpAddr, ok := addr.(*net.TCPAddr)
|
||||
if !ok {
|
||||
s.log.Warnf("connection rejected: non-TCP address %s", addr)
|
||||
return false
|
||||
}
|
||||
|
||||
remoteIP, ok := netip.AddrFromSlice(tcpAddr.IP)
|
||||
if !ok {
|
||||
s.log.Warnf("connection rejected: invalid remote IP %s", tcpAddr.IP)
|
||||
return false
|
||||
}
|
||||
remoteIP = remoteIP.Unmap()
|
||||
|
||||
if remoteIP.IsLoopback() && s.localAddr.IsLoopback() {
|
||||
return true
|
||||
}
|
||||
|
||||
if remoteIP == s.localAddr {
|
||||
s.log.Warnf("connection rejected from own IP %s", remoteIP)
|
||||
return false
|
||||
}
|
||||
|
||||
if !s.network.IsValid() {
|
||||
s.log.Warnf("connection rejected: overlay network not configured")
|
||||
return false
|
||||
}
|
||||
if !s.network.Contains(remoteIP) {
|
||||
s.log.Warnf("connection rejected from non-NetBird IP %s", remoteIP)
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) handleConnection(conn net.Conn) {
|
||||
connLog := s.log.WithField("remote", conn.RemoteAddr().String())
|
||||
|
||||
if !s.isAllowedSource(conn.RemoteAddr()) {
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
if !s.verifyAgentToken(conn, connLog) {
|
||||
return
|
||||
}
|
||||
header, err := readConnectionHeader(conn)
|
||||
if err != nil {
|
||||
connLog.Warnf("read connection header: %v", err)
|
||||
conn.Close()
|
||||
return
|
||||
}
|
||||
connLog, jwtUserID, ok := s.authorizeJWT(conn, header, connLog)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
capturer, injector, sessionCleanup, ok := s.acquireSessionResources(conn, header, &connLog)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
defer sessionCleanup()
|
||||
|
||||
sessionID := s.addSession(ActiveSessionInfo{
|
||||
RemoteAddress: conn.RemoteAddr().String(),
|
||||
Mode: modeString(header.mode),
|
||||
Username: header.username,
|
||||
JWTUsername: jwtUserID,
|
||||
})
|
||||
defer s.removeSession(sessionID)
|
||||
|
||||
if err := s.validateCapturer(capturer); err != nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeCapturerError, fmt.Sprintf("screen capturer: %v", err)))
|
||||
connLog.Warnf("capturer not ready: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
sess := &session{
|
||||
conn: conn,
|
||||
capturer: capturer,
|
||||
injector: injector,
|
||||
serverW: capturer.Width(),
|
||||
serverH: capturer.Height(),
|
||||
password: s.password,
|
||||
log: connLog,
|
||||
}
|
||||
sess.serve()
|
||||
}
|
||||
|
||||
// codeMessage formats a stable reject code with a human-readable message.
|
||||
// Dashboards split on the first ": " to recover the code without parsing the
|
||||
// free-text suffix.
|
||||
func codeMessage(code, msg string) string {
|
||||
return code + ": " + msg
|
||||
}
|
||||
|
||||
// jwtErrorCode maps a JWT auth error to a stable reject code.
|
||||
func jwtErrorCode(err error) string {
|
||||
if err == nil {
|
||||
return RejectCodeJWTInvalid
|
||||
}
|
||||
if errors.Is(err, nbjwt.ErrTokenExpired) {
|
||||
return RejectCodeJWTExpired
|
||||
}
|
||||
msg := err.Error()
|
||||
switch {
|
||||
case strings.Contains(msg, "JWT required but not provided"):
|
||||
return RejectCodeJWTMissing
|
||||
case strings.Contains(msg, "authorize") || strings.Contains(msg, "not authorized"):
|
||||
return RejectCodeAuthForbidden
|
||||
default:
|
||||
return RejectCodeJWTInvalid
|
||||
}
|
||||
}
|
||||
|
||||
// rejectConnection sends a minimal RFB handshake with a security failure
|
||||
// reason, so VNC clients display the error message instead of a generic
|
||||
// "unexpected disconnect."
|
||||
func rejectConnection(conn net.Conn, reason string) {
|
||||
defer conn.Close()
|
||||
// RFB 3.8 server version.
|
||||
if _, err := io.WriteString(conn, "RFB 003.008\n"); err != nil {
|
||||
return
|
||||
}
|
||||
// Read client version (12 bytes), ignore errors here so a short-lived
|
||||
// or pre-handshake client still gets the failure reason below.
|
||||
var clientVer [12]byte
|
||||
_ = conn.SetReadDeadline(time.Now().Add(2 * time.Second))
|
||||
_, _ = io.ReadFull(conn, clientVer[:])
|
||||
_ = conn.SetReadDeadline(time.Time{})
|
||||
// Send 0 security types = connection failed, followed by reason.
|
||||
msg := []byte(reason)
|
||||
buf := make([]byte, 1+4+len(msg))
|
||||
buf[0] = 0 // 0 security types = failure
|
||||
binary.BigEndian.PutUint32(buf[1:5], uint32(len(msg)))
|
||||
copy(buf[5:], msg)
|
||||
_, _ = conn.Write(buf)
|
||||
}
|
||||
|
||||
const defaultJWTMaxTokenAge = 10 * 60 // 10 minutes
|
||||
|
||||
// authenticateJWT validates the JWT from the connection header and checks
|
||||
// authorization. For attach mode, just checks membership in the authorized
|
||||
// user list. For session mode, additionally validates the OS user mapping.
|
||||
func (s *Server) authenticateJWT(header *connectionHeader) (string, error) {
|
||||
if header.jwt == "" {
|
||||
return "", fmt.Errorf("JWT required but not provided")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
if err := s.ensureJWTValidator(); err != nil {
|
||||
s.mu.Unlock()
|
||||
return "", fmt.Errorf("initialize JWT validator: %w", err)
|
||||
}
|
||||
validator := s.jwtValidator
|
||||
extractor := s.jwtExtractor
|
||||
s.mu.Unlock()
|
||||
|
||||
token, err := validator.ValidateAndParse(context.Background(), header.jwt)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("validate JWT: %w", err)
|
||||
}
|
||||
|
||||
if err := s.checkTokenAge(token); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
userAuth, err := extractor.ToUserAuth(token)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("extract user from JWT: %w", err)
|
||||
}
|
||||
if userAuth.UserId == "" {
|
||||
return "", fmt.Errorf("JWT has no user ID")
|
||||
}
|
||||
|
||||
switch header.mode {
|
||||
case ModeSession:
|
||||
// Session mode: check user + OS username mapping.
|
||||
if _, err := s.authorizer.Authorize(userAuth.UserId, header.username); err != nil {
|
||||
return "", fmt.Errorf("authorize session for %s: %w", header.username, err)
|
||||
}
|
||||
default:
|
||||
// Attach mode: just check user is in the authorized list (wildcard OS user).
|
||||
if _, err := s.authorizer.Authorize(userAuth.UserId, "*"); err != nil {
|
||||
return "", fmt.Errorf("user not authorized for VNC: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return userAuth.UserId, nil
|
||||
}
|
||||
|
||||
// ensureJWTValidator lazily initializes the JWT validator. Must be called with mu held.
|
||||
func (s *Server) ensureJWTValidator() error {
|
||||
if s.jwtValidator != nil && s.jwtExtractor != nil {
|
||||
return nil
|
||||
}
|
||||
if s.jwtConfig == nil {
|
||||
return fmt.Errorf("no JWT config")
|
||||
}
|
||||
|
||||
s.jwtValidator = nbjwt.NewValidator(
|
||||
s.jwtConfig.Issuer,
|
||||
s.jwtConfig.Audiences,
|
||||
s.jwtConfig.KeysLocation,
|
||||
false,
|
||||
)
|
||||
|
||||
var opts []nbjwt.ClaimsExtractorOption
|
||||
if len(s.jwtConfig.Audiences) > 0 {
|
||||
opts = append(opts, nbjwt.WithAudience(s.jwtConfig.Audiences[0]))
|
||||
}
|
||||
if claim := s.authorizer.GetUserIDClaim(); claim != "" {
|
||||
opts = append(opts, nbjwt.WithUserIDClaim(claim))
|
||||
}
|
||||
s.jwtExtractor = nbjwt.NewClaimsExtractor(opts...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) checkTokenAge(token *gojwt.Token) error {
|
||||
maxAge := defaultJWTMaxTokenAge
|
||||
if s.jwtConfig != nil && s.jwtConfig.MaxTokenAge > 0 {
|
||||
maxAge = int(s.jwtConfig.MaxTokenAge)
|
||||
}
|
||||
return nbjwt.CheckTokenAge(token, time.Duration(maxAge)*time.Second)
|
||||
}
|
||||
|
||||
// readConnectionHeader reads the NetBird VNC session header from the connection.
|
||||
// Format: [mode: 1 byte] [username_len: 2 bytes BE] [username: N bytes]
|
||||
//
|
||||
// [jwt_len: 2 bytes BE] [jwt: N bytes]
|
||||
//
|
||||
// Uses a short timeout: our WASM proxy sends the header immediately after
|
||||
// connecting. Standard VNC clients don't send anything first (server speaks
|
||||
// first in RFB), so they time out and get the default attach mode.
|
||||
func readConnectionHeader(conn net.Conn) (*connectionHeader, error) {
|
||||
if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil {
|
||||
return nil, fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
defer conn.SetReadDeadline(time.Time{}) //nolint:errcheck
|
||||
|
||||
var hdr [3]byte
|
||||
if _, err := io.ReadFull(conn, hdr[:]); err != nil {
|
||||
// Timeout or error: assume no header, use attach mode.
|
||||
return &connectionHeader{mode: ModeAttach}, nil
|
||||
}
|
||||
|
||||
// Restore a longer deadline for reading variable-length fields.
|
||||
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
return nil, fmt.Errorf("set deadline: %w", err)
|
||||
}
|
||||
|
||||
mode := hdr[0]
|
||||
usernameLen := binary.BigEndian.Uint16(hdr[1:3])
|
||||
|
||||
var username string
|
||||
if usernameLen > 0 {
|
||||
if usernameLen > 256 {
|
||||
return nil, fmt.Errorf("username too long: %d", usernameLen)
|
||||
}
|
||||
buf := make([]byte, usernameLen)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return nil, fmt.Errorf("read username: %w", err)
|
||||
}
|
||||
username = string(buf)
|
||||
}
|
||||
|
||||
// Read JWT token length and data.
|
||||
var jwtLenBuf [2]byte
|
||||
var jwtToken string
|
||||
if _, err := io.ReadFull(conn, jwtLenBuf[:]); err == nil {
|
||||
jwtLen := binary.BigEndian.Uint16(jwtLenBuf[:])
|
||||
if jwtLen >= 8192 {
|
||||
return nil, fmt.Errorf("jwt too long: %d (max 8191)", jwtLen)
|
||||
}
|
||||
if jwtLen > 0 {
|
||||
buf := make([]byte, jwtLen)
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
return nil, fmt.Errorf("read JWT: %w", err)
|
||||
}
|
||||
jwtToken = string(buf)
|
||||
}
|
||||
}
|
||||
|
||||
// Read optional Windows session ID (4 bytes BE). Missing = 0 (console/auto).
|
||||
var sessionID uint32
|
||||
var sidBuf [4]byte
|
||||
if _, err := io.ReadFull(conn, sidBuf[:]); err == nil {
|
||||
sessionID = binary.BigEndian.Uint32(sidBuf[:])
|
||||
}
|
||||
|
||||
// Read optional requested viewport size (2x uint16 BE). Missing = 0 (default).
|
||||
var width, height uint16
|
||||
var geomBuf [4]byte
|
||||
if _, err := io.ReadFull(conn, geomBuf[:]); err == nil {
|
||||
width = binary.BigEndian.Uint16(geomBuf[0:2])
|
||||
height = binary.BigEndian.Uint16(geomBuf[2:4])
|
||||
}
|
||||
|
||||
return &connectionHeader{
|
||||
mode: mode,
|
||||
username: username,
|
||||
jwt: jwtToken,
|
||||
sessionID: sessionID,
|
||||
width: width,
|
||||
height: height,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifyAgentToken validates the agent token prefix when configured. Returns
|
||||
// false when the token is invalid or unreadable; the connection is closed.
|
||||
func (s *Server) verifyAgentToken(conn net.Conn, connLog *log.Entry) bool {
|
||||
if len(s.agentToken) == 0 {
|
||||
return true
|
||||
}
|
||||
buf := make([]byte, len(s.agentToken))
|
||||
if err := conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
|
||||
connLog.Debugf("set agent token deadline: %v", err)
|
||||
conn.Close()
|
||||
return false
|
||||
}
|
||||
if _, err := io.ReadFull(conn, buf); err != nil {
|
||||
connLog.Warnf("agent auth: read token: %v", err)
|
||||
conn.Close()
|
||||
return false
|
||||
}
|
||||
if err := conn.SetReadDeadline(time.Time{}); err != nil {
|
||||
connLog.Debugf("clear agent token deadline: %v", err)
|
||||
}
|
||||
if subtle.ConstantTimeCompare(buf, s.agentToken) != 1 {
|
||||
connLog.Warn("agent auth: invalid token, rejecting")
|
||||
conn.Close()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// authorizeJWT performs JWT validation when auth is enabled. Returns the
|
||||
// enriched log entry, jwt user ID (empty when auth disabled), and ok=false
|
||||
// if the connection was rejected.
|
||||
func (s *Server) authorizeJWT(conn net.Conn, header *connectionHeader, connLog *log.Entry) (*log.Entry, string, bool) {
|
||||
if s.disableAuth {
|
||||
return connLog, "", true
|
||||
}
|
||||
if s.jwtConfig == nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeAuthConfig, "auth enabled but no identity provider configured"))
|
||||
connLog.Warn("auth rejected: no identity provider configured")
|
||||
return connLog, "", false
|
||||
}
|
||||
jwtUserID, err := s.authenticateJWT(header)
|
||||
if err != nil {
|
||||
rejectConnection(conn, codeMessage(jwtErrorCode(err), err.Error()))
|
||||
connLog.Warnf("auth rejected: %v", err)
|
||||
return connLog, "", false
|
||||
}
|
||||
return connLog.WithField("jwt_user", jwtUserID), jwtUserID, true
|
||||
}
|
||||
|
||||
// acquireSessionResources returns the capturer/injector to use for this
|
||||
// connection and a cleanup func to call when the session ends. ok is false
|
||||
// when the connection was rejected (and the caller must just return).
|
||||
func (s *Server) acquireSessionResources(conn net.Conn, header *connectionHeader, connLog **log.Entry) (ScreenCapturer, InputInjector, func(), bool) {
|
||||
switch header.mode {
|
||||
case ModeSession:
|
||||
return s.acquireVirtualSession(conn, header, connLog)
|
||||
default:
|
||||
return s.acquireAttachSession(), s.injector, attachSessionCleanup, true
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) acquireVirtualSession(conn net.Conn, header *connectionHeader, connLog **log.Entry) (ScreenCapturer, InputInjector, func(), bool) {
|
||||
if s.vmgr == nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeUnsupportedOS, "virtual sessions not supported on this platform"))
|
||||
(*connLog).Warn("session rejected: not supported on this platform")
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
if header.username == "" {
|
||||
rejectConnection(conn, codeMessage(RejectCodeBadRequest, "session mode requires a username"))
|
||||
(*connLog).Warn("session rejected: no username provided")
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
vs, err := s.vmgr.GetOrCreate(header.username, header.width, header.height)
|
||||
if err != nil {
|
||||
rejectConnection(conn, codeMessage(RejectCodeSessionError, fmt.Sprintf("create virtual session: %v", err)))
|
||||
(*connLog).Warnf("create virtual session for %s: %v", header.username, err)
|
||||
return nil, nil, nil, false
|
||||
}
|
||||
vs.ClientConnect()
|
||||
*connLog = (*connLog).WithField("vnc_user", header.username)
|
||||
(*connLog).Infof("session mode: user=%s display=%s", header.username, vs.Display())
|
||||
return vs.Capturer(), vs.Injector(), vs.ClientDisconnect, true
|
||||
}
|
||||
|
||||
func (s *Server) acquireAttachSession() ScreenCapturer {
|
||||
if cc, ok := s.capturer.(interface{ ClientConnect() }); ok {
|
||||
cc.ClientConnect()
|
||||
}
|
||||
return s.capturer
|
||||
}
|
||||
|
||||
// attachSessionCleanup is the no-op cleanup used by attach mode. Returned as a
|
||||
// named func rather than an inline closure so the empty body is unambiguous.
|
||||
func attachSessionCleanup() {
|
||||
// Attach mode keeps the shared capturer; nothing to release per session.
|
||||
}
|
||||
|
||||
// modeString returns a human-readable session mode name.
|
||||
func modeString(m byte) string {
|
||||
switch m {
|
||||
case ModeAttach:
|
||||
return "attach"
|
||||
case ModeSession:
|
||||
return "session"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
21
client/vnc/server/server_darwin.go
Normal file
21
client/vnc/server/server_darwin.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build darwin && !ios
|
||||
|
||||
package server
|
||||
|
||||
func (s *Server) platformInit() {
|
||||
// no-op on macOS
|
||||
}
|
||||
|
||||
// serviceAcceptLoop is not supported on macOS.
|
||||
func (s *Server) serviceAcceptLoop() {
|
||||
s.log.Warn("service mode not supported on macOS, falling back to direct mode")
|
||||
s.acceptLoop()
|
||||
}
|
||||
|
||||
func (s *Server) platformSessionManager() virtualSessionManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) platformShutdown() {
|
||||
// no-op on this platform
|
||||
}
|
||||
21
client/vnc/server/server_stub.go
Normal file
21
client/vnc/server/server_stub.go
Normal file
@@ -0,0 +1,21 @@
|
||||
//go:build (!windows && !darwin && !freebsd && !(linux && !android)) || (darwin && ios)
|
||||
|
||||
package server
|
||||
|
||||
func (s *Server) platformInit() {
|
||||
// no-op on unsupported platforms
|
||||
}
|
||||
|
||||
// serviceAcceptLoop is not supported on non-Windows platforms.
|
||||
func (s *Server) serviceAcceptLoop() {
|
||||
s.log.Warn("service mode not supported on this platform, falling back to direct mode")
|
||||
s.acceptLoop()
|
||||
}
|
||||
|
||||
func (s *Server) platformSessionManager() virtualSessionManager {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) platformShutdown() {
|
||||
// no-op on this platform
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user