Merge branch 'main' into embedded-vnc

# Conflicts:
#	client/ui/frontend/src/app.tsx
#	client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx
#	client/ui/i18n/locales/uk/common.json
#	go.sum
This commit is contained in:
Viktor Liu
2026-09-22 14:53:34 +02:00
205 changed files with 12118 additions and 1280 deletions
+21 -7
View File
@@ -14,12 +14,14 @@ import (
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/hashicorp/go-multierror"
"github.com/pion/ice/v4"
"github.com/pion/stun/v3"
log "github.com/sirupsen/logrus"
wgdevice "golang.zx2c4.com/wireguard/device"
"golang.zx2c4.com/wireguard/tun/netstack"
"golang.zx2c4.com/wireguard/wgctrl/wgtypes"
@@ -239,6 +241,12 @@ type Engine struct {
wgInterface WGIface
// wgDevice is a lock-free handle on the WireGuard device behind
// wgInterface. Reaching the device through wgInterface requires
// syncMsgMux, which handleSync holds while it adds and removes peers;
// SetPerformance must stay reachable exactly when that work is stuck.
wgDevice atomic.Pointer[wgdevice.Device]
udpMux *udpmux.UniversalUDPMuxDefault
// networkSerial is the latest CurrentSerial (state ID) of the network sent by the Management service
@@ -661,6 +669,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
log.Errorf("failed to pull up wgInterface [%s]: %s", e.wgInterface.Name(), err.Error())
return fmt.Errorf("up wg interface: %w", err)
}
e.wgDevice.Store(e.wgInterface.GetWGDevice())
// Set up notrack rules immediately after proxy is listening to prevent
// conntrack entries from being created before the rules are in place
@@ -2164,6 +2173,10 @@ func (e *Engine) close() {
log.Debugf("removing Netbird interface %s", e.config.WgIfaceName)
if e.wgInterface != nil {
// Drop the handle before the close starts: a retune that loads it
// afterwards would touch a device on its way out and report success
// for an engine that is already gone.
e.wgDevice.Store(nil)
if err := e.wgInterface.Close(); err != nil {
log.Errorf("failed closing Netbird interface %s %v", e.config.WgIfaceName, err)
}
@@ -2323,15 +2336,16 @@ type Performance struct {
}
// SetPerformance applies the given tuning to this engine's live Device.
//
// It deliberately does not take syncMsgMux. Raising the buffer pool cap is the
// recovery path for a device whose pool is exhausted, and an exhausted pool
// blocks peer removal inside handleSync, which holds syncMsgMux for as long as
// it stays blocked. Taking the lock here would make the retune unreachable in
// the one situation that needs it.
func (e *Engine) SetPerformance(t Performance) error {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
if e.wgInterface == nil {
return fmt.Errorf("wg interface not initialized")
}
dev := e.wgInterface.GetWGDevice()
dev := e.wgDevice.Load()
if dev == nil {
return fmt.Errorf("wg device not initialized")
return errors.New("wg device not initialized")
}
if t.PreallocatedBuffersPerPool != nil {
dev.SetPreallocatedBuffersPerPool(*t.PreallocatedBuffersPerPool)
+25 -9
View File
@@ -135,9 +135,10 @@ type Conn struct {
// used to store the remote Rosenpass key for Relayed connection in case of connection update from ice
rosenpassRemoteKey []byte
wgProxyICE wgproxy.Proxy
wgProxyRelay wgproxy.Proxy
handshaker *Handshaker
wgProxyICE wgproxy.Proxy
wgProxyRelay wgproxy.Proxy
relayedConnRef *relayClient.Conn
handshaker *Handshaker
guard *guard.Guard
wg sync.WaitGroup
@@ -560,7 +561,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.mu.Lock()
defer conn.mu.Unlock()
if conn.ctx.Err() != nil {
if conn.ctx.Err() != nil || rci.relayedConn.Context().Err() != nil {
if err := rci.relayedConn.Close(); err != nil {
conn.Log.Warnf("failed to close unnecessary relayed connection: %v", err)
}
@@ -575,7 +576,9 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.Log.Errorf("failed to add relayed net.Conn to local proxy: %v", err)
return
}
wgProxy.SetDisconnectListener(conn.onRelayDisconnected)
wgProxy.SetDisconnectListener(func() {
conn.onRelayDisconnected(rci.relayedConn)
})
conn.dumpState.NewLocalProxy()
@@ -583,7 +586,7 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
if conn.isICEActive() {
conn.Log.Debugf("do not switch to relay because current priority is: %s", conn.currentConnPriority.String())
conn.setRelayedProxy(wgProxy)
conn.setRelayedProxy(wgProxy, rci.relayedConn)
conn.statusRelay.SetConnected()
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, time.Now())
return
@@ -614,15 +617,26 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
conn.rosenpassRemoteKey = rci.rosenpassPubKey
conn.currentConnPriority = conntype.Relay
conn.statusRelay.SetConnected()
conn.setRelayedProxy(wgProxy)
conn.setRelayedProxy(wgProxy, rci.relayedConn)
conn.updateRelayStatus(rci.relayedConn.RemoteAddr().String(), rci.rosenpassPubKey, updateTime)
conn.Log.Infof("start to communicate with peer via relay")
conn.doOnConnected(rci.rosenpassPubKey, rci.rosenpassAddr, updateTime)
}
func (conn *Conn) onRelayDisconnected() {
// onRelayDisconnected reports the teardown of a relayed connection. relayedConn
// names the connection the signal belongs to, so a signal that arrives after
// its connection was replaced is ignored instead of tearing down its successor.
// A nil relayedConn means the caller does not track generations and the current
// connection is always torn down.
func (conn *Conn) onRelayDisconnected(relayedConn *relayClient.Conn) {
conn.mu.Lock()
defer conn.mu.Unlock()
if relayedConn != nil && conn.relayedConnRef != relayedConn {
conn.Log.Debugf("ignoring relay disconnect of a superseded connection")
return
}
conn.handleRelayDisconnectedLocked()
}
@@ -646,6 +660,7 @@ func (conn *Conn) handleRelayDisconnectedLocked() {
_ = conn.wgProxyRelay.CloseConn()
conn.wgProxyRelay = nil
}
conn.relayedConnRef = nil
changed := conn.statusRelay.Get() != worker.StatusDisconnected
if changed {
@@ -930,13 +945,14 @@ func (conn *Conn) logTraceConnState() {
}
}
func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy) {
func (conn *Conn) setRelayedProxy(proxy wgproxy.Proxy, relayedConn *relayClient.Conn) {
if conn.wgProxyRelay != nil {
if err := conn.wgProxyRelay.CloseConn(); err != nil {
conn.Log.Warnf("failed to close deprecated wg proxy conn: %v", err)
}
}
conn.wgProxyRelay = proxy
conn.relayedConnRef = relayedConn
}
// onWGHandshakeSuccess is called when the first WireGuard handshake is detected
+4 -4
View File
@@ -116,7 +116,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
for {
select {
case remoteOfferAnswer := <-h.remoteOffersCh:
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
h.log.Infof("received offer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
// Record signaling received for reconnection attempts
if h.metricsStages != nil {
@@ -138,7 +138,7 @@ func (h *Handshaker) Listen(ctx context.Context) {
continue
}
case remoteOfferAnswer := <-h.remoteAnswerCh:
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials())
h.log.Infof("received answer, running version %s, remote WireGuard listen port %d, session id: %s, remote ICE supported: %t, relay server: %s, relay IP: %s", remoteOfferAnswer.Version, remoteOfferAnswer.WgListenPort, remoteOfferAnswer.SessionIDString(), remoteOfferAnswer.hasICECredentials(), remoteOfferAnswer.RelaySrvAddress, remoteOfferAnswer.RelaySrvIP)
// Record signaling received for reconnection attempts
if h.metricsStages != nil {
@@ -209,14 +209,14 @@ func (h *Handshaker) sendOffer() error {
}
offer := h.buildOfferAnswer()
h.log.Debugf("sending offer with serial: %s", offer.SessionIDString())
h.log.Debugf("sending offer with serial: %s, relay server: %s, relay IP: %s", offer.SessionIDString(), offer.RelaySrvAddress, offer.RelaySrvIP)
return h.signaler.SignalOffer(offer, h.config.Key)
}
func (h *Handshaker) sendAnswer() error {
answer := h.buildOfferAnswer()
h.log.Debugf("sending answer with serial: %s", answer.SessionIDString())
h.log.Debugf("sending answer with serial: %s, relay server: %s, relay IP: %s", answer.SessionIDString(), answer.RelaySrvAddress, answer.RelaySrvIP)
return h.signaler.SignalAnswer(answer, h.config.Key)
}
+2 -2
View File
@@ -819,8 +819,8 @@ func (d *Status) SetSessionExpiresAt(deadline time.Time) {
// "none" would blank the UI at the exact moment it should say the session
// ended.
func (d *Status) GetSessionExpiresAt() time.Time {
d.mux.Lock()
defer d.mux.Unlock()
d.mux.RLock()
defer d.mux.RUnlock()
return d.sessionExpiresAt
}
+13 -14
View File
@@ -3,7 +3,6 @@ package peer
import (
"context"
"errors"
"net"
"net/netip"
"sync"
"sync/atomic"
@@ -14,7 +13,7 @@ import (
)
type RelayConnInfo struct {
relayedConn net.Conn
relayedConn *relayClient.Conn
rosenpassPubKey []byte
rosenpassAddr string
}
@@ -27,7 +26,7 @@ type WorkerRelay struct {
conn *Conn
relayManager *relayClient.Manager
relayedConn net.Conn
relayedConn *relayClient.Conn
relayLock sync.Mutex
relaySupportedOnRemotePeer atomic.Bool
@@ -80,12 +79,7 @@ func (w *WorkerRelay) OnNewOffer(remoteOfferAnswer *OfferAnswer) {
w.relayedConn = relayedConn
w.relayLock.Unlock()
err = w.relayManager.AddCloseListener(srv, w.onRelayClientDisconnected)
if err != nil {
log.Errorf("failed to add close listener: %s", err)
_ = relayedConn.Close()
return
}
go w.watchRelayedConn(relayedConn)
w.log.Debugf("peer conn opened via Relay: %s", srv)
go w.conn.onRelayConnectionIsReady(RelayConnInfo{
@@ -109,12 +103,15 @@ func (w *WorkerRelay) RelayIsSupportedLocally() bool {
func (w *WorkerRelay) CloseConn() {
w.relayLock.Lock()
defer w.relayLock.Unlock()
if w.relayedConn == nil {
conn := w.relayedConn
w.relayedConn = nil
w.relayLock.Unlock()
if conn == nil {
return
}
if err := w.relayedConn.Close(); err != nil {
if err := conn.Close(); err != nil {
w.log.Warnf("failed to close relay connection: %v", err)
}
}
@@ -133,6 +130,8 @@ func (w *WorkerRelay) preferredRelayServer(myRelayAddress, remoteRelayAddress st
return remoteRelayAddress
}
func (w *WorkerRelay) onRelayClientDisconnected() {
go w.conn.onRelayDisconnected()
func (w *WorkerRelay) watchRelayedConn(relayedConn *relayClient.Conn) {
<-relayedConn.Context().Done()
w.conn.onRelayDisconnected(relayedConn)
}
@@ -6,6 +6,7 @@ import (
"os/user"
"path/filepath"
"runtime"
"strconv"
log "github.com/sirupsen/logrus"
)
@@ -13,17 +14,21 @@ import (
const envSudoUser = "SUDO_USER"
var (
geteuid = os.Geteuid
lookupUser = user.Lookup
currentUser = user.Current
getegid = os.Getegid
geteuid = os.Geteuid
lookupUser = user.Lookup
)
// InvokingUser returns the user a CLI invocation acts for. Under sudo that is
// the user who ran sudo, not root: privileged flags force commands through
// sudo, and resolving profiles as root would silently switch the daemon to
// root's (default) profile instead of the invoking user's. Privilege decisions
// are not made here — those stay on the kernel credentials of the daemon
// connection, which SUDO_USER (a plain environment variable) can never
// influence; a forged value only selects a profile root could select anyway.
// root's (default) profile instead of the invoking user's. An unmapped positive
// process UID uses its numeric kernel identity; root, sudo lookup failures, and
// unavailable platform identities still fail closed. Privilege decisions stay
// on the kernel credentials of the daemon connection, which SUDO_USER (a plain
// environment variable) can never influence; a forged value only selects a
// profile root could select anyway.
func InvokingUser() (*user.User, error) {
if u, ok := sudoInvokingUser(); ok {
return u, nil
@@ -35,7 +40,23 @@ func InvokingUser() (*user.User, error) {
if sudoActive() {
return nil, fmt.Errorf("resolve sudo invoking user %q: refusing to fall back to root", os.Getenv(envSudoUser))
}
return user.Current()
u, err := currentUser()
if err == nil {
return u, nil
}
uid := geteuid()
if uid <= 0 {
return nil, err
}
log.Debugf("current user lookup for UID %d: %v; using numeric UID", uid, err)
uidString := strconv.Itoa(uid)
return &user.User{
Username: uidString,
Uid: uidString,
Gid: strconv.Itoa(getegid()),
}, nil
}
// IsPlainRoot reports that the process runs as root with no usable sudo
@@ -2,6 +2,7 @@ package profilemanager
import (
"errors"
"fmt"
"io/fs"
"os"
"os/user"
@@ -21,7 +22,51 @@ func TestInvokingUserFallsBackToProcessUser(t *testing.T) {
current, err := user.Current()
require.NoError(t, err)
assert.Equal(t, current.Username, got.Username)
assert.Equal(t, current.Username, got.Username, "invoking user should match the process user without sudo")
}
func TestInvokingUserFailsClosedWithoutPositiveUID(t *testing.T) {
for _, uid := range []int{0, -1} {
t.Run(fmt.Sprintf("UID%d", uid), func(t *testing.T) {
t.Setenv(envSudoUser, "")
lookupErr := errors.New("current user unavailable")
fakeUnmappedUser(t, uid, 0, lookupErr)
got, err := InvokingUser()
require.ErrorIs(t, err, lookupErr)
assert.Nil(t, got, "root or unavailable UID must not become a synthetic identity")
})
}
}
func TestProfileFilePathUsesNumericIdentityForUnmappedNonRoot(t *testing.T) {
t.Setenv(envSudoUser, "")
fakeUnmappedUser(t, 1001230000, 0, errors.New("user: unknown userid 1001230000"))
profilesRoot := t.TempDir()
origDir := DefaultConfigPathDir
origOverride := ConfigDirOverride
DefaultConfigPathDir = profilesRoot
ConfigDirOverride = ""
t.Cleanup(func() {
DefaultConfigPathDir = origDir
ConfigDirOverride = origOverride
})
profileID := ID("0123456789abcdef0123456789abcdef")
got, err := (&Profile{ID: profileID}).FilePath()
require.NoError(t, err)
assert.Equal(t,
filepath.Join(profilesRoot, "1001230000", profileID.String()+".json"),
got,
"profile path should use the numeric UID namespace",
)
entries, err := os.ReadDir(profilesRoot)
require.NoError(t, err)
require.Len(t, entries, 1, "only the numeric UID directory should be created")
assert.Equal(t, "1001230000", entries[0].Name(), "profile namespace should be numeric")
assert.True(t, entries[0].IsDir(), "profile namespace should be a directory")
}
func TestSudoInvokingUserInactiveWithoutSudoContext(t *testing.T) {
@@ -60,6 +105,13 @@ func TestInvokingUserFailsClosedWhenSudoLookupFails(t *testing.T) {
fakeSudo(t, filepath.Join("/home", "misha"))
lookupUser = func(string) (*user.User, error) { return nil, errors.New("nss unavailable") }
origCurrentUser := currentUser
currentUser = func() (*user.User, error) {
t.Fatal("currentUser must not be called after a sudo lookup failure")
return nil, errors.New("currentUser called unexpectedly")
}
t.Cleanup(func() { currentUser = origCurrentUser })
got, err := InvokingUser()
require.Error(t, err)
assert.Nil(t, got, "must not resolve to the root process user")
@@ -215,6 +267,22 @@ func fakeSudo(t *testing.T, home string) {
})
}
func fakeUnmappedUser(t *testing.T, uid, gid int, lookupErr error) {
t.Helper()
origCurrentUser := currentUser
origEuid := geteuid
origEgid := getegid
currentUser = func() (*user.User, error) { return nil, lookupErr }
geteuid = func() int { return uid }
getegid = func() int { return gid }
t.Cleanup(func() {
currentUser = origCurrentUser
geteuid = origEuid
getegid = origEgid
})
}
func assertNoEntries(t *testing.T, root string) {
t.Helper()
err := filepath.WalkDir(root, func(path string, _ fs.DirEntry, err error) error {