Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 6 additions & 6 deletions .devcontainer/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
FROM golang:1.25-bookworm

RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \
&& apt-get -y install --no-install-recommends\
gettext-base=0.21-12 \
iptables=1.8.9-2 \
libgl1-mesa-dev=22.3.6-1+deb12u1 \
xorg-dev=1:7.7+23 \
libayatana-appindicator3-dev=0.5.92-1 \
&& apt-get -y install --no-install-recommends \
gettext-base \
iptables \
libgl1-mesa-dev \
xorg-dev \
libayatana-appindicator3-dev \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/* \
&& go install -v golang.org/x/tools/gopls@latest
Expand Down
44 changes: 44 additions & 0 deletions .github/workflows/device-auth-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
name: Device Auth Tests

on:
push:
branches: [feature/tpm-cert-auth]
pull_request:
branches: [main]
paths:
- 'management/server/deviceauth/**'
- 'management/server/devicepki/**'
- 'management/server/http/handlers/device_auth/**'
- 'management/internals/shared/grpc/**'
- 'client/internal/enrollment/**'
- 'client/internal/tpm/**'
- 'shared/management/**'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
device-auth-tests:
name: Device Auth Tests (no sudo required)
runs-on: ubuntu-22.04
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"
cache: true

- name: Run device auth unit tests
run: |
go test -count=1 -timeout 300s -race \
./management/server/deviceauth/... \
./management/server/devicepki/... \
./management/server/http/handlers/device_auth/... \
./management/internals/shared/grpc/... \
./client/internal/enrollment/... \
./client/internal/tpm/... \
./shared/management/...
51 changes: 51 additions & 0 deletions .github/workflows/e2e-device-auth.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: E2E Device Auth Tests

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened]
paths:
- 'management/**'
- 'client/**'
- 'netbird-stand/**'
- 'scripts/e2e-*.sh'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
cancel-in-progress: true

jobs:
e2e-device-auth:
name: E2E Device Auth (docker-compose stand)
runs-on: ubuntu-22.04

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true

- name: Build management server
run: go build -o bin/management ./management/cmd/management

# TODO: Start management server - requires testdata/mgmt-e2e.json
# Uncomment when mgmt-e2e.json config is available:
# - name: Start management server
# run: |
# ./bin/management --config testdata/mgmt-e2e.json --port 8080 &
# echo "Management server started (PID $!)"
# env:
# NETBIRD_STORE_ENGINE: sqlite

- name: Validate E2E scripts
run: |
chmod +x scripts/e2e-lib.sh scripts/e2e-device-auth.sh
bash -n scripts/e2e-lib.sh
bash -n scripts/e2e-device-auth.sh
echo "Script syntax OK"
# Note: Full E2E requires management server with testdata/mgmt-e2e.json
# Run manually: NETBIRD_API_URL=http://localhost:8080 bash scripts/e2e-device-auth.sh
128 changes: 128 additions & 0 deletions .github/workflows/tpm-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
name: TPM Integration Tests

on:
push:
branches:
- main
- feature/tpm-cert-auth
paths:
- 'client/internal/tpm/**'
- 'client/internal/enrollment/**'
- 'management/server/devicepki/**'
- 'management/server/deviceinventory/**'
pull_request:
paths:
- 'client/internal/tpm/**'
- 'client/internal/enrollment/**'
- 'management/server/devicepki/**'
- 'management/server/deviceinventory/**'

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref || github.actor_id }}
cancel-in-progress: true

jobs:
tpm-integration:
name: "TPM 2.0 Integration (swtpm)"
runs-on: ubuntu-22.04

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"

- name: Install swtpm and tpm2-tools
run: |
sudo apt-get update -q
sudo apt-get install -y --no-install-recommends \
swtpm \
tpm2-tools \
libtpms-dev \
netcat-openbsd

- name: Start swtpm (TCP socket mode)
run: |
mkdir -p /tmp/swtpm-state
swtpm socket \
--tpmstate dir=/tmp/swtpm-state \
--tpm2 \
--flags not-need-init \
--server type=tcp,port=2321 \
--ctrl type=tcp,port=2322 \
--daemon
# Wait for swtpm to be ready on the command port.
for i in $(seq 1 15); do
nc -z localhost 2321 2>/dev/null && break
sleep 1
done
echo "NETBIRD_TPM_SIMULATOR=localhost:2321" >> $GITHUB_ENV
echo "swtpm ready on localhost:2321"

- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-tpm-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-tpm-

- name: Run TPM unit tests (mock provider)
env:
NETBIRD_TPM_SIMULATOR: ${{ env.NETBIRD_TPM_SIMULATOR }}
run: go test ./client/internal/tpm/... -v -count=1 -timeout 60s

- name: Run TPM integration tests (swtpm TCP or /dev/tpmrm0)
env:
NETBIRD_TPM_SIMULATOR: ${{ env.NETBIRD_TPM_SIMULATOR }}
run: |
go test ./client/internal/tpm/... \
-v \
-count=1 \
-timeout 120s \
-tags integration

- name: Run enrollment manager tests
run: go test ./client/internal/enrollment/... -v -count=1 -timeout 60s

- name: Run devicepki tests
run: go test ./management/server/devicepki/... -v -count=1 -timeout 60s

- name: Run deviceinventory tests
run: go test ./management/server/deviceinventory/... -v -count=1 -timeout 60s

attestation-verification:
name: "Attestation Verification Unit Tests"
runs-on: ubuntu-22.04

steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Install Go
uses: actions/setup-go@v5
with:
go-version-file: "go.mod"

- name: Cache Go modules
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
~/go/pkg/mod
key: ${{ runner.os }}-go-attestation-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-attestation-

- name: Run attestation verification tests
run: |
go test ./management/server/devicepki/... \
-v \
-count=1 \
-run TestVerifyAttestation \
-timeout 30s
9 changes: 9 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ infrastructure_files/**/zitadel.env
infrastructure_files/**/management.json
infrastructure_files/**/management-*.json
infrastructure_files/**/docker-compose.yml
# Stand-dex is a versioned dev stand — its config files must be tracked
!infrastructure_files/stand-dex/Caddyfile
!infrastructure_files/stand-dex/dashboard.env
!infrastructure_files/stand-dex/management.json
!infrastructure_files/stand-dex/docker-compose.yml
infrastructure_files/**/openid-configuration.json
infrastructure_files/**/turnserver.conf
infrastructure_files/**/management.json.bkp.**
Expand All @@ -33,3 +38,7 @@ infrastructure_files/setup-*.env
vendor/
/netbird
client/netbird-electron/
/dev-stand-init
/enroll-demo
/mtls-demo
infrastructure_files/stand/
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,15 @@ setup-hooks:
@git config core.hooksPath .githooks
@chmod +x .githooks/pre-push
@echo "✅ Git hooks configured! Pre-push will now run 'make lint'"

MANAGEMENT_DEV_IMAGE ?= netbird/management:tpm-dev

# Build management Docker image from source for local testing with netbird-stand.
# Uses management/Dockerfile.multistage which does a full Go build.
.PHONY: docker-management-dev
docker-management-dev:
@echo "Building $(MANAGEMENT_DEV_IMAGE) from feature/tpm-cert-auth..."
docker build -f management/Dockerfile.multistage -t $(MANAGEMENT_DEV_IMAGE) .
@echo ""
@echo "Image ready: $(MANAGEMENT_DEV_IMAGE)"
@echo "Next: cd /path/to/netbird-stand && bash scripts/up-device-auth.sh"
47 changes: 47 additions & 0 deletions client/internal/connect.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package internal

import (
"context"
"crypto/x509"
"errors"
"fmt"
"net"
Expand All @@ -22,12 +23,14 @@ import (
"github.com/netbirdio/netbird/client/iface/device"
"github.com/netbirdio/netbird/client/iface/netstack"
"github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/enrollment"
"github.com/netbirdio/netbird/client/internal/listener"
"github.com/netbirdio/netbird/client/internal/metrics"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/internal/statemanager"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/client/internal/tpm"
"github.com/netbirdio/netbird/client/internal/updater"
"github.com/netbirdio/netbird/client/internal/updater/installer"
nbnet "github.com/netbirdio/netbird/client/net"
Expand Down Expand Up @@ -258,6 +261,50 @@ func (c *ConnectClient) run(mobileDependency MobileDependency, runningChan chan
mgmNotifier := statusRecorderToMgmConnStateNotifier(c.statusRecorder)
mgmClient.SetConnStateListener(mgmNotifier)

// Device certificate enrollment — runs idempotently on each connection attempt.
// Skipped on platforms without TPM/Secure Enclave support (iOS, Android, WASM, etc.)
// to avoid noisy "not supported" log warnings on every reconnect.
stateDir := profilemanager.DefaultConfigPathDir
tpmProv := tpm.NewPlatformProvider(stateDir)
if tpmProv.Available() {
enrollMgr := enrollment.NewManager(tpmProv, mgmClient, stateDir, myPrivateKey.PublicKey().String())

enrollCtx, enrollCancel := context.WithTimeout(engineCtx, 30*time.Second)
defer enrollCancel()
if _, certErr := enrollMgr.EnsureCertificate(enrollCtx); certErr != nil {
log.Warnf("device cert enrollment: %v — continuing without certificate", certErr)
} else if tlsCert, buildErr := enrollMgr.BuildTLSCertificate(engineCtx); buildErr != nil && !errors.Is(buildErr, enrollment.ErrNotEnrolled) {
log.Warnf("device cert: build TLS cert: %v — continuing without", buildErr)
} else if tlsCert != nil && mgmTlsEnabled {
// Reconnect with the device certificate as mTLS client cert.
certMgmClient, dialErr := mgm.NewClientWithCert(engineCtx, c.config.ManagementURL.Host, myPrivateKey, tlsCert)
if dialErr != nil {
log.Warnf("device cert: mTLS reconnect failed: %v — continuing without certificate", dialErr)
} else {
// Swap plain → cert client. The existing defer mgmClient.Close() will
// now close certMgmClient (Go closures capture the variable, not the value).
if closeErr := mgmClient.Close(); closeErr != nil {
log.Warnf("close plain mgm client before cert upgrade: %v", closeErr)
}
mgmClient = certMgmClient
mgmClient.SetConnStateListener(mgmNotifier)
cn := "<unknown>"
if tlsCert.Leaf != nil {
cn = tlsCert.Leaf.Subject.CommonName
}
log.Infof("device cert: management connection upgraded to mTLS (cert CN=%s)", cn)

// Start the certificate renewal loop. When the cert is approaching expiry
// and a fresh one is issued, cancel the engine context so the connection
// restarts with the new certificate.
enrollMgr.StartRenewalLoop(engineCtx, func(_ *x509.Certificate) {
log.Info("device cert: certificate renewed — reconnecting to apply new cert")
cancel()
})
}
}
Comment on lines +267 to +305
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't enroll device certificates on plain-HTTP management connections.

This block runs EnsureCertificate whenever the platform provider is available, but the resulting certificate is only usable when mgmTlsEnabled is true. On http:// management URLs that means we still perform attestation/enrollment and create server-side enrollment state even though the client can never upgrade the channel to mTLS. Gate the whole enrollment path behind mgmTlsEnabled.

Suggested change
-		stateDir := profilemanager.DefaultConfigPathDir
-		tpmProv := tpm.NewPlatformProvider(stateDir)
-		if tpmProv.Available() {
+		stateDir := profilemanager.DefaultConfigPathDir
+		tpmProv := tpm.NewPlatformProvider(stateDir)
+		if mgmTlsEnabled && tpmProv.Available() {
 			enrollMgr := enrollment.NewManager(tpmProv, mgmClient, stateDir, myPrivateKey.PublicKey().String())
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
stateDir := profilemanager.DefaultConfigPathDir
tpmProv := tpm.NewPlatformProvider(stateDir)
if tpmProv.Available() {
enrollMgr := enrollment.NewManager(tpmProv, mgmClient, stateDir, myPrivateKey.PublicKey().String())
enrollCtx, enrollCancel := context.WithTimeout(engineCtx, 30*time.Second)
defer enrollCancel()
if _, certErr := enrollMgr.EnsureCertificate(enrollCtx); certErr != nil {
log.Warnf("device cert enrollment: %v — continuing without certificate", certErr)
} else if tlsCert, buildErr := enrollMgr.BuildTLSCertificate(engineCtx); buildErr != nil && !errors.Is(buildErr, enrollment.ErrNotEnrolled) {
log.Warnf("device cert: build TLS cert: %v — continuing without", buildErr)
} else if tlsCert != nil && mgmTlsEnabled {
// Reconnect with the device certificate as mTLS client cert.
certMgmClient, dialErr := mgm.NewClientWithCert(engineCtx, c.config.ManagementURL.Host, myPrivateKey, tlsCert)
if dialErr != nil {
log.Warnf("device cert: mTLS reconnect failed: %v — continuing without certificate", dialErr)
} else {
// Swap plain → cert client. The existing defer mgmClient.Close() will
// now close certMgmClient (Go closures capture the variable, not the value).
if closeErr := mgmClient.Close(); closeErr != nil {
log.Warnf("close plain mgm client before cert upgrade: %v", closeErr)
}
mgmClient = certMgmClient
mgmClient.SetConnStateListener(mgmNotifier)
cn := "<unknown>"
if tlsCert.Leaf != nil {
cn = tlsCert.Leaf.Subject.CommonName
}
log.Infof("device cert: management connection upgraded to mTLS (cert CN=%s)", cn)
// Start the certificate renewal loop. When the cert is approaching expiry
// and a fresh one is issued, cancel the engine context so the connection
// restarts with the new certificate.
enrollMgr.StartRenewalLoop(engineCtx, func(_ *x509.Certificate) {
log.Info("device cert: certificate renewed — reconnecting to apply new cert")
cancel()
})
}
}
stateDir := profilemanager.DefaultConfigPathDir
tpmProv := tpm.NewPlatformProvider(stateDir)
if mgmTlsEnabled && tpmProv.Available() {
enrollMgr := enrollment.NewManager(tpmProv, mgmClient, stateDir, myPrivateKey.PublicKey().String())
enrollCtx, enrollCancel := context.WithTimeout(engineCtx, 30*time.Second)
defer enrollCancel()
if _, certErr := enrollMgr.EnsureCertificate(enrollCtx); certErr != nil {
log.Warnf("device cert enrollment: %v — continuing without certificate", certErr)
} else if tlsCert, buildErr := enrollMgr.BuildTLSCertificate(engineCtx); buildErr != nil && !errors.Is(buildErr, enrollment.ErrNotEnrolled) {
log.Warnf("device cert: build TLS cert: %v — continuing without", buildErr)
} else if tlsCert != nil && mgmTlsEnabled {
// Reconnect with the device certificate as mTLS client cert.
certMgmClient, dialErr := mgm.NewClientWithCert(engineCtx, c.config.ManagementURL.Host, myPrivateKey, tlsCert)
if dialErr != nil {
log.Warnf("device cert: mTLS reconnect failed: %v — continuing without certificate", dialErr)
} else {
// Swap plain → cert client. The existing defer mgmClient.Close() will
// now close certMgmClient (Go closures capture the variable, not the value).
if closeErr := mgmClient.Close(); closeErr != nil {
log.Warnf("close plain mgm client before cert upgrade: %v", closeErr)
}
mgmClient = certMgmClient
mgmClient.SetConnStateListener(mgmNotifier)
cn := "<unknown>"
if tlsCert.Leaf != nil {
cn = tlsCert.Leaf.Subject.CommonName
}
log.Infof("device cert: management connection upgraded to mTLS (cert CN=%s)", cn)
// Start the certificate renewal loop. When the cert is approaching expiry
// and a fresh one is issued, cancel the engine context so the connection
// restarts with the new certificate.
enrollMgr.StartRenewalLoop(engineCtx, func(_ *x509.Certificate) {
log.Info("device cert: certificate renewed — reconnecting to apply new cert")
cancel()
})
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@client/internal/connect.go` around lines 267 - 305, The code performs TPM
enrollment even when management TLS is disabled (mgmTlsEnabled), creating
server-side enrollment state unnecessarily; modify the block that starts with
tpm.NewPlatformProvider(stateDir) and tpmProv.Available() so that you only
construct enrollment.NewManager, call enrollMgr.EnsureCertificate,
enrollMgr.BuildTLSCertificate, mgm.NewClientWithCert, and
enrollMgr.StartRenewalLoop when mgmTlsEnabled is true; in practice, add a guard
checking mgmTlsEnabled before creating enrollMgr or invoking
EnsureCertificate/BuildTLSCertificate and skip the entire enrollment/renewal
path if mgmTlsEnabled is false.

}

// Update metrics with actual deployment type after connection
deploymentType := metrics.DetermineDeploymentType(mgmClient.GetServerURL())
agentInfo := metrics.AgentInfo{
Expand Down
Loading