mirror of
https://github.com/netbirdio/netbird.git
synced 2026-07-16 11:39:57 +00:00
- **Wails v3 application** (`client/ui`) with a React + TypeScript + Tailwind frontend replacing the Fyne UI: main connection view, exit-node switcher, networks/peers browser with detail panels, profile management, settings (general, network, SSH, security, troubleshooting, appearance), debug-bundle creation, and a first-run welcome flow. - **Internationalization**: go-i18n bundle with 9 locales (en, de, es, fr, hu, it, pt, ru, zh-CN) shared between the tray and the frontend. - **New system tray** implementation with per-platform theme-aware icons, including a native XEmbed host for Linux (`xembed_tray_linux.c`) and a Linux theme watcher. - **Session handling**: auth session watcher (`client/internal/auth/sessionwatch`), pending login flow, session-expiration dialog and tray notifications, and `netbird login` improvements. - **Daemon API extensions** (`daemon.proto`): status stream subscription, event stream, networks/exit-node selection endpoints, and richer full status — with probe throttling on the daemon side to protect against UI-driven request storms. - **UI preferences store** persisted per profile, autostart management via the daemon (single source of truth in HKCU on Windows). - **Build system**: Taskfile-based builds per platform (macOS, Linux, Windows), Docker cross-compilation images, MSIX/NSIS/nfpm/AppImage packaging, and a new `frontend-ui` CI workflow. Co-authored-by: Zoltan Papp <zoltan.pmail@gmail.com> Co-authored-by: Eduard Gert <kontakt@eduardgert.de> Co-authored-by: braginini <bangvalo@gmail.com> Co-authored-by: Pascal Fischer <32096965+pascal-fischer@users.noreply.github.com> Co-authored-by: riccardom <riccardomanfrin@gmail.com>
324 lines
10 KiB
Go
324 lines
10 KiB
Go
package cmd
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/netbirdio/netbird/client/internal"
|
|
"github.com/netbirdio/netbird/client/proto"
|
|
nbstatus "github.com/netbirdio/netbird/client/status"
|
|
"github.com/netbirdio/netbird/util"
|
|
)
|
|
|
|
var (
|
|
detailFlag bool
|
|
ipv4Flag bool
|
|
ipv6Flag bool
|
|
jsonFlag bool
|
|
yamlFlag bool
|
|
ipsFilter []string
|
|
prefixNamesFilter []string
|
|
statusFilter string
|
|
ipsFilterMap map[string]struct{}
|
|
prefixNamesFilterMap map[string]struct{}
|
|
connectionTypeFilter string
|
|
checkFlag string
|
|
)
|
|
|
|
var statusCmd = &cobra.Command{
|
|
Use: "status",
|
|
Short: "Display NetBird client status",
|
|
Long: "Display the current status of the NetBird client, including connection status, peer information, and network details.",
|
|
RunE: statusFunc,
|
|
}
|
|
|
|
func init() {
|
|
ipsFilterMap = make(map[string]struct{})
|
|
prefixNamesFilterMap = make(map[string]struct{})
|
|
statusCmd.PersistentFlags().BoolVarP(&detailFlag, "detail", "d", false, "display detailed status information in human-readable format")
|
|
statusCmd.PersistentFlags().BoolVarP(&jsonFlag, "json", "j", false, "display detailed status information in json format")
|
|
statusCmd.PersistentFlags().BoolVarP(&yamlFlag, "yaml", "y", false, "display detailed status information in yaml format")
|
|
statusCmd.PersistentFlags().BoolVarP(&ipv4Flag, "ipv4", "4", false, "display only NetBird IPv4 of this peer, e.g., --ipv4 will output 100.64.0.33")
|
|
statusCmd.PersistentFlags().BoolVarP(&ipv6Flag, "ipv6", "6", false, "display only NetBird IPv6 of this peer")
|
|
statusCmd.MarkFlagsMutuallyExclusive("detail", "json", "yaml", "ipv4", "ipv6")
|
|
statusCmd.PersistentFlags().StringSliceVarP(&ipsFilter, "filter-by-ips", "I", []string{}, "filters the detailed output by a list of one or more IPs (v4 or v6), e.g., --filter-by-ips 100.64.0.100,fd00::1")
|
|
statusCmd.PersistentFlags().StringSliceVarP(&prefixNamesFilter, "filter-by-names", "N", []string{}, "filters the detailed output by a list of one or more peer FQDN or hostnames, e.g., --filter-by-names peer-a,peer-b.netbird.cloud")
|
|
statusCmd.PersistentFlags().StringVarP(&statusFilter, "filter-by-status", "S", "", "filters the detailed output by connection status(idle|connecting|connected), e.g., --filter-by-status connected")
|
|
statusCmd.PersistentFlags().StringVarP(&connectionTypeFilter, "filter-by-connection-type", "T", "", "filters the detailed output by connection type (P2P|Relayed), e.g., --filter-by-connection-type P2P")
|
|
statusCmd.PersistentFlags().StringVarP(&checkFlag, "check", "C", "", "run a health check and exit with code 0 on success, 1 on failure (live|ready|startup)")
|
|
}
|
|
|
|
func statusFunc(cmd *cobra.Command, args []string) error {
|
|
SetFlagsFromEnvVars(rootCmd)
|
|
|
|
cmd.SetOut(cmd.OutOrStdout())
|
|
|
|
if checkFlag != "" {
|
|
return runHealthCheck(cmd)
|
|
}
|
|
|
|
err := parseFilters()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
err = util.InitLog(logLevel, util.LogConsole)
|
|
if err != nil {
|
|
return fmt.Errorf("failed initializing log %v", err)
|
|
}
|
|
|
|
ctx := internal.CtxInitState(cmd.Context())
|
|
|
|
resp, err := getStatus(ctx, true, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
status := resp.GetStatus()
|
|
|
|
needsAuth := status == string(internal.StatusNeedsLogin) || status == string(internal.StatusLoginFailed) ||
|
|
status == string(internal.StatusSessionExpired)
|
|
|
|
if needsAuth && !jsonFlag && !yamlFlag {
|
|
cmd.Printf("Daemon status: %s\n\n"+
|
|
"Run UP command to log in with SSO (interactive login):\n\n"+
|
|
" netbird up \n\n"+
|
|
"If you are running a self-hosted version and no SSO provider has been configured in your Management Server,\n"+
|
|
"you can use a setup-key:\n\n netbird up --management-url <YOUR_MANAGEMENT_URL> --setup-key <YOUR_SETUP_KEY>\n\n"+
|
|
"More info: https://docs.netbird.io/how-to/register-machines-using-setup-keys\n\n",
|
|
resp.GetStatus(),
|
|
)
|
|
return nil
|
|
}
|
|
|
|
if ipv4Flag {
|
|
cmd.Print(parseInterfaceIP(resp.GetFullStatus().GetLocalPeerState().GetIP()))
|
|
return nil
|
|
}
|
|
|
|
if ipv6Flag {
|
|
ipv6 := resp.GetFullStatus().GetLocalPeerState().GetIpv6()
|
|
if ipv6 != "" {
|
|
cmd.Print(parseInterfaceIP(ipv6))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Resolve the active profile's display name via the daemon, which runs
|
|
// as root and can read the per-user profile files. The local profile
|
|
// manager only knows the active profile ID, not its display name.
|
|
profName := getActiveProfileName(ctx)
|
|
|
|
var sessionExpiresAt time.Time
|
|
if ts := resp.GetSessionExpiresAt(); ts.IsValid() {
|
|
sessionExpiresAt = ts.AsTime().UTC()
|
|
}
|
|
|
|
var outputInformationHolder = nbstatus.ConvertToStatusOutputOverview(resp.GetFullStatus(), nbstatus.ConvertOptions{
|
|
Anonymize: anonymizeFlag,
|
|
DaemonVersion: resp.GetDaemonVersion(),
|
|
DaemonStatus: nbstatus.ParseDaemonStatus(status),
|
|
StatusFilter: statusFilter,
|
|
PrefixNamesFilter: prefixNamesFilter,
|
|
PrefixNamesFilterMap: prefixNamesFilterMap,
|
|
IPsFilter: ipsFilterMap,
|
|
ConnectionTypeFilter: connectionTypeFilter,
|
|
ProfileName: profName,
|
|
SessionExpiresAt: sessionExpiresAt,
|
|
})
|
|
var statusOutputString string
|
|
switch {
|
|
case detailFlag:
|
|
statusOutputString = outputInformationHolder.FullDetailSummary()
|
|
case jsonFlag:
|
|
statusOutputString, err = outputInformationHolder.JSON()
|
|
case yamlFlag:
|
|
statusOutputString, err = outputInformationHolder.YAML()
|
|
default:
|
|
statusOutputString = outputInformationHolder.GeneralSummary(false, false, false, false)
|
|
}
|
|
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
cmd.Print(statusOutputString)
|
|
|
|
return nil
|
|
}
|
|
|
|
func getStatus(ctx context.Context, fullPeerStatus bool, shouldRunProbes bool) (*proto.StatusResponse, error) {
|
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
|
if err != nil {
|
|
//nolint
|
|
return nil, fmt.Errorf("failed to connect to daemon error: %v\n"+
|
|
"If the daemon is not running please run: "+
|
|
"\nnetbird service install \nnetbird service start\n", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
resp, err := proto.NewDaemonServiceClient(conn).Status(ctx, &proto.StatusRequest{GetFullPeerStatus: fullPeerStatus, ShouldRunProbes: shouldRunProbes})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("status failed: %v", status.Convert(err).Message())
|
|
}
|
|
|
|
return resp, nil
|
|
}
|
|
|
|
// getActiveProfileName asks the daemon for the active profile's display
|
|
// name. The daemon runs as root and can read the per-user profile files to
|
|
// resolve the ID to its human-readable name. Returns an empty string on any
|
|
// error so status output degrades gracefully.
|
|
func getActiveProfileName(ctx context.Context) string {
|
|
conn, err := DialClientGRPCServer(ctx, daemonAddr)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer conn.Close()
|
|
|
|
resp, err := proto.NewDaemonServiceClient(conn).GetActiveProfile(ctx, &proto.GetActiveProfileRequest{})
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
|
|
return resp.GetProfileName()
|
|
}
|
|
|
|
func parseFilters() error {
|
|
switch strings.ToLower(statusFilter) {
|
|
case "", "idle", "connecting", "connected":
|
|
if strings.ToLower(statusFilter) != "" {
|
|
enableDetailFlagWhenFilterFlag()
|
|
}
|
|
default:
|
|
return fmt.Errorf("wrong status filter, should be one of connected|connecting|idle, got: %s", statusFilter)
|
|
}
|
|
|
|
if len(ipsFilter) > 0 {
|
|
for _, addr := range ipsFilter {
|
|
_, err := netip.ParseAddr(addr)
|
|
if err != nil {
|
|
return fmt.Errorf("got an invalid IP address in the filter: address %s, error %s", addr, err)
|
|
}
|
|
ipsFilterMap[addr] = struct{}{}
|
|
enableDetailFlagWhenFilterFlag()
|
|
}
|
|
}
|
|
|
|
if len(prefixNamesFilter) > 0 {
|
|
for _, name := range prefixNamesFilter {
|
|
prefixNamesFilterMap[strings.ToLower(name)] = struct{}{}
|
|
}
|
|
enableDetailFlagWhenFilterFlag()
|
|
}
|
|
|
|
switch strings.ToLower(connectionTypeFilter) {
|
|
case "", "p2p", "relayed":
|
|
if strings.ToLower(connectionTypeFilter) != "" {
|
|
enableDetailFlagWhenFilterFlag()
|
|
}
|
|
default:
|
|
return fmt.Errorf("wrong connection-type filter, should be one of P2P|Relayed, got: %s", connectionTypeFilter)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func enableDetailFlagWhenFilterFlag() {
|
|
if !detailFlag && !jsonFlag && !yamlFlag {
|
|
detailFlag = true
|
|
}
|
|
}
|
|
|
|
func runHealthCheck(cmd *cobra.Command) error {
|
|
check := strings.ToLower(checkFlag)
|
|
switch check {
|
|
case "live", "ready", "startup":
|
|
default:
|
|
return fmt.Errorf("unknown check %q, must be one of: live, ready, startup", checkFlag)
|
|
}
|
|
|
|
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
|
|
return fmt.Errorf("init log: %w", err)
|
|
}
|
|
|
|
ctx := internal.CtxInitState(cmd.Context())
|
|
|
|
isStartup := check == "startup"
|
|
resp, err := getStatus(ctx, isStartup, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
switch check {
|
|
case "live":
|
|
return nil
|
|
case "ready":
|
|
return checkReadiness(resp)
|
|
case "startup":
|
|
return checkStartup(resp)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func checkReadiness(resp *proto.StatusResponse) error {
|
|
daemonStatus := internal.StatusType(resp.GetStatus())
|
|
switch daemonStatus {
|
|
case internal.StatusIdle, internal.StatusConnecting, internal.StatusConnected:
|
|
return nil
|
|
case internal.StatusNeedsLogin, internal.StatusLoginFailed, internal.StatusSessionExpired:
|
|
return fmt.Errorf("readiness check: daemon status is %s", daemonStatus)
|
|
default:
|
|
return fmt.Errorf("readiness check: unexpected daemon status %q", daemonStatus)
|
|
}
|
|
}
|
|
|
|
func checkStartup(resp *proto.StatusResponse) error {
|
|
fullStatus := resp.GetFullStatus()
|
|
if fullStatus == nil {
|
|
return fmt.Errorf("startup check: no full status available")
|
|
}
|
|
|
|
if !fullStatus.GetManagementState().GetConnected() {
|
|
return fmt.Errorf("startup check: management not connected")
|
|
}
|
|
|
|
if !fullStatus.GetSignalState().GetConnected() {
|
|
return fmt.Errorf("startup check: signal not connected")
|
|
}
|
|
|
|
var relayCount, relaysConnected int
|
|
for _, r := range fullStatus.GetRelays() {
|
|
uri := r.GetURI()
|
|
if !strings.HasPrefix(uri, "rel://") && !strings.HasPrefix(uri, "rels://") {
|
|
continue
|
|
}
|
|
relayCount++
|
|
if r.GetAvailable() {
|
|
relaysConnected++
|
|
}
|
|
}
|
|
|
|
if relayCount > 0 && relaysConnected == 0 {
|
|
return fmt.Errorf("startup check: no relay servers available (0/%d connected)", relayCount)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseInterfaceIP(interfaceIP string) string {
|
|
ip, _, err := net.ParseCIDR(interfaceIP)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s\n", ip)
|
|
}
|