Compare commits
1 Commits
prototype/
...
test/perft
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
920fe73096 |
37
.github/workflows/golang-test-linux.yml
vendored
@@ -409,19 +409,12 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Login to Docker hub
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
uses: docker/login-action@v3
|
||||
if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref)
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: docker login for root user
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
||||
|
||||
- name: download mysql image
|
||||
if: matrix.store == 'mysql'
|
||||
run: docker pull mlsmaycon/warmed-mysql:8
|
||||
@@ -504,18 +497,15 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Login to Docker hub
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
uses: docker/login-action@v3
|
||||
if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref)
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: docker login for root user
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
||||
- name: download mysql image
|
||||
if: matrix.store == 'mysql'
|
||||
run: docker pull mlsmaycon/warmed-mysql:8
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
@@ -596,18 +586,15 @@ jobs:
|
||||
run: git --no-pager diff --exit-code
|
||||
|
||||
- name: Login to Docker hub
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
uses: docker/login-action@v3
|
||||
if: matrix.store == 'mysql' && (github.repository == github.head.repo.full_name || !github.head_ref)
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USER }}
|
||||
password: ${{ secrets.DOCKER_TOKEN }}
|
||||
|
||||
- name: docker login for root user
|
||||
if: github.event.pull_request && github.event.pull_request.head.repo && github.event.pull_request.head.repo.full_name == '' || github.repository == github.event.pull_request.head.repo.full_name || !github.head_ref
|
||||
env:
|
||||
DOCKER_USER: ${{ secrets.DOCKER_USER }}
|
||||
DOCKER_TOKEN: ${{ secrets.DOCKER_TOKEN }}
|
||||
run: echo "$DOCKER_TOKEN" | sudo docker login --username "$DOCKER_USER" --password-stdin
|
||||
- name: download mysql image
|
||||
if: matrix.store == 'mysql'
|
||||
run: docker pull mlsmaycon/warmed-mysql:8
|
||||
|
||||
- name: Test
|
||||
run: |
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,6 @@ 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"
|
||||
)
|
||||
|
||||
@@ -81,15 +80,6 @@ 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
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -154,7 +144,6 @@ func init() {
|
||||
rootCmd.AddCommand(forwardingRulesCmd)
|
||||
rootCmd.AddCommand(debugCmd)
|
||||
rootCmd.AddCommand(profileCmd)
|
||||
rootCmd.AddCommand(exposeCmd)
|
||||
|
||||
networksCMD.AddCommand(routesListCmd)
|
||||
networksCMD.AddCommand(routesSelectCmd, routesDeselectCmd)
|
||||
@@ -396,6 +385,7 @@ 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)
|
||||
@@ -408,13 +398,3 @@ 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
|
||||
}
|
||||
|
||||
@@ -358,9 +358,9 @@ func incrementalUpdate(oldChecksum uint16, oldBytes, newBytes []byte) uint16 {
|
||||
// Fast path for IPv4 addresses (4 bytes) - most common case
|
||||
if len(oldBytes) == 4 && len(newBytes) == 4 {
|
||||
sum += uint32(^binary.BigEndian.Uint16(oldBytes[0:2]))
|
||||
sum += uint32(^binary.BigEndian.Uint16(oldBytes[2:4])) //nolint:gosec // length checked above
|
||||
sum += uint32(^binary.BigEndian.Uint16(oldBytes[2:4]))
|
||||
sum += uint32(binary.BigEndian.Uint16(newBytes[0:2]))
|
||||
sum += uint32(binary.BigEndian.Uint16(newBytes[2:4])) //nolint:gosec // length checked above
|
||||
sum += uint32(binary.BigEndian.Uint16(newBytes[2:4]))
|
||||
} else {
|
||||
// Fallback for other lengths
|
||||
for i := 0; i < len(oldBytes)-1; i += 2 {
|
||||
|
||||
@@ -5,18 +5,20 @@ 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 {
|
||||
_ = uapiSock.Close()
|
||||
log.Errorf("failed to listen on uapi socket: %v", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
|
||||
@@ -54,14 +54,6 @@ 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)
|
||||
|
||||
@@ -79,7 +79,7 @@ func (t *TunNetstackDevice) create() (WGConfigurer, error) {
|
||||
device.NewLogger(wgLogLevel(), "[netbird] "),
|
||||
)
|
||||
|
||||
t.configurer = configurer.NewUSPConfigurerNoUAPI(t.device, t.name, t.bind.ActivityRecorder())
|
||||
t.configurer = configurer.NewUSPConfigurer(t.device, t.name, t.bind.ActivityRecorder())
|
||||
err = t.configurer.ConfigureInterface(t.key, t.port)
|
||||
if err != nil {
|
||||
if cErr := tunIface.Close(); cErr != nil {
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
//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
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
//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
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
//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)
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ import (
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/hashicorp/go-multierror"
|
||||
nberrors "github.com/netbirdio/netbird/client/errors"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"golang.org/x/exp/maps"
|
||||
|
||||
@@ -24,7 +22,6 @@ import (
|
||||
|
||||
const (
|
||||
netbirdDNSStateKeyFormat = "State:/Network/Service/NetBird-%s/DNS"
|
||||
netbirdDNSStateKeyIndexedFormat = "State:/Network/Service/NetBird-%s-%d/DNS"
|
||||
globalIPv4State = "State:/Network/Global/IPv4"
|
||||
primaryServiceStateKeyFormat = "State:/Network/Service/%s/DNS"
|
||||
keySupplementalMatchDomains = "SupplementalMatchDomains"
|
||||
@@ -38,14 +35,6 @@ const (
|
||||
searchSuffix = "Search"
|
||||
matchSuffix = "Match"
|
||||
localSuffix = "Local"
|
||||
|
||||
// maxDomainsPerResolverEntry is the max number of domains per scutil resolver key.
|
||||
// scutil's d.add has maxArgs=101 (key + * + 99 values), so 99 is the hard cap.
|
||||
maxDomainsPerResolverEntry = 50
|
||||
|
||||
// maxDomainBytesPerResolverEntry is the max total bytes of domain strings per key.
|
||||
// scutil has an undocumented ~2048 byte value buffer; we stay well under it.
|
||||
maxDomainBytesPerResolverEntry = 1500
|
||||
)
|
||||
|
||||
type systemConfigurator struct {
|
||||
@@ -95,23 +84,28 @@ func (s *systemConfigurator) applyDNSConfig(config HostDNSConfig, stateManager *
|
||||
searchDomains = append(searchDomains, strings.TrimSuffix(""+dConf.Domain, "."))
|
||||
}
|
||||
|
||||
if err := s.removeKeysContaining(matchSuffix); err != nil {
|
||||
log.Warnf("failed to remove old match keys: %v", err)
|
||||
}
|
||||
matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)
|
||||
var err error
|
||||
if len(matchDomains) != 0 {
|
||||
if err := s.addBatchedDomains(matchSuffix, matchDomains, config.ServerIP, config.ServerPort, false); err != nil {
|
||||
return fmt.Errorf("add match domains: %w", err)
|
||||
}
|
||||
err = s.addMatchDomains(matchKey, strings.Join(matchDomains, " "), config.ServerIP, config.ServerPort)
|
||||
} else {
|
||||
log.Infof("removing match domains from the system")
|
||||
err = s.removeKeyFromSystemConfig(matchKey)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("add match domains: %w", err)
|
||||
}
|
||||
s.updateState(stateManager)
|
||||
|
||||
if err := s.removeKeysContaining(searchSuffix); err != nil {
|
||||
log.Warnf("failed to remove old search keys: %v", err)
|
||||
}
|
||||
searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix)
|
||||
if len(searchDomains) != 0 {
|
||||
if err := s.addBatchedDomains(searchSuffix, searchDomains, config.ServerIP, config.ServerPort, true); err != nil {
|
||||
return fmt.Errorf("add search domains: %w", err)
|
||||
}
|
||||
err = s.addSearchDomains(searchKey, strings.Join(searchDomains, " "), config.ServerIP, config.ServerPort)
|
||||
} else {
|
||||
log.Infof("removing search domains from the system")
|
||||
err = s.removeKeyFromSystemConfig(searchKey)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("add search domains: %w", err)
|
||||
}
|
||||
s.updateState(stateManager)
|
||||
|
||||
@@ -155,7 +149,8 @@ func (s *systemConfigurator) restoreHostDNS() error {
|
||||
|
||||
func (s *systemConfigurator) getRemovableKeysWithDefaults() []string {
|
||||
if len(s.createdKeys) == 0 {
|
||||
return s.discoverExistingKeys()
|
||||
// return defaults for startup calls
|
||||
return []string{getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix), getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(s.createdKeys))
|
||||
@@ -165,47 +160,6 @@ func (s *systemConfigurator) getRemovableKeysWithDefaults() []string {
|
||||
return keys
|
||||
}
|
||||
|
||||
// discoverExistingKeys probes scutil for all NetBird DNS keys that may exist.
|
||||
// This handles the case where createdKeys is empty (e.g., state file lost after unclean shutdown).
|
||||
func (s *systemConfigurator) discoverExistingKeys() []string {
|
||||
dnsKeys, err := getSystemDNSKeys()
|
||||
if err != nil {
|
||||
log.Errorf("failed to get system DNS keys: %v", err)
|
||||
return nil
|
||||
}
|
||||
|
||||
var keys []string
|
||||
|
||||
for _, suffix := range []string{searchSuffix, matchSuffix, localSuffix} {
|
||||
key := getKeyWithInput(netbirdDNSStateKeyFormat, suffix)
|
||||
if strings.Contains(dnsKeys, key) {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
for _, suffix := range []string{searchSuffix, matchSuffix} {
|
||||
for i := 0; ; i++ {
|
||||
key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, suffix, i)
|
||||
if !strings.Contains(dnsKeys, key) {
|
||||
break
|
||||
}
|
||||
keys = append(keys, key)
|
||||
}
|
||||
}
|
||||
|
||||
return keys
|
||||
}
|
||||
|
||||
// getSystemDNSKeys gets all DNS keys
|
||||
func getSystemDNSKeys() (string, error) {
|
||||
command := "list .*DNS\nquit\n"
|
||||
out, err := runSystemConfigCommand(command)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(out), nil
|
||||
}
|
||||
|
||||
func (s *systemConfigurator) removeKeyFromSystemConfig(key string) error {
|
||||
line := buildRemoveKeyOperation(key)
|
||||
_, err := runSystemConfigCommand(wrapCommand(line))
|
||||
@@ -230,11 +184,12 @@ func (s *systemConfigurator) addLocalDNS() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
domainsStr := strings.Join(s.systemDNSSettings.Domains, " ")
|
||||
if err := s.addDNSState(localKey, domainsStr, s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort, true); err != nil {
|
||||
return fmt.Errorf("add local dns state: %w", err)
|
||||
if err := s.addSearchDomains(
|
||||
localKey,
|
||||
strings.Join(s.systemDNSSettings.Domains, " "), s.systemDNSSettings.ServerIP, s.systemDNSSettings.ServerPort,
|
||||
); err != nil {
|
||||
return fmt.Errorf("add search domains: %w", err)
|
||||
}
|
||||
s.createdKeys[localKey] = struct{}{}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -325,77 +280,28 @@ func (s *systemConfigurator) getOriginalNameservers() []netip.Addr {
|
||||
return slices.Clone(s.origNameservers)
|
||||
}
|
||||
|
||||
// splitDomainsIntoBatches splits domains into batches respecting both element count and byte size limits.
|
||||
func splitDomainsIntoBatches(domains []string) [][]string {
|
||||
if len(domains) == 0 {
|
||||
return nil
|
||||
func (s *systemConfigurator) addSearchDomains(key, domains string, ip netip.Addr, port int) error {
|
||||
err := s.addDNSState(key, domains, ip, port, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add dns state: %w", err)
|
||||
}
|
||||
|
||||
var batches [][]string
|
||||
var current []string
|
||||
currentBytes := 0
|
||||
log.Infof("added %d search domains to the state. Domain list: %s", len(strings.Split(domains, " ")), domains)
|
||||
|
||||
for _, d := range domains {
|
||||
domainLen := len(d)
|
||||
newBytes := currentBytes + domainLen
|
||||
if currentBytes > 0 {
|
||||
newBytes++ // space separator
|
||||
}
|
||||
s.createdKeys[key] = struct{}{}
|
||||
|
||||
if len(current) > 0 && (len(current) >= maxDomainsPerResolverEntry || newBytes > maxDomainBytesPerResolverEntry) {
|
||||
batches = append(batches, current)
|
||||
current = nil
|
||||
currentBytes = 0
|
||||
}
|
||||
|
||||
current = append(current, d)
|
||||
if currentBytes > 0 {
|
||||
currentBytes += 1 + domainLen
|
||||
} else {
|
||||
currentBytes = domainLen
|
||||
}
|
||||
}
|
||||
|
||||
if len(current) > 0 {
|
||||
batches = append(batches, current)
|
||||
}
|
||||
|
||||
return batches
|
||||
return nil
|
||||
}
|
||||
|
||||
// removeKeysContaining removes all created keys that contain the given substring.
|
||||
func (s *systemConfigurator) removeKeysContaining(suffix string) error {
|
||||
var toRemove []string
|
||||
for key := range s.createdKeys {
|
||||
if strings.Contains(key, suffix) {
|
||||
toRemove = append(toRemove, key)
|
||||
}
|
||||
}
|
||||
var multiErr *multierror.Error
|
||||
for _, key := range toRemove {
|
||||
if err := s.removeKeyFromSystemConfig(key); err != nil {
|
||||
multiErr = multierror.Append(multiErr, fmt.Errorf("couldn't remove key %s: %w", key, err))
|
||||
}
|
||||
}
|
||||
return nberrors.FormatErrorOrNil(multiErr)
|
||||
}
|
||||
|
||||
// addBatchedDomains splits domains into batches and creates indexed scutil keys for each batch.
|
||||
func (s *systemConfigurator) addBatchedDomains(suffix string, domains []string, ip netip.Addr, port int, enableSearch bool) error {
|
||||
batches := splitDomainsIntoBatches(domains)
|
||||
|
||||
for i, batch := range batches {
|
||||
key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, suffix, i)
|
||||
domainsStr := strings.Join(batch, " ")
|
||||
|
||||
if err := s.addDNSState(key, domainsStr, ip, port, enableSearch); err != nil {
|
||||
return fmt.Errorf("add dns state for batch %d: %w", i, err)
|
||||
}
|
||||
|
||||
s.createdKeys[key] = struct{}{}
|
||||
func (s *systemConfigurator) addMatchDomains(key, domains string, dnsServer netip.Addr, port int) error {
|
||||
err := s.addDNSState(key, domains, dnsServer, port, false)
|
||||
if err != nil {
|
||||
return fmt.Errorf("add dns state: %w", err)
|
||||
}
|
||||
|
||||
log.Infof("added %d %s domains across %d resolver entries", len(domains), suffix, len(batches))
|
||||
log.Infof("added %d match domains to the state. Domain list: %s", len(strings.Split(domains, " ")), domains)
|
||||
|
||||
s.createdKeys[key] = struct{}{}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -458,6 +364,7 @@ func (s *systemConfigurator) flushDNSCache() error {
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return fmt.Errorf("restart mDNSResponder: %w, output: %s", err, out)
|
||||
}
|
||||
|
||||
log.Info("flushed DNS cache")
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3,10 +3,7 @@
|
||||
package dns
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
@@ -52,22 +49,17 @@ func TestDarwinDNSUncleanShutdownCleanup(t *testing.T) {
|
||||
|
||||
require.NoError(t, sm.PersistState(context.Background()))
|
||||
|
||||
searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix)
|
||||
matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)
|
||||
localKey := getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix)
|
||||
|
||||
// Collect all created keys for cleanup verification
|
||||
createdKeys := make([]string, 0, len(configurator.createdKeys))
|
||||
for key := range configurator.createdKeys {
|
||||
createdKeys = append(createdKeys, key)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for _, key := range createdKeys {
|
||||
for _, key := range []string{searchKey, matchKey, localKey} {
|
||||
_ = removeTestDNSKey(key)
|
||||
}
|
||||
_ = removeTestDNSKey(localKey)
|
||||
}()
|
||||
|
||||
for _, key := range createdKeys {
|
||||
for _, key := range []string{searchKey, matchKey, localKey} {
|
||||
exists, err := checkDNSKeyExists(key)
|
||||
require.NoError(t, err)
|
||||
if exists {
|
||||
@@ -91,223 +83,13 @@ func TestDarwinDNSUncleanShutdownCleanup(t *testing.T) {
|
||||
err = shutdownState.Cleanup()
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, key := range createdKeys {
|
||||
for _, key := range []string{searchKey, matchKey, localKey} {
|
||||
exists, err := checkDNSKeyExists(key)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists, "Key %s should NOT exist after cleanup", key)
|
||||
}
|
||||
}
|
||||
|
||||
// generateShortDomains generates domains like a.com, b.com, ..., aa.com, ab.com, etc.
|
||||
func generateShortDomains(count int) []string {
|
||||
domains := make([]string, 0, count)
|
||||
for i := range count {
|
||||
label := ""
|
||||
n := i
|
||||
for {
|
||||
label = string(rune('a'+n%26)) + label
|
||||
n = n/26 - 1
|
||||
if n < 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
domains = append(domains, label+".com")
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
// generateLongDomains generates domains like subdomain-000.department.organization-name.example.com
|
||||
func generateLongDomains(count int) []string {
|
||||
domains := make([]string, 0, count)
|
||||
for i := range count {
|
||||
domains = append(domains, fmt.Sprintf("subdomain-%03d.department.organization-name.example.com", i))
|
||||
}
|
||||
return domains
|
||||
}
|
||||
|
||||
// readDomainsFromKey reads the SupplementalMatchDomains array back from scutil for a given key.
|
||||
func readDomainsFromKey(t *testing.T, key string) []string {
|
||||
t.Helper()
|
||||
|
||||
cmd := exec.Command(scutilPath)
|
||||
cmd.Stdin = strings.NewReader(fmt.Sprintf("open\nshow %s\nquit\n", key))
|
||||
out, err := cmd.Output()
|
||||
require.NoError(t, err, "scutil show should succeed")
|
||||
|
||||
var domains []string
|
||||
inArray := false
|
||||
scanner := bufio.NewScanner(bytes.NewReader(out))
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if strings.HasPrefix(line, "SupplementalMatchDomains") && strings.Contains(line, "<array>") {
|
||||
inArray = true
|
||||
continue
|
||||
}
|
||||
if inArray {
|
||||
if line == "}" {
|
||||
break
|
||||
}
|
||||
// lines look like: "0 : a.com"
|
||||
parts := strings.SplitN(line, " : ", 2)
|
||||
if len(parts) == 2 {
|
||||
domains = append(domains, parts[1])
|
||||
}
|
||||
}
|
||||
}
|
||||
require.NoError(t, scanner.Err())
|
||||
return domains
|
||||
}
|
||||
|
||||
func TestSplitDomainsIntoBatches(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
domains []string
|
||||
expectedCount int
|
||||
checkAllPresent bool
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
domains: nil,
|
||||
expectedCount: 0,
|
||||
},
|
||||
{
|
||||
name: "under_limit",
|
||||
domains: generateShortDomains(10),
|
||||
expectedCount: 1,
|
||||
checkAllPresent: true,
|
||||
},
|
||||
{
|
||||
name: "at_element_limit",
|
||||
domains: generateShortDomains(50),
|
||||
expectedCount: 1,
|
||||
checkAllPresent: true,
|
||||
},
|
||||
{
|
||||
name: "over_element_limit",
|
||||
domains: generateShortDomains(51),
|
||||
expectedCount: 2,
|
||||
checkAllPresent: true,
|
||||
},
|
||||
{
|
||||
name: "triple_element_limit",
|
||||
domains: generateShortDomains(150),
|
||||
expectedCount: 3,
|
||||
checkAllPresent: true,
|
||||
},
|
||||
{
|
||||
name: "long_domains_hit_byte_limit",
|
||||
domains: generateLongDomains(50),
|
||||
checkAllPresent: true,
|
||||
},
|
||||
{
|
||||
name: "500_short_domains",
|
||||
domains: generateShortDomains(500),
|
||||
expectedCount: 10,
|
||||
checkAllPresent: true,
|
||||
},
|
||||
{
|
||||
name: "500_long_domains",
|
||||
domains: generateLongDomains(500),
|
||||
checkAllPresent: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
batches := splitDomainsIntoBatches(tc.domains)
|
||||
|
||||
if tc.expectedCount > 0 {
|
||||
assert.Len(t, batches, tc.expectedCount, "expected %d batches", tc.expectedCount)
|
||||
}
|
||||
|
||||
// Verify each batch respects limits
|
||||
for i, batch := range batches {
|
||||
assert.LessOrEqual(t, len(batch), maxDomainsPerResolverEntry,
|
||||
"batch %d exceeds element limit", i)
|
||||
|
||||
totalBytes := 0
|
||||
for j, d := range batch {
|
||||
if j > 0 {
|
||||
totalBytes++
|
||||
}
|
||||
totalBytes += len(d)
|
||||
}
|
||||
assert.LessOrEqual(t, totalBytes, maxDomainBytesPerResolverEntry,
|
||||
"batch %d exceeds byte limit (%d bytes)", i, totalBytes)
|
||||
}
|
||||
|
||||
if tc.checkAllPresent {
|
||||
var all []string
|
||||
for _, batch := range batches {
|
||||
all = append(all, batch...)
|
||||
}
|
||||
assert.Equal(t, tc.domains, all, "all domains should be present in order")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMatchDomainBatching writes increasing numbers of domains via the batching mechanism
|
||||
// and verifies all domains are readable across multiple scutil keys.
|
||||
func TestMatchDomainBatching(t *testing.T) {
|
||||
if testing.Short() {
|
||||
t.Skip("skipping scutil integration test in short mode")
|
||||
}
|
||||
|
||||
testCases := []struct {
|
||||
name string
|
||||
count int
|
||||
generator func(int) []string
|
||||
}{
|
||||
{"short_10", 10, generateShortDomains},
|
||||
{"short_50", 50, generateShortDomains},
|
||||
{"short_100", 100, generateShortDomains},
|
||||
{"short_200", 200, generateShortDomains},
|
||||
{"short_500", 500, generateShortDomains},
|
||||
{"long_10", 10, generateLongDomains},
|
||||
{"long_50", 50, generateLongDomains},
|
||||
{"long_100", 100, generateLongDomains},
|
||||
{"long_200", 200, generateLongDomains},
|
||||
{"long_500", 500, generateLongDomains},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
configurator := &systemConfigurator{
|
||||
createdKeys: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
defer func() {
|
||||
for key := range configurator.createdKeys {
|
||||
_ = removeTestDNSKey(key)
|
||||
}
|
||||
}()
|
||||
|
||||
domains := tc.generator(tc.count)
|
||||
err := configurator.addBatchedDomains(matchSuffix, domains, netip.MustParseAddr("100.64.0.1"), 53, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
batches := splitDomainsIntoBatches(domains)
|
||||
t.Logf("wrote %d domains across %d batched keys", tc.count, len(batches))
|
||||
|
||||
// Read back all domains from all batched keys
|
||||
var got []string
|
||||
for i := range batches {
|
||||
key := fmt.Sprintf(netbirdDNSStateKeyIndexedFormat, matchSuffix, i)
|
||||
exists, err := checkDNSKeyExists(key)
|
||||
require.NoError(t, err)
|
||||
require.True(t, exists, "key %s should exist", key)
|
||||
|
||||
got = append(got, readDomainsFromKey(t, key)...)
|
||||
}
|
||||
|
||||
t.Logf("read back %d/%d domains from %d keys", len(got), tc.count, len(batches))
|
||||
assert.Equal(t, tc.count, len(got), "all domains should be readable")
|
||||
assert.Equal(t, domains, got, "domains should match in order")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkDNSKeyExists(key string) (bool, error) {
|
||||
cmd := exec.Command(scutilPath)
|
||||
cmd.Stdin = strings.NewReader("show " + key + "\nquit\n")
|
||||
@@ -376,15 +158,15 @@ func setupTestConfigurator(t *testing.T) (*systemConfigurator, *statemanager.Man
|
||||
createdKeys: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
searchKey := getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix)
|
||||
matchKey := getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix)
|
||||
localKey := getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix)
|
||||
|
||||
cleanup := func() {
|
||||
_ = sm.Stop(context.Background())
|
||||
for key := range configurator.createdKeys {
|
||||
for _, key := range []string{searchKey, matchKey, localKey} {
|
||||
_ = removeTestDNSKey(key)
|
||||
}
|
||||
// Also clean up old-format keys and local key in case they exist
|
||||
_ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, searchSuffix))
|
||||
_ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, matchSuffix))
|
||||
_ = removeTestDNSKey(getKeyWithInput(netbirdDNSStateKeyFormat, localSuffix))
|
||||
}
|
||||
|
||||
return configurator, sm, cleanup
|
||||
|
||||
@@ -277,7 +277,7 @@ func (r *registryConfigurator) addDNSMatchPolicy(domains []string, ip netip.Addr
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("added %d NRPT rules for %d domains", ruleIndex, len(domains))
|
||||
log.Infof("added %d NRPT rules for %d domains. Domain list: %v", ruleIndex, len(domains), domains)
|
||||
return ruleIndex, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -376,9 +376,9 @@ func (m *Resolver) extractDomainsFromServerDomains(serverDomains dnsconfig.Serve
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
if serverDomains.Flow != "" {
|
||||
domains = append(domains, serverDomains.Flow)
|
||||
}
|
||||
|
||||
for _, stun := range serverDomains.Stuns {
|
||||
if stun != "" {
|
||||
|
||||
@@ -391,8 +391,7 @@ func TestResolver_PartialUpdateAddsNewTypePreservesExisting(t *testing.T) {
|
||||
}
|
||||
assert.Len(t, resolver.GetCachedDomains(), 3)
|
||||
|
||||
// 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)
|
||||
// Update with partial ServerDomains (only flow domain - new type, should preserve all existing)
|
||||
partialDomains := dnsconfig.ServerDomains{
|
||||
Flow: "github.com",
|
||||
}
|
||||
@@ -401,10 +400,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 only flow domain is provided")
|
||||
assert.Len(t, removedDomains, 0, "Should not remove any domains when adding new type")
|
||||
|
||||
finalDomains := resolver.GetCachedDomains()
|
||||
assert.Len(t, finalDomains, 3, "Flow domain is not cached; all original domains should be preserved")
|
||||
assert.Len(t, finalDomains, 4, "Should have all original domains plus new flow domain")
|
||||
|
||||
domainStrings := make([]string, len(finalDomains))
|
||||
for i, d := range finalDomains {
|
||||
@@ -413,5 +412,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.NotContains(t, domainStrings, "github.com")
|
||||
assert.Contains(t, domainStrings, "github.com")
|
||||
}
|
||||
|
||||
@@ -351,13 +351,9 @@ func (u *upstreamResolverBase) waitUntilResponse() {
|
||||
return fmt.Errorf("upstream check call error")
|
||||
}
|
||||
|
||||
err := backoff.Retry(operation, backoff.WithContext(exponentialBackOff, u.ctx))
|
||||
err := backoff.Retry(operation, exponentialBackOff)
|
||||
if err != nil {
|
||||
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)
|
||||
}
|
||||
log.Warn(err)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -28,15 +28,14 @@ import (
|
||||
"github.com/netbirdio/netbird/client/firewall"
|
||||
firewallManager "github.com/netbirdio/netbird/client/firewall/manager"
|
||||
"github.com/netbirdio/netbird/client/iface"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
nbnetstack "github.com/netbirdio/netbird/client/iface/netstack"
|
||||
"github.com/netbirdio/netbird/client/iface/device"
|
||||
"github.com/netbirdio/netbird/client/iface/udpmux"
|
||||
"github.com/netbirdio/netbird/client/internal/acl"
|
||||
"github.com/netbirdio/netbird/client/internal/debug"
|
||||
"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"
|
||||
@@ -54,11 +53,13 @@ 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"
|
||||
@@ -74,6 +75,7 @@ import (
|
||||
const (
|
||||
PeerConnectionTimeoutMax = 45000 // ms
|
||||
PeerConnectionTimeoutMin = 30000 // ms
|
||||
connInitLimit = 200
|
||||
disableAutoUpdate = "disabled"
|
||||
)
|
||||
|
||||
@@ -206,6 +208,7 @@ type Engine struct {
|
||||
syncRespMux sync.RWMutex
|
||||
persistSyncResponse bool
|
||||
latestSyncResponse *mgmProto.SyncResponse
|
||||
connSemaphore *semaphoregroup.SemaphoreGroup
|
||||
flowManager nftypes.FlowManager
|
||||
|
||||
// auto-update
|
||||
@@ -221,8 +224,6 @@ type Engine struct {
|
||||
|
||||
jobExecutor *jobexec.Executor
|
||||
jobExecutorWG sync.WaitGroup
|
||||
|
||||
exposeManager *expose.Manager
|
||||
}
|
||||
|
||||
// Peer is an instance of the Connection Peer
|
||||
@@ -265,6 +266,7 @@ func NewEngine(
|
||||
statusRecorder: statusRecorder,
|
||||
stateManager: stateManager,
|
||||
checks: checks,
|
||||
connSemaphore: semaphoregroup.NewSemaphoreGroup(connInitLimit),
|
||||
probeStunTurn: relay.NewStunTurnProbe(relay.DefaultCacheTTL),
|
||||
jobExecutor: jobexec.NewExecutor(),
|
||||
}
|
||||
@@ -417,7 +419,6 @@ 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 {
|
||||
@@ -800,7 +801,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()
|
||||
@@ -1538,6 +1539,7 @@ 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 {
|
||||
@@ -1560,10 +1562,8 @@ func (e *Engine) receiveSignalEvents() {
|
||||
defer e.shutdownWg.Done()
|
||||
// connect to a stream of messages coming from the signal server
|
||||
err := e.signal.Receive(e.ctx, func(msg *sProto.Message) error {
|
||||
start := time.Now()
|
||||
e.syncMsgMux.Lock()
|
||||
defer e.syncMsgMux.Unlock()
|
||||
gotLock := time.Since(start)
|
||||
|
||||
// Check context INSIDE lock to ensure atomicity with shutdown
|
||||
if e.ctx.Err() != nil {
|
||||
@@ -1587,8 +1587,6 @@ func (e *Engine) receiveSignalEvents() {
|
||||
return err
|
||||
}
|
||||
|
||||
log.Debugf("receiveMSG: took %s to get lock for peer %s with session id %s", gotLock, msg.Key, offerAnswer.SessionID)
|
||||
|
||||
if msg.Body.Type == sProto.Body_OFFER {
|
||||
conn.OnRemoteOffer(*offerAnswer)
|
||||
} else {
|
||||
@@ -1822,18 +1820,11 @@ 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 {
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -22,56 +22,51 @@ func prepareFd() (int, error) {
|
||||
|
||||
func routeCheck(ctx context.Context, fd int, nexthopv4, nexthopv6 systemops.Nexthop) error {
|
||||
for {
|
||||
// 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])
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
buf := make([]byte, 2048)
|
||||
n, err := unix.Read(fd, buf)
|
||||
if err != nil {
|
||||
log.Debugf("Network monitor: error parsing routing message: %v", err)
|
||||
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)
|
||||
continue
|
||||
}
|
||||
|
||||
if route.Dst.Bits() != 0 {
|
||||
continue
|
||||
}
|
||||
msg := (*unix.RtMsghdr)(unsafe.Pointer(&buf[0]))
|
||||
|
||||
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)
|
||||
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)
|
||||
// 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)
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,33 +90,3 @@ 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package peer
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"net/netip"
|
||||
"runtime"
|
||||
@@ -24,6 +25,7 @@ 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 {
|
||||
@@ -32,6 +34,7 @@ type ServiceDependencies struct {
|
||||
IFaceDiscover stdnet.ExternalIFaceDiscover
|
||||
RelayManager *relayClient.Manager
|
||||
SrWatcher *guard.SRWatcher
|
||||
Semaphore *semaphoregroup.SemaphoreGroup
|
||||
PeerConnDispatcher *dispatcher.ConnectionDispatcher
|
||||
}
|
||||
|
||||
@@ -108,8 +111,9 @@ type Conn struct {
|
||||
wgProxyRelay wgproxy.Proxy
|
||||
handshaker *Handshaker
|
||||
|
||||
guard *guard.Guard
|
||||
wg sync.WaitGroup
|
||||
guard *guard.Guard
|
||||
semaphore *semaphoregroup.SemaphoreGroup
|
||||
wg sync.WaitGroup
|
||||
|
||||
// debug purpose
|
||||
dumpState *stateDump
|
||||
@@ -135,6 +139,7 @@ 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,
|
||||
@@ -149,10 +154,15 @@ 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
|
||||
}
|
||||
|
||||
@@ -163,6 +173,7 @@ 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
|
||||
@@ -196,6 +207,10 @@ 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
|
||||
@@ -419,14 +434,14 @@ func (conn *Conn) onICEStateDisconnected(sessionChanged bool) {
|
||||
conn.resetEndpoint()
|
||||
}
|
||||
|
||||
// todo consider to move after the ConfigureWGEndpoint
|
||||
conn.wgProxyRelay.Work()
|
||||
|
||||
presharedKey := conn.presharedKey(conn.rosenpassRemoteKey)
|
||||
if err := conn.endpointUpdater.SwitchWGEndpoint(conn.wgProxyRelay.EndpointAddr(), presharedKey); err != nil {
|
||||
if err := conn.endpointUpdater.ConfigureWGEndpoint(conn.wgProxyRelay.EndpointAddr(), presharedKey); err != nil {
|
||||
conn.Log.Errorf("failed to switch to relay conn: %v", err)
|
||||
}
|
||||
|
||||
conn.wgProxyRelay.Work()
|
||||
conn.currentConnPriority = conntype.Relay
|
||||
} else {
|
||||
conn.Log.Infof("ICE disconnected, do not switch to Relay. Reset priority to: %s", conntype.None.String())
|
||||
@@ -488,22 +503,20 @@ func (conn *Conn) onRelayConnectionIsReady(rci RelayConnInfo) {
|
||||
return
|
||||
}
|
||||
|
||||
controller := isController(conn.config)
|
||||
wgProxy.Work()
|
||||
presharedKey := conn.presharedKey(rci.rosenpassPubKey)
|
||||
|
||||
if controller {
|
||||
wgProxy.Work()
|
||||
}
|
||||
conn.enableWgWatcherIfNeeded()
|
||||
if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), conn.presharedKey(rci.rosenpassPubKey)); err != nil {
|
||||
|
||||
if err := conn.endpointUpdater.ConfigureWGEndpoint(wgProxy.EndpointAddr(), presharedKey); err != nil {
|
||||
if err := wgProxy.CloseConn(); err != nil {
|
||||
conn.Log.Warnf("Failed to close relay connection: %v", err)
|
||||
}
|
||||
conn.Log.Errorf("Failed to update WireGuard peer configuration: %v", err)
|
||||
return
|
||||
}
|
||||
if !controller {
|
||||
wgProxy.Work()
|
||||
}
|
||||
|
||||
wgConfigWorkaround()
|
||||
conn.rosenpassRemoteKey = rci.rosenpassPubKey
|
||||
conn.currentConnPriority = conntype.Relay
|
||||
conn.statusRelay.SetConnected()
|
||||
@@ -655,6 +668,19 @@ 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:
|
||||
@@ -851,3 +877,9 @@ func isController(config ConnConfig) bool {
|
||||
func isRosenpassEnabled(remoteRosenpassPubKey []byte) bool {
|
||||
return remoteRosenpassPubKey != nil
|
||||
}
|
||||
|
||||
// wgConfigWorkaround is a workaround for the issue with WireGuard configuration update
|
||||
// When update a peer configuration in near to each other time, the second update can be ignored by WireGuard
|
||||
func wgConfigWorkaround() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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()
|
||||
@@ -52,6 +53,7 @@ func TestConn_GetKey(t *testing.T) {
|
||||
|
||||
sd := ServiceDependencies{
|
||||
SrWatcher: swWatcher,
|
||||
Semaphore: semaphoregroup.NewSemaphoreGroup(1),
|
||||
PeerConnDispatcher: testDispatcher,
|
||||
}
|
||||
conn, err := NewConn(connConf, sd)
|
||||
@@ -69,6 +71,7 @@ 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)
|
||||
@@ -107,6 +110,7 @@ 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)
|
||||
|
||||
@@ -34,27 +34,28 @@ func NewEndpointUpdater(log *logrus.Entry, wgConfig WgConfig, initiator bool) *E
|
||||
}
|
||||
}
|
||||
|
||||
// ConfigureWGEndpoint sets up the WireGuard endpoint configuration.
|
||||
// The initiator immediately configures the endpoint, while the non-initiator
|
||||
// waits for a fallback period before configuring to avoid handshake congestion.
|
||||
func (e *EndpointUpdater) ConfigureWGEndpoint(addr *net.UDPAddr, presharedKey *wgtypes.Key) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
if e.initiator {
|
||||
e.log.Debugf("configure up WireGuard as initiator")
|
||||
return e.configureAsInitiator(addr, presharedKey)
|
||||
e.log.Debugf("configure up WireGuard as initiatr")
|
||||
return e.updateWireGuardPeer(addr, presharedKey)
|
||||
}
|
||||
|
||||
e.log.Debugf("configure up WireGuard as responder")
|
||||
return e.configureAsResponder(addr, presharedKey)
|
||||
}
|
||||
|
||||
func (e *EndpointUpdater) SwitchWGEndpoint(addr *net.UDPAddr, presharedKey *wgtypes.Key) error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
// prevent to run new update while cancel the previous update
|
||||
e.waitForCloseTheDelayedUpdate()
|
||||
|
||||
return e.updateWireGuardPeer(addr, presharedKey)
|
||||
var ctx context.Context
|
||||
ctx, e.cancelFunc = context.WithCancel(context.Background())
|
||||
e.updateWg.Add(1)
|
||||
go e.scheduleDelayedUpdate(ctx, addr, presharedKey)
|
||||
|
||||
e.log.Debugf("configure up WireGuard and wait for handshake")
|
||||
return e.updateWireGuardPeer(nil, presharedKey)
|
||||
}
|
||||
|
||||
func (e *EndpointUpdater) RemoveWgPeer() error {
|
||||
@@ -66,37 +67,9 @@ func (e *EndpointUpdater) RemoveWgPeer() error {
|
||||
}
|
||||
|
||||
func (e *EndpointUpdater) RemoveEndpointAddress() error {
|
||||
e.mu.Lock()
|
||||
defer e.mu.Unlock()
|
||||
|
||||
e.waitForCloseTheDelayedUpdate()
|
||||
return e.wgConfig.WgInterface.RemoveEndpointAddress(e.wgConfig.RemoteKey)
|
||||
}
|
||||
|
||||
func (e *EndpointUpdater) configureAsInitiator(addr *net.UDPAddr, presharedKey *wgtypes.Key) error {
|
||||
if err := e.updateWireGuardPeer(addr, presharedKey); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EndpointUpdater) configureAsResponder(addr *net.UDPAddr, presharedKey *wgtypes.Key) error {
|
||||
// prevent to run new update while cancel the previous update
|
||||
e.waitForCloseTheDelayedUpdate()
|
||||
|
||||
e.log.Debugf("configure up WireGuard and wait for handshake")
|
||||
var ctx context.Context
|
||||
ctx, e.cancelFunc = context.WithCancel(context.Background())
|
||||
e.updateWg.Add(1)
|
||||
go e.scheduleDelayedUpdate(ctx, addr, presharedKey)
|
||||
|
||||
if err := e.updateWireGuardPeer(nil, presharedKey); err != nil {
|
||||
e.waitForCloseTheDelayedUpdate()
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *EndpointUpdater) waitForCloseTheDelayedUpdate() {
|
||||
if e.cancelFunc == nil {
|
||||
return
|
||||
@@ -132,9 +105,3 @@ func (e *EndpointUpdater) updateWireGuardPeer(endpoint *net.UDPAddr, presharedKe
|
||||
presharedKey,
|
||||
)
|
||||
}
|
||||
|
||||
// wgConfigWorkaround is a workaround for the issue with WireGuard configuration update
|
||||
// When update a peer configuration in near to each other time, the second update can be ignored by WireGuard
|
||||
func wgConfigWorkaround() {
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
|
||||
@@ -351,11 +351,6 @@ func (d *DnsInterceptor) writeMsg(w dns.ResponseWriter, r *dns.Msg, logger *log.
|
||||
logger.Errorf("failed to update domain prefixes: %v", err)
|
||||
}
|
||||
|
||||
// Allow time for route changes to be applied before sending
|
||||
// the DNS response (relevant on iOS where setTunnelNetworkSettings
|
||||
// is asynchronous).
|
||||
waitForRouteSettlement(logger)
|
||||
|
||||
d.replaceIPsInDNSResponse(r, newPrefixes, logger)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
//go:build ios
|
||||
|
||||
package dnsinterceptor
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const routeSettleDelay = 500 * time.Millisecond
|
||||
|
||||
// waitForRouteSettlement introduces a short delay on iOS to allow
|
||||
// setTunnelNetworkSettings to apply route changes before the DNS
|
||||
// response reaches the application. Without this, the first request
|
||||
// to a newly resolved domain may bypass the tunnel.
|
||||
func waitForRouteSettlement(logger *log.Entry) {
|
||||
logger.Tracef("waiting %v for iOS route settlement", routeSettleDelay)
|
||||
time.Sleep(routeSettleDelay)
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
//go:build !ios
|
||||
|
||||
package dnsinterceptor
|
||||
|
||||
import log "github.com/sirupsen/logrus"
|
||||
|
||||
func waitForRouteSettlement(_ *log.Entry) {
|
||||
// No-op on non-iOS platforms: route changes are applied synchronously by
|
||||
// the kernel, so no settlement delay is needed before the DNS response
|
||||
// reaches the application. The delay is only required on iOS where
|
||||
// setTunnelNetworkSettings applies routes asynchronously.
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,153 +0,0 @@
|
||||
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")
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||
// versions:
|
||||
// protoc-gen-go v1.36.6
|
||||
// protoc v6.33.3
|
||||
// protoc v6.32.1
|
||||
// source: daemon.proto
|
||||
|
||||
package proto
|
||||
@@ -88,58 +88,6 @@ 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
|
||||
|
||||
@@ -174,11 +122,11 @@ func (x OSLifecycleRequest_CycleType) String() string {
|
||||
}
|
||||
|
||||
func (OSLifecycleRequest_CycleType) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_daemon_proto_enumTypes[2].Descriptor()
|
||||
return file_daemon_proto_enumTypes[1].Descriptor()
|
||||
}
|
||||
|
||||
func (OSLifecycleRequest_CycleType) Type() protoreflect.EnumType {
|
||||
return &file_daemon_proto_enumTypes[2]
|
||||
return &file_daemon_proto_enumTypes[1]
|
||||
}
|
||||
|
||||
func (x OSLifecycleRequest_CycleType) Number() protoreflect.EnumNumber {
|
||||
@@ -226,11 +174,11 @@ func (x SystemEvent_Severity) String() string {
|
||||
}
|
||||
|
||||
func (SystemEvent_Severity) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_daemon_proto_enumTypes[3].Descriptor()
|
||||
return file_daemon_proto_enumTypes[2].Descriptor()
|
||||
}
|
||||
|
||||
func (SystemEvent_Severity) Type() protoreflect.EnumType {
|
||||
return &file_daemon_proto_enumTypes[3]
|
||||
return &file_daemon_proto_enumTypes[2]
|
||||
}
|
||||
|
||||
func (x SystemEvent_Severity) Number() protoreflect.EnumNumber {
|
||||
@@ -281,11 +229,11 @@ func (x SystemEvent_Category) String() string {
|
||||
}
|
||||
|
||||
func (SystemEvent_Category) Descriptor() protoreflect.EnumDescriptor {
|
||||
return file_daemon_proto_enumTypes[4].Descriptor()
|
||||
return file_daemon_proto_enumTypes[3].Descriptor()
|
||||
}
|
||||
|
||||
func (SystemEvent_Category) Type() protoreflect.EnumType {
|
||||
return &file_daemon_proto_enumTypes[4]
|
||||
return &file_daemon_proto_enumTypes[3]
|
||||
}
|
||||
|
||||
func (x SystemEvent_Category) Number() protoreflect.EnumNumber {
|
||||
@@ -5652,224 +5600,6 @@ 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"`
|
||||
@@ -5880,7 +5610,7 @@ type PortInfo_Range struct {
|
||||
|
||||
func (x *PortInfo_Range) Reset() {
|
||||
*x = PortInfo_Range{}
|
||||
mi := &file_daemon_proto_msgTypes[89]
|
||||
mi := &file_daemon_proto_msgTypes[86]
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
ms.StoreMessageInfo(mi)
|
||||
}
|
||||
@@ -5892,7 +5622,7 @@ func (x *PortInfo_Range) String() string {
|
||||
func (*PortInfo_Range) ProtoMessage() {}
|
||||
|
||||
func (x *PortInfo_Range) ProtoReflect() protoreflect.Message {
|
||||
mi := &file_daemon_proto_msgTypes[89]
|
||||
mi := &file_daemon_proto_msgTypes[86]
|
||||
if x != nil {
|
||||
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||
if ms.LoadMessageInfo() == nil {
|
||||
@@ -6419,25 +6149,7 @@ 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\"\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" +
|
||||
"\berrorMsg\x18\x02 \x01(\tR\berrorMsg*b\n" +
|
||||
"\bLogLevel\x12\v\n" +
|
||||
"\aUNKNOWN\x10\x00\x12\t\n" +
|
||||
"\x05PANIC\x10\x01\x12\t\n" +
|
||||
@@ -6446,14 +6158,7 @@ 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\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" +
|
||||
"\x05TRACE\x10\a2\xdd\x14\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" +
|
||||
@@ -6492,8 +6197,7 @@ 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\"\x00\x12M\n" +
|
||||
"\rExposeService\x12\x1c.daemon.ExposeServiceRequest\x1a\x1a.daemon.ExposeServiceEvent\"\x000\x01B\bZ\x06/protob\x06proto3"
|
||||
"\x12GetInstallerResult\x12\x1e.daemon.InstallerResultRequest\x1a\x1f.daemon.InstallerResultResponse\"\x00B\bZ\x06/protob\x06proto3"
|
||||
|
||||
var (
|
||||
file_daemon_proto_rawDescOnce sync.Once
|
||||
@@ -6507,222 +6211,214 @@ func file_daemon_proto_rawDescGZIP() []byte {
|
||||
return file_daemon_proto_rawDescData
|
||||
}
|
||||
|
||||
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
|
||||
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 91)
|
||||
var file_daemon_proto_enumTypes = make([]protoimpl.EnumInfo, 4)
|
||||
var file_daemon_proto_msgTypes = make([]protoimpl.MessageInfo, 88)
|
||||
var file_daemon_proto_goTypes = []any{
|
||||
(LogLevel)(0), // 0: daemon.LogLevel
|
||||
(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
|
||||
(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
|
||||
}
|
||||
var file_daemon_proto_depIdxs = []int32{
|
||||
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
|
||||
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
|
||||
0, // 21: daemon.GetLogLevelResponse.level:type_name -> daemon.LogLevel
|
||||
0, // 22: daemon.SetLogLevelRequest.level:type_name -> daemon.LogLevel
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
func init() { file_daemon_proto_init() }
|
||||
@@ -6743,16 +6439,13 @@ 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: 5,
|
||||
NumMessages: 91,
|
||||
NumEnums: 4,
|
||||
NumMessages: 88,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -103,9 +103,6 @@ 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) {}
|
||||
}
|
||||
|
||||
|
||||
@@ -804,32 +801,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -76,8 +76,6 @@ 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 {
|
||||
@@ -426,38 +424,6 @@ 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
|
||||
@@ -520,8 +486,6 @@ 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()
|
||||
}
|
||||
|
||||
@@ -634,9 +598,6 @@ 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.
|
||||
@@ -1283,27 +1244,6 @@ 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)
|
||||
@@ -1454,11 +1394,6 @@ var DaemonService_ServiceDesc = grpc.ServiceDesc{
|
||||
Handler: _DaemonService_SubscribeEvents_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
{
|
||||
StreamName: "ExposeService",
|
||||
Handler: _DaemonService_ExposeService_Handler,
|
||||
ServerStreams: true,
|
||||
},
|
||||
},
|
||||
Metadata: "daemon.proto",
|
||||
}
|
||||
|
||||
77
client/server/lifecycle.go
Normal file
@@ -0,0 +1,77 @@
|
||||
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
|
||||
}
|
||||
219
client/server/lifecycle_test.go
Normal file
@@ -0,0 +1,219 @@
|
||||
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())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,9 +21,7 @@ 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"
|
||||
@@ -87,7 +85,8 @@ type Server struct {
|
||||
profilesDisabled bool
|
||||
updateSettingsDisabled bool
|
||||
|
||||
sleepHandler *sleephandler.SleepHandler
|
||||
// sleepTriggeredDown holds a state indicated if the sleep handler triggered the last client down
|
||||
sleepTriggeredDown atomic.Bool
|
||||
|
||||
jwtCache *jwtCache
|
||||
}
|
||||
@@ -101,7 +100,7 @@ type oauthAuthFlow struct {
|
||||
|
||||
// New server instance constructor.
|
||||
func New(ctx context.Context, logFile string, configFile string, profilesDisabled bool, updateSettingsDisabled bool) *Server {
|
||||
s := &Server{
|
||||
return &Server{
|
||||
rootCtx: ctx,
|
||||
logFile: logFile,
|
||||
persistSyncResponse: true,
|
||||
@@ -111,10 +110,6 @@ 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 {
|
||||
@@ -1317,60 +1312,6 @@ 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
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -19,7 +19,6 @@ 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"
|
||||
@@ -269,7 +268,7 @@ func getDefaultDaemonAddr() string {
|
||||
if runtime.GOOS == "windows" {
|
||||
return DefaultDaemonAddrWindows
|
||||
}
|
||||
return daemonaddr.ResolveUnixDaemonAddr(DefaultDaemonAddr)
|
||||
return DefaultDaemonAddr
|
||||
}
|
||||
|
||||
// DialOptions contains options for SSH connections
|
||||
|
||||
3
client/uitauri/.gitignore
vendored
@@ -1,3 +0,0 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
src-tauri/target/
|
||||
@@ -1,26 +0,0 @@
|
||||
# NetBird Tauri UI
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Rust (https://rustup.rs)
|
||||
- Node.js 18+
|
||||
- Linux: `sudo apt install libwebkit2gtk-4.1-dev libgtk-3-dev libappindicator3-dev librsvg2-dev patchelf protobuf-compiler`
|
||||
|
||||
## Build & Run
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
cd frontend && npm install && npm run build && cd ..
|
||||
|
||||
# Backend (debug)
|
||||
cd src-tauri && cargo build
|
||||
|
||||
# Run
|
||||
RUST_LOG=info ./src-tauri/target/debug/netbird-ui
|
||||
|
||||
# Release build
|
||||
cd src-tauri && cargo build --release
|
||||
./src-tauri/target/release/netbird-ui
|
||||
```
|
||||
|
||||
The NetBird daemon must be running (`netbird service start`).
|
||||
@@ -1,13 +0,0 @@
|
||||
<!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>
|
||||
2516
client/uitauri/frontend/package-lock.json
generated
@@ -1,27 +0,0 @@
|
||||
{
|
||||
"name": "netbird-ui",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-notification": "^2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { HashRouter, Routes, Route, useNavigate } from 'react-router-dom'
|
||||
import { useEffect } from 'react'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
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 Rust backend
|
||||
* and programmatically navigates the React router.
|
||||
*/
|
||||
function Navigator() {
|
||||
const navigate = useNavigate()
|
||||
|
||||
useEffect(() => {
|
||||
const unlisten = listen<string>('navigate', (event) => {
|
||||
const path = event.payload
|
||||
if (path) navigate(path)
|
||||
})
|
||||
return () => {
|
||||
unlisten.then(fn => fn())
|
||||
}
|
||||
}, [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>
|
||||
)
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
/**
|
||||
* Type definitions for Tauri command responses.
|
||||
* These mirror the Rust serde-serialized DTOs.
|
||||
*/
|
||||
|
||||
// ---- 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
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
/* 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>
|
||||
)
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>,
|
||||
)
|
||||
@@ -1,180 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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 {
|
||||
const res = await invoke<DebugBundleResult>('create_debug_bundle', { params })
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,335 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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'
|
||||
|
||||
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 command: string
|
||||
if (tab === 'all') command = 'list_networks'
|
||||
else if (tab === 'overlapping') command = 'list_overlapping_networks'
|
||||
else command = 'list_exit_nodes'
|
||||
const data = await invoke<NetworkInfo[]>(command)
|
||||
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 invoke('deselect_network', { id })
|
||||
else await invoke('select_network', { id })
|
||||
await load()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
}
|
||||
}
|
||||
|
||||
async function selectAll() {
|
||||
try {
|
||||
await invoke('select_all_networks')
|
||||
await load()
|
||||
} catch (e) { setError(String(e)) }
|
||||
}
|
||||
|
||||
async function deselectAll() {
|
||||
try {
|
||||
await invoke('deselect_all_networks')
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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'
|
||||
|
||||
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 invoke<PeerInfo[]>('get_peers')
|
||||
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">↓</span> {formatBytes(peer.bytesRx)}
|
||||
</span>
|
||||
<span style={{ color: 'var(--color-text-tertiary)' }}>
|
||||
<span style={{ color: 'var(--color-accent)' }} title="Sent">↑</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>
|
||||
)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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 {
|
||||
const data = await invoke<ProfileInfo[]>('list_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 invoke('switch_profile', { profileName: confirm.profile })
|
||||
else if (confirm.action === 'remove') await invoke('remove_profile', { profileName: confirm.profile })
|
||||
else if (confirm.action === 'logout') await invoke('logout', { profileName: 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 invoke('add_profile', { profileName: 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>
|
||||
)
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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 {
|
||||
return await invoke<ConfigInfo>('get_config')
|
||||
} catch (e) {
|
||||
console.error('[Settings] GetConfig error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function setConfig(cfg: ConfigInfo): Promise<void> {
|
||||
await invoke('set_config', { 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>
|
||||
)
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
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 {
|
||||
return await invoke<StatusInfo>('get_status')
|
||||
} catch (e) {
|
||||
console.error('[Dashboard] GetStatus error:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
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 unlisten = listen<StatusInfo>('status-changed', (event) => {
|
||||
if (event.payload) setStatus(event.payload)
|
||||
})
|
||||
return () => {
|
||||
clearInterval(id)
|
||||
unlisten.then(fn => fn())
|
||||
}
|
||||
}, [refresh])
|
||||
|
||||
async function handleConnect() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await invoke('connect')
|
||||
await refresh()
|
||||
} catch (e) {
|
||||
setError(String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDisconnect() {
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
await invoke('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>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
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 {
|
||||
await invoke('trigger_update')
|
||||
} catch (e) {
|
||||
console.error('[Update] TriggerUpdate error:', e)
|
||||
setErrorMsg(String(e))
|
||||
setState('failed')
|
||||
return
|
||||
}
|
||||
|
||||
setState('polling')
|
||||
|
||||
try {
|
||||
const result = await invoke<InstallerResult>('get_installer_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>
|
||||
)
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
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,
|
||||
},
|
||||
// Tauri dev server expects clearScreen false
|
||||
clearScreen: false,
|
||||
server: {
|
||||
strictPort: true,
|
||||
},
|
||||
})
|
||||
5746
client/uitauri/src-tauri/Cargo.lock
generated
@@ -1,29 +0,0 @@
|
||||
[package]
|
||||
name = "netbird-ui"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
tonic-build = "0.12"
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-single-instance = "2"
|
||||
notify-rust = "4"
|
||||
tonic = "0.12"
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
tower = "0.5"
|
||||
hyper-util = "0.1"
|
||||
http = "1"
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
tokio-stream = "0.1"
|
||||
tower = "0.5"
|
||||
hyper-util = "0.1"
|
||||
@@ -1,7 +0,0 @@
|
||||
fn main() {
|
||||
// Compile the daemon.proto for tonic gRPC client
|
||||
tonic_build::compile_protos("../../proto/daemon.proto")
|
||||
.expect("Failed to compile daemon.proto");
|
||||
|
||||
tauri_build::build();
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicegui/nicegui/main/nicegui/static/tauri/capabilities-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default capabilities for the NetBird UI",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:tray:default"
|
||||
]
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for the NetBird UI","local":true,"windows":["main"],"permissions":["core:default","core:tray:default"]}}
|
||||
|
Before Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 4.7 KiB |
@@ -1,72 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::proto;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct StatusInfo {
|
||||
pub status: String,
|
||||
pub ip: String,
|
||||
pub public_key: String,
|
||||
pub fqdn: String,
|
||||
pub connected_peers: usize,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_status(state: State<'_, AppState>) -> Result<StatusInfo, String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let resp = client
|
||||
.status(proto::StatusRequest {
|
||||
get_full_peer_status: true,
|
||||
should_run_probes: false,
|
||||
wait_for_ready: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("status rpc: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
let mut info = StatusInfo {
|
||||
status: resp.status,
|
||||
ip: String::new(),
|
||||
public_key: String::new(),
|
||||
fqdn: String::new(),
|
||||
connected_peers: 0,
|
||||
};
|
||||
|
||||
if let Some(ref full) = resp.full_status {
|
||||
if let Some(ref lp) = full.local_peer_state {
|
||||
info.ip = lp.ip.clone();
|
||||
info.public_key = lp.pub_key.clone();
|
||||
info.fqdn = lp.fqdn.clone();
|
||||
}
|
||||
info.connected_peers = full.peers.len();
|
||||
}
|
||||
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn connect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.up(proto::UpRequest {
|
||||
profile_name: None,
|
||||
username: None,
|
||||
auto_update: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("connect: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disconnect(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.down(proto::DownRequest {})
|
||||
.await
|
||||
.map_err(|e| format!("disconnect: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::proto;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DebugBundleParams {
|
||||
pub anonymize: bool,
|
||||
pub system_info: bool,
|
||||
pub upload: bool,
|
||||
pub upload_url: String,
|
||||
pub run_duration_mins: u32,
|
||||
pub enable_persistence: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct DebugBundleResult {
|
||||
pub local_path: String,
|
||||
pub uploaded_key: String,
|
||||
pub upload_failure_reason: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_debug_bundle(
|
||||
state: State<'_, AppState>,
|
||||
params: DebugBundleParams,
|
||||
) -> Result<DebugBundleResult, String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
|
||||
// If run_duration_mins > 0, do the full debug cycle
|
||||
if params.run_duration_mins > 0 {
|
||||
configure_for_debug(&mut client, ¶ms).await?;
|
||||
}
|
||||
|
||||
let upload_url = if params.upload && !params.upload_url.is_empty() {
|
||||
params.upload_url.clone()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.debug_bundle(proto::DebugBundleRequest {
|
||||
anonymize: params.anonymize,
|
||||
system_info: params.system_info,
|
||||
upload_url: upload_url,
|
||||
log_file_count: 0,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("create debug bundle: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
Ok(DebugBundleResult {
|
||||
local_path: resp.path,
|
||||
uploaded_key: resp.uploaded_key,
|
||||
upload_failure_reason: resp.upload_failure_reason,
|
||||
})
|
||||
}
|
||||
|
||||
async fn configure_for_debug(
|
||||
client: &mut proto::daemon_service_client::DaemonServiceClient<tonic::transport::Channel>,
|
||||
params: &DebugBundleParams,
|
||||
) -> Result<(), String> {
|
||||
// Get current status
|
||||
let status_resp = client
|
||||
.status(proto::StatusRequest {
|
||||
get_full_peer_status: false,
|
||||
should_run_probes: false,
|
||||
wait_for_ready: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("get status: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
let was_connected =
|
||||
status_resp.status == "Connected" || status_resp.status == "Connecting";
|
||||
|
||||
// Get current log level
|
||||
let log_resp = client
|
||||
.get_log_level(proto::GetLogLevelRequest {})
|
||||
.await
|
||||
.map_err(|e| format!("get log level: {}", e))?
|
||||
.into_inner();
|
||||
let original_level = log_resp.level;
|
||||
|
||||
// Set trace log level
|
||||
client
|
||||
.set_log_level(proto::SetLogLevelRequest {
|
||||
level: proto::LogLevel::Trace.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("set log level: {}", e))?;
|
||||
|
||||
// Bring down then up
|
||||
let _ = client.down(proto::DownRequest {}).await;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
if params.enable_persistence {
|
||||
let _ = client
|
||||
.set_sync_response_persistence(proto::SetSyncResponsePersistenceRequest {
|
||||
enabled: true,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
client
|
||||
.up(proto::UpRequest {
|
||||
profile_name: None,
|
||||
username: None,
|
||||
auto_update: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("bring service up: {}", e))?;
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
|
||||
|
||||
let _ = client
|
||||
.start_cpu_profile(proto::StartCpuProfileRequest {})
|
||||
.await;
|
||||
|
||||
// Wait for collection duration
|
||||
let duration = std::time::Duration::from_secs(params.run_duration_mins as u64 * 60);
|
||||
tokio::time::sleep(duration).await;
|
||||
|
||||
let _ = client
|
||||
.stop_cpu_profile(proto::StopCpuProfileRequest {})
|
||||
.await;
|
||||
|
||||
// Restore original state
|
||||
if !was_connected {
|
||||
let _ = client.down(proto::DownRequest {}).await;
|
||||
}
|
||||
|
||||
if original_level < proto::LogLevel::Trace as i32 {
|
||||
let _ = client
|
||||
.set_log_level(proto::SetLogLevelRequest {
|
||||
level: original_level,
|
||||
})
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_log_level(state: State<'_, AppState>) -> Result<String, String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let resp = client
|
||||
.get_log_level(proto::GetLogLevelRequest {})
|
||||
.await
|
||||
.map_err(|e| format!("get log level rpc: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
let level_name = match proto::LogLevel::try_from(resp.level) {
|
||||
Ok(proto::LogLevel::Trace) => "TRACE",
|
||||
Ok(proto::LogLevel::Debug) => "DEBUG",
|
||||
Ok(proto::LogLevel::Info) => "INFO",
|
||||
Ok(proto::LogLevel::Warn) => "WARN",
|
||||
Ok(proto::LogLevel::Error) => "ERROR",
|
||||
Ok(proto::LogLevel::Fatal) => "FATAL",
|
||||
Ok(proto::LogLevel::Panic) => "PANIC",
|
||||
_ => "UNKNOWN",
|
||||
};
|
||||
Ok(level_name.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_log_level(state: State<'_, AppState>, level: String) -> Result<(), String> {
|
||||
let proto_level = match level.as_str() {
|
||||
"TRACE" => proto::LogLevel::Trace,
|
||||
"DEBUG" => proto::LogLevel::Debug,
|
||||
"INFO" => proto::LogLevel::Info,
|
||||
"WARN" | "WARNING" => proto::LogLevel::Warn,
|
||||
"ERROR" => proto::LogLevel::Error,
|
||||
_ => proto::LogLevel::Info,
|
||||
};
|
||||
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.set_log_level(proto::SetLogLevelRequest {
|
||||
level: proto_level.into(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("set log level rpc: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
pub mod connection;
|
||||
pub mod debug;
|
||||
pub mod network;
|
||||
pub mod peers;
|
||||
pub mod profile;
|
||||
pub mod settings;
|
||||
pub mod update;
|
||||
@@ -1,164 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::proto;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NetworkInfo {
|
||||
pub id: String,
|
||||
pub range: String,
|
||||
pub domains: Vec<String>,
|
||||
pub selected: bool,
|
||||
#[serde(rename = "resolvedIPs")]
|
||||
pub resolved_ips: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
fn network_from_proto(r: &proto::Network) -> NetworkInfo {
|
||||
let mut resolved = HashMap::new();
|
||||
for (domain, ip_list) in &r.resolved_i_ps {
|
||||
resolved.insert(domain.clone(), ip_list.ips.clone());
|
||||
}
|
||||
NetworkInfo {
|
||||
id: r.id.clone(),
|
||||
range: r.range.clone(),
|
||||
domains: r.domains.clone(),
|
||||
selected: r.selected,
|
||||
resolved_ips: resolved,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_networks(state: &State<'_, AppState>) -> Result<Vec<NetworkInfo>, String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let resp = client
|
||||
.list_networks(proto::ListNetworksRequest {})
|
||||
.await
|
||||
.map_err(|e| format!("list networks rpc: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
let mut routes: Vec<NetworkInfo> = resp.routes.iter().map(network_from_proto).collect();
|
||||
routes.sort_by(|a, b| a.id.to_lowercase().cmp(&b.id.to_lowercase()));
|
||||
Ok(routes)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_networks(state: State<'_, AppState>) -> Result<Vec<NetworkInfo>, String> {
|
||||
fetch_networks(&state).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_overlapping_networks(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<NetworkInfo>, String> {
|
||||
let all = fetch_networks(&state).await?;
|
||||
let mut by_range: HashMap<String, Vec<NetworkInfo>> = HashMap::new();
|
||||
for r in all {
|
||||
if !r.domains.is_empty() {
|
||||
continue;
|
||||
}
|
||||
by_range.entry(r.range.clone()).or_default().push(r);
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
for group in by_range.values() {
|
||||
if group.len() > 1 {
|
||||
result.extend(group.iter().cloned());
|
||||
}
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_exit_nodes(state: State<'_, AppState>) -> Result<Vec<NetworkInfo>, String> {
|
||||
let all = fetch_networks(&state).await?;
|
||||
Ok(all.into_iter().filter(|r| r.range == "0.0.0.0/0").collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_network(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.select_networks(proto::SelectNetworksRequest {
|
||||
network_i_ds: vec![id],
|
||||
append: true,
|
||||
all: false,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("select network: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn deselect_network(state: State<'_, AppState>, id: String) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.deselect_networks(proto::SelectNetworksRequest {
|
||||
network_i_ds: vec![id],
|
||||
append: false,
|
||||
all: false,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("deselect network: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_networks(state: State<'_, AppState>, ids: Vec<String>) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.select_networks(proto::SelectNetworksRequest {
|
||||
network_i_ds: ids,
|
||||
append: true,
|
||||
all: false,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("select networks: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn deselect_networks(
|
||||
state: State<'_, AppState>,
|
||||
ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.deselect_networks(proto::SelectNetworksRequest {
|
||||
network_i_ds: ids,
|
||||
append: false,
|
||||
all: false,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("deselect networks: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn select_all_networks(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.select_networks(proto::SelectNetworksRequest {
|
||||
network_i_ds: vec![],
|
||||
append: false,
|
||||
all: true,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("select all networks: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn deselect_all_networks(state: State<'_, AppState>) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.deselect_networks(proto::SelectNetworksRequest {
|
||||
network_i_ds: vec![],
|
||||
append: false,
|
||||
all: true,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("deselect all networks: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::proto;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PeerInfo {
|
||||
pub ip: String,
|
||||
pub pub_key: String,
|
||||
pub fqdn: String,
|
||||
pub conn_status: String,
|
||||
pub conn_status_update: String,
|
||||
pub relayed: bool,
|
||||
pub relay_address: String,
|
||||
pub latency_ms: f64,
|
||||
pub bytes_rx: i64,
|
||||
pub bytes_tx: i64,
|
||||
pub rosenpass_enabled: bool,
|
||||
pub networks: Vec<String>,
|
||||
pub last_handshake: String,
|
||||
pub local_ice_type: String,
|
||||
pub remote_ice_type: String,
|
||||
pub local_endpoint: String,
|
||||
pub remote_endpoint: String,
|
||||
}
|
||||
|
||||
fn format_timestamp(ts: &Option<prost_types::Timestamp>) -> String {
|
||||
match ts {
|
||||
Some(t) => {
|
||||
// Simple RFC3339-like formatting
|
||||
let secs = t.seconds;
|
||||
let nanos = t.nanos;
|
||||
format!("{}:{}", secs, nanos)
|
||||
}
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_peers(state: State<'_, AppState>) -> Result<Vec<PeerInfo>, String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let resp = client
|
||||
.status(proto::StatusRequest {
|
||||
get_full_peer_status: true,
|
||||
should_run_probes: false,
|
||||
wait_for_ready: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("status rpc: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
let peers = match resp.full_status {
|
||||
Some(ref full) => &full.peers,
|
||||
None => return Ok(vec![]),
|
||||
};
|
||||
|
||||
let result: Vec<PeerInfo> = peers
|
||||
.iter()
|
||||
.map(|p| {
|
||||
let latency_ms = p
|
||||
.latency
|
||||
.as_ref()
|
||||
.map(|d| d.seconds as f64 * 1000.0 + d.nanos as f64 / 1_000_000.0)
|
||||
.unwrap_or(0.0);
|
||||
|
||||
PeerInfo {
|
||||
ip: p.ip.clone(),
|
||||
pub_key: p.pub_key.clone(),
|
||||
fqdn: p.fqdn.clone(),
|
||||
conn_status: p.conn_status.clone(),
|
||||
conn_status_update: format_timestamp(&p.conn_status_update),
|
||||
relayed: p.relayed,
|
||||
relay_address: p.relay_address.clone(),
|
||||
latency_ms,
|
||||
bytes_rx: p.bytes_rx,
|
||||
bytes_tx: p.bytes_tx,
|
||||
rosenpass_enabled: p.rosenpass_enabled,
|
||||
networks: p.networks.clone(),
|
||||
last_handshake: format_timestamp(&p.last_wireguard_handshake),
|
||||
local_ice_type: p.local_ice_candidate_type.clone(),
|
||||
remote_ice_type: p.remote_ice_candidate_type.clone(),
|
||||
local_endpoint: p.local_ice_candidate_endpoint.clone(),
|
||||
remote_endpoint: p.remote_ice_candidate_endpoint.clone(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::proto;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ProfileInfo {
|
||||
pub name: String,
|
||||
pub is_active: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ActiveProfileInfo {
|
||||
pub profile_name: String,
|
||||
pub username: String,
|
||||
pub email: String,
|
||||
}
|
||||
|
||||
fn current_username() -> Result<String, String> {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
std::env::var("USER")
|
||||
.or_else(|_| std::env::var("LOGNAME"))
|
||||
.map_err(|_| "could not determine current user".to_string())
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
std::env::var("USERNAME")
|
||||
.map_err(|_| "could not determine current user".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_profiles(state: State<'_, AppState>) -> Result<Vec<ProfileInfo>, String> {
|
||||
let username = current_username()?;
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let resp = client
|
||||
.list_profiles(proto::ListProfilesRequest { username })
|
||||
.await
|
||||
.map_err(|e| format!("list profiles rpc: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
Ok(resp
|
||||
.profiles
|
||||
.iter()
|
||||
.map(|p| ProfileInfo {
|
||||
name: p.name.clone(),
|
||||
is_active: p.is_active,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_active_profile(state: State<'_, AppState>) -> Result<ActiveProfileInfo, String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let resp = client
|
||||
.get_active_profile(proto::GetActiveProfileRequest {})
|
||||
.await
|
||||
.map_err(|e| format!("get active profile rpc: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
Ok(ActiveProfileInfo {
|
||||
profile_name: resp.profile_name,
|
||||
username: resp.username,
|
||||
email: String::new(),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn switch_profile(
|
||||
state: State<'_, AppState>,
|
||||
profile_name: String,
|
||||
) -> Result<(), String> {
|
||||
let username = current_username()?;
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.switch_profile(proto::SwitchProfileRequest {
|
||||
profile_name: Some(profile_name),
|
||||
username: Some(username),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("switch profile: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn add_profile(
|
||||
state: State<'_, AppState>,
|
||||
profile_name: String,
|
||||
) -> Result<(), String> {
|
||||
let username = current_username()?;
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.add_profile(proto::AddProfileRequest {
|
||||
profile_name,
|
||||
username,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("add profile: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_profile(
|
||||
state: State<'_, AppState>,
|
||||
profile_name: String,
|
||||
) -> Result<(), String> {
|
||||
let username = current_username()?;
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.remove_profile(proto::RemoveProfileRequest {
|
||||
profile_name,
|
||||
username,
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("remove profile: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn logout(state: State<'_, AppState>, profile_name: String) -> Result<(), String> {
|
||||
let username = current_username()?;
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
client
|
||||
.logout(proto::LogoutRequest {
|
||||
profile_name: Some(profile_name),
|
||||
username: Some(username),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("logout: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::proto;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ConfigInfo {
|
||||
pub management_url: String,
|
||||
pub admin_url: String,
|
||||
pub pre_shared_key: String,
|
||||
pub interface_name: String,
|
||||
pub wireguard_port: i64,
|
||||
pub disable_auto_connect: bool,
|
||||
pub server_ssh_allowed: bool,
|
||||
pub rosenpass_enabled: bool,
|
||||
pub rosenpass_permissive: bool,
|
||||
pub lazy_connection_enabled: bool,
|
||||
pub block_inbound: bool,
|
||||
pub disable_notifications: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_config(state: State<'_, AppState>) -> Result<ConfigInfo, String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let resp = client
|
||||
.get_config(proto::GetConfigRequest {
|
||||
profile_name: String::new(),
|
||||
username: String::new(),
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("get config rpc: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
Ok(ConfigInfo {
|
||||
management_url: resp.management_url,
|
||||
admin_url: resp.admin_url,
|
||||
pre_shared_key: resp.pre_shared_key,
|
||||
interface_name: resp.interface_name,
|
||||
wireguard_port: resp.wireguard_port,
|
||||
disable_auto_connect: resp.disable_auto_connect,
|
||||
server_ssh_allowed: resp.server_ssh_allowed,
|
||||
rosenpass_enabled: resp.rosenpass_enabled,
|
||||
rosenpass_permissive: resp.rosenpass_permissive,
|
||||
lazy_connection_enabled: resp.lazy_connection_enabled,
|
||||
block_inbound: resp.block_inbound,
|
||||
disable_notifications: resp.disable_notifications,
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_config(state: State<'_, AppState>, cfg: ConfigInfo) -> Result<(), String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let req = proto::SetConfigRequest {
|
||||
username: String::new(),
|
||||
profile_name: String::new(),
|
||||
management_url: cfg.management_url,
|
||||
admin_url: cfg.admin_url,
|
||||
rosenpass_enabled: Some(cfg.rosenpass_enabled),
|
||||
interface_name: Some(cfg.interface_name),
|
||||
wireguard_port: Some(cfg.wireguard_port),
|
||||
optional_pre_shared_key: Some(cfg.pre_shared_key),
|
||||
disable_auto_connect: Some(cfg.disable_auto_connect),
|
||||
server_ssh_allowed: Some(cfg.server_ssh_allowed),
|
||||
rosenpass_permissive: Some(cfg.rosenpass_permissive),
|
||||
disable_notifications: Some(cfg.disable_notifications),
|
||||
lazy_connection_enabled: Some(cfg.lazy_connection_enabled),
|
||||
block_inbound: Some(cfg.block_inbound),
|
||||
// Fields we don't expose in the UI:
|
||||
network_monitor: None,
|
||||
disable_client_routes: None,
|
||||
disable_server_routes: None,
|
||||
disable_dns: None,
|
||||
disable_firewall: None,
|
||||
block_lan_access: None,
|
||||
nat_external_i_ps: vec![],
|
||||
clean_nat_external_i_ps: false,
|
||||
custom_dns_address: vec![],
|
||||
extra_i_face_blacklist: vec![],
|
||||
dns_labels: vec![],
|
||||
clean_dns_labels: false,
|
||||
dns_route_interval: None,
|
||||
mtu: None,
|
||||
enable_ssh_root: None,
|
||||
enable_sshsftp: None,
|
||||
enable_ssh_local_port_forwarding: None,
|
||||
enable_ssh_remote_port_forwarding: None,
|
||||
disable_ssh_auth: None,
|
||||
ssh_jwt_cache_ttl: None,
|
||||
};
|
||||
client
|
||||
.set_config(req)
|
||||
.await
|
||||
.map_err(|e| format!("set config: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Toggle helpers - each fetches config, modifies one field, and saves.
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_ssh(state: State<'_, AppState>, enabled: bool) -> Result<(), String> {
|
||||
let mut cfg = get_config(state.clone()).await?;
|
||||
cfg.server_ssh_allowed = enabled;
|
||||
set_config(state, cfg).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_auto_connect(state: State<'_, AppState>, enabled: bool) -> Result<(), String> {
|
||||
let mut cfg = get_config(state.clone()).await?;
|
||||
cfg.disable_auto_connect = !enabled;
|
||||
set_config(state, cfg).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_rosenpass(state: State<'_, AppState>, enabled: bool) -> Result<(), String> {
|
||||
let mut cfg = get_config(state.clone()).await?;
|
||||
cfg.rosenpass_enabled = enabled;
|
||||
set_config(state, cfg).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_lazy_conn(state: State<'_, AppState>, enabled: bool) -> Result<(), String> {
|
||||
let mut cfg = get_config(state.clone()).await?;
|
||||
cfg.lazy_connection_enabled = enabled;
|
||||
set_config(state, cfg).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_block_inbound(
|
||||
state: State<'_, AppState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = get_config(state.clone()).await?;
|
||||
cfg.block_inbound = enabled;
|
||||
set_config(state, cfg).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn toggle_notifications(
|
||||
state: State<'_, AppState>,
|
||||
enabled: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut cfg = get_config(state.clone()).await?;
|
||||
cfg.disable_notifications = !enabled;
|
||||
set_config(state, cfg).await
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::proto;
|
||||
use crate::state::AppState;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct InstallerResult {
|
||||
pub success: bool,
|
||||
pub error_msg: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn trigger_update() -> Result<(), String> {
|
||||
// Stub - same as the Go implementation
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_installer_result(state: State<'_, AppState>) -> Result<InstallerResult, String> {
|
||||
let mut client = state.grpc.get_client().await?;
|
||||
let resp = client
|
||||
.get_installer_result(proto::InstallerResultRequest {})
|
||||
.await;
|
||||
|
||||
match resp {
|
||||
Ok(r) => {
|
||||
let inner = r.into_inner();
|
||||
Ok(InstallerResult {
|
||||
success: inner.success,
|
||||
error_msg: inner.error_msg,
|
||||
})
|
||||
}
|
||||
Err(_) => {
|
||||
// Daemon may have restarted during update - treat as success
|
||||
Ok(InstallerResult {
|
||||
success: true,
|
||||
error_msg: String::new(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
use crate::grpc::GrpcClient;
|
||||
use crate::proto;
|
||||
|
||||
/// Start the daemon event subscription loop with exponential backoff.
|
||||
pub fn start_event_subscription(app: AppHandle, grpc: GrpcClient) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut backoff = Duration::from_secs(1);
|
||||
let max_backoff = Duration::from_secs(10);
|
||||
|
||||
loop {
|
||||
match stream_events(&app, &grpc).await {
|
||||
Ok(()) => {
|
||||
backoff = Duration::from_secs(1);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("event stream ended: {}", e);
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(backoff).await;
|
||||
backoff = (backoff * 2).min(max_backoff);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn stream_events(app: &AppHandle, grpc: &GrpcClient) -> Result<(), String> {
|
||||
let mut client = grpc.get_client().await?;
|
||||
let mut stream = client
|
||||
.subscribe_events(proto::SubscribeRequest {})
|
||||
.await
|
||||
.map_err(|e| format!("subscribe events: {}", e))?
|
||||
.into_inner();
|
||||
|
||||
log::info!("subscribed to daemon events");
|
||||
|
||||
while let Some(event) = stream
|
||||
.message()
|
||||
.await
|
||||
.map_err(|e| format!("receive event: {}", e))?
|
||||
{
|
||||
handle_event(app, &event);
|
||||
}
|
||||
|
||||
log::info!("event stream ended");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_event(app: &AppHandle, event: &proto::SystemEvent) {
|
||||
// Send desktop notification for events with user_message
|
||||
if !event.user_message.is_empty() {
|
||||
let title = get_event_title(event);
|
||||
let mut body = event.user_message.clone();
|
||||
if let Some(id) = event.metadata.get("id") {
|
||||
body.push_str(&format!(" ID: {}", id));
|
||||
}
|
||||
|
||||
if let Err(e) = notify_rust::Notification::new()
|
||||
.summary(&title)
|
||||
.body(&body)
|
||||
.appname("NetBird")
|
||||
.show()
|
||||
{
|
||||
log::debug!("notification failed: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Emit to frontend
|
||||
let _ = app.emit("daemon-event", &event.user_message);
|
||||
}
|
||||
|
||||
fn get_event_title(event: &proto::SystemEvent) -> String {
|
||||
let prefix = match proto::system_event::Severity::try_from(event.severity) {
|
||||
Ok(proto::system_event::Severity::Critical) => "Critical",
|
||||
Ok(proto::system_event::Severity::Error) => "Error",
|
||||
Ok(proto::system_event::Severity::Warning) => "Warning",
|
||||
_ => "Info",
|
||||
};
|
||||
|
||||
let category = match proto::system_event::Category::try_from(event.category) {
|
||||
Ok(proto::system_event::Category::Dns) => "DNS",
|
||||
Ok(proto::system_event::Category::Network) => "Network",
|
||||
Ok(proto::system_event::Category::Authentication) => "Authentication",
|
||||
Ok(proto::system_event::Category::Connectivity) => "Connectivity",
|
||||
_ => "System",
|
||||
};
|
||||
|
||||
format!("{}: {}", prefix, category)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::Mutex;
|
||||
use tonic::transport::{Channel, Endpoint, Uri};
|
||||
|
||||
use crate::proto::daemon_service_client::DaemonServiceClient;
|
||||
|
||||
/// GrpcClient manages a persistent gRPC connection to the NetBird daemon.
|
||||
#[derive(Clone)]
|
||||
pub struct GrpcClient {
|
||||
addr: String,
|
||||
client: Arc<Mutex<Option<DaemonServiceClient<Channel>>>>,
|
||||
}
|
||||
|
||||
impl GrpcClient {
|
||||
pub fn new(addr: String) -> Self {
|
||||
Self {
|
||||
addr,
|
||||
client: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a cached DaemonServiceClient, creating the connection on first use.
|
||||
/// If the connection fails or was previously dropped, a new connection is attempted.
|
||||
pub async fn get_client(&self) -> Result<DaemonServiceClient<Channel>, String> {
|
||||
let mut guard = self.client.lock().await;
|
||||
if let Some(ref client) = *guard {
|
||||
return Ok(client.clone());
|
||||
}
|
||||
|
||||
let channel = self.connect().await?;
|
||||
let client = DaemonServiceClient::new(channel);
|
||||
*guard = Some(client.clone());
|
||||
log::info!("gRPC connection established to {}", self.addr);
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Clears the cached client so the next call to get_client will reconnect.
|
||||
pub async fn reset(&self) {
|
||||
let mut guard = self.client.lock().await;
|
||||
*guard = None;
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<Channel, String> {
|
||||
let addr = &self.addr;
|
||||
|
||||
#[cfg(unix)]
|
||||
if addr.starts_with("unix://") {
|
||||
return self.connect_unix(addr).await;
|
||||
}
|
||||
|
||||
// TCP connection
|
||||
let target = if addr.starts_with("tcp://") {
|
||||
addr.strip_prefix("tcp://").unwrap_or(addr)
|
||||
} else {
|
||||
addr.as_str()
|
||||
};
|
||||
|
||||
let uri = format!("http://{}", target);
|
||||
Endpoint::from_shared(uri)
|
||||
.map_err(|e| format!("invalid endpoint: {}", e))?
|
||||
.connect()
|
||||
.await
|
||||
.map_err(|e| format!("connect tcp: {}", e))
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
async fn connect_unix(&self, addr: &str) -> Result<Channel, String> {
|
||||
let path = addr
|
||||
.strip_prefix("unix://")
|
||||
.unwrap_or(addr)
|
||||
.to_string();
|
||||
|
||||
// tonic requires a valid URI even for UDS; the actual connection
|
||||
// is made by the connector below, so the URI authority is ignored.
|
||||
let channel = Endpoint::try_from("http://[::]:50051")
|
||||
.map_err(|e| format!("invalid endpoint: {}", e))?
|
||||
.connect_with_connector(tower::service_fn(move |_: Uri| {
|
||||
let path = path.clone();
|
||||
async move {
|
||||
let stream = tokio::net::UnixStream::connect(&path).await?;
|
||||
Ok::<_, std::io::Error>(hyper_util::rt::TokioIo::new(stream))
|
||||
}
|
||||
}))
|
||||
.await
|
||||
.map_err(|e| format!("connect unix: {}", e))?;
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
/// Close the connection (drop the cached client).
|
||||
pub async fn close(&self) {
|
||||
let mut guard = self.client.lock().await;
|
||||
*guard = None;
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the default daemon address for the current platform.
|
||||
pub fn default_daemon_addr() -> String {
|
||||
if cfg!(windows) {
|
||||
"tcp://127.0.0.1:41731".to_string()
|
||||
} else {
|
||||
"unix:///var/run/netbird.sock".to_string()
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
// Prevents additional console window on Windows in release
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
mod commands;
|
||||
mod events;
|
||||
mod grpc;
|
||||
mod proto;
|
||||
mod state;
|
||||
mod tray;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
use grpc::{default_daemon_addr, GrpcClient};
|
||||
use state::AppState;
|
||||
|
||||
fn main() {
|
||||
env_logger::init();
|
||||
|
||||
// Linux WebKit workaround
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
|
||||
let daemon_addr =
|
||||
std::env::var("NETBIRD_DAEMON_ADDR").unwrap_or_else(|_| default_daemon_addr());
|
||||
|
||||
log::info!("NetBird UI starting, daemon address: {}", daemon_addr);
|
||||
|
||||
let grpc_client = GrpcClient::new(daemon_addr.clone());
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
// Focus existing window when second instance is launched
|
||||
if let Some(win) = app.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}))
|
||||
.manage(AppState {
|
||||
grpc: grpc_client.clone(),
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
// Connection
|
||||
commands::connection::get_status,
|
||||
commands::connection::connect,
|
||||
commands::connection::disconnect,
|
||||
// Settings
|
||||
commands::settings::get_config,
|
||||
commands::settings::set_config,
|
||||
commands::settings::toggle_ssh,
|
||||
commands::settings::toggle_auto_connect,
|
||||
commands::settings::toggle_rosenpass,
|
||||
commands::settings::toggle_lazy_conn,
|
||||
commands::settings::toggle_block_inbound,
|
||||
commands::settings::toggle_notifications,
|
||||
// Network
|
||||
commands::network::list_networks,
|
||||
commands::network::list_overlapping_networks,
|
||||
commands::network::list_exit_nodes,
|
||||
commands::network::select_network,
|
||||
commands::network::deselect_network,
|
||||
commands::network::select_networks,
|
||||
commands::network::deselect_networks,
|
||||
commands::network::select_all_networks,
|
||||
commands::network::deselect_all_networks,
|
||||
// Peers
|
||||
commands::peers::get_peers,
|
||||
// Profile
|
||||
commands::profile::list_profiles,
|
||||
commands::profile::get_active_profile,
|
||||
commands::profile::switch_profile,
|
||||
commands::profile::add_profile,
|
||||
commands::profile::remove_profile,
|
||||
commands::profile::logout,
|
||||
// Debug
|
||||
commands::debug::create_debug_bundle,
|
||||
commands::debug::get_log_level,
|
||||
commands::debug::set_log_level,
|
||||
// Update
|
||||
commands::update::trigger_update,
|
||||
commands::update::get_installer_result,
|
||||
])
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
|
||||
// Setup system tray
|
||||
if let Err(e) = tray::setup_tray(&handle) {
|
||||
log::error!("tray setup failed: {}", e);
|
||||
}
|
||||
|
||||
// Start daemon event subscription
|
||||
events::start_event_subscription(handle, grpc_client);
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
// Hide instead of quit when user closes the window
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
})
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error running tauri application");
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
tonic::include_proto!("daemon");
|
||||
@@ -1,6 +0,0 @@
|
||||
use crate::grpc::GrpcClient;
|
||||
|
||||
/// Application state shared across all Tauri commands.
|
||||
pub struct AppState {
|
||||
pub grpc: GrpcClient,
|
||||
}
|
||||
@@ -1,420 +0,0 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use tauri::image::Image;
|
||||
use tauri::menu::{CheckMenuItem, CheckMenuItemBuilder, MenuBuilder, MenuItem, MenuItemBuilder, SubmenuBuilder};
|
||||
use tauri::tray::TrayIconBuilder;
|
||||
use tauri::{AppHandle, Emitter, Manager};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::commands::connection::StatusInfo;
|
||||
use crate::grpc::GrpcClient;
|
||||
use crate::proto;
|
||||
use crate::state::AppState;
|
||||
|
||||
const STATUS_POLL_INTERVAL: Duration = Duration::from_secs(5);
|
||||
|
||||
// Icon bytes embedded at compile time
|
||||
const ICON_DISCONNECTED: &[u8] = include_bytes!("../icons/netbird-systemtray-disconnected.png");
|
||||
const ICON_CONNECTED: &[u8] = include_bytes!("../icons/netbird-systemtray-connected.png");
|
||||
const ICON_CONNECTING: &[u8] = include_bytes!("../icons/netbird-systemtray-connecting.png");
|
||||
const ICON_ERROR: &[u8] = include_bytes!("../icons/netbird-systemtray-error.png");
|
||||
|
||||
fn icon_for_status(status: &str) -> &'static [u8] {
|
||||
match status {
|
||||
"Connected" => ICON_CONNECTED,
|
||||
"Connecting" => ICON_CONNECTING,
|
||||
"Disconnected" | "" => ICON_DISCONNECTED,
|
||||
_ => ICON_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
/// Holds references to menu items we need to update at runtime.
|
||||
pub struct TrayMenuItems {
|
||||
pub status_item: MenuItem<tauri::Wry>,
|
||||
pub ssh_item: CheckMenuItem<tauri::Wry>,
|
||||
pub auto_connect_item: CheckMenuItem<tauri::Wry>,
|
||||
pub rosenpass_item: CheckMenuItem<tauri::Wry>,
|
||||
pub lazy_conn_item: CheckMenuItem<tauri::Wry>,
|
||||
pub block_inbound_item: CheckMenuItem<tauri::Wry>,
|
||||
pub notifications_item: CheckMenuItem<tauri::Wry>,
|
||||
}
|
||||
|
||||
pub type SharedTrayMenuItems = Arc<Mutex<Option<TrayMenuItems>>>;
|
||||
|
||||
pub fn setup_tray(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let grpc = app.state::<AppState>().grpc.clone();
|
||||
|
||||
// Build the tray menu
|
||||
let status_item = MenuItemBuilder::with_id("status", "Status: Disconnected")
|
||||
.enabled(false)
|
||||
.build(app)?;
|
||||
|
||||
let connect_item = MenuItemBuilder::with_id("connect", "Connect").build(app)?;
|
||||
let disconnect_item = MenuItemBuilder::with_id("disconnect", "Disconnect").build(app)?;
|
||||
|
||||
let ssh_item = CheckMenuItemBuilder::with_id("toggle_ssh", "Allow SSH connections")
|
||||
.checked(false)
|
||||
.build(app)?;
|
||||
let auto_connect_item =
|
||||
CheckMenuItemBuilder::with_id("toggle_auto_connect", "Connect automatically when service starts")
|
||||
.checked(false)
|
||||
.build(app)?;
|
||||
let rosenpass_item =
|
||||
CheckMenuItemBuilder::with_id("toggle_rosenpass", "Enable post-quantum security via Rosenpass")
|
||||
.checked(false)
|
||||
.build(app)?;
|
||||
let lazy_conn_item =
|
||||
CheckMenuItemBuilder::with_id("toggle_lazy_conn", "[Experimental] Enable lazy connections")
|
||||
.checked(false)
|
||||
.build(app)?;
|
||||
let block_inbound_item =
|
||||
CheckMenuItemBuilder::with_id("toggle_block_inbound", "Block inbound connections")
|
||||
.checked(false)
|
||||
.build(app)?;
|
||||
let notifications_item =
|
||||
CheckMenuItemBuilder::with_id("toggle_notifications", "Enable notifications")
|
||||
.checked(true)
|
||||
.build(app)?;
|
||||
|
||||
// Exit node submenu
|
||||
let exit_node_menu = SubmenuBuilder::with_id(app, "exit_node", "Exit Node")
|
||||
.item(
|
||||
&MenuItemBuilder::with_id("no_exit_nodes", "No exit nodes")
|
||||
.enabled(false)
|
||||
.build(app)?,
|
||||
)
|
||||
.build()?;
|
||||
|
||||
// Navigation items
|
||||
let nav_status = MenuItemBuilder::with_id("nav_status", "Status").build(app)?;
|
||||
let nav_settings = MenuItemBuilder::with_id("nav_settings", "Settings").build(app)?;
|
||||
let nav_peers = MenuItemBuilder::with_id("nav_peers", "Peers").build(app)?;
|
||||
let nav_networks = MenuItemBuilder::with_id("nav_networks", "Networks").build(app)?;
|
||||
let nav_profiles = MenuItemBuilder::with_id("nav_profiles", "Profiles").build(app)?;
|
||||
let nav_debug = MenuItemBuilder::with_id("nav_debug", "Debug").build(app)?;
|
||||
let nav_update = MenuItemBuilder::with_id("nav_update", "Update").build(app)?;
|
||||
|
||||
let quit_item = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||
|
||||
let menu = MenuBuilder::new(app)
|
||||
.item(&status_item)
|
||||
.separator()
|
||||
.item(&connect_item)
|
||||
.item(&disconnect_item)
|
||||
.separator()
|
||||
.item(&ssh_item)
|
||||
.item(&auto_connect_item)
|
||||
.item(&rosenpass_item)
|
||||
.item(&lazy_conn_item)
|
||||
.item(&block_inbound_item)
|
||||
.item(¬ifications_item)
|
||||
.separator()
|
||||
.item(&exit_node_menu)
|
||||
.separator()
|
||||
.item(&nav_status)
|
||||
.item(&nav_settings)
|
||||
.item(&nav_peers)
|
||||
.item(&nav_networks)
|
||||
.item(&nav_profiles)
|
||||
.item(&nav_debug)
|
||||
.item(&nav_update)
|
||||
.separator()
|
||||
.item(&quit_item)
|
||||
.build()?;
|
||||
|
||||
// Store menu item references for runtime updates
|
||||
let menu_items: SharedTrayMenuItems = Arc::new(Mutex::new(Some(TrayMenuItems {
|
||||
status_item,
|
||||
ssh_item: ssh_item.clone(),
|
||||
auto_connect_item: auto_connect_item.clone(),
|
||||
rosenpass_item: rosenpass_item.clone(),
|
||||
lazy_conn_item: lazy_conn_item.clone(),
|
||||
block_inbound_item: block_inbound_item.clone(),
|
||||
notifications_item: notifications_item.clone(),
|
||||
})));
|
||||
app.manage(menu_items.clone());
|
||||
|
||||
let _tray = TrayIconBuilder::with_id("main")
|
||||
.icon(Image::from_bytes(ICON_DISCONNECTED)?)
|
||||
.icon_as_template(cfg!(target_os = "macos"))
|
||||
.menu(&menu)
|
||||
.on_menu_event({
|
||||
let app_handle = app.clone();
|
||||
let grpc = grpc.clone();
|
||||
move |_app, event| {
|
||||
let id = event.id().as_ref();
|
||||
let app_handle = app_handle.clone();
|
||||
let grpc = grpc.clone();
|
||||
|
||||
match id {
|
||||
"connect" => {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut client = match grpc.get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("connect: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = client
|
||||
.up(proto::UpRequest {
|
||||
profile_name: None,
|
||||
username: None,
|
||||
auto_update: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
log::error!("connect: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
"disconnect" => {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut client = match grpc.get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("disconnect: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = client.down(proto::DownRequest {}).await {
|
||||
log::error!("disconnect: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
"toggle_ssh" | "toggle_auto_connect" | "toggle_rosenpass"
|
||||
| "toggle_lazy_conn" | "toggle_block_inbound" | "toggle_notifications" => {
|
||||
let toggle_id = id.to_string();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
handle_toggle(&app_handle, &grpc, &toggle_id).await;
|
||||
});
|
||||
}
|
||||
s if s.starts_with("nav_") => {
|
||||
let path = match s {
|
||||
"nav_status" => "/",
|
||||
"nav_settings" => "/settings",
|
||||
"nav_peers" => "/peers",
|
||||
"nav_networks" => "/networks",
|
||||
"nav_profiles" => "/profiles",
|
||||
"nav_debug" => "/debug",
|
||||
"nav_update" => "/update",
|
||||
_ => return,
|
||||
};
|
||||
let _ = app_handle.emit("navigate", path);
|
||||
if let Some(win) = app_handle.get_webview_window("main") {
|
||||
let _ = win.show();
|
||||
let _ = win.set_focus();
|
||||
}
|
||||
}
|
||||
"quit" => {
|
||||
app_handle.exit(0);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
})
|
||||
.build(app)?;
|
||||
|
||||
// Refresh toggle states
|
||||
let app_handle = app.clone();
|
||||
let grpc_clone = grpc.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
refresh_toggle_states(&app_handle, &grpc_clone).await;
|
||||
});
|
||||
|
||||
// Start status polling
|
||||
let app_handle = app.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
poll_status(app_handle, grpc).await;
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn poll_status(app: AppHandle, grpc: GrpcClient) {
|
||||
loop {
|
||||
tokio::time::sleep(STATUS_POLL_INTERVAL).await;
|
||||
|
||||
let mut client = match grpc.get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::warn!("pollStatus: {}", e);
|
||||
grpc.reset().await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let resp = match client
|
||||
.status(proto::StatusRequest {
|
||||
get_full_peer_status: true,
|
||||
should_run_probes: false,
|
||||
wait_for_ready: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.into_inner(),
|
||||
Err(e) => {
|
||||
log::warn!("pollStatus: status rpc: {}", e);
|
||||
grpc.reset().await;
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let mut info = StatusInfo {
|
||||
status: resp.status.clone(),
|
||||
ip: String::new(),
|
||||
public_key: String::new(),
|
||||
fqdn: String::new(),
|
||||
connected_peers: 0,
|
||||
};
|
||||
|
||||
if let Some(ref full) = resp.full_status {
|
||||
if let Some(ref lp) = full.local_peer_state {
|
||||
info.ip = lp.ip.clone();
|
||||
info.public_key = lp.pub_key.clone();
|
||||
info.fqdn = lp.fqdn.clone();
|
||||
}
|
||||
info.connected_peers = full.peers.len();
|
||||
}
|
||||
|
||||
// Update tray label
|
||||
let label = if info.ip.is_empty() {
|
||||
format!("Status: {}", info.status)
|
||||
} else {
|
||||
format!("Status: {} ({})", info.status, info.ip)
|
||||
};
|
||||
|
||||
// Update tray menu status label via stored reference
|
||||
let menu_items = app.state::<SharedTrayMenuItems>();
|
||||
if let Some(ref items) = *menu_items.lock().await {
|
||||
let _ = items.status_item.set_text(&label);
|
||||
}
|
||||
|
||||
// Update tray icon
|
||||
if let Some(tray) = app.tray_by_id("main") {
|
||||
let icon_bytes = icon_for_status(&info.status);
|
||||
if let Ok(icon) = Image::from_bytes(icon_bytes) {
|
||||
let _ = tray.set_icon(Some(icon));
|
||||
}
|
||||
}
|
||||
|
||||
// Emit status-changed event to frontend
|
||||
let _ = app.emit("status-changed", &info);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_toggle(app: &AppHandle, grpc: &GrpcClient, toggle_id: &str) {
|
||||
let mut client = match grpc.get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("toggle: get client: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Get current config
|
||||
let cfg = match client
|
||||
.get_config(proto::GetConfigRequest {
|
||||
profile_name: String::new(),
|
||||
username: String::new(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.into_inner(),
|
||||
Err(e) => {
|
||||
log::error!("toggle: get config: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Build set config request based on which toggle was clicked
|
||||
let mut req = proto::SetConfigRequest {
|
||||
username: String::new(),
|
||||
profile_name: String::new(),
|
||||
management_url: cfg.management_url,
|
||||
admin_url: cfg.admin_url,
|
||||
rosenpass_enabled: Some(cfg.rosenpass_enabled),
|
||||
interface_name: Some(cfg.interface_name),
|
||||
wireguard_port: Some(cfg.wireguard_port),
|
||||
optional_pre_shared_key: Some(cfg.pre_shared_key),
|
||||
disable_auto_connect: Some(cfg.disable_auto_connect),
|
||||
server_ssh_allowed: Some(cfg.server_ssh_allowed),
|
||||
rosenpass_permissive: Some(cfg.rosenpass_permissive),
|
||||
disable_notifications: Some(cfg.disable_notifications),
|
||||
lazy_connection_enabled: Some(cfg.lazy_connection_enabled),
|
||||
block_inbound: Some(cfg.block_inbound),
|
||||
network_monitor: None,
|
||||
disable_client_routes: None,
|
||||
disable_server_routes: None,
|
||||
disable_dns: None,
|
||||
disable_firewall: None,
|
||||
block_lan_access: None,
|
||||
nat_external_i_ps: vec![],
|
||||
clean_nat_external_i_ps: false,
|
||||
custom_dns_address: vec![],
|
||||
extra_i_face_blacklist: vec![],
|
||||
dns_labels: vec![],
|
||||
clean_dns_labels: false,
|
||||
dns_route_interval: None,
|
||||
mtu: None,
|
||||
enable_ssh_root: None,
|
||||
enable_sshsftp: None,
|
||||
enable_ssh_local_port_forwarding: None,
|
||||
enable_ssh_remote_port_forwarding: None,
|
||||
disable_ssh_auth: None,
|
||||
ssh_jwt_cache_ttl: None,
|
||||
};
|
||||
|
||||
match toggle_id {
|
||||
"toggle_ssh" => req.server_ssh_allowed = Some(!cfg.server_ssh_allowed),
|
||||
"toggle_auto_connect" => req.disable_auto_connect = Some(!cfg.disable_auto_connect),
|
||||
"toggle_rosenpass" => req.rosenpass_enabled = Some(!cfg.rosenpass_enabled),
|
||||
"toggle_lazy_conn" => req.lazy_connection_enabled = Some(!cfg.lazy_connection_enabled),
|
||||
"toggle_block_inbound" => req.block_inbound = Some(!cfg.block_inbound),
|
||||
"toggle_notifications" => req.disable_notifications = Some(!cfg.disable_notifications),
|
||||
_ => return,
|
||||
}
|
||||
|
||||
if let Err(e) = client.set_config(req).await {
|
||||
log::error!("toggle {}: set config: {}", toggle_id, e);
|
||||
}
|
||||
|
||||
// Refresh toggle states after change
|
||||
refresh_toggle_states(app, grpc).await;
|
||||
}
|
||||
|
||||
async fn refresh_toggle_states(app: &AppHandle, grpc: &GrpcClient) {
|
||||
let mut client = match grpc.get_client().await {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::debug!("refresh toggles: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let cfg = match client
|
||||
.get_config(proto::GetConfigRequest {
|
||||
profile_name: String::new(),
|
||||
username: String::new(),
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(r) => r.into_inner(),
|
||||
Err(e) => {
|
||||
log::debug!("refresh toggles: get config: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let menu_items = app.state::<SharedTrayMenuItems>();
|
||||
let guard = menu_items.lock().await;
|
||||
if let Some(ref items) = *guard {
|
||||
let _ = items.ssh_item.set_checked(cfg.server_ssh_allowed);
|
||||
let _ = items.auto_connect_item.set_checked(!cfg.disable_auto_connect);
|
||||
let _ = items.rosenpass_item.set_checked(cfg.rosenpass_enabled);
|
||||
let _ = items.lazy_conn_item.set_checked(cfg.lazy_connection_enabled);
|
||||
let _ = items.block_inbound_item.set_checked(cfg.block_inbound);
|
||||
let _ = items.notifications_item.set_checked(!cfg.disable_notifications);
|
||||
}
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
|
||||
"productName": "NetBird",
|
||||
"identifier": "io.netbird.client",
|
||||
"version": "0.1.0",
|
||||
"build": {
|
||||
"frontendDist": "../frontend/dist",
|
||||
"beforeBuildCommand": "cd ../frontend && npm run build"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "NetBird",
|
||||
"width": 900,
|
||||
"height": 650,
|
||||
"visible": false,
|
||||
"resizable": true,
|
||||
"skipTaskbar": true
|
||||
}
|
||||
],
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/netbird-systemtray-disconnected.png",
|
||||
"iconAsTemplate": true
|
||||
},
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/netbird.png"
|
||||
]
|
||||
}
|
||||
}
|
||||
4
client/uiwails/.gitignore
vendored
@@ -1,4 +0,0 @@
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
bin/
|
||||
.task/
|
||||
@@ -1,32 +0,0 @@
|
||||
version: '3'
|
||||
|
||||
includes:
|
||||
common: ./build/Taskfile.yml
|
||||
linux: ./build/linux/Taskfile.yml
|
||||
darwin: ./build/darwin/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}}
|
||||
|
Before Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 4.9 KiB |