mirror of
https://github.com/netbirdio/netbird.git
synced 2026-08-04 11:45:09 -04:00
## Describe your changes Pull fresh TUN settings on Android rebuild instead of push The Android TUN rebuild consumed state pushed through notifications and a Java-side snapshot, and both sources were unreliable. The DNS search-domain notifier fired OnNetworkChanged with an empty string, which the rebuild handler treated as the new route list, so any search domain change rebuilt the TUN with zero routes and cut all tunnel traffic. The rebuild also reused the search domains cached at the last establish, so search domain updates never reached the TUN at runtime. Make the notification a pure trigger and let the Java side pull a fresh snapshot instead. Expose GetTunSettings on the Android SDK client: it returns the current TUN route ranges, derived on demand by the route manager from the client routes, the exit-node selection and the fake IP blocks, together with the DNS search domains. The route notifier keeps only its last-announced baseline to suppress triggers for unchanged syncs; the TUN route state is owned by the route manager. SearchDomains now locks the DNS server mutex since the pull arrives from a Java thread. Requires the matching android-client change that switches recreateTUN to the pull API. ## Issue ticket number and link ## Stack <!-- branch-stack --> ### Checklist - [x] Is it a bug fix - [ ] Is a typo/documentation fix - [ ] 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). ## Documentation Select exactly one: - [ ] I added/updated documentation for this change - [x] Documentation is **not needed** for this change (explain why) ### Docs PR URL (required if "docs added" is checked) Paste the PR link from https://github.com/netbirdio/docs here: https://github.com/netbirdio/docs/pull/__ <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Added access to current TUN route ranges and DNS search domains. - TUN settings are returned in a mobile-friendly format for easier integration. - **Improvements** - Route changes are detected and synchronized more reliably. - Current routing information now reflects active routes, including supported fake-IP ranges. - Simplified network initialization for more consistent startup behavior. - **API Changes** - Removed the obsolete network-map retrieval method from the management client interface. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
605 lines
17 KiB
Go
605 lines
17 KiB
Go
//go:build android
|
|
|
|
package android
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"slices"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"golang.org/x/exp/maps"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
|
|
"github.com/netbirdio/netbird/client/iface/device"
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/internal/debug"
|
|
"github.com/netbirdio/netbird/client/internal/dns"
|
|
"github.com/netbirdio/netbird/client/internal/listener"
|
|
"github.com/netbirdio/netbird/client/internal/peer"
|
|
"github.com/netbirdio/netbird/client/internal/profilemanager"
|
|
"github.com/netbirdio/netbird/client/internal/routemanager"
|
|
"github.com/netbirdio/netbird/client/internal/stdnet"
|
|
"github.com/netbirdio/netbird/client/net"
|
|
"github.com/netbirdio/netbird/client/system"
|
|
"github.com/netbirdio/netbird/formatter"
|
|
"github.com/netbirdio/netbird/route"
|
|
"github.com/netbirdio/netbird/shared/management/domain"
|
|
types "github.com/netbirdio/netbird/upload-server/types"
|
|
)
|
|
|
|
// ConnectionListener export internal Listener for mobile
|
|
type ConnectionListener interface {
|
|
peer.Listener
|
|
}
|
|
|
|
// TunAdapter export internal TunAdapter for mobile
|
|
type TunAdapter interface {
|
|
device.TunAdapter
|
|
}
|
|
|
|
// IFaceDiscover export internal IFaceDiscover for mobile
|
|
type IFaceDiscover interface {
|
|
stdnet.ExternalIFaceDiscover
|
|
}
|
|
|
|
// NetworkChangeListener export internal NetworkChangeListener for mobile
|
|
type NetworkChangeListener interface {
|
|
listener.NetworkChangeListener
|
|
}
|
|
|
|
// DnsReadyListener export internal dns ReadyListener for mobile
|
|
type DnsReadyListener interface {
|
|
dns.ReadyListener
|
|
}
|
|
|
|
// TunSettings is a snapshot of the settings the TUN device is rebuilt with
|
|
type TunSettings struct {
|
|
Routes string
|
|
SearchDomains string
|
|
}
|
|
|
|
func init() {
|
|
formatter.SetLogcatFormatter(log.StandardLogger())
|
|
}
|
|
|
|
// Client struct manage the life circle of background service
|
|
type Client struct {
|
|
tunAdapter device.TunAdapter
|
|
iFaceDiscover IFaceDiscover
|
|
recorder *peer.Status
|
|
ctxCancel context.CancelFunc
|
|
ctxCancelLock *sync.Mutex
|
|
deviceName string
|
|
uiVersion string
|
|
networkChangeListener listener.NetworkChangeListener
|
|
|
|
stateMu sync.RWMutex
|
|
connectClient *internal.ConnectClient
|
|
config *profilemanager.Config
|
|
cacheDir string
|
|
|
|
stateChangeMu sync.Mutex
|
|
stateChangeSubID string
|
|
eventSub *peer.EventSubscription
|
|
// Closed to stop the watch goroutines from delivering buffered items to a
|
|
// listener that has been removed or replaced. See stopStateChangeWatchLocked.
|
|
stateChangeDone chan struct{}
|
|
|
|
// Latched "the server wants an interactive login": survives the engine
|
|
// restarts that replace the run loop's context state. See Client.Status.
|
|
// Guarded by loginRequiredMu together with loginCleared, which counts
|
|
// clears so a stale observation cannot re-latch over one.
|
|
loginRequiredMu sync.Mutex
|
|
loginRequired bool
|
|
loginCleared uint64
|
|
|
|
extendMu sync.Mutex
|
|
extendCancel context.CancelFunc
|
|
}
|
|
|
|
func (c *Client) setState(cfg *profilemanager.Config, cacheDir string, cc *internal.ConnectClient) {
|
|
c.stateMu.Lock()
|
|
defer c.stateMu.Unlock()
|
|
c.config = cfg
|
|
c.cacheDir = cacheDir
|
|
c.connectClient = cc
|
|
}
|
|
|
|
func (c *Client) stateSnapshot() (*profilemanager.Config, string, *internal.ConnectClient) {
|
|
c.stateMu.RLock()
|
|
defer c.stateMu.RUnlock()
|
|
return c.config, c.cacheDir, c.connectClient
|
|
}
|
|
|
|
func (c *Client) getConnectClient() *internal.ConnectClient {
|
|
c.stateMu.RLock()
|
|
defer c.stateMu.RUnlock()
|
|
return c.connectClient
|
|
}
|
|
|
|
// NewClient instantiate a new Client
|
|
func NewClient(androidSDKVersion int, deviceName string, uiVersion string, tunAdapter TunAdapter, iFaceDiscover IFaceDiscover, networkChangeListener NetworkChangeListener) *Client {
|
|
execWorkaround(androidSDKVersion)
|
|
|
|
net.SetAndroidProtectSocketFn(tunAdapter.ProtectSocket)
|
|
return &Client{
|
|
deviceName: deviceName,
|
|
uiVersion: uiVersion,
|
|
tunAdapter: tunAdapter,
|
|
iFaceDiscover: iFaceDiscover,
|
|
recorder: peer.NewRecorder(""),
|
|
ctxCancelLock: &sync.Mutex{},
|
|
networkChangeListener: networkChangeListener,
|
|
}
|
|
}
|
|
|
|
// Run start the internal client. It is a blocker function
|
|
func (c *Client) Run(platformFiles PlatformFiles, urlOpener URLOpener, isAndroidTV bool, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error {
|
|
exportEnvList(envList)
|
|
|
|
cfgFile := platformFiles.ConfigurationFilePath()
|
|
stateFile := platformFiles.StateFilePath()
|
|
cacheDir := platformFiles.CacheDir()
|
|
|
|
log.Infof("Starting client with config: %s, state: %s", cfgFile, stateFile)
|
|
|
|
cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: cfgFile,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
|
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
|
|
|
var ctx context.Context
|
|
//nolint
|
|
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
|
|
//nolint
|
|
ctxWithValues = context.WithValue(ctxWithValues, system.UiVersionCtxKey, c.uiVersion)
|
|
|
|
c.ctxCancelLock.Lock()
|
|
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
|
|
defer c.ctxCancel()
|
|
c.ctxCancelLock.Unlock()
|
|
|
|
auth := NewAuthWithConfig(ctx, cfg)
|
|
err = auth.login(urlOpener, isAndroidTV)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
// todo do not throw error in case of cancelled context
|
|
ctx = internal.CtxInitState(ctx)
|
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
|
c.setState(cfg, cacheDir, connectClient)
|
|
// This path runs the interactive SSO flow, so reaching here means the peer
|
|
// is authenticated again — release the latch Status() reports from. Clear
|
|
// only once the fresh connect client is installed: until then Status()
|
|
// still reads the previous run's context state, which holds the NeedsLogin
|
|
// that prompted this login, and would re-latch what was just cleared.
|
|
c.clearLoginRequired()
|
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
|
}
|
|
|
|
// RunWithoutLogin we apply this type of run function when the backed has been started without UI (i.e. after reboot).
|
|
// In this case make no sense handle registration steps.
|
|
func (c *Client) RunWithoutLogin(platformFiles PlatformFiles, dns *DNSList, dnsReadyListener DnsReadyListener, envList *EnvList) error {
|
|
exportEnvList(envList)
|
|
|
|
cfgFile := platformFiles.ConfigurationFilePath()
|
|
stateFile := platformFiles.StateFilePath()
|
|
cacheDir := platformFiles.CacheDir()
|
|
|
|
log.Infof("Starting client without login with config: %s, state: %s", cfgFile, stateFile)
|
|
|
|
cfg, err := profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: cfgFile,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.recorder.UpdateManagementAddress(cfg.ManagementURL.String())
|
|
c.recorder.UpdateRosenpass(cfg.RosenpassEnabled, cfg.RosenpassPermissive)
|
|
|
|
var ctx context.Context
|
|
//nolint
|
|
ctxWithValues := context.WithValue(context.Background(), system.DeviceNameCtxKey, c.deviceName)
|
|
c.ctxCancelLock.Lock()
|
|
ctx, c.ctxCancel = context.WithCancel(ctxWithValues)
|
|
defer c.ctxCancel()
|
|
c.ctxCancelLock.Unlock()
|
|
|
|
// todo do not throw error in case of cancelled context
|
|
ctx = internal.CtxInitState(ctx)
|
|
connectClient := internal.NewConnectClient(ctx, cfg, c.recorder)
|
|
c.setState(cfg, cacheDir, connectClient)
|
|
return connectClient.RunOnAndroid(c.tunAdapter, c.iFaceDiscover, c.networkChangeListener, slices.Clone(dns.items), dnsReadyListener, stateFile, cacheDir)
|
|
}
|
|
|
|
// Stop the internal client and free the resources
|
|
func (c *Client) Stop() {
|
|
c.ctxCancelLock.Lock()
|
|
defer c.ctxCancelLock.Unlock()
|
|
if c.ctxCancel == nil {
|
|
return
|
|
}
|
|
|
|
c.ctxCancel()
|
|
}
|
|
|
|
func (c *Client) RenewTun(fd int) error {
|
|
cc := c.getConnectClient()
|
|
if cc == nil {
|
|
return fmt.Errorf("engine not running")
|
|
}
|
|
|
|
e := cc.Engine()
|
|
if e == nil {
|
|
return fmt.Errorf("engine not initialized")
|
|
}
|
|
|
|
return e.RenewTun(fd)
|
|
}
|
|
|
|
func (c *Client) GetTunSettings() (*TunSettings, error) {
|
|
cc := c.getConnectClient()
|
|
if cc == nil {
|
|
return nil, fmt.Errorf("engine not running")
|
|
}
|
|
|
|
e := cc.Engine()
|
|
if e == nil {
|
|
return nil, fmt.Errorf("engine not initialized")
|
|
}
|
|
|
|
routes, searchDomains := e.TunSettings()
|
|
return &TunSettings{
|
|
Routes: strings.Join(routes, ";"),
|
|
SearchDomains: strings.Join(searchDomains, ";"),
|
|
}, nil
|
|
}
|
|
|
|
// DebugBundle generates a debug bundle, uploads it, and returns the upload key.
|
|
// It works both with and without a running engine.
|
|
func (c *Client) DebugBundle(platformFiles PlatformFiles, anonymize bool) (string, error) {
|
|
cfg, cacheDir, cc := c.stateSnapshot()
|
|
|
|
// If the engine hasn't been started, load config from disk
|
|
if cfg == nil {
|
|
var err error
|
|
cfg, err = profilemanager.UpdateOrCreateConfig(profilemanager.ConfigInput{
|
|
ConfigPath: platformFiles.ConfigurationFilePath(),
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("load config: %w", err)
|
|
}
|
|
cacheDir = platformFiles.CacheDir()
|
|
}
|
|
|
|
deps := debug.GeneratorDependencies{
|
|
InternalConfig: cfg,
|
|
StatusRecorder: c.recorder,
|
|
TempDir: cacheDir,
|
|
}
|
|
|
|
if cc != nil {
|
|
resp, err := cc.GetLatestSyncResponse()
|
|
if err != nil {
|
|
log.Warnf("get latest sync response: %v", err)
|
|
}
|
|
deps.SyncResponse = resp
|
|
|
|
if e := cc.Engine(); e != nil {
|
|
deps.RefreshStatus = func() {
|
|
e.RunHealthProbes(context.Background(), true)
|
|
}
|
|
if cm := e.GetClientMetrics(); cm != nil {
|
|
deps.ClientMetrics = cm
|
|
}
|
|
}
|
|
}
|
|
|
|
bundleGenerator := debug.NewBundleGenerator(
|
|
deps,
|
|
debug.BundleConfig{
|
|
Anonymize: anonymize,
|
|
IncludeSystemInfo: true,
|
|
},
|
|
)
|
|
|
|
path, err := bundleGenerator.Generate()
|
|
if err != nil {
|
|
return "", fmt.Errorf("generate debug bundle: %w", err)
|
|
}
|
|
defer func() {
|
|
if err := os.Remove(path); err != nil {
|
|
log.Errorf("failed to remove debug bundle file: %v", err)
|
|
}
|
|
}()
|
|
|
|
uploadCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
|
|
defer cancel()
|
|
|
|
key, err := debug.UploadDebugBundle(uploadCtx, types.DefaultBundleURL, cfg.ManagementURL.String(), path, false)
|
|
if err != nil {
|
|
return "", fmt.Errorf("upload debug bundle: %w", err)
|
|
}
|
|
|
|
log.Infof("debug bundle uploaded with key %s", key)
|
|
return key, nil
|
|
}
|
|
|
|
// SetTraceLogLevel configure the logger to trace level
|
|
func (c *Client) SetTraceLogLevel() {
|
|
log.SetLevel(log.TraceLevel)
|
|
}
|
|
|
|
// SetInfoLogLevel configure the logger to info level
|
|
func (c *Client) SetInfoLogLevel() {
|
|
log.SetLevel(log.InfoLevel)
|
|
}
|
|
|
|
// PeersList return with the list of the PeerInfos
|
|
func (c *Client) PeersList() *PeerInfoArray {
|
|
|
|
// The recorder only caches transfer counters and handshake times; nothing
|
|
// refreshes them on its own, so without this they read as zero. The desktop
|
|
// daemon does the same before serving a full peer status.
|
|
if err := c.recorder.RefreshWireGuardStats(); err != nil {
|
|
log.Debugf("failed to refresh WireGuard stats: %v", err)
|
|
}
|
|
|
|
fullStatus := c.recorder.GetFullStatus()
|
|
|
|
peerInfos := make([]PeerInfo, len(fullStatus.Peers))
|
|
for n, p := range fullStatus.Peers {
|
|
pi := PeerInfo{
|
|
IP: p.IP,
|
|
IPv6: p.IPv6,
|
|
FQDN: p.FQDN,
|
|
ConnStatus: int(p.ConnStatus),
|
|
Routes: PeerRoutes{routes: maps.Keys(p.GetRoutes())},
|
|
|
|
PubKey: p.PubKey,
|
|
Latency: formatDuration(p.Latency),
|
|
LatencyMs: p.Latency.Milliseconds(),
|
|
BytesRx: p.BytesRx,
|
|
BytesTx: p.BytesTx,
|
|
ConnStatusUpdate: formatTime(p.ConnStatusUpdate),
|
|
Relayed: p.Relayed,
|
|
RosenpassEnabled: p.RosenpassEnabled,
|
|
LastWireguardHandshake: formatTime(p.LastWireguardHandshake),
|
|
LocalIceCandidateType: p.LocalIceCandidateType,
|
|
RemoteIceCandidateType: p.RemoteIceCandidateType,
|
|
LocalIceCandidateEndpoint: p.LocalIceCandidateEndpoint,
|
|
RemoteIceCandidateEndpoint: p.RemoteIceCandidateEndpoint,
|
|
}
|
|
peerInfos[n] = pi
|
|
}
|
|
return &PeerInfoArray{items: peerInfos}
|
|
}
|
|
|
|
func (c *Client) Networks() *NetworkArray {
|
|
cc := c.getConnectClient()
|
|
if cc == nil {
|
|
log.Error("not connected")
|
|
return nil
|
|
}
|
|
|
|
engine := cc.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
|
|
}
|
|
|
|
routeSelector := routeManager.GetRouteSelector()
|
|
if routeSelector == nil {
|
|
log.Error("could not get route selector")
|
|
return nil
|
|
}
|
|
|
|
routesMap := routeManager.GetClientRoutesWithNetID()
|
|
v6Merged := route.V6ExitMergeSet(routesMap)
|
|
resolvedDomains := c.recorder.GetResolvedDomainsStates()
|
|
|
|
networkArray := &NetworkArray{
|
|
items: make([]Network, 0),
|
|
}
|
|
|
|
for id, routes := range routesMap {
|
|
if len(routes) == 0 {
|
|
continue
|
|
}
|
|
if _, skip := v6Merged[id]; skip {
|
|
continue
|
|
}
|
|
|
|
network := c.buildNetwork(id, routes, routeSelector.IsSelected(id), resolvedDomains, v6Merged)
|
|
if network == nil {
|
|
continue
|
|
}
|
|
networkArray.Add(*network)
|
|
}
|
|
return networkArray
|
|
}
|
|
|
|
func (c *Client) buildNetwork(id route.NetID, routes []*route.Route, selected bool, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo, v6Merged map[route.NetID]struct{}) *Network {
|
|
r := routes[0]
|
|
netStr := r.Network.String()
|
|
if r.IsDynamic() {
|
|
netStr = r.Domains.SafeString()
|
|
}
|
|
|
|
routePeer, err := c.findBestRoutePeer(routes)
|
|
if err != nil {
|
|
log.Errorf("could not get peer info for route %s: %v", id, err)
|
|
return nil
|
|
}
|
|
|
|
network := &Network{
|
|
Name: string(id),
|
|
Network: netStr,
|
|
Peer: routePeer.FQDN,
|
|
Status: routePeer.ConnStatus.String(),
|
|
IsSelected: selected,
|
|
Domains: c.getNetworkDomainsFromRoute(r, resolvedDomains),
|
|
}
|
|
|
|
if route.IsV4DefaultRoute(r.Network) && route.HasV6ExitPair(id, v6Merged) {
|
|
network.Network = "0.0.0.0/0, ::/0"
|
|
}
|
|
|
|
return network
|
|
}
|
|
|
|
// findBestRoutePeer returns the peer actively routing traffic for the given
|
|
// HA route group. Falls back to the first connected peer, then the first peer.
|
|
func (c *Client) findBestRoutePeer(routes []*route.Route) (peer.State, error) {
|
|
netStr := routes[0].Network.String()
|
|
|
|
fullStatus := c.recorder.GetFullStatus()
|
|
for _, p := range fullStatus.Peers {
|
|
if _, ok := p.GetRoutes()[netStr]; ok {
|
|
return p, nil
|
|
}
|
|
}
|
|
|
|
for _, r := range routes {
|
|
p, err := c.recorder.GetPeer(r.Peer)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if p.ConnStatus == peer.StatusConnected {
|
|
return p, nil
|
|
}
|
|
}
|
|
return c.recorder.GetPeer(routes[0].Peer)
|
|
}
|
|
|
|
// OnUpdatedHostDNS update the DNS servers addresses for root zones
|
|
func (c *Client) OnUpdatedHostDNS(list *DNSList) error {
|
|
dnsServer, err := dns.GetServerDns()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
dnsServer.OnUpdatedHostDNSServer(slices.Clone(list.items))
|
|
return nil
|
|
}
|
|
|
|
// SetConnectionListener set the network connection listener
|
|
func (c *Client) SetConnectionListener(listener ConnectionListener) {
|
|
c.recorder.SetConnectionListener(listener)
|
|
}
|
|
|
|
// RemoveConnectionListener remove connection listener
|
|
func (c *Client) RemoveConnectionListener() {
|
|
c.recorder.RemoveConnectionListener()
|
|
}
|
|
|
|
func (c *Client) getRouteManager() (routemanager.Manager, error) {
|
|
client := c.getConnectClient()
|
|
if client == nil {
|
|
return nil, fmt.Errorf("not connected")
|
|
}
|
|
|
|
engine := client.Engine()
|
|
if engine == nil {
|
|
return nil, fmt.Errorf("engine is not running")
|
|
}
|
|
|
|
manager := engine.GetRouteManager()
|
|
if manager == nil {
|
|
return nil, fmt.Errorf("could not get route manager")
|
|
}
|
|
|
|
return manager, nil
|
|
}
|
|
|
|
func (c *Client) SelectRoute(id string) error {
|
|
manager, err := c.getRouteManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return manager.SelectRoutes([]route.NetID{route.NetID(id)}, true)
|
|
}
|
|
|
|
func (c *Client) DeselectRoute(id string) error {
|
|
manager, err := c.getRouteManager()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return manager.DeselectRoutes([]route.NetID{route.NetID(id)})
|
|
}
|
|
|
|
// getNetworkDomainsFromRoute extracts domains from a route and enriches each domain
|
|
// with its resolved IP addresses from the provided resolvedDomains map.
|
|
func (c *Client) getNetworkDomainsFromRoute(route *route.Route, resolvedDomains map[domain.Domain]peer.ResolvedDomainInfo) NetworkDomains {
|
|
domains := NetworkDomains{}
|
|
|
|
for _, d := range route.Domains {
|
|
networkDomain := NetworkDomain{
|
|
Address: d.SafeString(),
|
|
}
|
|
|
|
if info, exists := resolvedDomains[d]; exists {
|
|
for _, prefix := range info.Prefixes {
|
|
networkDomain.addResolvedIP(prefix.Addr().String())
|
|
}
|
|
}
|
|
|
|
domains.Add(&networkDomain)
|
|
}
|
|
|
|
return domains
|
|
}
|
|
|
|
func exportEnvList(list *EnvList) {
|
|
if list == nil {
|
|
return
|
|
}
|
|
for k, v := range list.AllItems() {
|
|
if err := os.Setenv(k, v); err != nil {
|
|
log.Errorf("could not set env variable %s: %v", k, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// formatDuration renders a duration for display, trimming the fractional part
|
|
// to two digits so latencies read as "12.34ms" rather than "12.345678ms".
|
|
func formatDuration(d time.Duration) string {
|
|
ds := d.String()
|
|
dotIndex := strings.Index(ds, ".")
|
|
if dotIndex == -1 {
|
|
return ds
|
|
}
|
|
|
|
endIndex := min(dotIndex+3, len(ds))
|
|
|
|
// Skip the remaining digits so only the unit suffix is appended back.
|
|
unitStart := endIndex
|
|
for unitStart < len(ds) && ds[unitStart] >= '0' && ds[unitStart] <= '9' {
|
|
unitStart++
|
|
}
|
|
return ds[:endIndex] + ds[unitStart:]
|
|
}
|
|
|
|
// formatTime renders a timestamp in UTC using a fixed layout. The zero time is
|
|
// passed through as-is so the UI can recognise it and show "never" instead.
|
|
func formatTime(t time.Time) string {
|
|
return t.UTC().Format("2006-01-02 15:04:05")
|
|
}
|