Compare commits

...

21 Commits

Author SHA1 Message Date
Zoltán Papp
9ae1ca1c8e [client/uiwails] Windows cross-build support and notification actions
- Add build/windows/Taskfile.yml to cross-compile from Linux with
  mingw-w64 (CC=x86_64-w64-mingw32-gcc, CGO_ENABLED=1, -H=windowsgui).
- Rename xembed_tray.{c,h} to xembed_tray_linux.{c,h} so the Go
  toolchain only compiles these X11/GTK sources on Linux.
- Add sendShowWindowSignal on Windows: opens the named Global event
  and calls SetEvent so the already-running instance shows its window.
- Register a notification category with Open/Dismiss action buttons
  and wire a response handler. Do this inside the ApplicationStarted
  hook so it runs after the notifications service's Startup has
  initialized appName/appGUID on Windows; otherwise the category is
  saved under an empty registry path and SendNotificationWithActions
  silently falls back to a plain toast with no buttons.
- Bump github.com/wailsapp/wails/v3 to v3.0.0-alpha.78.
2026-04-22 16:53:40 +02:00
Zoltán Papp
1451cedf86 White mac native + notification test 2026-03-04 11:12:43 +01:00
Zoltán Papp
04a982263d Wails UI 2026-03-02 15:59:09 +01:00
Bethuel Mmbaga
9a6a72e88e [management] Fix user update permission validation (#5441) 2026-02-24 22:47:41 +03:00
Bethuel Mmbaga
afe6d9fca4 [management] Prevent deletion of groups linked to flow groups (#5439) 2026-02-24 21:19:43 +03:00
shuuri-labs
ef82905526 [client] Add non default socket file discovery (#5425)
- Automatic Unix daemon address discovery: if the default socket is missing, the client can find and use a single available socket.
- Client startup now resolves daemon addresses more robustly while preserving non-Unix behavior.
2026-02-24 17:02:06 +01:00
Zoltan Papp
d18747e846 [client] Exclude Flow domain from caching to prevent TLS failures (#5433)
* Exclude Flow domain from caching to prevent TLS failures due to stale records.

* Fix test
2026-02-24 16:48:38 +01:00
Maycon Santos
f341d69314 [management] Add custom domain counts and service metrics to self-hosted metrics (#5414) 2026-02-24 15:21:14 +01:00
Maycon Santos
327142837c [management] Refactor expose feature: move business logic from gRPC to manager (#5435)
Consolidate all expose business logic (validation, permission checks, TTL tracking, reaping) into the manager layer, making the gRPC layer a pure transport adapter that only handles proto conversion and authentication.

- Add ExposeServiceRequest/ExposeServiceResponse domain types with validation in the reverseproxy package
- Move expose tracker (TTL tracking, reaping, per-peer limits) from gRPC server into manager/expose_tracker.go
- Internalize tracking in CreateServiceFromPeer, RenewServiceFromPeer, and new StopServiceFromPeer so callers don't manage tracker state
- Untrack ephemeral services in DeleteService/DeleteAllServices to keep tracker in sync when services are deleted via API
- Simplify gRPC expose handlers to parse, auth, convert, delegate
- Remove tracker methods from Manager interface (internal detail)
2026-02-24 15:09:30 +01:00
Zoltan Papp
f8c0321aee [client] Simplify DNS logging by removing domain list from log output (#5396) 2026-02-24 10:35:45 +01:00
Zoltan Papp
89115ff76a [client] skip UAPI listener in netstack mode (#5397)
In netstack (proxy) mode, the process lacks permission to create
/var/run/wireguard, making the UAPI listener unnecessary and causing
a misleading error log. Introduce NewUSPConfigurerNoUAPI and use it
for the netstack device to avoid attempting to open the UAPI socket
entirely. Also consolidate UAPI error logging to a single call site.
2026-02-24 10:35:23 +01:00
Maycon Santos
63c83aa8d2 [client,management] Feature/client service expose (#5411)
CLI: new expose command to publish a local port with flags for PIN, password, user groups, custom domain, name prefix and protocol (HTTP default).
Management/API: create/renew/stop expose sessions (streamed status), automatic naming/domain, TTL renewals, background expiration, new management RPCs and client methods.
UI/API: account settings now include peer_expose_enabled and peer_expose_groups; new activity codes for peer expose events.
2026-02-24 10:02:16 +01:00
Zoltan Papp
37f025c966 Fix a race condition where a concurrent user-issued Up or Down command (#5418)
could interleave with a sleep/wake event causing out-of-order state
transitions. The mutex now covers the full duration of each handler
including the status check, the Up/Down call, and the flag update.

Note: if Up or Down commands are triggered in parallel with sleep/wake
events, the overall ordering of up/down/sleep/wake operations is still
not guaranteed beyond what the mutex provides within the handler itself.
2026-02-24 10:00:33 +01:00
Zoltan Papp
4a54f0d670 [Client] Remove connection semaphore (#5419)
* [Client] Remove connection semaphore

Remove the semaphore and the initial random sleep time (300ms) from the connectivity logic to speed up the initial connection time.

Note: Implement limiter logic that can prioritize router peers and keep the fast connection option for the first few peers.

* Remove unused function
2026-02-23 20:58:53 +01:00
Zoltan Papp
98890a29e3 [client] fix busy-loop in network monitor routing socket on macOS/BSD (#5424)
* [client] fix busy-loop in network monitor routing socket on macOS/BSD

After system wakeup, the AF_ROUTE socket created by Go's unix.Socket()
is non-blocking, causing unix.Read to return EAGAIN immediately and spin
at 100% CPU filling the log with thousands of warnings per second.

Replace the tight read loop with a unix.Select call that blocks until
the fd is readable, checking ctx cancellation on each 1-second timeout.
Fatal errors (EBADF, EINVAL) now return an error instead of looping.

* [client] add fd range validation in waitReadable to prevent out-of-bound errors
2026-02-23 20:58:27 +01:00
Pascal Fischer
9d123ec059 [proxy] add pre-shared key support (#5377) 2026-02-23 16:31:29 +01:00
Pascal Fischer
5d171f181a [proxy] Send proxy updates on account delete (#5375) 2026-02-23 16:08:28 +01:00
Vlad
22f878b3b7 [management] network map components assembling (#5193) 2026-02-23 15:34:35 +01:00
Misha Bragin
44ef1a18dd [self-hosted] add Embedded IdP metrics (#5407) 2026-02-22 11:58:35 +02:00
Misha Bragin
2b98dc4e52 [self-hosted] Support activity store engine in the combined server (#5406) 2026-02-22 11:58:17 +02:00
Zoltan Papp
2a26cb4567 [client] stop upstream retry loop immediately on context cancellation (#5403)
stop upstream retry loop immediately on context cancellation
2026-02-20 14:44:14 +01:00
185 changed files with 16597 additions and 995 deletions

194
client/cmd/expose.go Normal file
View File

@@ -0,0 +1,194 @@
package cmd
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"regexp"
"strconv"
"strings"
"syscall"
log "github.com/sirupsen/logrus"
"github.com/spf13/cobra"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/util"
)
var pinRegexp = regexp.MustCompile(`^\d{6}$`)
var (
exposePin string
exposePassword string
exposeUserGroups []string
exposeDomain string
exposeNamePrefix string
exposeProtocol string
)
var exposeCmd = &cobra.Command{
Use: "expose <port>",
Short: "Expose a local port via the NetBird reverse proxy",
Args: cobra.ExactArgs(1),
Example: "netbird expose --with-password safe-pass 8080",
RunE: exposeFn,
}
func init() {
exposeCmd.Flags().StringVar(&exposePin, "with-pin", "", "Protect the exposed service with a 6-digit PIN (e.g. --with-pin 123456)")
exposeCmd.Flags().StringVar(&exposePassword, "with-password", "", "Protect the exposed service with a password (e.g. --with-password my-secret)")
exposeCmd.Flags().StringSliceVar(&exposeUserGroups, "with-user-groups", nil, "Restrict access to specific user groups with SSO (e.g. --with-user-groups devops,Backend)")
exposeCmd.Flags().StringVar(&exposeDomain, "with-custom-domain", "", "Custom domain for the exposed service, must be configured to your account (e.g. --with-custom-domain myapp.example.com)")
exposeCmd.Flags().StringVar(&exposeNamePrefix, "with-name-prefix", "", "Prefix for the generated service name (e.g. --with-name-prefix my-app)")
exposeCmd.Flags().StringVar(&exposeProtocol, "protocol", "http", "Protocol to use, http/https is supported (e.g. --protocol http)")
}
func validateExposeFlags(cmd *cobra.Command, portStr string) (uint64, error) {
port, err := strconv.ParseUint(portStr, 10, 32)
if err != nil {
return 0, fmt.Errorf("invalid port number: %s", portStr)
}
if port == 0 || port > 65535 {
return 0, fmt.Errorf("invalid port number: must be between 1 and 65535")
}
if !isProtocolValid(exposeProtocol) {
return 0, fmt.Errorf("unsupported protocol %q: only 'http' or 'https' are supported", exposeProtocol)
}
if exposePin != "" && !pinRegexp.MatchString(exposePin) {
return 0, fmt.Errorf("invalid pin: must be exactly 6 digits")
}
if cmd.Flags().Changed("with-password") && exposePassword == "" {
return 0, fmt.Errorf("password cannot be empty")
}
if cmd.Flags().Changed("with-user-groups") && len(exposeUserGroups) == 0 {
return 0, fmt.Errorf("user groups cannot be empty")
}
return port, nil
}
func isProtocolValid(exposeProtocol string) bool {
return strings.ToLower(exposeProtocol) == "http" || strings.ToLower(exposeProtocol) == "https"
}
func exposeFn(cmd *cobra.Command, args []string) error {
SetFlagsFromEnvVars(rootCmd)
if err := util.InitLog(logLevel, util.LogConsole); err != nil {
log.Errorf("failed initializing log %v", err)
return err
}
cmd.Root().SilenceUsage = false
port, err := validateExposeFlags(cmd, args[0])
if err != nil {
return err
}
cmd.Root().SilenceUsage = true
ctx, cancel := context.WithCancel(cmd.Context())
defer cancel()
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
cancel()
}()
conn, err := DialClientGRPCServer(ctx, daemonAddr)
if err != nil {
return fmt.Errorf("connect to daemon: %w", err)
}
defer func() {
if err := conn.Close(); err != nil {
log.Debugf("failed to close daemon connection: %v", err)
}
}()
client := proto.NewDaemonServiceClient(conn)
protocol, err := toExposeProtocol(exposeProtocol)
if err != nil {
return err
}
stream, err := client.ExposeService(ctx, &proto.ExposeServiceRequest{
Port: uint32(port),
Protocol: protocol,
Pin: exposePin,
Password: exposePassword,
UserGroups: exposeUserGroups,
Domain: exposeDomain,
NamePrefix: exposeNamePrefix,
})
if err != nil {
return fmt.Errorf("expose service: %w", err)
}
if err := handleExposeReady(cmd, stream, port); err != nil {
return err
}
return waitForExposeEvents(cmd, ctx, stream)
}
func toExposeProtocol(exposeProtocol string) (proto.ExposeProtocol, error) {
switch strings.ToLower(exposeProtocol) {
case "http":
return proto.ExposeProtocol_EXPOSE_HTTP, nil
case "https":
return proto.ExposeProtocol_EXPOSE_HTTPS, nil
default:
return 0, fmt.Errorf("unsupported protocol %q: only 'http' or 'https' are supported", exposeProtocol)
}
}
func handleExposeReady(cmd *cobra.Command, stream proto.DaemonService_ExposeServiceClient, port uint64) error {
event, err := stream.Recv()
if err != nil {
return fmt.Errorf("receive expose event: %w", err)
}
switch e := event.Event.(type) {
case *proto.ExposeServiceEvent_Ready:
cmd.Println("Service exposed successfully!")
cmd.Printf(" Name: %s\n", e.Ready.ServiceName)
cmd.Printf(" URL: %s\n", e.Ready.ServiceUrl)
cmd.Printf(" Domain: %s\n", e.Ready.Domain)
cmd.Printf(" Protocol: %s\n", exposeProtocol)
cmd.Printf(" Port: %d\n", port)
cmd.Println()
cmd.Println("Press Ctrl+C to stop exposing.")
return nil
default:
return fmt.Errorf("unexpected expose event: %T", event.Event)
}
}
func waitForExposeEvents(cmd *cobra.Command, ctx context.Context, stream proto.DaemonService_ExposeServiceClient) error {
for {
_, err := stream.Recv()
if err != nil {
if ctx.Err() != nil {
cmd.Println("\nService stopped.")
//nolint:nilerr
return nil
}
if errors.Is(err, io.EOF) {
return fmt.Errorf("connection to daemon closed unexpectedly")
}
return fmt.Errorf("stream error: %w", err)
}
}
}

View File

@@ -22,6 +22,7 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
daddr "github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
)
@@ -80,6 +81,15 @@ var (
Short: "",
Long: "",
SilenceUsage: true,
PersistentPreRunE: func(cmd *cobra.Command, args []string) error {
SetFlagsFromEnvVars(cmd.Root())
// Don't resolve for service commands — they create the socket, not connect to it.
if !isServiceCmd(cmd) {
daemonAddr = daddr.ResolveUnixDaemonAddr(daemonAddr)
}
return nil
},
}
)
@@ -144,6 +154,7 @@ func init() {
rootCmd.AddCommand(forwardingRulesCmd)
rootCmd.AddCommand(debugCmd)
rootCmd.AddCommand(profileCmd)
rootCmd.AddCommand(exposeCmd)
networksCMD.AddCommand(routesListCmd)
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
@@ -385,7 +396,6 @@ func migrateToNetbird(oldPath, newPath string) bool {
}
func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) {
SetFlagsFromEnvVars(rootCmd)
cmd.SetOut(cmd.OutOrStdout())
conn, err := DialClientGRPCServer(cmd.Context(), daemonAddr)
@@ -398,3 +408,13 @@ func getClient(cmd *cobra.Command) (*grpc.ClientConn, error) {
return conn, nil
}
// isServiceCmd returns true if cmd is the "service" command or a child of it.
func isServiceCmd(cmd *cobra.Command) bool {
for c := cmd; c != nil; c = c.Parent() {
if c.Name() == "service" {
return true
}
}
return false
}

View File

@@ -5,20 +5,18 @@ package configurer
import (
"net"
log "github.com/sirupsen/logrus"
"golang.zx2c4.com/wireguard/ipc"
)
func openUAPI(deviceName string) (net.Listener, error) {
uapiSock, err := ipc.UAPIOpen(deviceName)
if err != nil {
log.Errorf("failed to open uapi socket: %v", err)
return nil, err
}
listener, err := ipc.UAPIListen(deviceName, uapiSock)
if err != nil {
log.Errorf("failed to listen on uapi socket: %v", err)
_ = uapiSock.Close()
return nil, err
}

View File

@@ -54,6 +54,14 @@ func NewUSPConfigurer(device *device.Device, deviceName string, activityRecorder
return wgCfg
}
func NewUSPConfigurerNoUAPI(device *device.Device, deviceName string, activityRecorder *bind.ActivityRecorder) *WGUSPConfigurer {
return &WGUSPConfigurer{
device: device,
deviceName: deviceName,
activityRecorder: activityRecorder,
}
}
func (c *WGUSPConfigurer) ConfigureInterface(privateKey string, port int) error {
log.Debugf("adding Wireguard private key")
key, err := wgtypes.ParseKey(privateKey)

View File

@@ -79,7 +79,7 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) {
device.NewLogger(wgLogLevel(), "[netbird] "),
)
t.configurer = configurer.NewUSPConfigurer(t.device, t.name, t.bind.ActivityRecorder())
t.configurer = configurer.NewUSPConfigurerNoUAPI(t.device, t.name, t.bind.ActivityRecorder())
err = t.configurer.ConfigureInterface(t.key, t.port)
if err != nil {
if cErr := tunIface.Close(); cErr != nil {

View File

@@ -0,0 +1,60 @@
//go:build !windows && !ios && !android
package daemonaddr
import (
"os"
"path/filepath"
"strings"
log "github.com/sirupsen/logrus"
)
var scanDir = "/var/run/netbird"
// setScanDir overrides the scan directory (used by tests).
func setScanDir(dir string) {
scanDir = dir
}
// ResolveUnixDaemonAddr checks whether the default Unix socket exists and, if not,
// scans /var/run/netbird/ for a single .sock file to use instead. This handles the
// mismatch between the netbird@.service template (which places the socket under
// /var/run/netbird/<instance>.sock) and the CLI default (/var/run/netbird.sock).
func ResolveUnixDaemonAddr(addr string) string {
if !strings.HasPrefix(addr, "unix://") {
return addr
}
sockPath := strings.TrimPrefix(addr, "unix://")
if _, err := os.Stat(sockPath); err == nil {
return addr
}
entries, err := os.ReadDir(scanDir)
if err != nil {
return addr
}
var found []string
for _, e := range entries {
if e.IsDir() {
continue
}
if strings.HasSuffix(e.Name(), ".sock") {
found = append(found, filepath.Join(scanDir, e.Name()))
}
}
switch len(found) {
case 1:
resolved := "unix://" + found[0]
log.Infof("Default daemon socket not found, using discovered socket: %s", resolved)
return resolved
case 0:
return addr
default:
log.Warnf("Default daemon socket not found and multiple sockets discovered in %s; pass --daemon-addr explicitly", scanDir)
return addr
}
}

View File

@@ -0,0 +1,8 @@
//go:build windows || ios || android
package daemonaddr
// ResolveUnixDaemonAddr is a no-op on platforms that don't use Unix sockets.
func ResolveUnixDaemonAddr(addr string) string {
return addr
}

View File

@@ -0,0 +1,121 @@
//go:build !windows && !ios && !android
package daemonaddr
import (
"os"
"path/filepath"
"testing"
)
// createSockFile creates a regular file with a .sock extension.
// ResolveUnixDaemonAddr uses os.Stat (not net.Dial), so a regular file is
// sufficient and avoids Unix socket path-length limits on macOS.
func createSockFile(t *testing.T, path string) {
t.Helper()
if err := os.WriteFile(path, nil, 0o600); err != nil {
t.Fatalf("failed to create test sock file at %s: %v", path, err)
}
}
func TestResolveUnixDaemonAddr_DefaultExists(t *testing.T) {
tmp := t.TempDir()
sock := filepath.Join(tmp, "netbird.sock")
createSockFile(t, sock)
addr := "unix://" + sock
got := ResolveUnixDaemonAddr(addr)
if got != addr {
t.Errorf("expected %s, got %s", addr, got)
}
}
func TestResolveUnixDaemonAddr_SingleDiscovered(t *testing.T) {
tmp := t.TempDir()
// Default socket does not exist
defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock")
// Create a scan dir with one socket
sd := filepath.Join(tmp, "netbird")
if err := os.MkdirAll(sd, 0o755); err != nil {
t.Fatal(err)
}
instanceSock := filepath.Join(sd, "main.sock")
createSockFile(t, instanceSock)
origScanDir := scanDir
setScanDir(sd)
t.Cleanup(func() { setScanDir(origScanDir) })
got := ResolveUnixDaemonAddr(defaultAddr)
expected := "unix://" + instanceSock
if got != expected {
t.Errorf("expected %s, got %s", expected, got)
}
}
func TestResolveUnixDaemonAddr_MultipleDiscovered(t *testing.T) {
tmp := t.TempDir()
defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock")
sd := filepath.Join(tmp, "netbird")
if err := os.MkdirAll(sd, 0o755); err != nil {
t.Fatal(err)
}
createSockFile(t, filepath.Join(sd, "main.sock"))
createSockFile(t, filepath.Join(sd, "other.sock"))
origScanDir := scanDir
setScanDir(sd)
t.Cleanup(func() { setScanDir(origScanDir) })
got := ResolveUnixDaemonAddr(defaultAddr)
if got != defaultAddr {
t.Errorf("expected original %s, got %s", defaultAddr, got)
}
}
func TestResolveUnixDaemonAddr_NoSocketsFound(t *testing.T) {
tmp := t.TempDir()
defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock")
sd := filepath.Join(tmp, "netbird")
if err := os.MkdirAll(sd, 0o755); err != nil {
t.Fatal(err)
}
origScanDir := scanDir
setScanDir(sd)
t.Cleanup(func() { setScanDir(origScanDir) })
got := ResolveUnixDaemonAddr(defaultAddr)
if got != defaultAddr {
t.Errorf("expected original %s, got %s", defaultAddr, got)
}
}
func TestResolveUnixDaemonAddr_NonUnixAddr(t *testing.T) {
addr := "tcp://127.0.0.1:41731"
got := ResolveUnixDaemonAddr(addr)
if got != addr {
t.Errorf("expected %s, got %s", addr, got)
}
}
func TestResolveUnixDaemonAddr_ScanDirMissing(t *testing.T) {
tmp := t.TempDir()
defaultAddr := "unix://" + filepath.Join(tmp, "netbird.sock")
origScanDir := scanDir
setScanDir(filepath.Join(tmp, "nonexistent"))
t.Cleanup(func() { setScanDir(origScanDir) })
got := ResolveUnixDaemonAddr(defaultAddr)
if got != defaultAddr {
t.Errorf("expected original %s, got %s", defaultAddr, got)
}
}

View File

@@ -277,7 +277,7 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
}
}
log.Infof("added %d NRPT rules for %d domains. Domain list: %v", ruleIndex, len(domains), domains)
log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains))
return ruleIndex, nil
}

View File

@@ -376,9 +376,9 @@ func (m *Resolver) extractDomainsFromServerDomains(serverDomains dnsconfig.Serve
}
}
if serverDomains.Flow != "" {
domains = append(domains, serverDomains.Flow)
}
// Flow receiver domain is intentionally excluded from caching.
// Cloud providers may rotate the IP behind this domain; a stale cached record
// causes TLS certificate verification failures on reconnect.
for _, stun := range serverDomains.Stuns {
if stun != "" {

View File

@@ -391,7 +391,8 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) {
}
assert.Len(t, resolver.GetCachedDomains(), 3)
// Update with partial ServerDomains (only flow domain - new type, should preserve all existing)
// Update with partial ServerDomains (only flow domain - flow is intentionally excluded from
// caching to prevent TLS failures from stale records, so all existing domains are preserved)
partialDomains := dnsconfig.ServerDomains{
Flow: "github.com",
}
@@ -400,10 +401,10 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) {
t.Skipf("Skipping test due to DNS resolution failure: %v", err)
}
assert.Len(t, removedDomains, 0, "Should not remove any domains when adding new type")
assert.Len(t, removedDomains, 0, "Should not remove any domains when only flow domain is provided")
finalDomains := resolver.GetCachedDomains()
assert.Len(t, finalDomains, 4, "Should have all original domains plus new flow domain")
assert.Len(t, finalDomains, 3, "Flow domain is not cached; all original domains should be preserved")
domainStrings := make([]string, len(finalDomains))
for i, d := range finalDomains {
@@ -412,5 +413,5 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) {
assert.Contains(t, domainStrings, "example.org")
assert.Contains(t, domainStrings, "google.com")
assert.Contains(t, domainStrings, "cloudflare.com")
assert.Contains(t, domainStrings, "github.com")
assert.NotContains(t, domainStrings, "github.com")
}

View File

@@ -351,9 +351,13 @@ func (u *upstreamResolverBase) waitUntilResponse() {
return fmt.Errorf("upstream check call error")
}
err := backoff.Retry(operation, exponentialBackOff)
err := backoff.Retry(operation, backoff.WithContext(exponentialBackOff, u.ctx))
if err != nil {
log.Warn(err)
if errors.Is(err, context.Canceled) {
log.Debugf("upstream retry loop exited for upstreams %s", u.upstreamServersString())
} else {
log.Warnf("upstream retry loop exited for upstreams %s: %v", u.upstreamServersString(), err)
}
return
}

View File

@@ -36,6 +36,7 @@ import (
"github.com/netbirdio/netbird/client/internal/dns"
dnsconfig "github.com/netbirdio/netbird/client/internal/dns/config"
"github.com/netbirdio/netbird/client/internal/dnsfwd"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/ingressgw"
"github.com/netbirdio/netbird/client/internal/netflow"
nftypes "github.com/netbirdio/netbird/client/internal/netflow/types"
@@ -53,13 +54,11 @@ import (
"github.com/netbirdio/netbird/client/internal/updatemanager"
"github.com/netbirdio/netbird/client/jobexec"
cProto "github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/shared/management/domain"
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
"github.com/netbirdio/netbird/client/system"
nbdns "github.com/netbirdio/netbird/dns"
"github.com/netbirdio/netbird/route"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
mgmProto "github.com/netbirdio/netbird/shared/management/proto"
auth "github.com/netbirdio/netbird/shared/relay/auth/hmac"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
@@ -75,7 +74,6 @@ import (
const (
PeerConnectionTimeoutMax = 45000 // ms
PeerConnectionTimeoutMin = 30000 // ms
connInitLimit = 200
disableAutoUpdate = "disabled"
)
@@ -208,7 +206,6 @@ type Engine struct {
syncRespMux sync.RWMutex
persistSyncResponse bool
latestSyncResponse *mgmProto.SyncResponse
connSemaphore *semaphoregroup.SemaphoreGroup
flowManager nftypes.FlowManager
// auto-update
@@ -224,6 +221,8 @@ type Engine struct {
jobExecutor *jobexec.Executor
jobExecutorWG sync.WaitGroup
exposeManager *expose.Manager
}
// Peer is an instance of the Connection Peer
@@ -266,7 +265,6 @@ func NewEngine(
statusRecorder: statusRecorder,
stateManager: stateManager,
checks: checks,
connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit),
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
jobExecutor: jobexec.NewExecutor(),
}
@@ -419,6 +417,7 @@ func (e *Engine) Start(netbirdConfig *mgmProto.NetbirdConfig, mgmtURL *url.URL)
e.cancel()
}
e.ctx, e.cancel = context.WithCancel(e.clientCtx)
e.exposeManager = expose.NewManager(e.ctx, e.mgmClient)
wgIface, err := e.newWgIface()
if err != nil {
@@ -801,7 +800,7 @@ func (e *Engine) handleAutoUpdateVersion(autoUpdateSettings *mgmProto.AutoUpdate
disabled := autoUpdateSettings.Version == disableAutoUpdate
// Stop and cleanup if disabled
// stop and cleanup if disabled
if e.updateManager != nil && disabled {
log.Infof("auto-update is disabled, stopping update manager")
e.updateManager.Stop()
@@ -1539,7 +1538,6 @@ func (e *Engine) createPeerConn(pubKey string, allowedIPs []netip.Prefix, agentV
IFaceDiscover: e.mobileDep.IFaceDiscover,
RelayManager: e.relayManager,
SrWatcher: e.srWatcher,
Semaphore: e.connSemaphore,
}
peerConn, err := peer.NewConn(config, serviceDependencies)
if err != nil {
@@ -1824,11 +1822,18 @@ func (e *Engine) GetRouteManager() routemanager.Manager {
return e.routeManager
}
// GetFirewallManager returns the firewall manager
// GetFirewallManager returns the firewall manager.
func (e *Engine) GetFirewallManager() firewallManager.Manager {
return e.firewall
}
// GetExposeManager returns the expose session manager.
func (e *Engine) GetExposeManager() *expose.Manager {
e.syncMsgMux.Lock()
defer e.syncMsgMux.Unlock()
return e.exposeManager
}
func findIPFromInterfaceName(ifaceName string) (net.IP, error) {
iface, err := net.InterfaceByName(ifaceName)
if err != nil {

View File

@@ -0,0 +1,95 @@
package expose
import (
"context"
"time"
mgm "github.com/netbirdio/netbird/shared/management/client"
log "github.com/sirupsen/logrus"
)
const renewTimeout = 10 * time.Second
// Response holds the response from exposing a service.
type Response struct {
ServiceName string
ServiceURL string
Domain string
}
type Request struct {
NamePrefix string
Domain string
Port uint16
Protocol int
Pin string
Password string
UserGroups []string
}
type ManagementClient interface {
CreateExpose(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error)
RenewExpose(ctx context.Context, domain string) error
StopExpose(ctx context.Context, domain string) error
}
// Manager handles expose session lifecycle via the management client.
type Manager struct {
mgmClient ManagementClient
ctx context.Context
}
// NewManager creates a new expose Manager using the given management client.
func NewManager(ctx context.Context, mgmClient ManagementClient) *Manager {
return &Manager{mgmClient: mgmClient, ctx: ctx}
}
// Expose creates a new expose session via the management server.
func (m *Manager) Expose(ctx context.Context, req Request) (*Response, error) {
log.Infof("exposing service on port %d", req.Port)
resp, err := m.mgmClient.CreateExpose(ctx, toClientExposeRequest(req))
if err != nil {
return nil, err
}
log.Infof("expose session created for %s", resp.Domain)
return fromClientExposeResponse(resp), nil
}
func (m *Manager) KeepAlive(ctx context.Context, domain string) error {
ticker := time.NewTicker(30 * time.Second)
defer ticker.Stop()
defer m.stop(domain)
for {
select {
case <-ctx.Done():
log.Infof("context canceled, stopping keep alive for %s", domain)
return nil
case <-ticker.C:
if err := m.renew(ctx, domain); err != nil {
log.Errorf("renewing expose session for %s: %v", domain, err)
return err
}
}
}
}
// renew extends the TTL of an active expose session.
func (m *Manager) renew(ctx context.Context, domain string) error {
renewCtx, cancel := context.WithTimeout(ctx, renewTimeout)
defer cancel()
return m.mgmClient.RenewExpose(renewCtx, domain)
}
// stop terminates an active expose session.
func (m *Manager) stop(domain string) {
stopCtx, cancel := context.WithTimeout(m.ctx, renewTimeout)
defer cancel()
err := m.mgmClient.StopExpose(stopCtx, domain)
if err != nil {
log.Warnf("Failed stopping expose session for %s: %v", domain, err)
}
}

View File

@@ -0,0 +1,95 @@
package expose
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
daemonProto "github.com/netbirdio/netbird/client/proto"
mgm "github.com/netbirdio/netbird/shared/management/client"
)
func TestManager_Expose_Success(t *testing.T) {
mock := &mgm.MockClient{
CreateExposeFunc: func(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) {
return &mgm.ExposeResponse{
ServiceName: "my-service",
ServiceURL: "https://my-service.example.com",
Domain: "my-service.example.com",
}, nil
},
}
m := NewManager(context.Background(), mock)
result, err := m.Expose(context.Background(), Request{Port: 8080})
require.NoError(t, err)
assert.Equal(t, "my-service", result.ServiceName, "service name should match")
assert.Equal(t, "https://my-service.example.com", result.ServiceURL, "service URL should match")
assert.Equal(t, "my-service.example.com", result.Domain, "domain should match")
}
func TestManager_Expose_Error(t *testing.T) {
mock := &mgm.MockClient{
CreateExposeFunc: func(ctx context.Context, req mgm.ExposeRequest) (*mgm.ExposeResponse, error) {
return nil, errors.New("permission denied")
},
}
m := NewManager(context.Background(), mock)
_, err := m.Expose(context.Background(), Request{Port: 8080})
require.Error(t, err)
assert.Contains(t, err.Error(), "permission denied", "error should propagate")
}
func TestManager_Renew_Success(t *testing.T) {
mock := &mgm.MockClient{
RenewExposeFunc: func(ctx context.Context, domain string) error {
assert.Equal(t, "my-service.example.com", domain, "domain should be passed through")
return nil
},
}
m := NewManager(context.Background(), mock)
err := m.renew(context.Background(), "my-service.example.com")
require.NoError(t, err)
}
func TestManager_Renew_Timeout(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel()
mock := &mgm.MockClient{
RenewExposeFunc: func(ctx context.Context, domain string) error {
return ctx.Err()
},
}
m := NewManager(ctx, mock)
err := m.renew(ctx, "my-service.example.com")
require.Error(t, err)
}
func TestNewRequest(t *testing.T) {
req := &daemonProto.ExposeServiceRequest{
Port: 8080,
Protocol: daemonProto.ExposeProtocol_EXPOSE_HTTPS,
Pin: "123456",
Password: "secret",
UserGroups: []string{"group1", "group2"},
Domain: "custom.example.com",
NamePrefix: "my-prefix",
}
exposeReq := NewRequest(req)
assert.Equal(t, uint16(8080), exposeReq.Port, "port should match")
assert.Equal(t, int(daemonProto.ExposeProtocol_EXPOSE_HTTPS), exposeReq.Protocol, "protocol should match")
assert.Equal(t, "123456", exposeReq.Pin, "pin should match")
assert.Equal(t, "secret", exposeReq.Password, "password should match")
assert.Equal(t, []string{"group1", "group2"}, exposeReq.UserGroups, "user groups should match")
assert.Equal(t, "custom.example.com", exposeReq.Domain, "domain should match")
assert.Equal(t, "my-prefix", exposeReq.NamePrefix, "name prefix should match")
}

View File

@@ -0,0 +1,39 @@
package expose
import (
daemonProto "github.com/netbirdio/netbird/client/proto"
mgm "github.com/netbirdio/netbird/shared/management/client"
)
// NewRequest converts a daemon ExposeServiceRequest to a management ExposeServiceRequest.
func NewRequest(req *daemonProto.ExposeServiceRequest) *Request {
return &Request{
Port: uint16(req.Port),
Protocol: int(req.Protocol),
Pin: req.Pin,
Password: req.Password,
UserGroups: req.UserGroups,
Domain: req.Domain,
NamePrefix: req.NamePrefix,
}
}
func toClientExposeRequest(req Request) mgm.ExposeRequest {
return mgm.ExposeRequest{
NamePrefix: req.NamePrefix,
Domain: req.Domain,
Port: req.Port,
Protocol: req.Protocol,
Pin: req.Pin,
Password: req.Password,
UserGroups: req.UserGroups,
}
}
func fromClientExposeResponse(response *mgm.ExposeResponse) *Response {
return &Response{
ServiceName: response.ServiceName,
Domain: response.Domain,
ServiceURL: response.ServiceURL,
}
}

View File

@@ -22,51 +22,56 @@ func prepareFd() (int, error) {
func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Nexthop) error {
for {
select {
case <-ctx.Done():
return ctx.Err()
default:
buf := make([]byte, 2048)
n, err := unix.Read(fd, buf)
// Wait until fd is readable or context is cancelled, to avoid a busy-loop
// when the routing socket returns EAGAIN (e.g. immediately after wakeup).
if err := waitReadable(ctx, fd); err != nil {
return err
}
buf := make([]byte, 2048)
n, err := unix.Read(fd, buf)
if err != nil {
if errors.Is(err, unix.EAGAIN) || errors.Is(err, unix.EINTR) {
continue
}
if errors.Is(err, unix.EBADF) || errors.Is(err, unix.EINVAL) {
return fmt.Errorf("routing socket closed: %w", err)
}
return fmt.Errorf("read routing socket: %w", err)
}
if n < unix.SizeofRtMsghdr {
log.Debugf("Network monitor: read from routing socket returned less than expected: %d bytes", n)
continue
}
msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0]))
switch msg.Type {
// handle route changes
case unix.RTM_ADD, syscall.RTM_DELETE:
route, err := parseRouteMessage(buf[:n])
if err != nil {
if !errors.Is(err, unix.EBADF) && !errors.Is(err, unix.EINVAL) {
log.Warnf("Network monitor: failed to read from routing socket: %v", err)
}
continue
}
if n < unix.SizeofRtMsghdr {
log.Debugf("Network monitor: read from routing socket returned less than expected: %d bytes", n)
log.Debugf("Network monitor: error parsing routing message: %v", err)
continue
}
msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0]))
if route.Dst.Bits() != 0 {
continue
}
intf := "<nil>"
if route.Interface != nil {
intf = route.Interface.Name
}
switch msg.Type {
// handle route changes
case unix.RTM_ADD, syscall.RTM_DELETE:
route, err := parseRouteMessage(buf[:n])
if err != nil {
log.Debugf("Network monitor: error parsing routing message: %v", err)
continue
}
if route.Dst.Bits() != 0 {
continue
}
intf := "<nil>"
if route.Interface != nil {
intf = route.Interface.Name
}
switch msg.Type {
case unix.RTM_ADD:
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
case unix.RTM_ADD:
log.Infof("Network monitor: default route changed: via %s, interface %s", route.Gw, intf)
return nil
case unix.RTM_DELETE:
if nexthopv4.Intf != nil && route.Gw.Compare(nexthopv4.IP) == 0 || nexthopv6.Intf != nil && route.Gw.Compare(nexthopv6.IP) == 0 {
log.Infof("Network monitor: default route removed: via %s, interface %s", route.Gw, intf)
return nil
case unix.RTM_DELETE:
if nexthopv4.Intf != nil && route.Gw.Compare(nexthopv4.IP) == 0 || nexthopv6.Intf != nil && route.Gw.Compare(nexthopv6.IP) == 0 {
log.Infof("Network monitor: default route removed: via %s, interface %s", route.Gw, intf)
return nil
}
}
}
}
@@ -90,3 +95,33 @@ func parseRouteMessage(buf []byte) (*systemops.Route, error) {
return systemops.MsgToRoute(msg)
}
// waitReadable blocks until fd has data to read, or ctx is cancelled.
func waitReadable(ctx context.Context, fd int) error {
var fdset unix.FdSet
if fd < 0 || fd/unix.NFDBITS >= len(fdset.Bits) {
return fmt.Errorf("fd %d out of range for FdSet", fd)
}
for {
if err := ctx.Err(); err != nil {
return err
}
fdset = unix.FdSet{}
fdset.Set(fd)
// Use a 1-second timeout so we can re-check ctx periodically.
tv := unix.Timeval{Sec: 1}
n, err := unix.Select(fd+1, &fdset, nil, nil, &tv)
if err != nil {
if errors.Is(err, unix.EINTR) {
continue
}
return fmt.Errorf("select on routing socket: %w", err)
}
if n > 0 {
return nil
}
// timeout — loop back and re-check ctx
}
}

View File

@@ -3,7 +3,6 @@ package peer
import (
"context"
"fmt"
"math/rand"
"net"
"net/netip"
"runtime"
@@ -25,7 +24,6 @@ import (
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/route"
relayClient "github.com/netbirdio/netbird/shared/relay/client"
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
)
type ServiceDependencies struct {
@@ -34,7 +32,6 @@ type ServiceDependencies struct {
IFaceDiscover stdnet.ExternalIFaceDiscover
RelayManager *relayClient.Manager
SrWatcher *guard.SRWatcher
Semaphore *semaphoregroup.SemaphoreGroup
PeerConnDispatcher *dispatcher.ConnectionDispatcher
}
@@ -111,9 +108,8 @@ type Conn struct {
wgProxyRelay wgproxy.Proxy
handshaker *Handshaker
guard *guard.Guard
semaphore *semaphoregroup.SemaphoreGroup
wg sync.WaitGroup
guard *guard.Guard
wg sync.WaitGroup
// debug purpose
dumpState *stateDump
@@ -139,7 +135,6 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
iFaceDiscover: services.IFaceDiscover,
relayManager: services.RelayManager,
srWatcher: services.SrWatcher,
semaphore: services.Semaphore,
statusRelay: worker.NewAtomicStatus(),
statusICE: worker.NewAtomicStatus(),
dumpState: dumpState,
@@ -154,15 +149,10 @@ func NewConn(config ConnConfig, services ServiceDependencies) (*Conn, error) {
// It will try to establish a connection using ICE and in parallel with relay. The higher priority connection type will
// be used.
func (conn *Conn) Open(engineCtx context.Context) error {
if err := conn.semaphore.Add(engineCtx); err != nil {
return err
}
conn.mu.Lock()
defer conn.mu.Unlock()
if conn.opened {
conn.semaphore.Done()
return nil
}
@@ -173,7 +163,6 @@ func (conn *Conn) Open(engineCtx context.Context) error {
relayIsSupportedLocally := conn.workerRelay.RelayIsSupportedLocally()
workerICE, err := NewWorkerICE(conn.ctx, conn.Log, conn.config, conn, conn.signaler, conn.iFaceDiscover, conn.statusRecorder, relayIsSupportedLocally)
if err != nil {
conn.semaphore.Done()
return err
}
conn.workerICE = workerICE
@@ -207,10 +196,6 @@ func (conn *Conn) Open(engineCtx context.Context) error {
conn.wg.Add(1)
go func() {
defer conn.wg.Done()
conn.waitInitialRandomSleepTime(conn.ctx)
conn.semaphore.Done()
conn.guard.Start(conn.ctx, conn.onGuardEvent)
}()
conn.opened = true
@@ -670,19 +655,6 @@ func (conn *Conn) doOnConnected(remoteRosenpassPubKey []byte, remoteRosenpassAdd
}
}
func (conn *Conn) waitInitialRandomSleepTime(ctx context.Context) {
maxWait := 300
duration := time.Duration(rand.Intn(maxWait)) * time.Millisecond
timeout := time.NewTimer(duration)
defer timeout.Stop()
select {
case <-ctx.Done():
case <-timeout.C:
}
}
func (conn *Conn) isRelayed() bool {
switch conn.currentConnPriority {
case conntype.Relay, conntype.ICETurn:

View File

@@ -15,7 +15,6 @@ import (
"github.com/netbirdio/netbird/client/internal/peer/ice"
"github.com/netbirdio/netbird/client/internal/stdnet"
"github.com/netbirdio/netbird/util"
semaphoregroup "github.com/netbirdio/netbird/util/semaphore-group"
)
var testDispatcher = dispatcher.NewConnectionDispatcher()
@@ -53,7 +52,6 @@ func TestConn_GetKey(t *testing.T) {
sd := ServiceDependencies{
SrWatcher: swWatcher,
Semaphore: semaphoregroup.NewSemaphoreGroup(1),
PeerConnDispatcher: testDispatcher,
}
conn, err := NewConn(connConf, sd)
@@ -71,7 +69,6 @@ func TestConn_OnRemoteOffer(t *testing.T) {
sd := ServiceDependencies{
StatusRecorder: NewRecorder("https://mgm"),
SrWatcher: swWatcher,
Semaphore: semaphoregroup.NewSemaphoreGroup(1),
PeerConnDispatcher: testDispatcher,
}
conn, err := NewConn(connConf, sd)
@@ -110,7 +107,6 @@ func TestConn_OnRemoteAnswer(t *testing.T) {
sd := ServiceDependencies{
StatusRecorder: NewRecorder("https://mgm"),
SrWatcher: swWatcher,
Semaphore: semaphoregroup.NewSemaphoreGroup(1),
PeerConnDispatcher: testDispatcher,
}
conn, err := NewConn(connConf, sd)

View File

@@ -0,0 +1,80 @@
package handler
import (
"context"
"sync"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
)
type Agent interface {
Up(ctx context.Context) error
Down(ctx context.Context) error
Status() (internal.StatusType, error)
}
type SleepHandler struct {
agent Agent
mu sync.Mutex
// sleepTriggeredDown indicates whether the sleep handler triggered the last client down, to avoid unnecessary up on wake
sleepTriggeredDown bool
}
func New(agent Agent) *SleepHandler {
return &SleepHandler{
agent: agent,
}
}
func (s *SleepHandler) HandleWakeUp(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
if !s.sleepTriggeredDown {
log.Info("skipping up because wasn't sleep down")
return nil
}
// avoid other wakeup runs if sleep didn't make the computer sleep
s.sleepTriggeredDown = false
log.Info("running up after wake up")
err := s.agent.Up(ctx)
if err != nil {
log.Errorf("running up failed: %v", err)
return err
}
log.Info("running up command executed successfully")
return nil
}
func (s *SleepHandler) HandleSleep(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
status, err := s.agent.Status()
if err != nil {
return err
}
if status != internal.StatusConnecting && status != internal.StatusConnected {
log.Infof("skipping setting the agent down because status is %s", status)
return nil
}
log.Info("running down after system started sleeping")
if err = s.agent.Down(ctx); err != nil {
log.Errorf("running down failed: %v", err)
return err
}
s.sleepTriggeredDown = true
log.Info("running down executed successfully")
return nil
}

View File

@@ -0,0 +1,153 @@
package handler
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal"
)
type mockAgent struct {
upErr error
downErr error
statusErr error
status internal.StatusType
upCalls int
}
func (m *mockAgent) Up(_ context.Context) error {
m.upCalls++
return m.upErr
}
func (m *mockAgent) Down(_ context.Context) error {
return m.downErr
}
func (m *mockAgent) Status() (internal.StatusType, error) {
return m.status, m.statusErr
}
func newHandler(status internal.StatusType) (*SleepHandler, *mockAgent) {
agent := &mockAgent{status: status}
return New(agent), agent
}
func TestHandleWakeUp_SkipsWhenFlagFalse(t *testing.T) {
h, agent := newHandler(internal.StatusIdle)
err := h.HandleWakeUp(context.Background())
require.NoError(t, err)
assert.Equal(t, 0, agent.upCalls, "Up should not be called when flag is false")
}
func TestHandleWakeUp_ResetsFlagBeforeUp(t *testing.T) {
h, _ := newHandler(internal.StatusIdle)
h.sleepTriggeredDown = true
// Even if Up fails, flag should be reset
_ = h.HandleWakeUp(context.Background())
assert.False(t, h.sleepTriggeredDown, "flag must be reset before calling Up")
}
func TestHandleWakeUp_CallsUpWhenFlagSet(t *testing.T) {
h, agent := newHandler(internal.StatusIdle)
h.sleepTriggeredDown = true
err := h.HandleWakeUp(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, agent.upCalls)
assert.False(t, h.sleepTriggeredDown)
}
func TestHandleWakeUp_ReturnsErrorFromUp(t *testing.T) {
h, agent := newHandler(internal.StatusIdle)
h.sleepTriggeredDown = true
agent.upErr = errors.New("up failed")
err := h.HandleWakeUp(context.Background())
assert.ErrorIs(t, err, agent.upErr)
assert.False(t, h.sleepTriggeredDown, "flag should still be reset even when Up fails")
}
func TestHandleWakeUp_SecondCallIsNoOp(t *testing.T) {
h, agent := newHandler(internal.StatusIdle)
h.sleepTriggeredDown = true
_ = h.HandleWakeUp(context.Background())
err := h.HandleWakeUp(context.Background())
require.NoError(t, err)
assert.Equal(t, 1, agent.upCalls, "second wakeup should be no-op")
}
func TestHandleSleep_SkipsForNonActiveStates(t *testing.T) {
tests := []struct {
name string
status internal.StatusType
}{
{"Idle", internal.StatusIdle},
{"NeedsLogin", internal.StatusNeedsLogin},
{"LoginFailed", internal.StatusLoginFailed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, _ := newHandler(tt.status)
err := h.HandleSleep(context.Background())
require.NoError(t, err)
assert.False(t, h.sleepTriggeredDown)
})
}
}
func TestHandleSleep_ProceedsForActiveStates(t *testing.T) {
tests := []struct {
name string
status internal.StatusType
}{
{"Connecting", internal.StatusConnecting},
{"Connected", internal.StatusConnected},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, _ := newHandler(tt.status)
err := h.HandleSleep(context.Background())
require.NoError(t, err)
assert.True(t, h.sleepTriggeredDown)
})
}
}
func TestHandleSleep_ReturnsErrorFromStatus(t *testing.T) {
agent := &mockAgent{statusErr: errors.New("status error")}
h := New(agent)
err := h.HandleSleep(context.Background())
assert.ErrorIs(t, err, agent.statusErr)
assert.False(t, h.sleepTriggeredDown)
}
func TestHandleSleep_ReturnsErrorFromDown(t *testing.T) {
agent := &mockAgent{status: internal.StatusConnected, downErr: errors.New("down failed")}
h := New(agent)
err := h.HandleSleep(context.Background())
assert.ErrorIs(t, err, agent.downErr)
assert.False(t, h.sleepTriggeredDown, "flag should not be set when Down fails")
}

View File

@@ -1,7 +1,7 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.6
// protoc v6.32.1
// protoc v6.33.3
// source: daemon.proto
package proto
@@ -88,6 +88,58 @@ func (LogLevel) EnumDescriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{0}
}
type ExposeProtocol int32
const (
ExposeProtocol_EXPOSE_HTTP ExposeProtocol = 0
ExposeProtocol_EXPOSE_HTTPS ExposeProtocol = 1
ExposeProtocol_EXPOSE_TCP ExposeProtocol = 2
ExposeProtocol_EXPOSE_UDP ExposeProtocol = 3
)
// Enum value maps for ExposeProtocol.
var (
ExposeProtocol_name = map[int32]string{
0: "EXPOSE_HTTP",
1: "EXPOSE_HTTPS",
2: "EXPOSE_TCP",
3: "EXPOSE_UDP",
}
ExposeProtocol_value = map[string]int32{
"EXPOSE_HTTP": 0,
"EXPOSE_HTTPS": 1,
"EXPOSE_TCP": 2,
"EXPOSE_UDP": 3,
}
)
func (x ExposeProtocol) Enum() *ExposeProtocol {
p := new(ExposeProtocol)
*p = x
return p
}
func (x ExposeProtocol) String() string {
return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
}
func (ExposeProtocol) Descriptor() protoreflect.EnumDescriptor {
return file_daemon_proto_enumTypes[1].Descriptor()
}
func (ExposeProtocol) Type() protoreflect.EnumType {
return &file_daemon_proto_enumTypes[1]
}
func (x ExposeProtocol) Number() protoreflect.EnumNumber {
return protoreflect.EnumNumber(x)
}
// Deprecated: Use ExposeProtocol.Descriptor instead.
func (ExposeProtocol) EnumDescriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{1}
}
// avoid collision with loglevel enum
type OSLifecycleRequest_CycleType int32
@@ -122,11 +174,11 @@ func (x OSLifecycleRequest_CycleType) String() string {
}
func (OSLifecycleRequest_CycleType) Descriptor() protoreflect.EnumDescriptor {
return file_daemon_proto_enumTypes[1].Descriptor()
return file_daemon_proto_enumTypes[2].Descriptor()
}
func (OSLifecycleRequest_CycleType) Type() protoreflect.EnumType {
return &file_daemon_proto_enumTypes[1]
return &file_daemon_proto_enumTypes[2]
}
func (x OSLifecycleRequest_CycleType) Number() protoreflect.EnumNumber {
@@ -174,11 +226,11 @@ func (x SystemEvent_Severity) String() string {
}
func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor {
return file_daemon_proto_enumTypes[2].Descriptor()
return file_daemon_proto_enumTypes[3].Descriptor()
}
func (SystemEvent_Severity) Type() protoreflect.EnumType {
return &file_daemon_proto_enumTypes[2]
return &file_daemon_proto_enumTypes[3]
}
func (x SystemEvent_Severity) Number() protoreflect.EnumNumber {
@@ -229,11 +281,11 @@ func (x SystemEvent_Category) String() string {
}
func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor {
return file_daemon_proto_enumTypes[3].Descriptor()
return file_daemon_proto_enumTypes[4].Descriptor()
}
func (SystemEvent_Category) Type() protoreflect.EnumType {
return &file_daemon_proto_enumTypes[3]
return &file_daemon_proto_enumTypes[4]
}
func (x SystemEvent_Category) Number() protoreflect.EnumNumber {
@@ -5600,6 +5652,224 @@ func (x *InstallerResultResponse) GetErrorMsg() string {
return ""
}
type ExposeServiceRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Port uint32 `protobuf:"varint,1,opt,name=port,proto3" json:"port,omitempty"`
Protocol ExposeProtocol `protobuf:"varint,2,opt,name=protocol,proto3,enum=daemon.ExposeProtocol" json:"protocol,omitempty"`
Pin string `protobuf:"bytes,3,opt,name=pin,proto3" json:"pin,omitempty"`
Password string `protobuf:"bytes,4,opt,name=password,proto3" json:"password,omitempty"`
UserGroups []string `protobuf:"bytes,5,rep,name=user_groups,json=userGroups,proto3" json:"user_groups,omitempty"`
Domain string `protobuf:"bytes,6,opt,name=domain,proto3" json:"domain,omitempty"`
NamePrefix string `protobuf:"bytes,7,opt,name=name_prefix,json=namePrefix,proto3" json:"name_prefix,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExposeServiceRequest) Reset() {
*x = ExposeServiceRequest{}
mi := &file_daemon_proto_msgTypes[85]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExposeServiceRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExposeServiceRequest) ProtoMessage() {}
func (x *ExposeServiceRequest) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[85]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExposeServiceRequest.ProtoReflect.Descriptor instead.
func (*ExposeServiceRequest) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{85}
}
func (x *ExposeServiceRequest) GetPort() uint32 {
if x != nil {
return x.Port
}
return 0
}
func (x *ExposeServiceRequest) GetProtocol() ExposeProtocol {
if x != nil {
return x.Protocol
}
return ExposeProtocol_EXPOSE_HTTP
}
func (x *ExposeServiceRequest) GetPin() string {
if x != nil {
return x.Pin
}
return ""
}
func (x *ExposeServiceRequest) GetPassword() string {
if x != nil {
return x.Password
}
return ""
}
func (x *ExposeServiceRequest) GetUserGroups() []string {
if x != nil {
return x.UserGroups
}
return nil
}
func (x *ExposeServiceRequest) GetDomain() string {
if x != nil {
return x.Domain
}
return ""
}
func (x *ExposeServiceRequest) GetNamePrefix() string {
if x != nil {
return x.NamePrefix
}
return ""
}
type ExposeServiceEvent struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Types that are valid to be assigned to Event:
//
// *ExposeServiceEvent_Ready
Event isExposeServiceEvent_Event `protobuf_oneof:"event"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExposeServiceEvent) Reset() {
*x = ExposeServiceEvent{}
mi := &file_daemon_proto_msgTypes[86]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExposeServiceEvent) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExposeServiceEvent) ProtoMessage() {}
func (x *ExposeServiceEvent) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[86]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExposeServiceEvent.ProtoReflect.Descriptor instead.
func (*ExposeServiceEvent) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{86}
}
func (x *ExposeServiceEvent) GetEvent() isExposeServiceEvent_Event {
if x != nil {
return x.Event
}
return nil
}
func (x *ExposeServiceEvent) GetReady() *ExposeServiceReady {
if x != nil {
if x, ok := x.Event.(*ExposeServiceEvent_Ready); ok {
return x.Ready
}
}
return nil
}
type isExposeServiceEvent_Event interface {
isExposeServiceEvent_Event()
}
type ExposeServiceEvent_Ready struct {
Ready *ExposeServiceReady `protobuf:"bytes,1,opt,name=ready,proto3,oneof"`
}
func (*ExposeServiceEvent_Ready) isExposeServiceEvent_Event() {}
type ExposeServiceReady struct {
state protoimpl.MessageState `protogen:"open.v1"`
ServiceName string `protobuf:"bytes,1,opt,name=service_name,json=serviceName,proto3" json:"service_name,omitempty"`
ServiceUrl string `protobuf:"bytes,2,opt,name=service_url,json=serviceUrl,proto3" json:"service_url,omitempty"`
Domain string `protobuf:"bytes,3,opt,name=domain,proto3" json:"domain,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ExposeServiceReady) Reset() {
*x = ExposeServiceReady{}
mi := &file_daemon_proto_msgTypes[87]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ExposeServiceReady) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ExposeServiceReady) ProtoMessage() {}
func (x *ExposeServiceReady) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[87]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ExposeServiceReady.ProtoReflect.Descriptor instead.
func (*ExposeServiceReady) Descriptor() ([]byte, []int) {
return file_daemon_proto_rawDescGZIP(), []int{87}
}
func (x *ExposeServiceReady) GetServiceName() string {
if x != nil {
return x.ServiceName
}
return ""
}
func (x *ExposeServiceReady) GetServiceUrl() string {
if x != nil {
return x.ServiceUrl
}
return ""
}
func (x *ExposeServiceReady) GetDomain() string {
if x != nil {
return x.Domain
}
return ""
}
type PortInfo_Range struct {
state protoimpl.MessageState `protogen:"open.v1"`
Start uint32 `protobuf:"varint,1,opt,name=start,proto3" json:"start,omitempty"`
@@ -5610,7 +5880,7 @@ type PortInfo_Range struct {
func (x *PortInfo_Range) Reset() {
*x = PortInfo_Range{}
mi := &file_daemon_proto_msgTypes[86]
mi := &file_daemon_proto_msgTypes[89]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
@@ -5622,7 +5892,7 @@ func (x *PortInfo_Range) String() string {
func (*PortInfo_Range) ProtoMessage() {}
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
mi := &file_daemon_proto_msgTypes[86]
mi := &file_daemon_proto_msgTypes[89]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
@@ -6149,7 +6419,25 @@ const file_daemon_proto_rawDesc = "" +
"\x16InstallerResultRequest\"O\n" +
"\x17InstallerResultResponse\x12\x18\n" +
"\asuccess\x18\x01 \x01(\bR\asuccess\x12\x1a\n" +
"\berrorMsg\x18\x02 \x01(\tR\berrorMsg*b\n" +
"\berrorMsg\x18\x02 \x01(\tR\berrorMsg\"\xe6\x01\n" +
"\x14ExposeServiceRequest\x12\x12\n" +
"\x04port\x18\x01 \x01(\rR\x04port\x122\n" +
"\bprotocol\x18\x02 \x01(\x0e2\x16.daemon.ExposeProtocolR\bprotocol\x12\x10\n" +
"\x03pin\x18\x03 \x01(\tR\x03pin\x12\x1a\n" +
"\bpassword\x18\x04 \x01(\tR\bpassword\x12\x1f\n" +
"\vuser_groups\x18\x05 \x03(\tR\n" +
"userGroups\x12\x16\n" +
"\x06domain\x18\x06 \x01(\tR\x06domain\x12\x1f\n" +
"\vname_prefix\x18\a \x01(\tR\n" +
"namePrefix\"Q\n" +
"\x12ExposeServiceEvent\x122\n" +
"\x05ready\x18\x01 \x01(\v2\x1a.daemon.ExposeServiceReadyH\x00R\x05readyB\a\n" +
"\x05event\"p\n" +
"\x12ExposeServiceReady\x12!\n" +
"\fservice_name\x18\x01 \x01(\tR\vserviceName\x12\x1f\n" +
"\vservice_url\x18\x02 \x01(\tR\n" +
"serviceUrl\x12\x16\n" +
"\x06domain\x18\x03 \x01(\tR\x06domain*b\n" +
"\bLogLevel\x12\v\n" +
"\aUNKNOWN\x10\x00\x12\t\n" +
"\x05PANIC\x10\x01\x12\t\n" +
@@ -6158,7 +6446,14 @@ const file_daemon_proto_rawDesc = "" +
"\x04WARN\x10\x04\x12\b\n" +
"\x04INFO\x10\x05\x12\t\n" +
"\x05DEBUG\x10\x06\x12\t\n" +
"\x05TRACE\x10\a2\xdd\x14\n" +
"\x05TRACE\x10\a*S\n" +
"\x0eExposeProtocol\x12\x0f\n" +
"\vEXPOSE_HTTP\x10\x00\x12\x10\n" +
"\fEXPOSE_HTTPS\x10\x01\x12\x0e\n" +
"\n" +
"EXPOSE_TCP\x10\x02\x12\x0e\n" +
"\n" +
"EXPOSE_UDP\x10\x032\xac\x15\n" +
"\rDaemonService\x126\n" +
"\x05Login\x12\x14.daemon.LoginRequest\x1a\x15.daemon.LoginResponse\"\x00\x12K\n" +
"\fWaitSSOLogin\x12\x1b.daemon.WaitSSOLoginRequest\x1a\x1c.daemon.WaitSSOLoginResponse\"\x00\x12-\n" +
@@ -6197,7 +6492,8 @@ const file_daemon_proto_rawDesc = "" +
"\x0fStartCPUProfile\x12\x1e.daemon.StartCPUProfileRequest\x1a\x1f.daemon.StartCPUProfileResponse\"\x00\x12Q\n" +
"\x0eStopCPUProfile\x12\x1d.daemon.StopCPUProfileRequest\x1a\x1e.daemon.StopCPUProfileResponse\"\x00\x12N\n" +
"\x11NotifyOSLifecycle\x12\x1a.daemon.OSLifecycleRequest\x1a\x1b.daemon.OSLifecycleResponse\"\x00\x12W\n" +
"\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00B\bZ\x06/protob\x06proto3"
"\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00\x12M\n" +
"\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3"
var (
file_daemon_proto_rawDescOnce sync.Once
@@ -6211,214 +6507,222 @@ func file_daemon_proto_rawDescGZIP() []byte {
return file_daemon_proto_rawDescData
}
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 88)
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 91)
var file_daemon_proto_goTypes = []any{
(LogLevel)(0), // 0: daemon.LogLevel
(OSLifecycleRequest_CycleType)(0), // 1: daemon.OSLifecycleRequest.CycleType
(SystemEvent_Severity)(0), // 2: daemon.SystemEvent.Severity
(SystemEvent_Category)(0), // 3: daemon.SystemEvent.Category
(*EmptyRequest)(nil), // 4: daemon.EmptyRequest
(*OSLifecycleRequest)(nil), // 5: daemon.OSLifecycleRequest
(*OSLifecycleResponse)(nil), // 6: daemon.OSLifecycleResponse
(*LoginRequest)(nil), // 7: daemon.LoginRequest
(*LoginResponse)(nil), // 8: daemon.LoginResponse
(*WaitSSOLoginRequest)(nil), // 9: daemon.WaitSSOLoginRequest
(*WaitSSOLoginResponse)(nil), // 10: daemon.WaitSSOLoginResponse
(*UpRequest)(nil), // 11: daemon.UpRequest
(*UpResponse)(nil), // 12: daemon.UpResponse
(*StatusRequest)(nil), // 13: daemon.StatusRequest
(*StatusResponse)(nil), // 14: daemon.StatusResponse
(*DownRequest)(nil), // 15: daemon.DownRequest
(*DownResponse)(nil), // 16: daemon.DownResponse
(*GetConfigRequest)(nil), // 17: daemon.GetConfigRequest
(*GetConfigResponse)(nil), // 18: daemon.GetConfigResponse
(*PeerState)(nil), // 19: daemon.PeerState
(*LocalPeerState)(nil), // 20: daemon.LocalPeerState
(*SignalState)(nil), // 21: daemon.SignalState
(*ManagementState)(nil), // 22: daemon.ManagementState
(*RelayState)(nil), // 23: daemon.RelayState
(*NSGroupState)(nil), // 24: daemon.NSGroupState
(*SSHSessionInfo)(nil), // 25: daemon.SSHSessionInfo
(*SSHServerState)(nil), // 26: daemon.SSHServerState
(*FullStatus)(nil), // 27: daemon.FullStatus
(*ListNetworksRequest)(nil), // 28: daemon.ListNetworksRequest
(*ListNetworksResponse)(nil), // 29: daemon.ListNetworksResponse
(*SelectNetworksRequest)(nil), // 30: daemon.SelectNetworksRequest
(*SelectNetworksResponse)(nil), // 31: daemon.SelectNetworksResponse
(*IPList)(nil), // 32: daemon.IPList
(*Network)(nil), // 33: daemon.Network
(*PortInfo)(nil), // 34: daemon.PortInfo
(*ForwardingRule)(nil), // 35: daemon.ForwardingRule
(*ForwardingRulesResponse)(nil), // 36: daemon.ForwardingRulesResponse
(*DebugBundleRequest)(nil), // 37: daemon.DebugBundleRequest
(*DebugBundleResponse)(nil), // 38: daemon.DebugBundleResponse
(*GetLogLevelRequest)(nil), // 39: daemon.GetLogLevelRequest
(*GetLogLevelResponse)(nil), // 40: daemon.GetLogLevelResponse
(*SetLogLevelRequest)(nil), // 41: daemon.SetLogLevelRequest
(*SetLogLevelResponse)(nil), // 42: daemon.SetLogLevelResponse
(*State)(nil), // 43: daemon.State
(*ListStatesRequest)(nil), // 44: daemon.ListStatesRequest
(*ListStatesResponse)(nil), // 45: daemon.ListStatesResponse
(*CleanStateRequest)(nil), // 46: daemon.CleanStateRequest
(*CleanStateResponse)(nil), // 47: daemon.CleanStateResponse
(*DeleteStateRequest)(nil), // 48: daemon.DeleteStateRequest
(*DeleteStateResponse)(nil), // 49: daemon.DeleteStateResponse
(*SetSyncResponsePersistenceRequest)(nil), // 50: daemon.SetSyncResponsePersistenceRequest
(*SetSyncResponsePersistenceResponse)(nil), // 51: daemon.SetSyncResponsePersistenceResponse
(*TCPFlags)(nil), // 52: daemon.TCPFlags
(*TracePacketRequest)(nil), // 53: daemon.TracePacketRequest
(*TraceStage)(nil), // 54: daemon.TraceStage
(*TracePacketResponse)(nil), // 55: daemon.TracePacketResponse
(*SubscribeRequest)(nil), // 56: daemon.SubscribeRequest
(*SystemEvent)(nil), // 57: daemon.SystemEvent
(*GetEventsRequest)(nil), // 58: daemon.GetEventsRequest
(*GetEventsResponse)(nil), // 59: daemon.GetEventsResponse
(*SwitchProfileRequest)(nil), // 60: daemon.SwitchProfileRequest
(*SwitchProfileResponse)(nil), // 61: daemon.SwitchProfileResponse
(*SetConfigRequest)(nil), // 62: daemon.SetConfigRequest
(*SetConfigResponse)(nil), // 63: daemon.SetConfigResponse
(*AddProfileRequest)(nil), // 64: daemon.AddProfileRequest
(*AddProfileResponse)(nil), // 65: daemon.AddProfileResponse
(*RemoveProfileRequest)(nil), // 66: daemon.RemoveProfileRequest
(*RemoveProfileResponse)(nil), // 67: daemon.RemoveProfileResponse
(*ListProfilesRequest)(nil), // 68: daemon.ListProfilesRequest
(*ListProfilesResponse)(nil), // 69: daemon.ListProfilesResponse
(*Profile)(nil), // 70: daemon.Profile
(*GetActiveProfileRequest)(nil), // 71: daemon.GetActiveProfileRequest
(*GetActiveProfileResponse)(nil), // 72: daemon.GetActiveProfileResponse
(*LogoutRequest)(nil), // 73: daemon.LogoutRequest
(*LogoutResponse)(nil), // 74: daemon.LogoutResponse
(*GetFeaturesRequest)(nil), // 75: daemon.GetFeaturesRequest
(*GetFeaturesResponse)(nil), // 76: daemon.GetFeaturesResponse
(*GetPeerSSHHostKeyRequest)(nil), // 77: daemon.GetPeerSSHHostKeyRequest
(*GetPeerSSHHostKeyResponse)(nil), // 78: daemon.GetPeerSSHHostKeyResponse
(*RequestJWTAuthRequest)(nil), // 79: daemon.RequestJWTAuthRequest
(*RequestJWTAuthResponse)(nil), // 80: daemon.RequestJWTAuthResponse
(*WaitJWTTokenRequest)(nil), // 81: daemon.WaitJWTTokenRequest
(*WaitJWTTokenResponse)(nil), // 82: daemon.WaitJWTTokenResponse
(*StartCPUProfileRequest)(nil), // 83: daemon.StartCPUProfileRequest
(*StartCPUProfileResponse)(nil), // 84: daemon.StartCPUProfileResponse
(*StopCPUProfileRequest)(nil), // 85: daemon.StopCPUProfileRequest
(*StopCPUProfileResponse)(nil), // 86: daemon.StopCPUProfileResponse
(*InstallerResultRequest)(nil), // 87: daemon.InstallerResultRequest
(*InstallerResultResponse)(nil), // 88: daemon.InstallerResultResponse
nil, // 89: daemon.Network.ResolvedIPsEntry
(*PortInfo_Range)(nil), // 90: daemon.PortInfo.Range
nil, // 91: daemon.SystemEvent.MetadataEntry
(*durationpb.Duration)(nil), // 92: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 93: google.protobuf.Timestamp
(ExposeProtocol)(0), // 1: daemon.ExposeProtocol
(OSLifecycleRequest_CycleType)(0), // 2: daemon.OSLifecycleRequest.CycleType
(SystemEvent_Severity)(0), // 3: daemon.SystemEvent.Severity
(SystemEvent_Category)(0), // 4: daemon.SystemEvent.Category
(*EmptyRequest)(nil), // 5: daemon.EmptyRequest
(*OSLifecycleRequest)(nil), // 6: daemon.OSLifecycleRequest
(*OSLifecycleResponse)(nil), // 7: daemon.OSLifecycleResponse
(*LoginRequest)(nil), // 8: daemon.LoginRequest
(*LoginResponse)(nil), // 9: daemon.LoginResponse
(*WaitSSOLoginRequest)(nil), // 10: daemon.WaitSSOLoginRequest
(*WaitSSOLoginResponse)(nil), // 11: daemon.WaitSSOLoginResponse
(*UpRequest)(nil), // 12: daemon.UpRequest
(*UpResponse)(nil), // 13: daemon.UpResponse
(*StatusRequest)(nil), // 14: daemon.StatusRequest
(*StatusResponse)(nil), // 15: daemon.StatusResponse
(*DownRequest)(nil), // 16: daemon.DownRequest
(*DownResponse)(nil), // 17: daemon.DownResponse
(*GetConfigRequest)(nil), // 18: daemon.GetConfigRequest
(*GetConfigResponse)(nil), // 19: daemon.GetConfigResponse
(*PeerState)(nil), // 20: daemon.PeerState
(*LocalPeerState)(nil), // 21: daemon.LocalPeerState
(*SignalState)(nil), // 22: daemon.SignalState
(*ManagementState)(nil), // 23: daemon.ManagementState
(*RelayState)(nil), // 24: daemon.RelayState
(*NSGroupState)(nil), // 25: daemon.NSGroupState
(*SSHSessionInfo)(nil), // 26: daemon.SSHSessionInfo
(*SSHServerState)(nil), // 27: daemon.SSHServerState
(*FullStatus)(nil), // 28: daemon.FullStatus
(*ListNetworksRequest)(nil), // 29: daemon.ListNetworksRequest
(*ListNetworksResponse)(nil), // 30: daemon.ListNetworksResponse
(*SelectNetworksRequest)(nil), // 31: daemon.SelectNetworksRequest
(*SelectNetworksResponse)(nil), // 32: daemon.SelectNetworksResponse
(*IPList)(nil), // 33: daemon.IPList
(*Network)(nil), // 34: daemon.Network
(*PortInfo)(nil), // 35: daemon.PortInfo
(*ForwardingRule)(nil), // 36: daemon.ForwardingRule
(*ForwardingRulesResponse)(nil), // 37: daemon.ForwardingRulesResponse
(*DebugBundleRequest)(nil), // 38: daemon.DebugBundleRequest
(*DebugBundleResponse)(nil), // 39: daemon.DebugBundleResponse
(*GetLogLevelRequest)(nil), // 40: daemon.GetLogLevelRequest
(*GetLogLevelResponse)(nil), // 41: daemon.GetLogLevelResponse
(*SetLogLevelRequest)(nil), // 42: daemon.SetLogLevelRequest
(*SetLogLevelResponse)(nil), // 43: daemon.SetLogLevelResponse
(*State)(nil), // 44: daemon.State
(*ListStatesRequest)(nil), // 45: daemon.ListStatesRequest
(*ListStatesResponse)(nil), // 46: daemon.ListStatesResponse
(*CleanStateRequest)(nil), // 47: daemon.CleanStateRequest
(*CleanStateResponse)(nil), // 48: daemon.CleanStateResponse
(*DeleteStateRequest)(nil), // 49: daemon.DeleteStateRequest
(*DeleteStateResponse)(nil), // 50: daemon.DeleteStateResponse
(*SetSyncResponsePersistenceRequest)(nil), // 51: daemon.SetSyncResponsePersistenceRequest
(*SetSyncResponsePersistenceResponse)(nil), // 52: daemon.SetSyncResponsePersistenceResponse
(*TCPFlags)(nil), // 53: daemon.TCPFlags
(*TracePacketRequest)(nil), // 54: daemon.TracePacketRequest
(*TraceStage)(nil), // 55: daemon.TraceStage
(*TracePacketResponse)(nil), // 56: daemon.TracePacketResponse
(*SubscribeRequest)(nil), // 57: daemon.SubscribeRequest
(*SystemEvent)(nil), // 58: daemon.SystemEvent
(*GetEventsRequest)(nil), // 59: daemon.GetEventsRequest
(*GetEventsResponse)(nil), // 60: daemon.GetEventsResponse
(*SwitchProfileRequest)(nil), // 61: daemon.SwitchProfileRequest
(*SwitchProfileResponse)(nil), // 62: daemon.SwitchProfileResponse
(*SetConfigRequest)(nil), // 63: daemon.SetConfigRequest
(*SetConfigResponse)(nil), // 64: daemon.SetConfigResponse
(*AddProfileRequest)(nil), // 65: daemon.AddProfileRequest
(*AddProfileResponse)(nil), // 66: daemon.AddProfileResponse
(*RemoveProfileRequest)(nil), // 67: daemon.RemoveProfileRequest
(*RemoveProfileResponse)(nil), // 68: daemon.RemoveProfileResponse
(*ListProfilesRequest)(nil), // 69: daemon.ListProfilesRequest
(*ListProfilesResponse)(nil), // 70: daemon.ListProfilesResponse
(*Profile)(nil), // 71: daemon.Profile
(*GetActiveProfileRequest)(nil), // 72: daemon.GetActiveProfileRequest
(*GetActiveProfileResponse)(nil), // 73: daemon.GetActiveProfileResponse
(*LogoutRequest)(nil), // 74: daemon.LogoutRequest
(*LogoutResponse)(nil), // 75: daemon.LogoutResponse
(*GetFeaturesRequest)(nil), // 76: daemon.GetFeaturesRequest
(*GetFeaturesResponse)(nil), // 77: daemon.GetFeaturesResponse
(*GetPeerSSHHostKeyRequest)(nil), // 78: daemon.GetPeerSSHHostKeyRequest
(*GetPeerSSHHostKeyResponse)(nil), // 79: daemon.GetPeerSSHHostKeyResponse
(*RequestJWTAuthRequest)(nil), // 80: daemon.RequestJWTAuthRequest
(*RequestJWTAuthResponse)(nil), // 81: daemon.RequestJWTAuthResponse
(*WaitJWTTokenRequest)(nil), // 82: daemon.WaitJWTTokenRequest
(*WaitJWTTokenResponse)(nil), // 83: daemon.WaitJWTTokenResponse
(*StartCPUProfileRequest)(nil), // 84: daemon.StartCPUProfileRequest
(*StartCPUProfileResponse)(nil), // 85: daemon.StartCPUProfileResponse
(*StopCPUProfileRequest)(nil), // 86: daemon.StopCPUProfileRequest
(*StopCPUProfileResponse)(nil), // 87: daemon.StopCPUProfileResponse
(*InstallerResultRequest)(nil), // 88: daemon.InstallerResultRequest
(*InstallerResultResponse)(nil), // 89: daemon.InstallerResultResponse
(*ExposeServiceRequest)(nil), // 90: daemon.ExposeServiceRequest
(*ExposeServiceEvent)(nil), // 91: daemon.ExposeServiceEvent
(*ExposeServiceReady)(nil), // 92: daemon.ExposeServiceReady
nil, // 93: daemon.Network.ResolvedIPsEntry
(*PortInfo_Range)(nil), // 94: daemon.PortInfo.Range
nil, // 95: daemon.SystemEvent.MetadataEntry
(*durationpb.Duration)(nil), // 96: google.protobuf.Duration
(*timestamppb.Timestamp)(nil), // 97: google.protobuf.Timestamp
}
var file_daemon_proto_depIdxs = []int32{
1, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType
92, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
27, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
93, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
93, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
92, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
25, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
22, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
21, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
20, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
19, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState
23, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState
24, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
57, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
26, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
33, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
89, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
90, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
34, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
34, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
35, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
2, // 0: daemon.OSLifecycleRequest.type:type_name -> daemon.OSLifecycleRequest.CycleType
96, // 1: daemon.LoginRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
28, // 2: daemon.StatusResponse.fullStatus:type_name -> daemon.FullStatus
97, // 3: daemon.PeerState.connStatusUpdate:type_name -> google.protobuf.Timestamp
97, // 4: daemon.PeerState.lastWireguardHandshake:type_name -> google.protobuf.Timestamp
96, // 5: daemon.PeerState.latency:type_name -> google.protobuf.Duration
26, // 6: daemon.SSHServerState.sessions:type_name -> daemon.SSHSessionInfo
23, // 7: daemon.FullStatus.managementState:type_name -> daemon.ManagementState
22, // 8: daemon.FullStatus.signalState:type_name -> daemon.SignalState
21, // 9: daemon.FullStatus.localPeerState:type_name -> daemon.LocalPeerState
20, // 10: daemon.FullStatus.peers:type_name -> daemon.PeerState
24, // 11: daemon.FullStatus.relays:type_name -> daemon.RelayState
25, // 12: daemon.FullStatus.dns_servers:type_name -> daemon.NSGroupState
58, // 13: daemon.FullStatus.events:type_name -> daemon.SystemEvent
27, // 14: daemon.FullStatus.sshServerState:type_name -> daemon.SSHServerState
34, // 15: daemon.ListNetworksResponse.routes:type_name -> daemon.Network
93, // 16: daemon.Network.resolvedIPs:type_name -> daemon.Network.ResolvedIPsEntry
94, // 17: daemon.PortInfo.range:type_name -> daemon.PortInfo.Range
35, // 18: daemon.ForwardingRule.destinationPort:type_name -> daemon.PortInfo
35, // 19: daemon.ForwardingRule.translatedPort:type_name -> daemon.PortInfo
36, // 20: daemon.ForwardingRulesResponse.rules:type_name -> daemon.ForwardingRule
0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
43, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State
52, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
54, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
2, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
3, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
93, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
91, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
57, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
92, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
70, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
32, // 33: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
7, // 34: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
9, // 35: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
11, // 36: daemon.DaemonService.Up:input_type -> daemon.UpRequest
13, // 37: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
15, // 38: daemon.DaemonService.Down:input_type -> daemon.DownRequest
17, // 39: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
28, // 40: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
30, // 41: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
30, // 42: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
4, // 43: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
37, // 44: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
39, // 45: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
41, // 46: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
44, // 47: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
46, // 48: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
48, // 49: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
50, // 50: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
53, // 51: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
56, // 52: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
58, // 53: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
60, // 54: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
62, // 55: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
64, // 56: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
66, // 57: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
68, // 58: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
71, // 59: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
73, // 60: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
75, // 61: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
77, // 62: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
79, // 63: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
81, // 64: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
83, // 65: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
85, // 66: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
5, // 67: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest
87, // 68: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
8, // 69: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
10, // 70: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
12, // 71: daemon.DaemonService.Up:output_type -> daemon.UpResponse
14, // 72: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
16, // 73: daemon.DaemonService.Down:output_type -> daemon.DownResponse
18, // 74: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
29, // 75: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
31, // 76: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
31, // 77: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
36, // 78: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
38, // 79: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
40, // 80: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
42, // 81: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
45, // 82: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
47, // 83: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
49, // 84: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
51, // 85: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
55, // 86: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
57, // 87: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
59, // 88: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
61, // 89: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
63, // 90: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
65, // 91: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
67, // 92: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
69, // 93: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
72, // 94: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
74, // 95: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
76, // 96: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
78, // 97: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
80, // 98: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
82, // 99: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
84, // 100: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
86, // 101: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
6, // 102: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse
88, // 103: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
69, // [69:104] is the sub-list for method output_type
34, // [34:69] is the sub-list for method input_type
34, // [34:34] is the sub-list for extension type_name
34, // [34:34] is the sub-list for extension extendee
0, // [0:34] is the sub-list for field type_name
44, // 23: daemon.ListStatesResponse.states:type_name -> daemon.State
53, // 24: daemon.TracePacketRequest.tcp_flags:type_name -> daemon.TCPFlags
55, // 25: daemon.TracePacketResponse.stages:type_name -> daemon.TraceStage
3, // 26: daemon.SystemEvent.severity:type_name -> daemon.SystemEvent.Severity
4, // 27: daemon.SystemEvent.category:type_name -> daemon.SystemEvent.Category
97, // 28: daemon.SystemEvent.timestamp:type_name -> google.protobuf.Timestamp
95, // 29: daemon.SystemEvent.metadata:type_name -> daemon.SystemEvent.MetadataEntry
58, // 30: daemon.GetEventsResponse.events:type_name -> daemon.SystemEvent
96, // 31: daemon.SetConfigRequest.dnsRouteInterval:type_name -> google.protobuf.Duration
71, // 32: daemon.ListProfilesResponse.profiles:type_name -> daemon.Profile
1, // 33: daemon.ExposeServiceRequest.protocol:type_name -> daemon.ExposeProtocol
92, // 34: daemon.ExposeServiceEvent.ready:type_name -> daemon.ExposeServiceReady
33, // 35: daemon.Network.ResolvedIPsEntry.value:type_name -> daemon.IPList
8, // 36: daemon.DaemonService.Login:input_type -> daemon.LoginRequest
10, // 37: daemon.DaemonService.WaitSSOLogin:input_type -> daemon.WaitSSOLoginRequest
12, // 38: daemon.DaemonService.Up:input_type -> daemon.UpRequest
14, // 39: daemon.DaemonService.Status:input_type -> daemon.StatusRequest
16, // 40: daemon.DaemonService.Down:input_type -> daemon.DownRequest
18, // 41: daemon.DaemonService.GetConfig:input_type -> daemon.GetConfigRequest
29, // 42: daemon.DaemonService.ListNetworks:input_type -> daemon.ListNetworksRequest
31, // 43: daemon.DaemonService.SelectNetworks:input_type -> daemon.SelectNetworksRequest
31, // 44: daemon.DaemonService.DeselectNetworks:input_type -> daemon.SelectNetworksRequest
5, // 45: daemon.DaemonService.ForwardingRules:input_type -> daemon.EmptyRequest
38, // 46: daemon.DaemonService.DebugBundle:input_type -> daemon.DebugBundleRequest
40, // 47: daemon.DaemonService.GetLogLevel:input_type -> daemon.GetLogLevelRequest
42, // 48: daemon.DaemonService.SetLogLevel:input_type -> daemon.SetLogLevelRequest
45, // 49: daemon.DaemonService.ListStates:input_type -> daemon.ListStatesRequest
47, // 50: daemon.DaemonService.CleanState:input_type -> daemon.CleanStateRequest
49, // 51: daemon.DaemonService.DeleteState:input_type -> daemon.DeleteStateRequest
51, // 52: daemon.DaemonService.SetSyncResponsePersistence:input_type -> daemon.SetSyncResponsePersistenceRequest
54, // 53: daemon.DaemonService.TracePacket:input_type -> daemon.TracePacketRequest
57, // 54: daemon.DaemonService.SubscribeEvents:input_type -> daemon.SubscribeRequest
59, // 55: daemon.DaemonService.GetEvents:input_type -> daemon.GetEventsRequest
61, // 56: daemon.DaemonService.SwitchProfile:input_type -> daemon.SwitchProfileRequest
63, // 57: daemon.DaemonService.SetConfig:input_type -> daemon.SetConfigRequest
65, // 58: daemon.DaemonService.AddProfile:input_type -> daemon.AddProfileRequest
67, // 59: daemon.DaemonService.RemoveProfile:input_type -> daemon.RemoveProfileRequest
69, // 60: daemon.DaemonService.ListProfiles:input_type -> daemon.ListProfilesRequest
72, // 61: daemon.DaemonService.GetActiveProfile:input_type -> daemon.GetActiveProfileRequest
74, // 62: daemon.DaemonService.Logout:input_type -> daemon.LogoutRequest
76, // 63: daemon.DaemonService.GetFeatures:input_type -> daemon.GetFeaturesRequest
78, // 64: daemon.DaemonService.GetPeerSSHHostKey:input_type -> daemon.GetPeerSSHHostKeyRequest
80, // 65: daemon.DaemonService.RequestJWTAuth:input_type -> daemon.RequestJWTAuthRequest
82, // 66: daemon.DaemonService.WaitJWTToken:input_type -> daemon.WaitJWTTokenRequest
84, // 67: daemon.DaemonService.StartCPUProfile:input_type -> daemon.StartCPUProfileRequest
86, // 68: daemon.DaemonService.StopCPUProfile:input_type -> daemon.StopCPUProfileRequest
6, // 69: daemon.DaemonService.NotifyOSLifecycle:input_type -> daemon.OSLifecycleRequest
88, // 70: daemon.DaemonService.GetInstallerResult:input_type -> daemon.InstallerResultRequest
90, // 71: daemon.DaemonService.ExposeService:input_type -> daemon.ExposeServiceRequest
9, // 72: daemon.DaemonService.Login:output_type -> daemon.LoginResponse
11, // 73: daemon.DaemonService.WaitSSOLogin:output_type -> daemon.WaitSSOLoginResponse
13, // 74: daemon.DaemonService.Up:output_type -> daemon.UpResponse
15, // 75: daemon.DaemonService.Status:output_type -> daemon.StatusResponse
17, // 76: daemon.DaemonService.Down:output_type -> daemon.DownResponse
19, // 77: daemon.DaemonService.GetConfig:output_type -> daemon.GetConfigResponse
30, // 78: daemon.DaemonService.ListNetworks:output_type -> daemon.ListNetworksResponse
32, // 79: daemon.DaemonService.SelectNetworks:output_type -> daemon.SelectNetworksResponse
32, // 80: daemon.DaemonService.DeselectNetworks:output_type -> daemon.SelectNetworksResponse
37, // 81: daemon.DaemonService.ForwardingRules:output_type -> daemon.ForwardingRulesResponse
39, // 82: daemon.DaemonService.DebugBundle:output_type -> daemon.DebugBundleResponse
41, // 83: daemon.DaemonService.GetLogLevel:output_type -> daemon.GetLogLevelResponse
43, // 84: daemon.DaemonService.SetLogLevel:output_type -> daemon.SetLogLevelResponse
46, // 85: daemon.DaemonService.ListStates:output_type -> daemon.ListStatesResponse
48, // 86: daemon.DaemonService.CleanState:output_type -> daemon.CleanStateResponse
50, // 87: daemon.DaemonService.DeleteState:output_type -> daemon.DeleteStateResponse
52, // 88: daemon.DaemonService.SetSyncResponsePersistence:output_type -> daemon.SetSyncResponsePersistenceResponse
56, // 89: daemon.DaemonService.TracePacket:output_type -> daemon.TracePacketResponse
58, // 90: daemon.DaemonService.SubscribeEvents:output_type -> daemon.SystemEvent
60, // 91: daemon.DaemonService.GetEvents:output_type -> daemon.GetEventsResponse
62, // 92: daemon.DaemonService.SwitchProfile:output_type -> daemon.SwitchProfileResponse
64, // 93: daemon.DaemonService.SetConfig:output_type -> daemon.SetConfigResponse
66, // 94: daemon.DaemonService.AddProfile:output_type -> daemon.AddProfileResponse
68, // 95: daemon.DaemonService.RemoveProfile:output_type -> daemon.RemoveProfileResponse
70, // 96: daemon.DaemonService.ListProfiles:output_type -> daemon.ListProfilesResponse
73, // 97: daemon.DaemonService.GetActiveProfile:output_type -> daemon.GetActiveProfileResponse
75, // 98: daemon.DaemonService.Logout:output_type -> daemon.LogoutResponse
77, // 99: daemon.DaemonService.GetFeatures:output_type -> daemon.GetFeaturesResponse
79, // 100: daemon.DaemonService.GetPeerSSHHostKey:output_type -> daemon.GetPeerSSHHostKeyResponse
81, // 101: daemon.DaemonService.RequestJWTAuth:output_type -> daemon.RequestJWTAuthResponse
83, // 102: daemon.DaemonService.WaitJWTToken:output_type -> daemon.WaitJWTTokenResponse
85, // 103: daemon.DaemonService.StartCPUProfile:output_type -> daemon.StartCPUProfileResponse
87, // 104: daemon.DaemonService.StopCPUProfile:output_type -> daemon.StopCPUProfileResponse
7, // 105: daemon.DaemonService.NotifyOSLifecycle:output_type -> daemon.OSLifecycleResponse
89, // 106: daemon.DaemonService.GetInstallerResult:output_type -> daemon.InstallerResultResponse
91, // 107: daemon.DaemonService.ExposeService:output_type -> daemon.ExposeServiceEvent
72, // [72:108] is the sub-list for method output_type
36, // [36:72] is the sub-list for method input_type
36, // [36:36] is the sub-list for extension type_name
36, // [36:36] is the sub-list for extension extendee
0, // [0:36] is the sub-list for field type_name
}
func init() { file_daemon_proto_init() }
@@ -6439,13 +6743,16 @@ func file_daemon_proto_init() {
file_daemon_proto_msgTypes[58].OneofWrappers = []any{}
file_daemon_proto_msgTypes[69].OneofWrappers = []any{}
file_daemon_proto_msgTypes[75].OneofWrappers = []any{}
file_daemon_proto_msgTypes[86].OneofWrappers = []any{
(*ExposeServiceEvent_Ready)(nil),
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_daemon_proto_rawDesc), len(file_daemon_proto_rawDesc)),
NumEnums: 4,
NumMessages: 88,
NumEnums: 5,
NumMessages: 91,
NumExtensions: 0,
NumServices: 1,
},

View File

@@ -103,6 +103,9 @@ service DaemonService {
rpc NotifyOSLifecycle(OSLifecycleRequest) returns(OSLifecycleResponse) {}
rpc GetInstallerResult(InstallerResultRequest) returns (InstallerResultResponse) {}
// ExposeService exposes a local port via the NetBird reverse proxy
rpc ExposeService(ExposeServiceRequest) returns (stream ExposeServiceEvent) {}
}
@@ -801,3 +804,32 @@ message InstallerResultResponse {
bool success = 1;
string errorMsg = 2;
}
enum ExposeProtocol {
EXPOSE_HTTP = 0;
EXPOSE_HTTPS = 1;
EXPOSE_TCP = 2;
EXPOSE_UDP = 3;
}
message ExposeServiceRequest {
uint32 port = 1;
ExposeProtocol protocol = 2;
string pin = 3;
string password = 4;
repeated string user_groups = 5;
string domain = 6;
string name_prefix = 7;
}
message ExposeServiceEvent {
oneof event {
ExposeServiceReady ready = 1;
}
}
message ExposeServiceReady {
string service_name = 1;
string service_url = 2;
string domain = 3;
}

View File

@@ -76,6 +76,8 @@ type DaemonServiceClient interface {
StopCPUProfile(ctx context.Context, in *StopCPUProfileRequest, opts ...grpc.CallOption) (*StopCPUProfileResponse, error)
NotifyOSLifecycle(ctx context.Context, in *OSLifecycleRequest, opts ...grpc.CallOption) (*OSLifecycleResponse, error)
GetInstallerResult(ctx context.Context, in *InstallerResultRequest, opts ...grpc.CallOption) (*InstallerResultResponse, error)
// ExposeService exposes a local port via the NetBird reverse proxy
ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error)
}
type daemonServiceClient struct {
@@ -424,6 +426,38 @@ func (c *daemonServiceClient) GetInstallerResult(ctx context.Context, in *Instal
return out, nil
}
func (c *daemonServiceClient) ExposeService(ctx context.Context, in *ExposeServiceRequest, opts ...grpc.CallOption) (DaemonService_ExposeServiceClient, error) {
stream, err := c.cc.NewStream(ctx, &DaemonService_ServiceDesc.Streams[1], "/daemon.DaemonService/ExposeService", opts...)
if err != nil {
return nil, err
}
x := &daemonServiceExposeServiceClient{stream}
if err := x.ClientStream.SendMsg(in); err != nil {
return nil, err
}
if err := x.ClientStream.CloseSend(); err != nil {
return nil, err
}
return x, nil
}
type DaemonService_ExposeServiceClient interface {
Recv() (*ExposeServiceEvent, error)
grpc.ClientStream
}
type daemonServiceExposeServiceClient struct {
grpc.ClientStream
}
func (x *daemonServiceExposeServiceClient) Recv() (*ExposeServiceEvent, error) {
m := new(ExposeServiceEvent)
if err := x.ClientStream.RecvMsg(m); err != nil {
return nil, err
}
return m, nil
}
// DaemonServiceServer is the server API for DaemonService service.
// All implementations must embed UnimplementedDaemonServiceServer
// for forward compatibility
@@ -486,6 +520,8 @@ type DaemonServiceServer interface {
StopCPUProfile(context.Context, *StopCPUProfileRequest) (*StopCPUProfileResponse, error)
NotifyOSLifecycle(context.Context, *OSLifecycleRequest) (*OSLifecycleResponse, error)
GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error)
// ExposeService exposes a local port via the NetBird reverse proxy
ExposeService(*ExposeServiceRequest, DaemonService_ExposeServiceServer) error
mustEmbedUnimplementedDaemonServiceServer()
}
@@ -598,6 +634,9 @@ func (UnimplementedDaemonServiceServer) NotifyOSLifecycle(context.Context, *OSLi
func (UnimplementedDaemonServiceServer) GetInstallerResult(context.Context, *InstallerResultRequest) (*InstallerResultResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetInstallerResult not implemented")
}
func (UnimplementedDaemonServiceServer) ExposeService(*ExposeServiceRequest, DaemonService_ExposeServiceServer) error {
return status.Errorf(codes.Unimplemented, "method ExposeService not implemented")
}
func (UnimplementedDaemonServiceServer) mustEmbedUnimplementedDaemonServiceServer() {}
// UnsafeDaemonServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -1244,6 +1283,27 @@ func _DaemonService_GetInstallerResult_Handler(srv interface{}, ctx context.Cont
return interceptor(ctx, in, info, handler)
}
func _DaemonService_ExposeService_Handler(srv interface{}, stream grpc.ServerStream) error {
m := new(ExposeServiceRequest)
if err := stream.RecvMsg(m); err != nil {
return err
}
return srv.(DaemonServiceServer).ExposeService(m, &daemonServiceExposeServiceServer{stream})
}
type DaemonService_ExposeServiceServer interface {
Send(*ExposeServiceEvent) error
grpc.ServerStream
}
type daemonServiceExposeServiceServer struct {
grpc.ServerStream
}
func (x *daemonServiceExposeServiceServer) Send(m *ExposeServiceEvent) error {
return x.ServerStream.SendMsg(m)
}
// DaemonService_ServiceDesc is the grpc.ServiceDesc for DaemonService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -1394,6 +1454,11 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
Handler: _DaemonService_SubscribeEvents_Handler,
ServerStreams: true,
},
{
StreamName: "ExposeService",
Handler: _DaemonService_ExposeService_Handler,
ServerStreams: true,
},
},
Metadata: "daemon.proto",
}

View File

@@ -1,77 +0,0 @@
package server
import (
"context"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
// NotifyOSLifecycle handles operating system lifecycle events by executing appropriate logic based on the request type.
func (s *Server) NotifyOSLifecycle(callerCtx context.Context, req *proto.OSLifecycleRequest) (*proto.OSLifecycleResponse, error) {
switch req.GetType() {
case proto.OSLifecycleRequest_WAKEUP:
return s.handleWakeUp(callerCtx)
case proto.OSLifecycleRequest_SLEEP:
return s.handleSleep(callerCtx)
default:
log.Errorf("unknown OSLifecycleRequest type: %v", req.GetType())
}
return &proto.OSLifecycleResponse{}, nil
}
// handleWakeUp processes a wake-up event by triggering the Up command if the system was previously put to sleep.
// It resets the sleep state and logs the process. Returns a response or an error if the Up command fails.
func (s *Server) handleWakeUp(callerCtx context.Context) (*proto.OSLifecycleResponse, error) {
if !s.sleepTriggeredDown.Load() {
log.Info("skipping up because wasn't sleep down")
return &proto.OSLifecycleResponse{}, nil
}
// avoid other wakeup runs if sleep didn't make the computer sleep
s.sleepTriggeredDown.Store(false)
log.Info("running up after wake up")
_, err := s.Up(callerCtx, &proto.UpRequest{})
if err != nil {
log.Errorf("running up failed: %v", err)
return &proto.OSLifecycleResponse{}, err
}
log.Info("running up command executed successfully")
return &proto.OSLifecycleResponse{}, nil
}
// handleSleep handles the sleep event by initiating a "down" sequence if the system is in a connected or connecting state.
func (s *Server) handleSleep(callerCtx context.Context) (*proto.OSLifecycleResponse, error) {
s.mutex.Lock()
state := internal.CtxGetState(s.rootCtx)
status, err := state.Status()
if err != nil {
s.mutex.Unlock()
return &proto.OSLifecycleResponse{}, err
}
if status != internal.StatusConnecting && status != internal.StatusConnected {
log.Infof("skipping setting the agent down because status is %s", status)
s.mutex.Unlock()
return &proto.OSLifecycleResponse{}, nil
}
s.mutex.Unlock()
log.Info("running down after system started sleeping")
_, err = s.Down(callerCtx, &proto.DownRequest{})
if err != nil {
log.Errorf("running down failed: %v", err)
return &proto.OSLifecycleResponse{}, err
}
s.sleepTriggeredDown.Store(true)
log.Info("running down executed successfully")
return &proto.OSLifecycleResponse{}, nil
}

View File

@@ -1,219 +0,0 @@
package server
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/internal/peer"
"github.com/netbirdio/netbird/client/proto"
)
func newTestServer() *Server {
ctx := internal.CtxInitState(context.Background())
return &Server{
rootCtx: ctx,
statusRecorder: peer.NewRecorder(""),
}
}
func TestNotifyOSLifecycle_WakeUp_SkipsWhenNotSleepTriggered(t *testing.T) {
s := newTestServer()
// sleepTriggeredDown is false by default
assert.False(t, s.sleepTriggeredDown.Load())
resp, err := s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_WAKEUP,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, s.sleepTriggeredDown.Load(), "flag should remain false")
}
func TestNotifyOSLifecycle_Sleep_SkipsWhenStatusIdle(t *testing.T) {
s := newTestServer()
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusIdle)
resp, err := s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_SLEEP,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, s.sleepTriggeredDown.Load(), "flag should remain false when status is Idle")
}
func TestNotifyOSLifecycle_Sleep_SkipsWhenStatusNeedsLogin(t *testing.T) {
s := newTestServer()
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusNeedsLogin)
resp, err := s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_SLEEP,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, s.sleepTriggeredDown.Load(), "flag should remain false when status is NeedsLogin")
}
func TestNotifyOSLifecycle_Sleep_SetsFlag_WhenConnecting(t *testing.T) {
s := newTestServer()
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusConnecting)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.actCancel = cancel
resp, err := s.NotifyOSLifecycle(ctx, &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_SLEEP,
})
require.NoError(t, err)
assert.NotNil(t, resp, "handleSleep returns not nil response on success")
assert.True(t, s.sleepTriggeredDown.Load(), "flag should be set after sleep when connecting")
}
func TestNotifyOSLifecycle_Sleep_SetsFlag_WhenConnected(t *testing.T) {
s := newTestServer()
state := internal.CtxGetState(s.rootCtx)
state.Set(internal.StatusConnected)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.actCancel = cancel
resp, err := s.NotifyOSLifecycle(ctx, &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_SLEEP,
})
require.NoError(t, err)
assert.NotNil(t, resp, "handleSleep returns not nil response on success")
assert.True(t, s.sleepTriggeredDown.Load(), "flag should be set after sleep when connected")
}
func TestNotifyOSLifecycle_WakeUp_ResetsFlag(t *testing.T) {
s := newTestServer()
// Manually set the flag to simulate prior sleep down
s.sleepTriggeredDown.Store(true)
// WakeUp will try to call Up which fails without proper setup, but flag should reset first
_, _ = s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_WAKEUP,
})
assert.False(t, s.sleepTriggeredDown.Load(), "flag should be reset after WakeUp attempt")
}
func TestNotifyOSLifecycle_MultipleWakeUpCalls(t *testing.T) {
s := newTestServer()
// First wakeup without prior sleep - should be no-op
resp, err := s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_WAKEUP,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, s.sleepTriggeredDown.Load())
// Simulate prior sleep
s.sleepTriggeredDown.Store(true)
// First wakeup after sleep - should reset flag
_, _ = s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_WAKEUP,
})
assert.False(t, s.sleepTriggeredDown.Load())
// Second wakeup - should be no-op
resp, err = s.NotifyOSLifecycle(context.Background(), &proto.OSLifecycleRequest{
Type: proto.OSLifecycleRequest_WAKEUP,
})
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, s.sleepTriggeredDown.Load())
}
func TestHandleWakeUp_SkipsWhenFlagFalse(t *testing.T) {
s := newTestServer()
resp, err := s.handleWakeUp(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
}
func TestHandleWakeUp_ResetsFlagBeforeUp(t *testing.T) {
s := newTestServer()
s.sleepTriggeredDown.Store(true)
// Even if Up fails, flag should be reset
_, _ = s.handleWakeUp(context.Background())
assert.False(t, s.sleepTriggeredDown.Load(), "flag must be reset before calling Up")
}
func TestHandleSleep_SkipsForNonActiveStates(t *testing.T) {
tests := []struct {
name string
status internal.StatusType
}{
{"Idle", internal.StatusIdle},
{"NeedsLogin", internal.StatusNeedsLogin},
{"LoginFailed", internal.StatusLoginFailed},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer()
state := internal.CtxGetState(s.rootCtx)
state.Set(tt.status)
resp, err := s.handleSleep(context.Background())
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, s.sleepTriggeredDown.Load())
})
}
}
func TestHandleSleep_ProceedsForActiveStates(t *testing.T) {
tests := []struct {
name string
status internal.StatusType
}{
{"Connecting", internal.StatusConnecting},
{"Connected", internal.StatusConnected},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
s := newTestServer()
state := internal.CtxGetState(s.rootCtx)
state.Set(tt.status)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.actCancel = cancel
resp, err := s.handleSleep(ctx)
require.NoError(t, err)
assert.NotNil(t, resp)
assert.True(t, s.sleepTriggeredDown.Load())
})
}
}

View File

@@ -21,7 +21,9 @@ import (
gstatus "google.golang.org/grpc/status"
"github.com/netbirdio/netbird/client/internal/auth"
"github.com/netbirdio/netbird/client/internal/expose"
"github.com/netbirdio/netbird/client/internal/profilemanager"
sleephandler "github.com/netbirdio/netbird/client/internal/sleep/handler"
"github.com/netbirdio/netbird/client/system"
mgm "github.com/netbirdio/netbird/shared/management/client"
"github.com/netbirdio/netbird/shared/management/domain"
@@ -85,8 +87,7 @@ type Server struct {
profilesDisabled bool
updateSettingsDisabled bool
// sleepTriggeredDown holds a state indicated if the sleep handler triggered the last client down
sleepTriggeredDown atomic.Bool
sleepHandler *sleephandler.SleepHandler
jwtCache *jwtCache
}
@@ -100,7 +101,7 @@ type oauthAuthFlow struct {
// New server instance constructor.
func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool) *Server {
return &Server{
s := &Server{
rootCtx: ctx,
logFile: logFile,
persistSyncResponse: true,
@@ -110,6 +111,10 @@ func New(ctx context.Context, logFile string, configFile string, profilesDisable
updateSettingsDisabled: updateSettingsDisabled,
jwtCache: newJWTCache(),
}
agent := &serverAgent{s}
s.sleepHandler = sleephandler.New(agent)
return s
}
func (s *Server) Start() error {
@@ -1312,6 +1317,60 @@ func (s *Server) WaitJWTToken(
}, nil
}
// ExposeService exposes a local port via the NetBird reverse proxy.
func (s *Server) ExposeService(req *proto.ExposeServiceRequest, srv proto.DaemonService_ExposeServiceServer) error {
s.mutex.Lock()
if !s.clientRunning {
s.mutex.Unlock()
return gstatus.Errorf(codes.FailedPrecondition, "client is not running, run 'netbird up' first")
}
connectClient := s.connectClient
s.mutex.Unlock()
if connectClient == nil {
return gstatus.Errorf(codes.FailedPrecondition, "client not initialized")
}
engine := connectClient.Engine()
if engine == nil {
return gstatus.Errorf(codes.FailedPrecondition, "engine not initialized")
}
mgr := engine.GetExposeManager()
if mgr == nil {
return gstatus.Errorf(codes.Internal, "expose manager not available")
}
ctx := srv.Context()
exposeCtx, exposeCancel := context.WithTimeout(ctx, 30*time.Second)
defer exposeCancel()
mgmReq := expose.NewRequest(req)
result, err := mgr.Expose(exposeCtx, *mgmReq)
if err != nil {
return err
}
if err := srv.Send(&proto.ExposeServiceEvent{
Event: &proto.ExposeServiceEvent_Ready{
Ready: &proto.ExposeServiceReady{
ServiceName: result.ServiceName,
ServiceUrl: result.ServiceURL,
Domain: result.Domain,
},
},
}); err != nil {
return err
}
err = mgr.KeepAlive(ctx, result.Domain)
if err != nil {
return err
}
return nil
}
func isUnixRunningDesktop() bool {
if runtime.GOOS != "linux" && runtime.GOOS != "freebsd" {
return false

46
client/server/sleep.go Normal file
View File

@@ -0,0 +1,46 @@
package server
import (
"context"
log "github.com/sirupsen/logrus"
"github.com/netbirdio/netbird/client/internal"
"github.com/netbirdio/netbird/client/proto"
)
// serverAgent adapts Server to the handler.Agent and handler.StatusChecker interfaces
type serverAgent struct {
s *Server
}
func (a *serverAgent) Up(ctx context.Context) error {
_, err := a.s.Up(ctx, &proto.UpRequest{})
return err
}
func (a *serverAgent) Down(ctx context.Context) error {
_, err := a.s.Down(ctx, &proto.DownRequest{})
return err
}
func (a *serverAgent) Status() (internal.StatusType, error) {
return internal.CtxGetState(a.s.rootCtx).Status()
}
// NotifyOSLifecycle handles operating system lifecycle events by executing appropriate logic based on the request type.
func (s *Server) NotifyOSLifecycle(callerCtx context.Context, req *proto.OSLifecycleRequest) (*proto.OSLifecycleResponse, error) {
switch req.GetType() {
case proto.OSLifecycleRequest_WAKEUP:
if err := s.sleepHandler.HandleWakeUp(callerCtx); err != nil {
return &proto.OSLifecycleResponse{}, err
}
case proto.OSLifecycleRequest_SLEEP:
if err := s.sleepHandler.HandleSleep(callerCtx); err != nil {
return &proto.OSLifecycleResponse{}, err
}
default:
log.Errorf("unknown OSLifecycleRequest type: %v", req.GetType())
}
return &proto.OSLifecycleResponse{}, nil
}

View File

@@ -19,6 +19,7 @@ import (
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/internal/daemonaddr"
"github.com/netbirdio/netbird/client/internal/profilemanager"
"github.com/netbirdio/netbird/client/proto"
nbssh "github.com/netbirdio/netbird/client/ssh"
@@ -268,7 +269,7 @@ func getDefaultDaemonAddr() string {
if runtime.GOOS == "windows" {
return DefaultDaemonAddrWindows
}
return DefaultDaemonAddr
return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr)
}
// DialOptions contains options for SSH connections

4
client/uiwails/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
frontend/node_modules/
frontend/dist/
bin/
.task/

View File

@@ -0,0 +1,33 @@
version: '3'
includes:
common: ./build/Taskfile.yml
linux: ./build/linux/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
windows: ./build/windows/Taskfile.yml
vars:
APP_NAME: "netbird-ui"
BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{OS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

View File

@@ -0,0 +1,61 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
dir: frontend
sources:
- package.json
- package-lock.json
generates:
- node_modules
preconditions:
- sh: npm version
msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
cmds:
- npm install
build:frontend:
label: build:frontend (DEV={{.DEV}})
summary: Build the frontend project
dir: frontend
sources:
- "**/*"
- exclude: node_modules/**/*
generates:
- dist/**/*
deps:
- task: install:frontend:deps
cmds:
- npm run {{.BUILD_COMMAND}} -q
env:
PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
vars:
BUILD_COMMAND: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` from an image
dir: build
sources:
- "appicon.png"
generates:
- "icons.icns"
- "icon.ico"
cmds:
- echo "Icon generation skipped (no appicon.png)"
status:
- test ! -f appicon.png
dev:frontend:
summary: Runs the frontend in development mode
dir: frontend
deps:
- task: install:frontend:deps
cmds:
- npm run dev -- --port {{.VITE_PORT}} --strictPort

View File

@@ -0,0 +1,24 @@
#!/bin/bash
# Build script for NetBird Wails v3 on Linux
set -e
echo "Installing system dependencies for Wails v3 on Linux..."
sudo apt-get update
sudo apt-get install -y \
libayatana-appindicator3-dev \
gcc \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libglib2.0-dev \
libsoup-3.0-dev \
libx11-dev \
npm
echo "Installing wails3 CLI..."
go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha.72
echo "Building fancyui..."
cd "$(dirname "$0")/.."
wails3 build
echo "Build complete."

View File

@@ -0,0 +1,35 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Builds the application for macOS
cmds:
- task: build:native
vars:
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
build:native:
summary: Builds the application natively on macOS
internal: true
deps:
- task: common:build:frontend
vars:
DEV:
ref: .DEV
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View File

@@ -0,0 +1,35 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Builds the application for Linux
cmds:
- task: build:native
vars:
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
build:native:
summary: Builds the application natively on Linux
internal: true
deps:
- task: common:build:frontend
vars:
DEV:
ref: .DEV
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: linux
CGO_ENABLED: 1
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View File

@@ -0,0 +1,41 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Cross-compiles the application for Windows from Linux using mingw-w64
cmds:
- task: build:cross
vars:
DEV: '{{.DEV}}'
OUTPUT: '{{.OUTPUT}}'
build:cross:
summary: Cross-compiles for Windows with mingw-w64
internal: true
deps:
- task: common:build:frontend
vars:
DEV:
ref: .DEV
preconditions:
- sh: command -v {{.CC}}
msg: "{{.CC}} not found. Install with: sudo apt-get install gcc-mingw-w64-x86-64"
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .DEV "true"}}-buildvcs=false -gcflags=all="-l" -ldflags="-H=windowsgui"{{else}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H=windowsgui"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}.exe'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
CC: '{{.CC | default "x86_64-w64-mingw32-gcc"}}'
env:
GOOS: windows
GOARCH: amd64
CGO_ENABLED: 1
CC: '{{.CC}}'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'

View File

@@ -0,0 +1,217 @@
//go:build !(linux && 386)
package event
import (
"context"
"fmt"
"slices"
"strings"
"sync"
"time"
"github.com/cenkalti/backoff/v4"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/version"
)
// NotifyFunc is a callback used to send desktop notifications.
type NotifyFunc func(title, body string)
// Handler is a callback invoked for each received daemon event.
type Handler func(*proto.SystemEvent)
// Manager subscribes to daemon events and dispatches them.
type Manager struct {
addr string
notify NotifyFunc
mu sync.Mutex
ctx context.Context
cancel context.CancelFunc
enabled bool
handlers []Handler
connMu sync.Mutex
conn *grpc.ClientConn
client proto.DaemonServiceClient
}
// NewManager creates a new event Manager.
func NewManager(addr string, notify NotifyFunc) *Manager {
return &Manager{
addr: addr,
notify: notify,
}
}
// Start begins event streaming with exponential backoff reconnection.
func (m *Manager) Start(ctx context.Context) {
m.mu.Lock()
m.ctx, m.cancel = context.WithCancel(ctx)
m.mu.Unlock()
expBackOff := backoff.WithContext(&backoff.ExponentialBackOff{
InitialInterval: time.Second,
RandomizationFactor: backoff.DefaultRandomizationFactor,
Multiplier: backoff.DefaultMultiplier,
MaxInterval: 10 * time.Second,
MaxElapsedTime: 0,
Stop: backoff.Stop,
Clock: backoff.SystemClock,
}, ctx)
if err := backoff.Retry(m.streamEvents, expBackOff); err != nil {
log.Errorf("event stream ended: %v", err)
}
}
func (m *Manager) streamEvents() error {
m.mu.Lock()
ctx := m.ctx
m.mu.Unlock()
client, err := m.getClient()
if err != nil {
return fmt.Errorf("create client: %w", err)
}
stream, err := client.SubscribeEvents(ctx, &proto.SubscribeRequest{})
if err != nil {
return fmt.Errorf("subscribe events: %w", err)
}
log.Info("subscribed to daemon events")
defer log.Info("unsubscribed from daemon events")
for {
event, err := stream.Recv()
if err != nil {
return fmt.Errorf("receive event: %w", err)
}
m.handleEvent(event)
}
}
// Stop cancels the event stream and closes the connection.
func (m *Manager) Stop() {
m.mu.Lock()
if m.cancel != nil {
m.cancel()
}
m.mu.Unlock()
m.connMu.Lock()
if m.conn != nil {
m.conn.Close()
m.conn = nil
m.client = nil
}
m.connMu.Unlock()
}
// SetNotificationsEnabled enables or disables desktop notifications.
func (m *Manager) SetNotificationsEnabled(enabled bool) {
m.mu.Lock()
defer m.mu.Unlock()
m.enabled = enabled
}
// AddHandler registers an event handler.
func (m *Manager) AddHandler(h Handler) {
m.mu.Lock()
defer m.mu.Unlock()
m.handlers = append(m.handlers, h)
}
func (m *Manager) handleEvent(event *proto.SystemEvent) {
m.mu.Lock()
enabled := m.enabled
handlers := slices.Clone(m.handlers)
m.mu.Unlock()
// Critical events are always shown.
if !enabled && event.Severity != proto.SystemEvent_CRITICAL {
goto dispatch
}
if event.UserMessage != "" && m.notify != nil {
title := getEventTitle(event)
body := event.UserMessage
if id := event.Metadata["id"]; id != "" {
body += fmt.Sprintf(" ID: %s", id)
}
m.notify(title, body)
}
dispatch:
for _, h := range handlers {
go h(event)
}
}
func getEventTitle(event *proto.SystemEvent) string {
var prefix string
switch event.Severity {
case proto.SystemEvent_CRITICAL:
prefix = "Critical"
case proto.SystemEvent_ERROR:
prefix = "Error"
case proto.SystemEvent_WARNING:
prefix = "Warning"
default:
prefix = "Info"
}
var category string
switch event.Category {
case proto.SystemEvent_DNS:
category = "DNS"
case proto.SystemEvent_NETWORK:
category = "Network"
case proto.SystemEvent_AUTHENTICATION:
category = "Authentication"
case proto.SystemEvent_CONNECTIVITY:
category = "Connectivity"
default:
category = "System"
}
return fmt.Sprintf("%s: %s", prefix, category)
}
// getClient returns a cached gRPC client, creating the connection on first use.
func (m *Manager) getClient() (proto.DaemonServiceClient, error) {
m.connMu.Lock()
defer m.connMu.Unlock()
if m.client != nil {
return m.client, nil
}
target := m.addr
if strings.HasPrefix(target, "tcp://") {
target = strings.TrimPrefix(target, "tcp://")
} else if strings.HasPrefix(target, "unix://") {
target = "unix:" + strings.TrimPrefix(target, "unix://")
}
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUserAgent("netbird-fancyui/"+version.NetbirdVersion()),
)
if err != nil {
return nil, err
}
m.conn = conn
m.client = proto.NewDaemonServiceClient(conn)
log.Debugf("event manager: gRPC connection established to %s", m.addr)
return m.client, nil
}

View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="color-scheme" content="light dark" />
<title>NetBird</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2502
client/uiwails/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "netbird-fancyui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"@wailsio/runtime": "latest",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.6",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.3.4",
"tailwindcss": "^4.0.6",
"typescript": "^5.6.3",
"vite": "^6.0.5"
}
}

View File

@@ -0,0 +1,59 @@
import { HashRouter, Routes, Route, useNavigate } from 'react-router-dom'
import { useEffect } from 'react'
import { Events } from '@wailsio/runtime'
import Status from './pages/Status'
import Settings from './pages/Settings'
import Networks from './pages/Networks'
import Profiles from './pages/Profiles'
import Peers from './pages/Peers'
import Debug from './pages/Debug'
import Update from './pages/Update'
import NavBar from './components/NavBar'
/**
* Navigator listens for the "navigate" event emitted by the Go backend
* and programmatically navigates the React router.
*/
function Navigator() {
const navigate = useNavigate()
useEffect(() => {
const unsub = Events.On('navigate', (event: { data: string[] }) => {
const path = event.data[0]
if (path) navigate(path)
})
return () => {
if (typeof unsub === 'function') unsub()
}
}, [navigate])
return null
}
export default function App() {
return (
<HashRouter>
<Navigator />
<div
className="min-h-screen flex"
style={{
backgroundColor: 'var(--color-bg-primary)',
color: 'var(--color-text-primary)',
}}
>
<NavBar />
<main className="flex-1 px-10 py-8 overflow-y-auto h-screen">
<Routes>
<Route path="/" element={<Status />} />
<Route path="/settings" element={<Settings />} />
<Route path="/peers" element={<Peers />} />
<Route path="/networks" element={<Networks />} />
<Route path="/profiles" element={<Profiles />} />
<Route path="/debug" element={<Debug />} />
<Route path="/update" element={<Update />} />
</Routes>
</main>
</div>
</HashRouter>
)
}

View File

@@ -0,0 +1,126 @@
/**
* Type definitions for the auto-generated Wails v3 service bindings.
* Run `wails3 generate bindings` to regenerate the actual TypeScript bindings
* from the Go service methods. These types mirror the Go structs.
*
* The actual binding files will be generated into frontend/bindings/ by the
* Wails CLI. This file serves as a centralized re-export and type reference.
*/
// ---- Connection service ----
export interface StatusInfo {
status: string
ip: string
publicKey: string
fqdn: string
connectedPeers: number
}
// ---- Settings service ----
export interface ConfigInfo {
managementUrl: string
adminUrl: string
preSharedKey: string
interfaceName: string
wireguardPort: number
disableAutoConnect: boolean
serverSshAllowed: boolean
rosenpassEnabled: boolean
rosenpassPermissive: boolean
lazyConnectionEnabled: boolean
blockInbound: boolean
disableNotifications: boolean
}
// ---- Network service ----
export interface NetworkInfo {
id: string
range: string
domains: string[]
selected: boolean
resolvedIPs: Record<string, string[]>
}
// ---- Profile service ----
export interface ProfileInfo {
name: string
isActive: boolean
}
export interface ActiveProfileInfo {
profileName: string
username: string
email: string
}
// ---- Debug service ----
export interface DebugBundleParams {
anonymize: boolean
systemInfo: boolean
upload: boolean
uploadUrl: string
runDurationMins: number
enablePersistence: boolean
}
export interface DebugBundleResult {
localPath: string
uploadedKey: string
uploadFailureReason: string
}
// ---- Peers service ----
export interface PeerInfo {
ip: string
pubKey: string
fqdn: string
connStatus: string
connStatusUpdate: string
relayed: boolean
relayAddress: string
latencyMs: number
bytesRx: number
bytesTx: number
rosenpassEnabled: boolean
networks: string[]
lastHandshake: string
localIceType: string
remoteIceType: string
localEndpoint: string
remoteEndpoint: string
}
// ---- Update service ----
export interface InstallerResult {
success: boolean
errorMsg: string
}
/**
* Wails v3 service call helper.
* After running `wails3 generate bindings`, use the generated functions directly.
* This helper wraps window.__wails.call for manual use during development.
*/
export async function call<T>(service: string, method: string, ...args: unknown[]): Promise<T> {
// This will be replaced by generated bindings after `wails3 generate bindings`
// For now, call via the Wails runtime bridge
const w = window as typeof window & {
go?: {
[svc: string]: {
[method: string]: (...args: unknown[]) => Promise<T>
}
}
}
const svc = w.go?.[service]
if (!svc) throw new Error(`Service ${service} not found. Run wails3 generate bindings.`)
const fn = svc[method]
if (!fn) throw new Error(`Method ${service}.${method} not found.`)
return fn(...args)
}

View File

@@ -0,0 +1,162 @@
import { NavLink } from 'react-router-dom'
import NetBirdLogo from './NetBirdLogo'
const mainItems = [
{ to: '/', label: 'Status', icon: StatusIcon },
{ to: '/peers', label: 'Peers', icon: PeersIcon },
{ to: '/networks', label: 'Networks', icon: NetworksIcon },
{ to: '/profiles', label: 'Profiles', icon: ProfilesIcon },
]
const systemItems = [
{ to: '/settings', label: 'Settings', icon: SettingsIcon },
{ to: '/debug', label: 'Debug', icon: DebugIcon },
{ to: '/update', label: 'Update', icon: UpdateIcon },
]
function NavGroup({ items }: { items: typeof mainItems }) {
return (
<div className="space-y-0.5">
{items.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
className="block"
>
{({ isActive }) => (
<div
className="flex items-center gap-2.5 px-2.5 py-[5px] rounded-[var(--radius-sidebar-item)] text-[13px] transition-colors"
style={{
backgroundColor: isActive ? 'var(--color-sidebar-selected)' : 'transparent',
fontWeight: isActive ? 500 : 400,
color: isActive ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
}}
onMouseEnter={e => {
if (!isActive) e.currentTarget.style.backgroundColor = 'var(--color-sidebar-hover)'
}}
onMouseLeave={e => {
if (!isActive) e.currentTarget.style.backgroundColor = 'transparent'
}}
>
<item.icon active={isActive} />
<span>{item.label}</span>
</div>
)}
</NavLink>
))}
</div>
)
}
export default function NavBar() {
return (
<nav
className="w-[216px] min-w-[216px] flex flex-col h-screen"
style={{
backgroundColor: 'var(--color-bg-sidebar)',
backdropFilter: 'blur(20px)',
WebkitBackdropFilter: 'blur(20px)',
borderRight: '0.5px solid var(--color-separator)',
}}
>
{/* Logo */}
<div className="px-4 py-4" style={{ borderBottom: '0.5px solid var(--color-separator)' }}>
<NetBirdLogo full />
</div>
{/* Nav items */}
<div className="flex-1 px-2.5 py-3 overflow-y-auto">
<NavGroup items={mainItems} />
<div className="my-2 mx-2.5" style={{ borderTop: '0.5px solid var(--color-separator)' }} />
<NavGroup items={systemItems} />
</div>
{/* Version footer */}
<div className="px-4 py-2.5 text-[11px]" style={{ color: 'var(--color-text-quaternary)', borderTop: '0.5px solid var(--color-separator)' }}>
NetBird Client
</div>
</nav>
)
}
/* ── Icons (18px, stroke) ──────────────────────────────────────── */
function StatusIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M22 12h-4l-3 9L9 3l-3 9H2" />
</svg>
)
}
function PeersIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="2" y="3" width="20" height="14" rx="2" />
<line x1="8" y1="21" x2="16" y2="21" />
<line x1="12" y1="17" x2="12" y2="21" />
</svg>
)
}
function NetworksIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="5" r="2" />
<circle cx="5" cy="19" r="2" />
<circle cx="19" cy="19" r="2" />
<line x1="12" y1="7" x2="5" y2="17" />
<line x1="12" y1="7" x2="19" y2="17" />
</svg>
)
}
function ProfilesIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M22 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
)
}
function SettingsIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
<circle cx="12" cy="12" r="3" />
</svg>
)
}
function DebugIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="m8 2 1.88 1.88" />
<path d="M14.12 3.88 16 2" />
<path d="M9 7.13v-1a3.003 3.003 0 1 1 6 0v1" />
<path d="M12 20c-3.3 0-6-2.7-6-6v-3a4 4 0 0 1 4-4h4a4 4 0 0 1 4 4v3c0 3.3-2.7 6-6 6" />
<path d="M12 20v-9" />
<path d="M6.53 9C4.6 8.8 3 7.1 3 5" />
<path d="M6 13H2" />
<path d="M3 21c0-2.1 1.7-3.9 3.8-4" />
<path d="M20.97 5c0 2.1-1.6 3.8-3.5 4" />
<path d="M22 13h-4" />
<path d="M17.2 17c2.1.1 3.8 1.9 3.8 4" />
</svg>
)
}
function UpdateIcon({ active }: { active: boolean }) {
return (
<svg className="w-[18px] h-[18px] shrink-0" style={{ color: active ? 'var(--color-accent)' : 'var(--color-text-secondary)' }} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
<path d="M3 3v5h5" />
<path d="M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
<path d="M16 16h5v5" />
</svg>
)
}

View File

@@ -0,0 +1,20 @@
function BirdMark({ className }: { className?: string }) {
return (
<svg className={className} width="31" height="23" viewBox="0 0 31 23" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M21.4631 0.523438C17.8173 0.857913 16.0028 2.95675 15.3171 4.01871L4.66406 22.4734H17.5163L30.1929 0.523438H21.4631Z" fill="#F68330"/>
<path d="M17.5265 22.4737L0 3.88525C0 3.88525 19.8177 -1.44128 21.7493 15.1738L17.5265 22.4737Z" fill="#F68330"/>
<path d="M14.9236 4.70563L9.54688 14.0208L17.5158 22.4747L21.7385 15.158C21.0696 9.44682 18.2851 6.32784 14.9236 4.69727" fill="#F05252"/>
</svg>
)
}
export default function NetBirdLogo({ full = false, className }: { full?: boolean; className?: string }) {
if (!full) return <BirdMark className={className} />
return (
<div className={`flex items-center gap-2 ${className ?? ''}`}>
<BirdMark />
<span className="text-lg font-bold tracking-wide" style={{ color: 'var(--color-text-primary)' }}>NETBIRD</span>
</div>
)
}

View File

@@ -0,0 +1,35 @@
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'destructive'
size?: 'sm' | 'md'
}
const styles: Record<string, React.CSSProperties> = {
primary: {
backgroundColor: 'var(--color-accent)',
color: '#ffffff',
},
secondary: {
backgroundColor: 'var(--color-control-bg)',
color: 'var(--color-text-primary)',
},
destructive: {
backgroundColor: 'var(--color-status-red-bg)',
color: 'var(--color-status-red)',
},
}
export default function Button({ variant = 'primary', size = 'md', className, style, children, ...props }: ButtonProps) {
const variantStyle = styles[variant]
const pad = size === 'sm' ? '4px 12px' : '6px 20px'
const fontSize = size === 'sm' ? 12 : 13
return (
<button
className={`inline-flex items-center justify-center gap-1.5 font-medium rounded-[8px] transition-opacity hover:opacity-85 active:opacity-75 disabled:opacity-50 disabled:cursor-not-allowed ${className ?? ''}`}
style={{ padding: pad, fontSize, ...variantStyle, ...style }}
{...props}
>
{children}
</button>
)
}

View File

@@ -0,0 +1,26 @@
interface CardProps {
label?: string
children: React.ReactNode
className?: string
}
export default function Card({ label, children, className }: CardProps) {
return (
<div className={className}>
{label && (
<h3 className="text-[11px] font-semibold uppercase tracking-wide px-4 mb-1.5" style={{ color: 'var(--color-text-tertiary)' }}>
{label}
</h3>
)}
<div
className="rounded-[var(--radius-card)] overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
}}
>
{children}
</div>
</div>
)
}

View File

@@ -0,0 +1,31 @@
interface CardRowProps {
label?: string
description?: string
children?: React.ReactNode
className?: string
onClick?: () => void
}
export default function CardRow({ label, description, children, className, onClick }: CardRowProps) {
return (
<div
className={`flex items-center justify-between gap-4 px-4 py-3 min-h-[44px] ${onClick ? 'cursor-pointer' : ''} ${className ?? ''}`}
style={{ borderBottom: '0.5px solid var(--color-separator)' }}
onClick={onClick}
>
<div className="flex flex-col min-w-0 flex-1">
{label && (
<span className="text-[13px]" style={{ color: 'var(--color-text-primary)' }}>
{label}
</span>
)}
{description && (
<span className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>
{description}
</span>
)}
</div>
{children && <div className="shrink-0 flex items-center">{children}</div>}
</div>
)
}

View File

@@ -0,0 +1,40 @@
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
label?: string
}
export default function Input({ label, className, style, ...props }: InputProps) {
const input = (
<input
className={`w-full rounded-[var(--radius-control)] text-[13px] outline-none transition-shadow ${className ?? ''}`}
style={{
height: 28,
padding: '0 8px',
backgroundColor: 'var(--color-input-bg)',
border: '0.5px solid var(--color-input-border)',
color: 'var(--color-text-primary)',
boxShadow: 'none',
...style,
}}
onFocus={e => {
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(246,131,48,0.3)'
e.currentTarget.style.borderColor = 'var(--color-accent)'
}}
onBlur={e => {
e.currentTarget.style.boxShadow = 'none'
e.currentTarget.style.borderColor = 'var(--color-input-border)'
}}
{...props}
/>
)
if (!label) return input
return (
<div>
<label className="block text-[11px] font-medium mb-1" style={{ color: 'var(--color-text-secondary)' }}>
{label}
</label>
{input}
</div>
)
}

View File

@@ -0,0 +1,46 @@
import Button from './Button'
interface ModalProps {
title: string
message: string
confirmLabel?: string
cancelLabel?: string
destructive?: boolean
loading?: boolean
onConfirm: () => void
onCancel: () => void
}
export default function Modal({ title, message, confirmLabel = 'Confirm', cancelLabel = 'Cancel', destructive, loading, onConfirm, onCancel }: ModalProps) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center" style={{ backgroundColor: 'rgba(0,0,0,0.4)' }}>
<div
className="max-w-sm w-full mx-4 p-5 rounded-[12px]"
style={{
backgroundColor: 'var(--color-bg-elevated)',
boxShadow: 'var(--shadow-elevated)',
}}
>
<h2 className="text-[15px] font-semibold mb-1" style={{ color: 'var(--color-text-primary)' }}>
{title}
</h2>
<p className="text-[13px] mb-5" style={{ color: 'var(--color-text-secondary)' }}>
{message}
</p>
<div className="flex gap-2 justify-end">
<Button variant="secondary" size="sm" onClick={onCancel}>
{cancelLabel}
</Button>
<Button
variant={destructive ? 'destructive' : 'primary'}
size="sm"
onClick={onConfirm}
disabled={loading}
>
{confirmLabel}
</Button>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,47 @@
interface SearchInputProps {
value: string
onChange: (value: string) => void
placeholder?: string
className?: string
}
export default function SearchInput({ value, onChange, placeholder = 'Search...', className }: SearchInputProps) {
return (
<div className={`relative ${className ?? ''}`}>
<svg
className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5"
style={{ color: 'var(--color-text-tertiary)' }}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<circle cx={11} cy={11} r={8} />
<path d="m21 21-4.3-4.3" />
</svg>
<input
className="w-full text-[13px] outline-none transition-shadow"
style={{
height: 28,
paddingLeft: 28,
paddingRight: 8,
backgroundColor: 'var(--color-control-bg)',
border: '0.5px solid transparent',
borderRadius: 999,
color: 'var(--color-text-primary)',
}}
placeholder={placeholder}
value={value}
onChange={e => onChange(e.target.value)}
onFocus={e => {
e.currentTarget.style.boxShadow = '0 0 0 3px rgba(246,131,48,0.3)'
e.currentTarget.style.borderColor = 'var(--color-accent)'
}}
onBlur={e => {
e.currentTarget.style.boxShadow = 'none'
e.currentTarget.style.borderColor = 'transparent'
}}
/>
</div>
)
}

View File

@@ -0,0 +1,34 @@
interface SegmentedControlProps<T extends string> {
options: { value: T; label: string }[]
value: T
onChange: (value: T) => void
className?: string
}
export default function SegmentedControl<T extends string>({ options, value, onChange, className }: SegmentedControlProps<T>) {
return (
<div
className={`inline-flex rounded-[8px] p-[3px] ${className ?? ''}`}
style={{ backgroundColor: 'var(--color-control-bg)' }}
>
{options.map(opt => {
const active = opt.value === value
return (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className="relative px-3 py-1 text-[12px] font-medium rounded-[6px] transition-all duration-200"
style={{
backgroundColor: active ? 'var(--color-bg-elevated)' : 'transparent',
color: active ? 'var(--color-text-primary)' : 'var(--color-text-secondary)',
boxShadow: active ? 'var(--shadow-segment)' : 'none',
minWidth: 64,
}}
>
{opt.label}
</button>
)
})}
</div>
)
}

View File

@@ -0,0 +1,34 @@
interface StatusBadgeProps {
status: 'connected' | 'disconnected' | 'connecting' | string
label?: string
}
function getStatusColors(status: string): { dot: string; text: string; bg: string } {
switch (status.toLowerCase()) {
case 'connected':
return { dot: 'var(--color-status-green)', text: 'var(--color-status-green)', bg: 'var(--color-status-green-bg)' }
case 'connecting':
return { dot: 'var(--color-status-yellow)', text: 'var(--color-status-yellow)', bg: 'var(--color-status-yellow-bg)' }
case 'disconnected':
return { dot: 'var(--color-status-gray)', text: 'var(--color-text-secondary)', bg: 'var(--color-status-gray-bg)' }
default:
return { dot: 'var(--color-status-red)', text: 'var(--color-status-red)', bg: 'var(--color-status-red-bg)' }
}
}
export default function StatusBadge({ status, label }: StatusBadgeProps) {
const colors = getStatusColors(status)
return (
<span
className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[11px] font-medium"
style={{ backgroundColor: colors.bg, color: colors.text }}
>
<span
className={`w-1.5 h-1.5 rounded-full ${status.toLowerCase() === 'connecting' ? 'animate-pulse' : ''}`}
style={{ backgroundColor: colors.dot }}
/>
{label ?? status}
</span>
)
}

View File

@@ -0,0 +1,72 @@
/* Table primitives for macOS System Settings style tables */
export function TableContainer({ children }: { children: React.ReactNode }) {
return (
<div
className="rounded-[var(--radius-card)] overflow-hidden"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
}}
>
{children}
</div>
)
}
export function TableHeader({ children }: { children: React.ReactNode }) {
return (
<thead>
<tr style={{ borderBottom: '0.5px solid var(--color-separator)' }}>
{children}
</tr>
</thead>
)
}
export function TableHeaderCell({ children, onClick, className }: { children: React.ReactNode; onClick?: () => void; className?: string }) {
return (
<th
className={`px-4 py-2.5 text-left text-[11px] font-semibold uppercase tracking-wide ${onClick ? 'cursor-pointer select-none' : ''} ${className ?? ''}`}
style={{ color: 'var(--color-text-tertiary)' }}
onClick={onClick}
>
{children}
</th>
)
}
export function TableRow({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<tr
className={`transition-colors group/row ${className ?? ''}`}
style={{ borderBottom: '0.5px solid var(--color-separator)' }}
onMouseEnter={e => (e.currentTarget.style.backgroundColor = 'var(--color-sidebar-hover)')}
onMouseLeave={e => (e.currentTarget.style.backgroundColor = 'transparent')}
>
{children}
</tr>
)
}
export function TableCell({ children, className }: { children: React.ReactNode; className?: string }) {
return (
<td className={`px-4 py-3 align-middle ${className ?? ''}`}>
{children}
</td>
)
}
export function TableFooter({ children }: { children: React.ReactNode }) {
return (
<div
className="px-4 py-2 text-[11px]"
style={{
borderTop: '0.5px solid var(--color-separator)',
color: 'var(--color-text-tertiary)',
}}
>
{children}
</div>
)
}

View File

@@ -0,0 +1,39 @@
interface ToggleProps {
checked: boolean
onChange: (value: boolean) => void
small?: boolean
disabled?: boolean
}
export default function Toggle({ checked, onChange, small, disabled }: ToggleProps) {
const w = small ? 30 : 38
const h = small ? 18 : 22
const thumb = small ? 14 : 18
const travel = w - thumb - 4
return (
<button
role="switch"
aria-checked={checked}
disabled={disabled}
onClick={() => onChange(!checked)}
className="relative inline-flex shrink-0 cursor-pointer items-center rounded-full transition-colors duration-200 disabled:cursor-not-allowed disabled:opacity-50"
style={{
width: w,
height: h,
backgroundColor: checked ? 'var(--color-accent)' : 'var(--color-control-bg)',
padding: 2,
}}
>
<span
className="block rounded-full bg-white transition-transform duration-200"
style={{
width: thumb,
height: thumb,
transform: `translateX(${checked ? travel : 0}px)`,
boxShadow: '0 1px 3px rgba(0,0,0,0.15), 0 0.5px 1px rgba(0,0,0,0.1)',
}}
/>
</button>
)
}

View File

@@ -0,0 +1,186 @@
@import "tailwindcss";
/* ── Light-mode tokens (default) ────────────────────────────────── */
:root {
--color-bg-primary: #ffffff;
--color-bg-secondary: #f5f5f7;
--color-bg-tertiary: #e8e8ed;
--color-bg-elevated: #ffffff;
--color-bg-sidebar: rgba(245, 245, 247, 0.8);
--color-sidebar-selected: rgba(0, 0, 0, 0.06);
--color-sidebar-hover: rgba(0, 0, 0, 0.04);
--color-text-primary: #1d1d1f;
--color-text-secondary: #6e6e73;
--color-text-tertiary: #86868b;
--color-text-quaternary: #aeaeb2;
--color-separator: rgba(0, 0, 0, 0.09);
--color-separator-heavy: rgba(0, 0, 0, 0.16);
--color-accent: #f68330;
--color-accent-hover: #e55311;
--color-status-green: #34c759;
--color-status-green-bg: rgba(52, 199, 89, 0.12);
--color-status-yellow: #ff9f0a;
--color-status-yellow-bg: rgba(255, 159, 10, 0.12);
--color-status-red: #ff3b30;
--color-status-red-bg: rgba(255, 59, 48, 0.12);
--color-status-gray: #8e8e93;
--color-status-gray-bg: rgba(142, 142, 147, 0.12);
--color-input-bg: #ffffff;
--color-input-border: rgba(0, 0, 0, 0.12);
--color-input-focus: var(--color-accent);
--color-control-bg: rgba(116, 116, 128, 0.08);
--shadow-card: 0 0.5px 1px rgba(0,0,0,0.05), 0 1px 3px rgba(0,0,0,0.08);
--shadow-elevated: 0 2px 8px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.08);
--shadow-segment: 0 1px 3px rgba(0,0,0,0.12), 0 0.5px 1px rgba(0,0,0,0.06);
--radius-card: 10px;
--radius-control: 6px;
--radius-sidebar-item: 7px;
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 16px;
--spacing-lg: 24px;
--spacing-xl: 32px;
color-scheme: light dark;
}
/* ── Dark-mode tokens ───────────────────────────────────────────── */
@media (prefers-color-scheme: dark) {
:root {
--color-bg-primary: #1c1c1e;
--color-bg-secondary: #2c2c2e;
--color-bg-tertiary: #3a3a3c;
--color-bg-elevated: #2c2c2e;
--color-bg-sidebar: rgba(44, 44, 46, 0.8);
--color-sidebar-selected: rgba(255, 255, 255, 0.08);
--color-sidebar-hover: rgba(255, 255, 255, 0.05);
--color-text-primary: #f5f5f7;
--color-text-secondary: #98989d;
--color-text-tertiary: #6e6e73;
--color-text-quaternary: #48484a;
--color-separator: rgba(255, 255, 255, 0.08);
--color-separator-heavy: rgba(255, 255, 255, 0.15);
--color-status-green: #30d158;
--color-status-green-bg: rgba(48, 209, 88, 0.15);
--color-status-yellow: #ffd60a;
--color-status-yellow-bg: rgba(255, 214, 10, 0.15);
--color-status-red: #ff453a;
--color-status-red-bg: rgba(255, 69, 58, 0.15);
--color-status-gray: #636366;
--color-status-gray-bg: rgba(99, 99, 102, 0.15);
--color-input-bg: rgba(255, 255, 255, 0.05);
--color-input-border: rgba(255, 255, 255, 0.1);
--color-control-bg: rgba(118, 118, 128, 0.24);
--shadow-card: 0 0.5px 1px rgba(0,0,0,0.2), 0 1px 3px rgba(0,0,0,0.3);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.3);
--shadow-segment: 0 1px 3px rgba(0,0,0,0.3), 0 0.5px 1px rgba(0,0,0,0.2);
}
}
/* Manual toggle for WebKitGTK fallback */
[data-theme="dark"] {
--color-bg-primary: #1c1c1e;
--color-bg-secondary: #2c2c2e;
--color-bg-tertiary: #3a3a3c;
--color-bg-elevated: #2c2c2e;
--color-bg-sidebar: rgba(44, 44, 46, 0.8);
--color-sidebar-selected: rgba(255, 255, 255, 0.08);
--color-sidebar-hover: rgba(255, 255, 255, 0.05);
--color-text-primary: #f5f5f7;
--color-text-secondary: #98989d;
--color-text-tertiary: #6e6e73;
--color-text-quaternary: #48484a;
--color-separator: rgba(255, 255, 255, 0.08);
--color-separator-heavy: rgba(255, 255, 255, 0.15);
--color-status-green: #30d158;
--color-status-green-bg: rgba(48, 209, 88, 0.15);
--color-status-yellow: #ffd60a;
--color-status-yellow-bg: rgba(255, 214, 10, 0.15);
--color-status-red: #ff453a;
--color-status-red-bg: rgba(255, 69, 58, 0.15);
--color-status-gray: #636366;
--color-status-gray-bg: rgba(99, 99, 102, 0.15);
--color-input-bg: rgba(255, 255, 255, 0.05);
--color-input-border: rgba(255, 255, 255, 0.1);
--color-control-bg: rgba(118, 118, 128, 0.24);
--shadow-card: 0 0.5px 1px rgba(0,0,0,0.2), 0 1px 3px rgba(0,0,0,0.3);
--shadow-elevated: 0 4px 16px rgba(0,0,0,0.4), 0 1px 4px rgba(0,0,0,0.3);
--shadow-segment: 0 1px 3px rgba(0,0,0,0.3), 0 0.5px 1px rgba(0,0,0,0.2);
}
@theme {
--color-netbird-50: #fff6ed;
--color-netbird-100: #feecd6;
--color-netbird-150: #ffdfb8;
--color-netbird-200: #ffd4a6;
--color-netbird-300: #fab677;
--color-netbird-400: #f68330;
--color-netbird-DEFAULT: #f68330;
--color-netbird-500: #f46d1b;
--color-netbird-600: #e55311;
--color-netbird-700: #be3e10;
--color-netbird-800: #973215;
--color-netbird-900: #7a2b14;
--color-netbird-950: #421308;
--font-sans: -apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif;
}
/* ── Base ────────────────────────────────────────────────────────── */
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: var(--font-sans);
font-size: 13px;
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
/* ── Scrollbar (macOS-like thin) ────────────────────────────────── */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--color-text-quaternary);
border-radius: 4px;
border: 2px solid transparent;
background-clip: content-box;
}
::-webkit-scrollbar-thumb:hover {
background: var(--color-text-tertiary);
background-clip: content-box;
}

View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,182 @@
import { useState } from 'react'
import { Call } from '@wailsio/runtime'
import type { DebugBundleParams, DebugBundleResult } from '../bindings'
import Card from '../components/ui/Card'
import CardRow from '../components/ui/CardRow'
import Toggle from '../components/ui/Toggle'
import Input from '../components/ui/Input'
import Button from '../components/ui/Button'
const DEFAULT_UPLOAD_URL = 'https://upload.netbird.io'
export default function Debug() {
const [anonymize, setAnonymize] = useState(false)
const [systemInfo, setSystemInfo] = useState(true)
const [upload, setUpload] = useState(true)
const [uploadUrl, setUploadUrl] = useState(DEFAULT_UPLOAD_URL)
const [runForDuration, setRunForDuration] = useState(true)
const [durationMins, setDurationMins] = useState(1)
const [running, setRunning] = useState(false)
const [progress, setProgress] = useState('')
const [result, setResult] = useState<DebugBundleResult | null>(null)
const [error, setError] = useState<string | null>(null)
async function handleCreate() {
if (upload && !uploadUrl) {
setError('Upload URL is required when upload is enabled')
return
}
setRunning(true)
setError(null)
setResult(null)
setProgress(runForDuration ? `Running with trace logs for ${durationMins} minute(s)\u2026` : 'Creating debug bundle\u2026')
const params: DebugBundleParams = {
anonymize,
systemInfo,
upload,
uploadUrl: upload ? uploadUrl : '',
runDurationMins: runForDuration ? durationMins : 0,
enablePersistence: true,
}
try {
console.log('[Debug] calling services.DebugService.CreateDebugBundle')
const res = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.DebugService.CreateDebugBundle', params) as DebugBundleResult
console.log('[Debug] CreateDebugBundle result:', JSON.stringify(res))
if (res) {
setResult(res)
setProgress('Bundle created successfully')
}
} catch (e) {
console.error('[Debug] CreateDebugBundle error:', e)
setError(String(e))
setProgress('')
} finally {
setRunning(false)
}
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-semibold mb-1" style={{ color: 'var(--color-text-primary)' }}>Debug</h1>
<p className="text-[13px] mb-6" style={{ color: 'var(--color-text-secondary)' }}>
Create a debug bundle to help troubleshoot issues with NetBird.
</p>
<Card label="OPTIONS" className="mb-5">
<CardRow label="Anonymize sensitive information">
<Toggle checked={anonymize} onChange={setAnonymize} />
</CardRow>
<CardRow label="Include system information">
<Toggle checked={systemInfo} onChange={setSystemInfo} />
</CardRow>
<CardRow label="Upload bundle automatically">
<Toggle checked={upload} onChange={setUpload} />
</CardRow>
</Card>
{upload && (
<Card label="UPLOAD" className="mb-5">
<CardRow label="Upload URL">
<Input
value={uploadUrl}
onChange={e => setUploadUrl(e.target.value)}
disabled={running}
style={{ width: 240 }}
/>
</CardRow>
</Card>
)}
<Card label="TRACE LOGGING" className="mb-5">
<CardRow label="Run with trace logs before creating bundle">
<Toggle checked={runForDuration} onChange={setRunForDuration} />
</CardRow>
{runForDuration && (
<CardRow label="Duration">
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
max={60}
value={durationMins}
onChange={e => setDurationMins(Math.max(1, parseInt(e.target.value) || 1))}
disabled={running}
style={{ width: 64, textAlign: 'center' }}
/>
<span className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
{durationMins === 1 ? 'minute' : 'minutes'}
</span>
</div>
</CardRow>
)}
{runForDuration && (
<div className="px-4 py-2 text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>
Note: NetBird will be brought up and down during collection.
</div>
)}
</Card>
{error && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{ backgroundColor: 'var(--color-status-red-bg)', color: 'var(--color-status-red)' }}
>
{error}
</div>
)}
{progress && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
color: running ? 'var(--color-status-yellow)' : 'var(--color-status-green)',
}}
>
<span className={running ? 'animate-pulse' : ''}>{progress}</span>
</div>
)}
{result && (
<Card className="mb-4">
<div className="px-4 py-3 space-y-2 text-[13px]">
{result.uploadedKey ? (
<>
<p style={{ color: 'var(--color-status-green)' }} className="font-medium">Bundle uploaded successfully!</p>
<div className="flex items-center gap-2">
<span style={{ color: 'var(--color-text-secondary)' }}>Upload key:</span>
<code
className="px-2 py-0.5 rounded text-[12px] font-mono"
style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
>
{result.uploadedKey}
</code>
</div>
</>
) : result.uploadFailureReason ? (
<p style={{ color: 'var(--color-status-yellow)' }}>Upload failed: {result.uploadFailureReason}</p>
) : null}
<div className="flex items-center gap-2">
<span style={{ color: 'var(--color-text-secondary)' }}>Local path:</span>
<code
className="px-2 py-0.5 rounded text-[12px] font-mono break-all"
style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
>
{result.localPath}
</code>
</div>
</div>
</Card>
)}
<Button onClick={handleCreate} disabled={running}>
{running ? 'Running\u2026' : 'Create Debug Bundle'}
</Button>
</div>
)
}

View File

@@ -0,0 +1,337 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Call } from '@wailsio/runtime'
import type { NetworkInfo } from '../bindings'
import SearchInput from '../components/ui/SearchInput'
import Button from '../components/ui/Button'
import Toggle from '../components/ui/Toggle'
import SegmentedControl from '../components/ui/SegmentedControl'
import { TableContainer, TableHeader, TableHeaderCell, TableRow, TableCell, TableFooter } from '../components/ui/Table'
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.NetworkService'
type Tab = 'all' | 'overlapping' | 'exit-node'
type SortKey = 'id' | 'range'
type SortDir = 'asc' | 'desc'
const tabOptions: { value: Tab; label: string }[] = [
{ value: 'all', label: 'All Networks' },
{ value: 'overlapping', label: 'Overlapping' },
{ value: 'exit-node', label: 'Exit Nodes' },
]
export default function Networks() {
const [networks, setNetworks] = useState<NetworkInfo[]>([])
const [tab, setTab] = useState<Tab>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('id')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
let method: string
if (tab === 'all') method = 'ListNetworks'
else if (tab === 'overlapping') method = 'ListOverlappingNetworks'
else method = 'ListExitNodes'
const data = await Call.ByName(`${SVC}.${method}`) as NetworkInfo[]
setNetworks(data ?? [])
} catch (e) {
console.error('[Networks] load error:', e)
setError(String(e))
} finally {
setLoading(false)
}
}, [tab])
useEffect(() => {
load()
const id = setInterval(load, 10000)
return () => clearInterval(id)
}, [load])
const filtered = useMemo(() => {
let list = networks
if (search) {
const q = search.toLowerCase()
list = list.filter(n =>
n.id.toLowerCase().includes(q) ||
n.range?.toLowerCase().includes(q) ||
n.domains?.some(d => d.toLowerCase().includes(q))
)
}
return [...list].sort((a, b) => {
const aVal = sortKey === 'id' ? a.id : (a.range ?? '')
const bVal = sortKey === 'id' ? b.id : (b.range ?? '')
const cmp = aVal.localeCompare(bVal)
return sortDir === 'asc' ? cmp : -cmp
})
}, [networks, search, sortKey, sortDir])
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
}
}
async function toggle(id: string, selected: boolean) {
try {
if (selected) await Call.ByName(`${SVC}.DeselectNetwork`, id)
else await Call.ByName(`${SVC}.SelectNetwork`, id)
await load()
} catch (e) {
setError(String(e))
}
}
async function selectAll() {
try {
await Call.ByName(`${SVC}.SelectAllNetworks`)
await load()
} catch (e) { setError(String(e)) }
}
async function deselectAll() {
try {
await Call.ByName(`${SVC}.DeselectAllNetworks`)
await load()
} catch (e) { setError(String(e)) }
}
const selectedCount = networks.filter(n => n.selected).length
return (
<div className="max-w-5xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Networks</h1>
<SegmentedControl options={tabOptions} value={tab} onChange={setTab} className="mb-5" />
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by name, range or domain..."
className="flex-1 max-w-sm"
/>
<div className="flex gap-2 ml-auto">
<Button variant="secondary" size="sm" onClick={selectAll}>Select All</Button>
<Button variant="secondary" size="sm" onClick={deselectAll}>Deselect All</Button>
<Button variant="secondary" size="sm" onClick={load}>Refresh</Button>
</div>
</div>
{error && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[12px]"
style={{ backgroundColor: 'var(--color-status-red-bg)', color: 'var(--color-status-red)' }}
>
{error}
</div>
)}
{selectedCount > 0 && (
<div className="mb-3 text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
{selectedCount} of {networks.length} network{networks.length !== 1 ? 's' : ''} selected
</div>
)}
{loading && networks.length === 0 ? (
<TableSkeleton />
) : filtered.length === 0 && networks.length === 0 ? (
<EmptyState tab={tab} />
) : filtered.length === 0 ? (
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
No networks match your search.
<button onClick={() => setSearch('')} className="ml-2 hover:underline" style={{ color: 'var(--color-accent)' }}>Clear search</button>
</div>
) : (
<TableContainer>
<table className="w-full text-[13px]">
<TableHeader>
<SortableHeader label="Network" sortKey="id" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="Range / Domains" sortKey="range" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<TableHeaderCell>Resolved IPs</TableHeaderCell>
<TableHeaderCell className="w-20">Active</TableHeaderCell>
</TableHeader>
<tbody>
{filtered.map(n => (
<NetworkRow key={n.id} network={n} onToggle={() => toggle(n.id, n.selected)} />
))}
</tbody>
</table>
<TableFooter>
Showing {filtered.length} of {networks.length} network{networks.length !== 1 ? 's' : ''}
</TableFooter>
</TableContainer>
)}
</div>
)
}
/* ---- Row ---- */
function NetworkRow({ network, onToggle }: { network: NetworkInfo; onToggle: () => void }) {
const domains = network.domains ?? []
const resolvedEntries = Object.entries(network.resolvedIPs ?? {})
const hasDomains = domains.length > 0
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-3 min-w-[180px]">
<NetworkSquare name={network.id} active={network.selected} />
<div className="flex flex-col">
<span className="font-medium text-[13px]" style={{ color: 'var(--color-text-primary)' }}>{network.id}</span>
{hasDomains && domains.length > 1 && (
<span className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>{domains.length} domains</span>
)}
</div>
</div>
</TableCell>
<TableCell>
{hasDomains ? (
<div className="flex flex-col gap-1">
{domains.slice(0, 2).map(d => (
<span key={d} className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{d}</span>
))}
{domains.length > 2 && (
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }} title={domains.join(', ')}>+{domains.length - 2} more</span>
)}
</div>
) : (
<span className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{network.range}</span>
)}
</TableCell>
<TableCell>
{resolvedEntries.length > 0 ? (
<div className="flex flex-col gap-1">
{resolvedEntries.slice(0, 2).map(([domain, ips]) => (
<span key={domain} className="font-mono text-[11px]" style={{ color: 'var(--color-text-tertiary)' }} title={`${domain}: ${ips.join(', ')}`}>
{ips[0]}{ips.length > 1 && <span style={{ color: 'var(--color-text-quaternary)' }}> +{ips.length - 1}</span>}
</span>
))}
{resolvedEntries.length > 2 && (
<span className="text-[11px]" style={{ color: 'var(--color-text-quaternary)' }}>+{resolvedEntries.length - 2} more</span>
)}
</div>
) : (
<span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)}
</TableCell>
<TableCell>
<Toggle checked={network.selected} onChange={onToggle} small />
</TableCell>
</TableRow>
)
}
/* ---- Network Icon Square ---- */
function NetworkSquare({ name, active }: { name: string; active: boolean }) {
const initials = name.substring(0, 2).toUpperCase()
return (
<div
className="relative h-10 w-10 shrink-0 rounded-[var(--radius-control)] flex items-center justify-center text-[13px] font-medium uppercase"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
color: 'var(--color-text-primary)',
}}
>
{initials}
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full"
style={{
backgroundColor: active ? 'var(--color-status-green)' : 'var(--color-status-gray)',
border: '2px solid var(--color-bg-secondary)',
}}
/>
</div>
)
}
/* ---- Sortable Header ---- */
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
label: string; sortKey: SortKey; currentKey: SortKey; dir: SortDir; onSort: (k: SortKey) => void
}) {
const isActive = currentKey === sortKey
return (
<TableHeaderCell onClick={() => onSort(sortKey)}>
<span className="inline-flex items-center gap-1">
{label}
{isActive && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{dir === 'asc' ? <path d="M5 15l7-7 7 7" /> : <path d="M19 9l-7 7-7-7" />}
</svg>
)}
</span>
</TableHeaderCell>
)
}
/* ---- Empty State ---- */
function EmptyState({ tab }: { tab: Tab }) {
const msg = tab === 'exit-node'
? 'No exit nodes configured.'
: tab === 'overlapping'
? 'No overlapping networks detected.'
: 'No networks found.'
return (
<div
className="rounded-[var(--radius-card)] py-16 flex flex-col items-center gap-3"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
}}
>
<div
className="h-12 w-12 rounded-[var(--radius-card)] flex items-center justify-center"
style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
>
<svg className="w-6 h-6" style={{ color: 'var(--color-text-tertiary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M12 21a9.004 9.004 0 008.716-6.747M12 21a9.004 9.004 0 01-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 017.843 4.582M12 3a8.997 8.997 0 00-7.843 4.582m15.686 0A11.953 11.953 0 0112 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0121 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0112 16.5a17.92 17.92 0 01-8.716-2.247m0 0A8.966 8.966 0 013 12c0-1.777.514-3.434 1.4-4.832" />
</svg>
</div>
<p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>{msg}</p>
</div>
)
}
/* ---- Loading Skeleton ---- */
function TableSkeleton() {
return (
<div
className="rounded-[var(--radius-card)] overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-secondary)', boxShadow: 'var(--shadow-card)' }}
>
<div className="h-11" style={{ backgroundColor: 'var(--color-bg-tertiary)', opacity: 0.5 }} />
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 px-4 py-4 animate-pulse"
style={{ borderBottom: '0.5px solid var(--color-separator)' }}
>
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-[var(--radius-control)]" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-24 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div>
<div className="h-4 w-32 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-20 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-6 w-12 rounded-full" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,334 @@
import { useState, useEffect, useCallback, useMemo } from 'react'
import { Call } from '@wailsio/runtime'
import type { PeerInfo } from '../bindings'
import SearchInput from '../components/ui/SearchInput'
import Button from '../components/ui/Button'
import StatusBadge from '../components/ui/StatusBadge'
import { TableContainer, TableHeader, TableHeaderCell, TableRow, TableCell, TableFooter } from '../components/ui/Table'
const SVC = 'github.com/netbirdio/netbird/client/uiwails/services.PeersService'
type SortKey = 'fqdn' | 'ip' | 'status' | 'latency'
type SortDir = 'asc' | 'desc'
function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i > 0 ? 1 : 0)} ${units[i]}`
}
function formatLatency(ms: number): string {
if (ms <= 0) return '\u2014'
if (ms < 1) return '<1 ms'
return `${ms.toFixed(1)} ms`
}
function peerName(p: PeerInfo): string {
if (p.fqdn) return p.fqdn.replace(/\.netbird\.cloud\.?$/, '')
return p.ip || p.pubKey.substring(0, 8)
}
export default function Peers() {
const [peers, setPeers] = useState<PeerInfo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [sortKey, setSortKey] = useState<SortKey>('fqdn')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await Call.ByName(`${SVC}.GetPeers`) as PeerInfo[]
setPeers(data ?? [])
} catch (e) {
console.error('[Peers] load error:', e)
setError(String(e))
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
load()
const id = setInterval(load, 10000)
return () => clearInterval(id)
}, [load])
const connectedCount = useMemo(() => peers.filter(p => p.connStatus === 'Connected').length, [peers])
const filtered = useMemo(() => {
let list = peers
if (search) {
const q = search.toLowerCase()
list = list.filter(p =>
peerName(p).toLowerCase().includes(q) ||
p.ip?.toLowerCase().includes(q) ||
p.connStatus?.toLowerCase().includes(q) ||
p.fqdn?.toLowerCase().includes(q)
)
}
return [...list].sort((a, b) => {
let cmp = 0
switch (sortKey) {
case 'fqdn': cmp = peerName(a).localeCompare(peerName(b)); break
case 'ip': cmp = (a.ip ?? '').localeCompare(b.ip ?? ''); break
case 'status': cmp = (a.connStatus ?? '').localeCompare(b.connStatus ?? ''); break
case 'latency': cmp = (a.latencyMs ?? 0) - (b.latencyMs ?? 0); break
}
return sortDir === 'asc' ? cmp : -cmp
})
}, [peers, search, sortKey, sortDir])
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc')
} else {
setSortKey(key)
setSortDir('asc')
}
}
return (
<div className="max-w-5xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Peers</h1>
{/* Toolbar */}
<div className="flex items-center gap-3 mb-4">
<SearchInput
value={search}
onChange={setSearch}
placeholder="Search by name, IP or status..."
className="flex-1 max-w-sm"
/>
<div className="flex gap-2 ml-auto">
<Button variant="secondary" size="sm" onClick={load}>Refresh</Button>
</div>
</div>
{error && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[12px]"
style={{ backgroundColor: 'var(--color-status-red-bg)', color: 'var(--color-status-red)' }}
>
{error}
</div>
)}
{peers.length > 0 && (
<div className="mb-3 text-[12px]" style={{ color: 'var(--color-text-tertiary)' }}>
{connectedCount} of {peers.length} peer{peers.length !== 1 ? 's' : ''} connected
</div>
)}
{loading && peers.length === 0 ? (
<TableSkeleton />
) : peers.length === 0 ? (
<EmptyState />
) : filtered.length === 0 ? (
<div className="py-12 text-center text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>
No peers match your search.
<button onClick={() => setSearch('')} className="ml-2 hover:underline" style={{ color: 'var(--color-accent)' }}>Clear search</button>
</div>
) : (
<TableContainer>
<table className="w-full text-[13px]">
<TableHeader>
<SortableHeader label="Peer" sortKey="fqdn" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="IP" sortKey="ip" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<SortableHeader label="Status" sortKey="status" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<TableHeaderCell>Connection</TableHeaderCell>
<SortableHeader label="Latency" sortKey="latency" currentKey={sortKey} dir={sortDir} onSort={toggleSort} />
<TableHeaderCell>Transfer</TableHeaderCell>
</TableHeader>
<tbody>
{filtered.map(p => (
<PeerRow key={p.pubKey} peer={p} />
))}
</tbody>
</table>
<TableFooter>
Showing {filtered.length} of {peers.length} peer{peers.length !== 1 ? 's' : ''}
</TableFooter>
</TableContainer>
)}
</div>
)
}
/* ---- Row ---- */
function PeerRow({ peer }: { peer: PeerInfo }) {
const name = peerName(peer)
const connected = peer.connStatus === 'Connected'
return (
<TableRow>
<TableCell>
<div className="flex items-center gap-3 min-w-[160px]">
<PeerSquare name={name} connected={connected} />
<div className="flex flex-col">
<span className="font-medium text-[13px] truncate max-w-[200px]" style={{ color: 'var(--color-text-primary)' }} title={peer.fqdn}>{name}</span>
{peer.networks && peer.networks.length > 0 && (
<span className="text-[11px] mt-0.5" style={{ color: 'var(--color-text-tertiary)' }}>{peer.networks.length} network{peer.networks.length !== 1 ? 's' : ''}</span>
)}
</div>
</div>
</TableCell>
<TableCell>
<span className="font-mono text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>{peer.ip || '\u2014'}</span>
</TableCell>
<TableCell>
<StatusBadge status={peer.connStatus} />
</TableCell>
<TableCell>
<div className="flex flex-col gap-0.5">
{connected ? (
<>
<span className="text-[12px]" style={{ color: 'var(--color-text-secondary)' }}>
{peer.relayed ? 'Relayed' : 'Direct'}{' '}
{peer.rosenpassEnabled && (
<span style={{ color: 'var(--color-status-green)' }} title="Rosenpass post-quantum security enabled">PQ</span>
)}
</span>
{peer.relayed && peer.relayAddress && (
<span className="text-[11px] font-mono" style={{ color: 'var(--color-text-tertiary)' }} title={peer.relayAddress}>
via {peer.relayAddress.length > 24 ? peer.relayAddress.substring(0, 24) + '...' : peer.relayAddress}
</span>
)}
{!peer.relayed && peer.localIceType && (
<span className="text-[11px]" style={{ color: 'var(--color-text-tertiary)' }}>{peer.localIceType} / {peer.remoteIceType}</span>
)}
</>
) : (
<span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)}
</div>
</TableCell>
<TableCell>
<span className="text-[13px]" style={{ color: peer.latencyMs > 0 ? 'var(--color-text-secondary)' : 'var(--color-text-quaternary)' }}>
{formatLatency(peer.latencyMs)}
</span>
</TableCell>
<TableCell>
{(peer.bytesRx > 0 || peer.bytesTx > 0) ? (
<div className="flex flex-col gap-0.5 text-[11px]">
<span style={{ color: 'var(--color-text-tertiary)' }}>
<span style={{ color: 'var(--color-status-green)' }} title="Received">&#8595;</span> {formatBytes(peer.bytesRx)}
</span>
<span style={{ color: 'var(--color-text-tertiary)' }}>
<span style={{ color: 'var(--color-accent)' }} title="Sent">&#8593;</span> {formatBytes(peer.bytesTx)}
</span>
</div>
) : (
<span style={{ color: 'var(--color-text-quaternary)' }}>{'\u2014'}</span>
)}
</TableCell>
</TableRow>
)
}
/* ---- Peer Icon Square ---- */
function PeerSquare({ name, connected }: { name: string; connected: boolean }) {
const initials = name.substring(0, 2).toUpperCase()
return (
<div
className="relative h-10 w-10 shrink-0 rounded-[var(--radius-control)] flex items-center justify-center text-[13px] font-medium uppercase"
style={{
backgroundColor: 'var(--color-bg-tertiary)',
color: 'var(--color-text-primary)',
}}
>
{initials}
<span
className="absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full"
style={{
backgroundColor: connected ? 'var(--color-status-green)' : 'var(--color-status-gray)',
border: '2px solid var(--color-bg-secondary)',
}}
/>
</div>
)
}
/* ---- Sortable Header ---- */
function SortableHeader({ label, sortKey, currentKey, dir, onSort }: {
label: string; sortKey: SortKey; currentKey: SortKey; dir: SortDir; onSort: (k: SortKey) => void
}) {
const isActive = currentKey === sortKey
return (
<TableHeaderCell onClick={() => onSort(sortKey)}>
<span className="inline-flex items-center gap-1">
{label}
{isActive && (
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
{dir === 'asc' ? <path d="M5 15l7-7 7 7" /> : <path d="M19 9l-7 7-7-7" />}
</svg>
)}
</span>
</TableHeaderCell>
)
}
/* ---- Empty State ---- */
function EmptyState() {
return (
<div
className="rounded-[var(--radius-card)] py-16 flex flex-col items-center gap-3"
style={{
backgroundColor: 'var(--color-bg-secondary)',
boxShadow: 'var(--shadow-card)',
}}
>
<div
className="h-12 w-12 rounded-[var(--radius-card)] flex items-center justify-center"
style={{ backgroundColor: 'var(--color-bg-tertiary)' }}
>
<svg className="w-6 h-6" style={{ color: 'var(--color-text-tertiary)' }} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
</svg>
</div>
<p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>No peers found. Connect to a network to see peers.</p>
</div>
)
}
/* ---- Loading Skeleton ---- */
function TableSkeleton() {
return (
<div
className="rounded-[var(--radius-card)] overflow-hidden"
style={{ backgroundColor: 'var(--color-bg-secondary)', boxShadow: 'var(--shadow-card)' }}
>
<div className="h-11" style={{ backgroundColor: 'var(--color-bg-tertiary)', opacity: 0.5 }} />
{Array.from({ length: 5 }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 px-4 py-4 animate-pulse"
style={{ borderBottom: '0.5px solid var(--color-separator)' }}
>
<div className="flex items-center gap-3 flex-1">
<div className="w-10 h-10 rounded-[var(--radius-control)]" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-28 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div>
<div className="h-4 w-24 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-5 w-20 rounded-full" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-14 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
<div className="h-4 w-16 rounded" style={{ backgroundColor: 'var(--color-bg-tertiary)' }} />
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,170 @@
import { useState, useEffect } from 'react'
import { Call } from '@wailsio/runtime'
import type { ProfileInfo } from '../bindings'
import Card from '../components/ui/Card'
import CardRow from '../components/ui/CardRow'
import Button from '../components/ui/Button'
import Input from '../components/ui/Input'
import Modal from '../components/ui/Modal'
export default function Profiles() {
const [profiles, setProfiles] = useState<ProfileInfo[]>([])
const [newName, setNewName] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [info, setInfo] = useState<string | null>(null)
const [confirm, setConfirm] = useState<{ action: string; profile: string } | null>(null)
async function refresh() {
try {
console.log('[Profiles] calling services.ProfileService.ListProfiles')
const data = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.ListProfiles') as ProfileInfo[]
console.log('[Profiles] ListProfiles returned', data?.length ?? 0, 'profiles')
setProfiles(data ?? [])
} catch (e) {
console.error('[Profiles] ListProfiles error:', e)
setError(String(e))
}
}
useEffect(() => { refresh() }, [])
function showInfo(msg: string) {
setInfo(msg)
setTimeout(() => setInfo(null), 3000)
}
async function handleConfirm() {
if (!confirm) return
setLoading(true)
setError(null)
try {
if (confirm.action === 'switch') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.SwitchProfile', confirm.profile)
else if (confirm.action === 'remove') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.RemoveProfile', confirm.profile)
else if (confirm.action === 'logout') await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.Logout', confirm.profile)
showInfo(`${confirm.action === 'switch' ? 'Switched to' : confirm.action === 'remove' ? 'Removed' : 'Deregistered from'} profile '${confirm.profile}'`)
await refresh()
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
setConfirm(null)
}
}
async function handleAdd() {
if (!newName.trim()) return
setLoading(true)
setError(null)
try {
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ProfileService.AddProfile', newName.trim())
showInfo(`Profile '${newName.trim()}' created`)
setNewName('')
await refresh()
} catch (e) {
setError(String(e))
} finally {
setLoading(false)
}
}
function confirmTitle(): string {
if (!confirm) return ''
if (confirm.action === 'switch') return 'Switch Profile'
if (confirm.action === 'remove') return 'Remove Profile'
return 'Deregister Profile'
}
function confirmMessage(): string {
if (!confirm) return ''
if (confirm.action === 'switch') return `Switch to profile '${confirm.profile}'?`
if (confirm.action === 'remove') return `Delete profile '${confirm.profile}'? This cannot be undone.`
return `Deregister from '${confirm.profile}'?`
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Profiles</h1>
{error && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{ backgroundColor: 'var(--color-status-red-bg)', color: 'var(--color-status-red)' }}
>
{error}
</div>
)}
{info && (
<div
className="mb-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{ backgroundColor: 'var(--color-status-green-bg)', color: 'var(--color-status-green)' }}
>
{info}
</div>
)}
{confirm && (
<Modal
title={confirmTitle()}
message={confirmMessage()}
destructive={confirm.action === 'remove'}
loading={loading}
onConfirm={handleConfirm}
onCancel={() => setConfirm(null)}
/>
)}
{/* Profile list */}
<Card label="PROFILES" className="mb-6">
{profiles.length === 0 ? (
<div className="p-4 text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>No profiles found.</div>
) : (
profiles.map(p => (
<CardRow key={p.name} label={p.name}>
<div className="flex items-center gap-2">
{p.isActive && (
<span
className="text-[11px] px-2 py-0.5 rounded-full font-medium"
style={{
backgroundColor: 'var(--color-status-green-bg)',
color: 'var(--color-status-green)',
}}
>
Active
</span>
)}
{!p.isActive && (
<Button variant="primary" size="sm" onClick={() => setConfirm({ action: 'switch', profile: p.name })}>
Select
</Button>
)}
<Button variant="secondary" size="sm" onClick={() => setConfirm({ action: 'logout', profile: p.name })}>
Deregister
</Button>
<Button variant="destructive" size="sm" onClick={() => setConfirm({ action: 'remove', profile: p.name })}>
Remove
</Button>
</div>
</CardRow>
))
)}
</Card>
{/* Add new profile */}
<Card label="ADD PROFILE">
<div className="flex items-center gap-3 px-4 py-3">
<Input
className="flex-1"
placeholder="New profile name"
value={newName}
onChange={e => setNewName(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAdd()}
/>
<Button onClick={handleAdd} disabled={!newName.trim() || loading} size="sm">
Add
</Button>
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,175 @@
import { useState, useEffect } from 'react'
import { Call } from '@wailsio/runtime'
import type { ConfigInfo } from '../bindings'
import Card from '../components/ui/Card'
import CardRow from '../components/ui/CardRow'
import Toggle from '../components/ui/Toggle'
import Input from '../components/ui/Input'
import Button from '../components/ui/Button'
import SegmentedControl from '../components/ui/SegmentedControl'
async function getConfig(): Promise<ConfigInfo | null> {
try {
console.log('[Settings] calling services.SettingsService.GetConfig')
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.SettingsService.GetConfig')
console.log('[Settings] GetConfig result:', JSON.stringify(result))
return result as ConfigInfo
} catch (e) {
console.error('[Settings] GetConfig error:', e)
return null
}
}
async function setConfig(cfg: ConfigInfo): Promise<void> {
console.log('[Settings] calling services.SettingsService.SetConfig')
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.SettingsService.SetConfig', cfg)
}
type Tab = 'connection' | 'network' | 'security'
const tabOptions: { value: Tab; label: string }[] = [
{ value: 'connection', label: 'Connection' },
{ value: 'network', label: 'Network' },
{ value: 'security', label: 'Security' },
]
export default function Settings() {
const [config, setConfigState] = useState<ConfigInfo | null>(null)
const [tab, setTab] = useState<Tab>('connection')
const [saving, setSaving] = useState(false)
const [saved, setSaved] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
getConfig().then(c => { if (c) setConfigState(c) })
}, [])
function update<K extends keyof ConfigInfo>(key: K, value: ConfigInfo[K]) {
setConfigState(prev => prev ? { ...prev, [key]: value } : prev)
}
async function handleSave() {
if (!config) return
setSaving(true)
setError(null)
setSaved(false)
try {
await setConfig(config)
setSaved(true)
setTimeout(() => setSaved(false), 2000)
} catch (e) {
setError(String(e))
} finally {
setSaving(false)
}
}
if (!config) {
return <div style={{ color: 'var(--color-text-secondary)' }}>Loading settings\u2026</div>
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Settings</h1>
<SegmentedControl options={tabOptions} value={tab} onChange={setTab} className="mb-6" />
{tab === 'connection' && (
<>
<Card label="SERVER CONFIGURATION" className="mb-5">
<CardRow label="Management URL">
<Input
value={config.managementUrl}
onChange={e => update('managementUrl', e.target.value)}
placeholder="https://api.netbird.io:443"
style={{ width: 240 }}
/>
</CardRow>
<CardRow label="Admin URL">
<Input
value={config.adminUrl}
onChange={e => update('adminUrl', e.target.value)}
style={{ width: 240 }}
/>
</CardRow>
<CardRow label="Pre-shared Key">
<Input
type="password"
value={config.preSharedKey}
onChange={e => update('preSharedKey', e.target.value)}
placeholder="Leave empty to clear"
style={{ width: 240 }}
/>
</CardRow>
</Card>
<Card label="BEHAVIOR" className="mb-5">
<CardRow label="Connect automatically">
<Toggle checked={!config.disableAutoConnect} onChange={v => update('disableAutoConnect', !v)} />
</CardRow>
<CardRow label="Enable notifications">
<Toggle checked={!config.disableNotifications} onChange={v => update('disableNotifications', !v)} />
</CardRow>
</Card>
</>
)}
{tab === 'network' && (
<>
<Card label="INTERFACE" className="mb-5">
<CardRow label="Interface Name">
<Input
value={config.interfaceName}
onChange={e => update('interfaceName', e.target.value)}
placeholder="netbird0"
style={{ width: 180 }}
/>
</CardRow>
<CardRow label="WireGuard Port">
<Input
type="number"
min={1}
max={65535}
value={config.wireguardPort}
onChange={e => update('wireguardPort', parseInt(e.target.value) || 0)}
placeholder="51820"
style={{ width: 100 }}
/>
</CardRow>
</Card>
<Card label="OPTIONS" className="mb-5">
<CardRow label="Lazy connections" description="Experimental">
<Toggle checked={config.lazyConnectionEnabled} onChange={v => update('lazyConnectionEnabled', v)} />
</CardRow>
<CardRow label="Block inbound connections">
<Toggle checked={config.blockInbound} onChange={v => update('blockInbound', v)} />
</CardRow>
</Card>
</>
)}
{tab === 'security' && (
<Card label="SECURITY" className="mb-5">
<CardRow label="Allow SSH connections">
<Toggle checked={config.serverSshAllowed} onChange={v => update('serverSshAllowed', v)} />
</CardRow>
<CardRow label="Rosenpass post-quantum security">
<Toggle checked={config.rosenpassEnabled} onChange={v => update('rosenpassEnabled', v)} />
</CardRow>
<CardRow label="Rosenpass permissive mode">
<Toggle checked={config.rosenpassPermissive} onChange={v => update('rosenpassPermissive', v)} />
</CardRow>
</Card>
)}
<div className="flex items-center gap-3">
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Saving\u2026' : 'Save'}
</Button>
{saved && <span className="text-[13px]" style={{ color: 'var(--color-status-green)' }}>Saved!</span>}
{error && <span className="text-[13px]" style={{ color: 'var(--color-status-red)' }}>{error}</span>}
</div>
</div>
)
}

View File

@@ -0,0 +1,164 @@
import { useState, useEffect, useCallback } from 'react'
import { Events, Call } from '@wailsio/runtime'
import type { StatusInfo } from '../bindings'
import Card from '../components/ui/Card'
import CardRow from '../components/ui/CardRow'
import Button from '../components/ui/Button'
async function getStatus(): Promise<StatusInfo | null> {
try {
console.log('[Dashboard] calling services.ConnectionService.GetStatus')
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.GetStatus')
console.log('[Dashboard] GetStatus result:', JSON.stringify(result))
return result as StatusInfo
} catch (e) {
console.error('[Dashboard] GetStatus error:', e)
return null
}
}
async function connect(): Promise<void> {
console.log('[Dashboard] calling services.ConnectionService.Connect')
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Connect')
}
async function disconnect(): Promise<void> {
console.log('[Dashboard] calling services.ConnectionService.Disconnect')
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.ConnectionService.Disconnect')
}
function statusDotColor(status: string): string {
switch (status) {
case 'Connected': return 'var(--color-status-green)'
case 'Connecting': return 'var(--color-status-yellow)'
case 'Disconnected': return 'var(--color-status-gray)'
default: return 'var(--color-status-red)'
}
}
function statusTextColor(status: string): string {
switch (status) {
case 'Connected': return 'var(--color-status-green)'
case 'Connecting': return 'var(--color-status-yellow)'
case 'Disconnected': return 'var(--color-text-secondary)'
default: return 'var(--color-status-red)'
}
}
export default function Status() {
const [status, setStatus] = useState<StatusInfo | null>(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState<string | null>(null)
const refresh = useCallback(async () => {
const s = await getStatus()
if (s) setStatus(s)
}, [])
useEffect(() => {
refresh()
const id = setInterval(refresh, 10000)
const unsub = Events.On('status-changed', (event: { data: StatusInfo[] }) => {
if (event.data[0]) setStatus(event.data[0])
})
return () => {
clearInterval(id)
if (typeof unsub === 'function') unsub()
}
}, [refresh])
async function handleConnect() {
setBusy(true)
setError(null)
try {
await connect()
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
async function handleDisconnect() {
setBusy(true)
setError(null)
try {
await disconnect()
await refresh()
} catch (e) {
setError(String(e))
} finally {
setBusy(false)
}
}
const isConnected = status?.status === 'Connected'
const isConnecting = status?.status === 'Connecting'
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-xl font-semibold mb-6" style={{ color: 'var(--color-text-primary)' }}>Status</h1>
{/* Status hero */}
<Card className="mb-6">
<div className="px-4 py-5">
<div className="flex items-center gap-3 mb-4">
<span
className={`w-3 h-3 rounded-full ${status?.status === 'Connecting' ? 'animate-pulse' : ''}`}
style={{ backgroundColor: status ? statusDotColor(status.status) : 'var(--color-status-gray)' }}
/>
<span
className="text-xl font-semibold"
style={{ color: status ? statusTextColor(status.status) : 'var(--color-text-secondary)' }}
>
{status?.status ?? 'Loading\u2026'}
</span>
</div>
</div>
{status?.ip && (
<CardRow label="IP Address">
<span className="font-mono text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>{status.ip}</span>
</CardRow>
)}
{status?.fqdn && (
<CardRow label="Hostname">
<span className="font-mono text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>{status.fqdn}</span>
</CardRow>
)}
{status && status.connectedPeers > 0 && (
<CardRow label="Connected Peers">
<span style={{ color: 'var(--color-text-secondary)' }}>{status.connectedPeers}</span>
</CardRow>
)}
</Card>
{/* Actions */}
<div className="flex gap-3">
{!isConnected && !isConnecting && (
<Button onClick={handleConnect} disabled={busy}>
{busy ? 'Connecting\u2026' : 'Connect'}
</Button>
)}
{(isConnected || isConnecting) && (
<Button variant="secondary" onClick={handleDisconnect} disabled={busy}>
{busy ? 'Disconnecting\u2026' : 'Disconnect'}
</Button>
)}
</div>
{error && (
<div
className="mt-4 p-3 rounded-[var(--radius-control)] text-[13px]"
style={{
backgroundColor: 'var(--color-status-red-bg)',
color: 'var(--color-status-red)',
}}
>
{error}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,106 @@
import { useState, useEffect, useRef } from 'react'
import { Call } from '@wailsio/runtime'
import type { InstallerResult } from '../bindings'
import Card from '../components/ui/Card'
import Button from '../components/ui/Button'
type UpdateState = 'idle' | 'triggering' | 'polling' | 'success' | 'failed' | 'timeout'
export default function Update() {
const [state, setState] = useState<UpdateState>('idle')
const [dots, setDots] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
if (state !== 'polling') return
let count = 0
const id = setInterval(() => {
count = (count + 1) % 4
setDots('.'.repeat(count))
}, 500)
return () => clearInterval(id)
}, [state])
async function handleTriggerUpdate() {
abortRef.current?.abort()
abortRef.current = new AbortController()
setState('triggering')
setErrorMsg('')
try {
console.log('[Update] calling services.UpdateService.TriggerUpdate')
await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.TriggerUpdate')
} catch (e) {
console.error('[Update] TriggerUpdate error:', e)
setErrorMsg(String(e))
setState('failed')
return
}
setState('polling')
try {
console.log('[Update] calling services.UpdateService.GetInstallerResult')
const result = await Call.ByName('github.com/netbirdio/netbird/client/uiwails/services.UpdateService.GetInstallerResult') as InstallerResult
console.log('[Update] GetInstallerResult:', JSON.stringify(result))
if (result?.success) {
setState('success')
} else {
setErrorMsg(result?.errorMsg ?? 'Update failed')
setState('failed')
}
} catch {
setState('success')
}
}
return (
<div className="max-w-lg mx-auto">
<h1 className="text-xl font-semibold mb-1" style={{ color: 'var(--color-text-primary)' }}>Update</h1>
<p className="text-[13px] mb-8" style={{ color: 'var(--color-text-secondary)' }}>
Trigger an automatic client update managed by the NetBird daemon.
</p>
<Card>
<div className="px-6 py-8 text-center">
{state === 'idle' && (
<>
<p className="text-[13px] mb-5" style={{ color: 'var(--color-text-secondary)' }}>Click below to trigger a daemon-managed update.</p>
<Button onClick={handleTriggerUpdate}>Trigger Update</Button>
</>
)}
{state === 'triggering' && (
<p className="animate-pulse text-[15px]" style={{ color: 'var(--color-status-yellow)' }}>Triggering update\u2026</p>
)}
{state === 'polling' && (
<div>
<p className="text-[17px] mb-2" style={{ color: 'var(--color-status-yellow)' }}>Updating{dots}</p>
<p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>The daemon is installing the update. Please wait.</p>
</div>
)}
{state === 'success' && (
<div>
<p className="text-[17px] font-semibold mb-2" style={{ color: 'var(--color-status-green)' }}>Update Successful!</p>
<p className="text-[13px]" style={{ color: 'var(--color-text-secondary)' }}>The client has been updated. You may need to restart.</p>
</div>
)}
{state === 'failed' && (
<div>
<p className="text-[17px] font-semibold mb-2" style={{ color: 'var(--color-status-red)' }}>Update Failed</p>
{errorMsg && <p className="text-[13px] mb-4" style={{ color: 'var(--color-text-secondary)' }}>{errorMsg}</p>}
<Button variant="secondary" onClick={() => { setState('idle'); setErrorMsg('') }}>
Try Again
</Button>
</div>
)}
</div>
</Card>
</div>
)
}

View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

View File

@@ -0,0 +1,15 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
react(),
tailwindcss(),
],
build: {
outDir: 'dist',
emptyOutDir: true,
},
})

84
client/uiwails/grpc.go Normal file
View File

@@ -0,0 +1,84 @@
//go:build !(linux && 386)
package main
import (
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"github.com/netbirdio/netbird/client/proto"
"github.com/netbirdio/netbird/version"
)
const (
defaultFailTimeout = 3 * time.Second
failFastTimeout = time.Second
)
// GRPCClient manages a single persistent gRPC connection to the NetBird daemon.
type GRPCClient struct {
addr string
mu sync.Mutex
conn *grpc.ClientConn
client proto.DaemonServiceClient
}
// NewGRPCClient creates a new GRPCClient for the given daemon address.
func NewGRPCClient(addr string) *GRPCClient {
return &GRPCClient{addr: addr}
}
// GetClient returns a cached DaemonServiceClient, creating the connection on first use.
func (g *GRPCClient) GetClient(timeout time.Duration) (proto.DaemonServiceClient, error) {
g.mu.Lock()
defer g.mu.Unlock()
if g.client != nil {
return g.client, nil
}
target := g.addr
if strings.HasPrefix(target, "tcp://") {
target = strings.TrimPrefix(target, "tcp://")
} else if strings.HasPrefix(target, "unix://") {
target = "unix:" + strings.TrimPrefix(target, "unix://")
}
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithUserAgent(getUIUserAgent()),
)
if err != nil {
return nil, err
}
g.conn = conn
g.client = proto.NewDaemonServiceClient(conn)
log.Debugf("gRPC connection established to %s", g.addr)
return g.client, nil
}
// Close closes the underlying gRPC connection.
func (g *GRPCClient) Close() error {
g.mu.Lock()
defer g.mu.Unlock()
if g.conn != nil {
err := g.conn.Close()
g.conn = nil
g.client = nil
return err
}
return nil
}
func getUIUserAgent() string {
return "netbird-fancyui/" + version.NetbirdVersion()
}

Some files were not shown because too many files have changed in this diff Show More