Compare commits

...

9 Commits

Author SHA1 Message Date
Maycon Santos
dd9917f1a8 [misc] add missing images (#3987) 2025-06-16 21:05:49 +02:00
Viktor Liu
8df8c1012f [client] Support wildcard DNS on iOS (#3979) 2025-06-16 18:33:51 +02:00
Viktor Liu
bfa5c21d2d [client] Improve icmp conntrack log (#3963) 2025-06-16 10:12:59 +02:00
Maycon Santos
b1247a14ba [management] Use xID for setup key IDs to avoid id collisions (#3977)
This PR addresses potential ID collisions by switching the setup key ID generation from a hash-based approach to using xid-generated IDs.

Replace the hash function with xid.New().String()
Remove obsolete imports and the Hash() function
2025-06-14 12:24:16 +01:00
Philippe Vaucher
f595057a0b [signal] Set flags from environment variables (#3972) 2025-06-14 00:08:34 +02:00
hakansa
089d442fb2 [client] Display login popup on session expiration (#3955)
This PR implements a feature enhancement to display a login popup when the session expires. Key changes include updating flag handling and client construction to support a new login URL popup, revising login and notification handling logic to use the new popup, and updating status and server-side session state management accordingly.
2025-06-13 23:51:57 +02:00
Viktor Liu
04a3765391 [client] Fix unncessary UI updates (#3785) 2025-06-13 20:38:50 +02:00
Zoltan Papp
d24d8328f9 [client] Propagation networks for Android client (#3966)
Add networks propagation
2025-06-13 11:04:17 +02:00
Vlad
4f63996ae8 [management] added events streaming metrics (#3814) 2025-06-12 18:48:54 +01:00
30 changed files with 434 additions and 113 deletions

View File

@@ -149,6 +149,7 @@ nfpms:
dockers:
- image_templates:
- netbirdio/netbird:{{ .Version }}-amd64
- ghcr.io/netbirdio/netbird:{{ .Version }}-amd64
ids:
- netbird
goarch: amd64
@@ -164,6 +165,7 @@ dockers:
- "--label=maintainer=dev@netbird.io"
- image_templates:
- netbirdio/netbird:{{ .Version }}-arm64v8
- ghcr.io/netbirdio/netbird:{{ .Version }}-arm64v8
ids:
- netbird
goarch: arm64

View File

@@ -59,6 +59,8 @@ type Client struct {
deviceName string
uiVersion string
networkChangeListener listener.NetworkChangeListener
connectClient *internal.ConnectClient
}
// NewClient instantiate a new Client
@@ -106,8 +108,8 @@ func (c *Client) Run(urlOpener URLOpener, dns *DNSList, dnsReadyListener DnsRead
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, dns.items, dnsReadyListener)
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, dns.items, dnsReadyListener)
}
// RunWithoutLogin we apply this type of run function when the backed has been started without UI (i.e. after reboot).
@@ -132,8 +134,8 @@ func (c *Client) RunWithoutLogin(dns *DNSList, dnsReadyListener DnsReadyListener
// todo do not throw error in case of cancelled context
ctx = internal.CtxInitState(ctx)
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, dns.items, dnsReadyListener)
c.connectClient = internal.NewConnectClient(ctx, cfg, c.recorder)
return c.connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, dns.items, dnsReadyListener)
}
// Stop the internal client and free the resources
@@ -174,6 +176,53 @@ func (c *Client) PeersList() *PeerInfoArray {
return &PeerInfoArray{items: peerInfos}
}
func (c *Client) Networks() *NetworkArray {
if c.connectClient == nil {
log.Error("not connected")
return nil
}
engine := c.connectClient.Engine()
if engine == nil {
log.Error("could not get engine")
return nil
}
routeManager := engine.GetRouteManager()
if routeManager == nil {
log.Error("could not get route manager")
return nil
}
networkArray := &NetworkArray{
items: make([]Network, 0),
}
for id, routes := range routeManager.GetClientRoutesWithNetID() {
if len(routes) == 0 {
continue
}
if routes[0].IsDynamic() {
continue
}
peer, err := c.recorder.GetPeer(routes[0].Peer)
if err != nil {
log.Errorf("could not get peer info for %s: %v", routes[0].Peer, err)
continue
}
network := Network{
Name: string(id),
Network: routes[0].Network.String(),
Peer: peer.FQDN,
Status: peer.ConnStatus.String(),
}
networkArray.Add(network)
}
return networkArray
}
// OnUpdatedHostDNS update the DNS servers addresses for root zones
func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
dnsServer, err := dns.GetServerDns()

View File

@@ -0,0 +1,27 @@
//go:build android
package android
type Network struct {
Name string
Network string
Peer string
Status string
}
type NetworkArray struct {
items []Network
}
func (array *NetworkArray) Add(s Network) *NetworkArray {
array.items = append(array.items, s)
return array
}
func (array *NetworkArray) Get(i int) *Network {
return &array.items[i]
}
func (array *NetworkArray) Size() int {
return len(array.items)
}

View File

@@ -7,30 +7,23 @@ type PeerInfo struct {
ConnStatus string // Todo replace to enum
}
// PeerInfoCollection made for Java layer to get non default types as collection
type PeerInfoCollection interface {
Add(s string) PeerInfoCollection
Get(i int) string
Size() int
}
// PeerInfoArray is the implementation of the PeerInfoCollection
// PeerInfoArray is a wrapper of []PeerInfo
type PeerInfoArray struct {
items []PeerInfo
}
// Add new PeerInfo to the collection
func (array PeerInfoArray) Add(s PeerInfo) PeerInfoArray {
func (array *PeerInfoArray) Add(s PeerInfo) *PeerInfoArray {
array.items = append(array.items, s)
return array
}
// Get return an element of the collection
func (array PeerInfoArray) Get(i int) *PeerInfo {
func (array *PeerInfoArray) Get(i int) *PeerInfo {
return &array.items[i]
}
// Size return with the size of the collection
func (array PeerInfoArray) Size() int {
func (array *PeerInfoArray) Size() int {
return len(array.items)
}

View File

@@ -69,7 +69,10 @@ func statusFunc(cmd *cobra.Command, args []string) error {
return err
}
if resp.GetStatus() == string(internal.StatusNeedsLogin) || resp.GetStatus() == string(internal.StatusLoginFailed) {
status := resp.GetStatus()
if status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
status == string(internal.StatusSessionExpired) {
cmd.Printf("Daemon status: %s\n\n"+
"Run UP command to log in with SSO (interactive login):\n\n"+
" netbird up \n\n"+

View File

@@ -118,7 +118,7 @@ func tracePacket(cmd *cobra.Command, args []string) error {
}
func printTrace(cmd *cobra.Command, src, dst, proto string, sport, dport uint16, resp *proto.TracePacketResponse) {
cmd.Printf("Packet trace %s:%d -> %s:%d (%s)\n\n", src, sport, dst, dport, strings.ToUpper(proto))
cmd.Printf("Packet trace %s:%d %s:%d (%s)\n\n", src, sport, dst, dport, strings.ToUpper(proto))
for _, stage := range resp.Stages {
if stage.ForwardingDetails != nil {

View File

@@ -62,5 +62,5 @@ type ConnKey struct {
}
func (c ConnKey) String() string {
return fmt.Sprintf("%s:%d -> %s:%d", c.SrcIP.Unmap(), c.SrcPort, c.DstIP.Unmap(), c.DstPort)
return fmt.Sprintf("%s:%d %s:%d", c.SrcIP.Unmap(), c.SrcPort, c.DstIP.Unmap(), c.DstPort)
}

View File

@@ -3,6 +3,7 @@ package conntrack
import (
"context"
"fmt"
"net"
"net/netip"
"sync"
"time"
@@ -19,6 +20,10 @@ const (
DefaultICMPTimeout = 30 * time.Second
// ICMPCleanupInterval is how often we check for stale ICMP connections
ICMPCleanupInterval = 15 * time.Second
// MaxICMPPayloadLength is the maximum length of ICMP payload we consider for original packet info,
// which includes the IP header (20 bytes) and transport header (8 bytes)
MaxICMPPayloadLength = 28
)
// ICMPConnKey uniquely identifies an ICMP connection
@@ -29,7 +34,7 @@ type ICMPConnKey struct {
}
func (i ICMPConnKey) String() string {
return fmt.Sprintf("%s -> %s (id %d)", i.SrcIP, i.DstIP, i.ID)
return fmt.Sprintf("%s %s (id %d)", i.SrcIP, i.DstIP, i.ID)
}
// ICMPConnTrack represents an ICMP connection state
@@ -50,6 +55,72 @@ type ICMPTracker struct {
flowLogger nftypes.FlowLogger
}
// ICMPInfo holds ICMP type, code, and payload for lazy string formatting in logs
type ICMPInfo struct {
TypeCode layers.ICMPv4TypeCode
PayloadData [MaxICMPPayloadLength]byte
// actual length of valid data
PayloadLen int
}
// String implements fmt.Stringer for lazy evaluation in log messages
func (info ICMPInfo) String() string {
if info.isErrorMessage() && info.PayloadLen >= MaxICMPPayloadLength {
if origInfo := info.parseOriginalPacket(); origInfo != "" {
return fmt.Sprintf("%s (original: %s)", info.TypeCode, origInfo)
}
}
return info.TypeCode.String()
}
// isErrorMessage returns true if this ICMP type carries original packet info
func (info ICMPInfo) isErrorMessage() bool {
typ := info.TypeCode.Type()
return typ == 3 || // Destination Unreachable
typ == 5 || // Redirect
typ == 11 || // Time Exceeded
typ == 12 // Parameter Problem
}
// parseOriginalPacket extracts info about the original packet from ICMP payload
func (info ICMPInfo) parseOriginalPacket() string {
if info.PayloadLen < MaxICMPPayloadLength {
return ""
}
// TODO: handle IPv6
if version := (info.PayloadData[0] >> 4) & 0xF; version != 4 {
return ""
}
protocol := info.PayloadData[9]
srcIP := net.IP(info.PayloadData[12:16])
dstIP := net.IP(info.PayloadData[16:20])
transportData := info.PayloadData[20:]
switch nftypes.Protocol(protocol) {
case nftypes.TCP:
srcPort := uint16(transportData[0])<<8 | uint16(transportData[1])
dstPort := uint16(transportData[2])<<8 | uint16(transportData[3])
return fmt.Sprintf("TCP %s:%d → %s:%d", srcIP, srcPort, dstIP, dstPort)
case nftypes.UDP:
srcPort := uint16(transportData[0])<<8 | uint16(transportData[1])
dstPort := uint16(transportData[2])<<8 | uint16(transportData[3])
return fmt.Sprintf("UDP %s:%d → %s:%d", srcIP, srcPort, dstIP, dstPort)
case nftypes.ICMP:
icmpType := transportData[0]
icmpCode := transportData[1]
return fmt.Sprintf("ICMP %s → %s (type %d code %d)", srcIP, dstIP, icmpType, icmpCode)
default:
return fmt.Sprintf("Proto %d %s → %s", protocol, srcIP, dstIP)
}
}
// NewICMPTracker creates a new ICMP connection tracker
func NewICMPTracker(timeout time.Duration, logger *nblog.Logger, flowLogger nftypes.FlowLogger) *ICMPTracker {
if timeout == 0 {
@@ -93,30 +164,64 @@ func (t *ICMPTracker) updateIfExists(srcIP netip.Addr, dstIP netip.Addr, id uint
}
// TrackOutbound records an outbound ICMP connection
func (t *ICMPTracker) TrackOutbound(srcIP netip.Addr, dstIP netip.Addr, id uint16, typecode layers.ICMPv4TypeCode, size int) {
func (t *ICMPTracker) TrackOutbound(
srcIP netip.Addr,
dstIP netip.Addr,
id uint16,
typecode layers.ICMPv4TypeCode,
payload []byte,
size int,
) {
if _, exists := t.updateIfExists(dstIP, srcIP, id, nftypes.Egress, size); !exists {
// if (inverted direction) conn is not tracked, track this direction
t.track(srcIP, dstIP, id, typecode, nftypes.Egress, nil, size)
t.track(srcIP, dstIP, id, typecode, nftypes.Egress, nil, payload, size)
}
}
// TrackInbound records an inbound ICMP Echo Request
func (t *ICMPTracker) TrackInbound(srcIP netip.Addr, dstIP netip.Addr, id uint16, typecode layers.ICMPv4TypeCode, ruleId []byte, size int) {
t.track(srcIP, dstIP, id, typecode, nftypes.Ingress, ruleId, size)
func (t *ICMPTracker) TrackInbound(
srcIP netip.Addr,
dstIP netip.Addr,
id uint16,
typecode layers.ICMPv4TypeCode,
ruleId []byte,
payload []byte,
size int,
) {
t.track(srcIP, dstIP, id, typecode, nftypes.Ingress, ruleId, payload, size)
}
// track is the common implementation for tracking both inbound and outbound ICMP connections
func (t *ICMPTracker) track(srcIP netip.Addr, dstIP netip.Addr, id uint16, typecode layers.ICMPv4TypeCode, direction nftypes.Direction, ruleId []byte, size int) {
func (t *ICMPTracker) track(
srcIP netip.Addr,
dstIP netip.Addr,
id uint16,
typecode layers.ICMPv4TypeCode,
direction nftypes.Direction,
ruleId []byte,
payload []byte,
size int,
) {
key, exists := t.updateIfExists(srcIP, dstIP, id, direction, size)
if exists {
return
}
typ, code := typecode.Type(), typecode.Code()
icmpInfo := ICMPInfo{
TypeCode: typecode,
}
if len(payload) > 0 {
icmpInfo.PayloadLen = len(payload)
if icmpInfo.PayloadLen > MaxICMPPayloadLength {
icmpInfo.PayloadLen = MaxICMPPayloadLength
}
copy(icmpInfo.PayloadData[:], payload[:icmpInfo.PayloadLen])
}
// non echo requests don't need tracking
if typ != uint8(layers.ICMPv4TypeEchoRequest) {
t.logger.Trace("New %s ICMP connection %s type %d code %d", direction, key, typ, code)
t.logger.Trace("New %s ICMP connection %s - %s", direction, key, icmpInfo)
t.sendStartEvent(direction, srcIP, dstIP, typ, code, ruleId, size)
return
}
@@ -138,7 +243,7 @@ func (t *ICMPTracker) track(srcIP netip.Addr, dstIP netip.Addr, id uint16, typec
t.connections[key] = conn
t.mutex.Unlock()
t.logger.Trace("New %s ICMP connection %s type %d code %d", direction, key, typ, code)
t.logger.Trace("New %s ICMP connection %s - %s", direction, key, icmpInfo)
t.sendEvent(nftypes.TypeStart, conn, ruleId)
}

View File

@@ -15,7 +15,7 @@ func BenchmarkICMPTracker(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
tracker.TrackOutbound(srcIP, dstIP, uint16(i%65535), 0, 0)
tracker.TrackOutbound(srcIP, dstIP, uint16(i%65535), 0, []byte{}, 0)
}
})
@@ -28,7 +28,7 @@ func BenchmarkICMPTracker(b *testing.B) {
// Pre-populate some connections
for i := 0; i < 1000; i++ {
tracker.TrackOutbound(srcIP, dstIP, uint16(i), 0, 0)
tracker.TrackOutbound(srcIP, dstIP, uint16(i), 0, []byte{}, 0)
}
b.ResetTimer()

View File

@@ -86,5 +86,5 @@ type epID stack.TransportEndpointID
func (i epID) String() string {
// src and remote is swapped
return fmt.Sprintf("%s:%d -> %s:%d", i.RemoteAddress, i.RemotePort, i.LocalAddress, i.LocalPort)
return fmt.Sprintf("%s:%d %s:%d", i.RemoteAddress, i.RemotePort, i.LocalAddress, i.LocalPort)
}

View File

@@ -111,12 +111,12 @@ func (f *Forwarder) proxyTCP(id stack.TransportEndpointID, inConn *gonet.TCPConn
if errInToOut != nil {
if !isClosedError(errInToOut) {
f.logger.Error("proxyTCP: copy error (in -> out) for %s: %v", epID(id), errInToOut)
f.logger.Error("proxyTCP: copy error (in out) for %s: %v", epID(id), errInToOut)
}
}
if errOutToIn != nil {
if !isClosedError(errOutToIn) {
f.logger.Error("proxyTCP: copy error (out -> in) for %s: %v", epID(id), errOutToIn)
f.logger.Error("proxyTCP: copy error (out in) for %s: %v", epID(id), errOutToIn)
}
}

View File

@@ -250,10 +250,10 @@ func (f *Forwarder) proxyUDP(ctx context.Context, pConn *udpPacketConn, id stack
wg.Wait()
if outboundErr != nil && !isClosedError(outboundErr) {
f.logger.Error("proxyUDP: copy error (outbound->inbound) for %s: %v", epID(id), outboundErr)
f.logger.Error("proxyUDP: copy error (outboundinbound) for %s: %v", epID(id), outboundErr)
}
if inboundErr != nil && !isClosedError(inboundErr) {
f.logger.Error("proxyUDP: copy error (inbound->outbound) for %s: %v", epID(id), inboundErr)
f.logger.Error("proxyUDP: copy error (inboundoutbound) for %s: %v", epID(id), inboundErr)
}
var rxPackets, txPackets uint64

View File

@@ -671,7 +671,7 @@ func (m *Manager) trackOutbound(d *decoder, srcIP, dstIP netip.Addr, size int) {
flags := getTCPFlags(&d.tcp)
m.tcpTracker.TrackOutbound(srcIP, dstIP, uint16(d.tcp.SrcPort), uint16(d.tcp.DstPort), flags, size)
case layers.LayerTypeICMPv4:
m.icmpTracker.TrackOutbound(srcIP, dstIP, d.icmp4.Id, d.icmp4.TypeCode, size)
m.icmpTracker.TrackOutbound(srcIP, dstIP, d.icmp4.Id, d.icmp4.TypeCode, d.icmp4.Payload, size)
}
}
@@ -684,7 +684,7 @@ func (m *Manager) trackInbound(d *decoder, srcIP, dstIP netip.Addr, ruleID []byt
flags := getTCPFlags(&d.tcp)
m.tcpTracker.TrackInbound(srcIP, dstIP, uint16(d.tcp.SrcPort), uint16(d.tcp.DstPort), flags, ruleID, size)
case layers.LayerTypeICMPv4:
m.icmpTracker.TrackInbound(srcIP, dstIP, d.icmp4.Id, d.icmp4.TypeCode, ruleID, size)
m.icmpTracker.TrackInbound(srcIP, dstIP, d.icmp4.Id, d.icmp4.TypeCode, ruleID, d.icmp4.Payload, size)
}
}

View File

@@ -84,3 +84,10 @@ func (u *upstreamResolver) isLocalResolver(upstream string) bool {
}
return false
}
func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Duration) (*dns.Client, error) {
return &dns.Client{
Timeout: dialTimeout,
Net: "udp",
}, nil
}

View File

@@ -36,3 +36,10 @@ func newUpstreamResolver(
func (u *upstreamResolver) exchange(ctx context.Context, upstream string, r *dns.Msg) (rm *dns.Msg, t time.Duration, err error) {
return ExchangeWithFallback(ctx, &dns.Client{}, r, upstream)
}
func GetClientPrivate(ip netip.Addr, interfaceName string, dialTimeout time.Duration) (*dns.Client, error) {
return &dns.Client{
Timeout: dialTimeout,
Net: "udp",
}, nil
}

View File

@@ -204,7 +204,7 @@ func (c *ConnTrack) handleEvent(event nfct.Event) {
eventStr = "Ended"
}
log.Tracef("%s %s %s connection: %s:%d -> %s:%d", eventStr, direction, proto, srcIP, srcPort, dstIP, dstPort)
log.Tracef("%s %s %s connection: %s:%d %s:%d", eventStr, direction, proto, srcIP, srcPort, dstIP, dstPort)
c.flowLogger.StoreEvent(nftypes.EventFields{
FlowID: flowID,

View File

@@ -4,7 +4,6 @@ import (
"context"
"fmt"
"reflect"
"runtime"
"time"
log "github.com/sirupsen/logrus"
@@ -23,7 +22,7 @@ import (
const (
handlerTypeDynamic = iota
handlerTypeDomain
handlerTypeDnsInterceptor
handlerTypeStatic
)
@@ -566,13 +565,14 @@ func HandlerFromRoute(
useNewDNSRoute bool,
) RouteHandler {
switch handlerType(rt, useNewDNSRoute) {
case handlerTypeDomain:
case handlerTypeDnsInterceptor:
return dnsinterceptor.New(
rt,
routeRefCounter,
allowedIPsRefCounter,
statusRecorder,
dnsServer,
wgInterface,
peerStore,
)
case handlerTypeDynamic:
@@ -596,8 +596,8 @@ func handlerType(rt *route.Route, useNewDNSRoute bool) int {
return handlerTypeStatic
}
if useNewDNSRoute && runtime.GOOS != "ios" {
return handlerTypeDomain
if useNewDNSRoute {
return handlerTypeDnsInterceptor
}
return handlerTypeDynamic
}

View File

@@ -12,6 +12,7 @@ import (
log "github.com/sirupsen/logrus"
nberrors "github.com/netbirdio/netbird/client/errors"
"github.com/netbirdio/netbird/client/iface/wgaddr"
nbdns "github.com/netbirdio/netbird/client/internal/dns"
"github.com/netbirdio/netbird/client/internal/dnsfwd"
"github.com/netbirdio/netbird/client/internal/peer"
@@ -23,6 +24,11 @@ import (
type domainMap map[domain.Domain][]netip.Prefix
type wgInterface interface {
Name() string
Address() wgaddr.Address
}
type DnsInterceptor struct {
mu sync.RWMutex
route *route.Route
@@ -32,6 +38,7 @@ type DnsInterceptor struct {
dnsServer nbdns.Server
currentPeerKey string
interceptedDomains domainMap
wgInterface wgInterface
peerStore *peerstore.Store
}
@@ -41,6 +48,7 @@ func New(
allowedIPsRefCounter *refcounter.AllowedIPsRefCounter,
statusRecorder *peer.Status,
dnsServer nbdns.Server,
wgInterface wgInterface,
peerStore *peerstore.Store,
) *DnsInterceptor {
return &DnsInterceptor{
@@ -49,6 +57,7 @@ func New(
allowedIPsRefcounter: allowedIPsRefCounter,
statusRecorder: statusRecorder,
dnsServer: dnsServer,
wgInterface: wgInterface,
interceptedDomains: make(domainMap),
peerStore: peerStore,
}
@@ -162,13 +171,16 @@ func (d *DnsInterceptor) ServeDNS(w dns.ResponseWriter, r *dns.Msg) {
return
}
client, err := nbdns.GetClientPrivate(d.wgInterface.Address().IP, d.wgInterface.Name(), nbdns.UpstreamTimeout)
if err != nil {
d.writeDNSError(w, r, fmt.Sprintf("create DNS client: %v", err))
return
}
if r.Extra == nil {
r.MsgHdr.AuthenticatedData = true
}
client := &dns.Client{
Timeout: nbdns.UpstreamTimeout,
Net: "udp",
}
upstream := fmt.Sprintf("%s:%d", upstreamIP.String(), dnsfwd.ListenPort)
reply, _, err := nbdns.ExchangeWithFallback(context.TODO(), client, r, upstream)
if err != nil {

View File

@@ -10,10 +10,11 @@ type StatusType string
const (
StatusIdle StatusType = "Idle"
StatusConnecting StatusType = "Connecting"
StatusConnected StatusType = "Connected"
StatusNeedsLogin StatusType = "NeedsLogin"
StatusLoginFailed StatusType = "LoginFailed"
StatusConnecting StatusType = "Connecting"
StatusConnected StatusType = "Connected"
StatusNeedsLogin StatusType = "NeedsLogin"
StatusLoginFailed StatusType = "LoginFailed"
StatusSessionExpired StatusType = "SessionExpired"
)
// CtxInitState setup context state into the context tree.

View File

@@ -8,6 +8,7 @@ import (
"runtime"
"strconv"
"sync"
"sync/atomic"
"time"
"github.com/cenkalti/backoff/v4"
@@ -66,6 +67,7 @@ type Server struct {
lastProbe time.Time
persistNetworkMap bool
isSessionActive atomic.Bool
}
type oauthAuthFlow struct {
@@ -567,9 +569,6 @@ func (s *Server) WaitSSOLogin(callerCtx context.Context, msg *proto.WaitSSOLogin
tokenInfo, err := s.oauthAuthFlow.flow.WaitToken(waitCTX, flowInfo)
if err != nil {
if err == context.Canceled {
return nil, nil //nolint:nilnil
}
s.mutex.Lock()
s.oauthAuthFlow.expiresAt = time.Now()
s.mutex.Unlock()
@@ -640,6 +639,7 @@ func (s *Server) Up(callerCtx context.Context, _ *proto.UpRequest) (*proto.UpRes
for {
select {
case <-runningChan:
s.isSessionActive.Store(true)
return &proto.UpResponse{}, nil
case <-callerCtx.Done():
log.Debug("context done, stopping the wait for engine to become ready")
@@ -668,6 +668,7 @@ func (s *Server) Down(ctx context.Context, _ *proto.DownRequest) (*proto.DownRes
log.Errorf("failed to shut down properly: %v", err)
return nil, err
}
s.isSessionActive.Store(false)
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusIdle)
@@ -694,6 +695,12 @@ func (s *Server) Status(
return nil, err
}
if status == internal.StatusNeedsLogin && s.isSessionActive.Load() {
log.Debug("status requested while session is active, returning SessionExpired")
status = internal.StatusSessionExpired
s.isSessionActive.Store(false)
}
statusResponse := proto.StatusResponse{Status: string(status), DaemonVersion: version.NetbirdVersion()}
s.statusRecorder.UpdateManagementAddress(s.config.ManagementURL.String())

View File

@@ -20,7 +20,10 @@ import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"fyne.io/systray"
@@ -51,7 +54,7 @@ const (
)
func main() {
daemonAddr, showSettings, showNetworks, showDebug, errorMsg, saveLogsInFile := parseFlags()
daemonAddr, showSettings, showNetworks, showLoginURL, showDebug, errorMsg, saveLogsInFile := parseFlags()
// Initialize file logging if needed.
var logFile string
@@ -77,13 +80,13 @@ func main() {
}
// Create the service client (this also builds the settings or networks UI if requested).
client := newServiceClient(daemonAddr, logFile, a, showSettings, showNetworks, showDebug)
client := newServiceClient(daemonAddr, logFile, a, showSettings, showNetworks, showLoginURL, showDebug)
// Watch for theme/settings changes to update the icon.
go watchSettingsChanges(a, client)
// Run in window mode if any UI flag was set.
if showSettings || showNetworks || showDebug {
if showSettings || showNetworks || showDebug || showLoginURL {
a.Run()
return
}
@@ -104,7 +107,7 @@ func main() {
}
// parseFlags reads and returns all needed command-line flags.
func parseFlags() (daemonAddr string, showSettings, showNetworks, showDebug bool, errorMsg string, saveLogsInFile bool) {
func parseFlags() (daemonAddr string, showSettings, showNetworks, showLoginURL, showDebug bool, errorMsg string, saveLogsInFile bool) {
defaultDaemonAddr := "unix:///var/run/netbird.sock"
if runtime.GOOS == "windows" {
defaultDaemonAddr = "tcp://127.0.0.1:41731"
@@ -112,6 +115,7 @@ func parseFlags() (daemonAddr string, showSettings, showNetworks, showDebug bool
flag.StringVar(&daemonAddr, "daemon-addr", defaultDaemonAddr, "Daemon service address to serve CLI requests [unix|tcp]://[path|host:port]")
flag.BoolVar(&showSettings, "settings", false, "run settings window")
flag.BoolVar(&showNetworks, "networks", false, "run networks window")
flag.BoolVar(&showLoginURL, "login-url", false, "show login URL in a popup window")
flag.BoolVar(&showDebug, "debug", false, "run debug window")
flag.StringVar(&errorMsg, "error-msg", "", "displays an error message window")
flag.BoolVar(&saveLogsInFile, "use-log-file", false, fmt.Sprintf("save logs in a file: %s/netbird-ui-PID.log", os.TempDir()))
@@ -253,6 +257,7 @@ type serviceClient struct {
exitNodeStates []exitNodeState
mExitNodeDeselectAll *systray.MenuItem
logFile string
wLoginURL fyne.Window
}
type menuHandler struct {
@@ -263,7 +268,7 @@ type menuHandler struct {
// newServiceClient instance constructor
//
// This constructor also builds the UI elements for the settings window.
func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool, showNetworks bool, showDebug bool) *serviceClient {
func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool, showNetworks bool, showLoginURL bool, showDebug bool) *serviceClient {
ctx, cancel := context.WithCancel(context.Background())
s := &serviceClient{
ctx: ctx,
@@ -286,6 +291,8 @@ func newServiceClient(addr string, logFile string, a fyne.App, showSettings bool
s.showSettingsUI()
case showNetworks:
s.showNetworksUI()
case showLoginURL:
s.showLoginURL()
case showDebug:
s.showDebugUI()
}
@@ -445,11 +452,11 @@ func (s *serviceClient) getSettingsForm() *widget.Form {
}
}
func (s *serviceClient) login() error {
func (s *serviceClient) login(openURL bool) (*proto.LoginResponse, error) {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
log.Errorf("get client: %v", err)
return err
return nil, err
}
loginResp, err := conn.Login(s.ctx, &proto.LoginRequest{
@@ -457,24 +464,24 @@ func (s *serviceClient) login() error {
})
if err != nil {
log.Errorf("login to management URL with: %v", err)
return err
return nil, err
}
if loginResp.NeedsSSOLogin {
if loginResp.NeedsSSOLogin && openURL {
err = open.Run(loginResp.VerificationURIComplete)
if err != nil {
log.Errorf("opening the verification uri in the browser failed: %v", err)
return err
return nil, err
}
_, err = conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: loginResp.UserCode})
if err != nil {
log.Errorf("waiting sso login failed with: %v", err)
return err
return nil, err
}
}
return nil
return loginResp, nil
}
func (s *serviceClient) menuUpClick() error {
@@ -486,7 +493,7 @@ func (s *serviceClient) menuUpClick() error {
return err
}
err = s.login()
_, err = s.login(true)
if err != nil {
log.Errorf("login failed with: %v", err)
return err
@@ -558,14 +565,14 @@ func (s *serviceClient) updateStatus() error {
defer s.updateIndicationLock.Unlock()
// notify the user when the session has expired
if status.Status == string(internal.StatusNeedsLogin) {
if status.Status == string(internal.StatusSessionExpired) {
s.onSessionExpire()
}
var systrayIconState bool
switch {
case status.Status == string(internal.StatusConnected):
case status.Status == string(internal.StatusConnected) && !s.mUp.Disabled():
s.connected = true
s.sendNotification = true
if s.isUpdateIconActive {
@@ -732,7 +739,6 @@ func (s *serviceClient) onTrayReady() {
go s.eventHandler.listen(s.ctx)
}
func (s *serviceClient) attachOutput(cmd *exec.Cmd) *os.File {
if s.logFile == "" {
// attach child's streams to parent's streams
@@ -871,17 +877,9 @@ func (s *serviceClient) onUpdateAvailable() {
// onSessionExpire sends a notification to the user when the session expires.
func (s *serviceClient) onSessionExpire() {
s.sendNotification = true
if s.sendNotification {
title := "Connection session expired"
if runtime.GOOS == "darwin" {
title = "NetBird connection session expired"
}
s.app.SendNotification(
fyne.NewNotification(
title,
"Please re-authenticate to connect to the network",
),
)
s.eventHandler.runSelfCommand("login-url", "true")
s.sendNotification = false
}
}
@@ -955,9 +953,9 @@ func (s *serviceClient) updateConfig() error {
ServerSSHAllowed: &sshAllowed,
RosenpassEnabled: &rosenpassEnabled,
DisableAutoConnect: &disableAutoStart,
DisableNotifications: &notificationsDisabled,
LazyConnectionEnabled: &lazyConnectionEnabled,
BlockInbound: &blockInbound,
DisableNotifications: &notificationsDisabled,
}
if err := s.restartClient(&loginRequest); err != nil {
@@ -991,6 +989,87 @@ func (s *serviceClient) restartClient(loginRequest *proto.LoginRequest) error {
return nil
}
// showLoginURL creates a borderless window styled like a pop-up in the top-right corner using s.wLoginURL.
func (s *serviceClient) showLoginURL() {
resp, err := s.login(false)
if err != nil {
log.Errorf("failed to fetch login URL: %v", err)
return
}
verificationURL := resp.VerificationURIComplete
if verificationURL == "" {
verificationURL = resp.VerificationURI
}
if verificationURL == "" {
log.Error("no verification URL provided in the login response")
return
}
resIcon := fyne.NewStaticResource("netbird.png", iconAbout)
if s.wLoginURL == nil {
s.wLoginURL = s.app.NewWindow("NetBird Session Expired")
s.wLoginURL.Resize(fyne.NewSize(400, 200))
s.wLoginURL.SetIcon(resIcon)
}
// add a description label
label := widget.NewLabel("Your NetBird session has expired.\nPlease re-authenticate to continue using NetBird.")
btn := widget.NewButtonWithIcon("Re-authenticate", theme.ViewRefreshIcon(), func() {
conn, err := s.getSrvClient(defaultFailTimeout)
if err != nil {
log.Errorf("get client: %v", err)
return
}
if err := openURL(verificationURL); err != nil {
log.Errorf("failed to open login URL: %v", err)
return
}
_, err = conn.WaitSSOLogin(s.ctx, &proto.WaitSSOLoginRequest{UserCode: resp.UserCode})
if err != nil {
log.Errorf("Waiting sso login failed with: %v", err)
label.SetText("Waiting login failed, please create \na debug bundle in the settings and contact support.")
return
}
label.SetText("Re-authentication successful.\nReconnecting")
time.Sleep(300 * time.Millisecond)
_, err = conn.Up(s.ctx, &proto.UpRequest{})
if err != nil {
label.SetText("Reconnecting failed, please create \na debug bundle in the settings and contact support.")
log.Errorf("Reconnecting failed with: %v", err)
return
}
label.SetText("Connection successful.\nClosing this window.")
time.Sleep(time.Second)
s.wLoginURL.Close()
})
img := canvas.NewImageFromResource(resIcon)
img.FillMode = canvas.ImageFillContain
img.SetMinSize(fyne.NewSize(64, 64))
img.Resize(fyne.NewSize(64, 64))
// center the content vertically
content := container.NewVBox(
layout.NewSpacer(),
img,
label,
btn,
layout.NewSpacer(),
)
s.wLoginURL.SetContent(container.NewCenter(content))
s.wLoginURL.Show()
}
func openURL(url string) error {
var err error
switch runtime.GOOS {

View File

@@ -358,8 +358,6 @@ func (s *serviceClient) updateExitNodes() {
} else {
s.mExitNode.Disable()
}
log.Debugf("Exit nodes updated: %d", len(s.mExitNodeItems))
}
func (s *serviceClient) recreateExitNodeMenu(exitNodes []*proto.Network) {

2
go.mod
View File

@@ -63,7 +63,7 @@ require (
github.com/miekg/dns v1.1.59
github.com/mitchellh/hashstructure/v2 v2.0.2
github.com/nadoo/ipset v0.5.0
github.com/netbirdio/management-integrations/integrations v0.0.0-20250529122842-6700aa91190c
github.com/netbirdio/management-integrations/integrations v0.0.0-20250612164546-6bd7e2338d65
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250514131221-a464fd5f30cb
github.com/okta/okta-sdk-golang/v2 v2.18.0
github.com/oschwald/maxminddb-golang v1.12.0

4
go.sum
View File

@@ -503,8 +503,8 @@ github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944 h1:TDtJKmM6S
github.com/netbirdio/go-netroute v0.0.0-20240611143515-f59b0e1d3944/go.mod h1:sHA6TRxjQ6RLbnI+3R4DZo2Eseg/iKiPRfNmcuNySVQ=
github.com/netbirdio/ice/v3 v3.0.0-20240315174635-e72a50fcb64e h1:PURA50S8u4mF6RrkYYCAvvPCixhqqEiEy3Ej6avh04c=
github.com/netbirdio/ice/v3 v3.0.0-20240315174635-e72a50fcb64e/go.mod h1:YMLU7qbKfVjmEv7EoZPIVEI+kNYxWCdPK3VS0BU+U4Q=
github.com/netbirdio/management-integrations/integrations v0.0.0-20250529122842-6700aa91190c h1:SdZxYjR9XXHLyRsTbS1EHBr6+RI15oie1K9Q8yvi3FY=
github.com/netbirdio/management-integrations/integrations v0.0.0-20250529122842-6700aa91190c/go.mod h1:Gi9raplYzCCyh07Olw/DVfCJTFgpr1WCXJ/Q+8TSA9Q=
github.com/netbirdio/management-integrations/integrations v0.0.0-20250612164546-6bd7e2338d65 h1:5OfYiLjpr4dbQYJI5ouZaylkVdi2KlErLFOwBeBo5Hw=
github.com/netbirdio/management-integrations/integrations v0.0.0-20250612164546-6bd7e2338d65/go.mod h1:Gi9raplYzCCyh07Olw/DVfCJTFgpr1WCXJ/Q+8TSA9Q=
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502 h1:3tHlFmhTdX9axERMVN63dqyFqnvuD+EMJHzM7mNGON8=
github.com/netbirdio/service v0.0.0-20240911161631-f62744f42502/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM=
github.com/netbirdio/signal-dispatcher/dispatcher v0.0.0-20250514131221-a464fd5f30cb h1:Cr6age+ePALqlSvtp7wc6lYY97XN7rkD1K4XEDmY+TU=

View File

@@ -159,6 +159,12 @@ var (
if err != nil {
return err
}
integrationMetrics, err := integrations.InitIntegrationMetrics(ctx, appMetrics)
if err != nil {
return err
}
store, err := store.NewStore(ctx, config.StoreConfig.Engine, config.Datadir, appMetrics, false)
if err != nil {
return fmt.Errorf("failed creating Store: %s: %v", config.Datadir, err)
@@ -176,7 +182,7 @@ var (
if disableSingleAccMode {
mgmtSingleAccModeDomain = ""
}
eventStore, key, err := integrations.InitEventStore(ctx, config.Datadir, config.DataStoreEncryptionKey)
eventStore, key, err := integrations.InitEventStore(ctx, config.Datadir, config.DataStoreEncryptionKey, integrationMetrics)
if err != nil {
return fmt.Errorf("failed to initialize database: %s", err)
}

View File

@@ -5,7 +5,6 @@ import (
"crypto/sha256"
"encoding/base64"
"fmt"
"strconv"
"strings"
"testing"
"time"
@@ -182,7 +181,7 @@ func TestDefaultAccountManager_CreateSetupKey(t *testing.T) {
}
assertKey(t, key, tCase.expectedKeyName, false, tCase.expectedType, tCase.expectedUsedTimes,
tCase.expectedCreatedAt, tCase.expectedExpiresAt, strconv.Itoa(int(types.Hash(key.Key))),
tCase.expectedCreatedAt, tCase.expectedExpiresAt, key.Id,
tCase.expectedUpdatedAt, tCase.expectedGroups, false)
// check the corresponding events that should have been generated
@@ -258,10 +257,10 @@ func TestGenerateDefaultSetupKey(t *testing.T) {
expectedExpiresAt := time.Now().UTC().Add(24 * 30 * time.Hour)
var expectedAutoGroups []string
key, plainKey := types.GenerateDefaultSetupKey()
key, _ := types.GenerateDefaultSetupKey()
assertKey(t, key, expectedName, expectedRevoke, expectedType, expectedUsedTimes, expectedCreatedAt,
expectedExpiresAt, strconv.Itoa(int(types.Hash(plainKey))), expectedUpdatedAt, expectedAutoGroups, true)
expectedExpiresAt, key.Id, expectedUpdatedAt, expectedAutoGroups, true)
}
@@ -275,10 +274,10 @@ func TestGenerateSetupKey(t *testing.T) {
expectedUpdatedAt := time.Now().UTC()
var expectedAutoGroups []string
key, plain := types.GenerateSetupKey(expectedName, types.SetupKeyOneOff, time.Hour, []string{}, types.SetupKeyUnlimitedUsage, false, false)
key, _ := types.GenerateSetupKey(expectedName, types.SetupKeyOneOff, time.Hour, []string{}, types.SetupKeyUnlimitedUsage, false, false)
assertKey(t, key, expectedName, expectedRevoke, expectedType, expectedUsedTimes, expectedCreatedAt,
expectedExpiresAt, strconv.Itoa(int(types.Hash(plain))), expectedUpdatedAt, expectedAutoGroups, true)
expectedExpiresAt, key.Id, expectedUpdatedAt, expectedAutoGroups, true)
}

View File

@@ -184,10 +184,10 @@ func (appMetrics *defaultAppMetrics) Expose(ctx context.Context, port int, endpo
}
appMetrics.listener = listener
go func() {
err := http.Serve(listener, rootRouter)
if err != nil {
return
if err := http.Serve(listener, rootRouter); err != nil && err != http.ErrServerClosed {
log.WithContext(ctx).Errorf("metrics server error: %v", err)
}
log.WithContext(ctx).Info("metrics server stopped")
}()
log.WithContext(ctx).Infof("enabled application metrics and exposing on http://%s", listener.Addr().String())
@@ -204,7 +204,7 @@ func (appMetrics *defaultAppMetrics) GetMeter() metric2.Meter {
func NewDefaultAppMetrics(ctx context.Context) (AppMetrics, error) {
exporter, err := prometheus.New()
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to create prometheus exporter: %w", err)
}
provider := metric.NewMeterProvider(metric.WithReader(exporter))
@@ -213,32 +213,32 @@ func NewDefaultAppMetrics(ctx context.Context) (AppMetrics, error) {
idpMetrics, err := NewIDPMetrics(ctx, meter)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to initialize IDP metrics: %w", err)
}
middleware, err := NewMetricsMiddleware(ctx, meter)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to initialize HTTP middleware metrics: %w", err)
}
grpcMetrics, err := NewGRPCMetrics(ctx, meter)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to initialize gRPC metrics: %w", err)
}
storeMetrics, err := NewStoreMetrics(ctx, meter)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to initialize store metrics: %w", err)
}
updateChannelMetrics, err := NewUpdateChannelMetrics(ctx, meter)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to initialize update channel metrics: %w", err)
}
accountManagerMetrics, err := NewAccountManagerMetrics(ctx, meter)
if err != nil {
return nil, err
return nil, fmt.Errorf("failed to initialize account manager metrics: %w", err)
}
return &defaultAppMetrics{

View File

@@ -3,13 +3,12 @@ package types
import (
"crypto/sha256"
b64 "encoding/base64"
"hash/fnv"
"strconv"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"github.com/rs/xid"
"github.com/netbirdio/netbird/management/server/util"
)
@@ -170,7 +169,7 @@ func GenerateSetupKey(name string, t SetupKeyType, validFor time.Duration, autoG
encodedHashedKey := b64.StdEncoding.EncodeToString(hashedKey[:])
return &SetupKey{
Id: strconv.Itoa(int(Hash(key))),
Id: xid.New().String(),
Key: encodedHashedKey,
KeySecret: HiddenKey(key, 4),
Name: name,
@@ -192,12 +191,3 @@ func GenerateDefaultSetupKey() (*SetupKey, string) {
return GenerateSetupKey(DefaultSetupKeyName, SetupKeyReusable, DefaultSetupKeyDuration, []string{},
SetupKeyUnlimitedUsage, false, false)
}
func Hash(s string) uint32 {
h := fnv.New32a()
_, err := h.Write([]byte(s))
if err != nil {
panic(err)
}
return h.Sum32()
}

35
signal/cmd/env.go Normal file
View File

@@ -0,0 +1,35 @@
package cmd
import (
"os"
"strings"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// setFlagsFromEnvVars reads and updates flag values from environment variables with prefix NB_
func setFlagsFromEnvVars(cmd *cobra.Command) {
flags := cmd.PersistentFlags()
flags.VisitAll(func(f *pflag.Flag) {
newEnvVar := flagNameToEnvVar(f.Name, "NB_")
value, present := os.LookupEnv(newEnvVar)
if !present {
return
}
err := flags.Set(f.Name, value)
if err != nil {
log.Infof("unable to configure flag %s using variable %s, err: %v", f.Name, newEnvVar, err)
}
})
}
// flagNameToEnvVar converts flag name to environment var name adding a prefix,
// replacing dashes and making all uppercase (e.g. setup-keys is converted to NB_SETUP_KEYS according to the input prefix)
func flagNameToEnvVar(cmdFlag string, prefix string) string {
parsed := strings.ReplaceAll(cmdFlag, "-", "_")
upper := strings.ToUpper(parsed)
return prefix + upper
}

View File

@@ -303,4 +303,5 @@ func init() {
runCmd.Flags().StringVar(&signalLetsencryptDomain, "letsencrypt-domain", "", "a domain to issue Let's Encrypt certificate for. Enables TLS using Let's Encrypt. Will fetch and renew certificate, and run the server with TLS")
runCmd.Flags().StringVar(&signalCertFile, "cert-file", "", "Location of your SSL certificate. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect")
runCmd.Flags().StringVar(&signalCertKey, "cert-key", "", "Location of your SSL certificate private key. Can be used when you have an existing certificate and don't want a new certificate be generated automatically. If letsencrypt-domain is specified this property has no effect")
setFlagsFromEnvVars(runCmd)
}