From 8eafe4ac2c8dfeebaf50c7749d1a49b559758096 Mon Sep 17 00:00:00 2001 From: Andrew_Moryakov Date: Tue, 17 Feb 2026 20:21:11 +0800 Subject: [PATCH] feat: add NetBird VPN support and VPN autostart mutual exclusion Add NetBird as a second VPN option alongside Tailscale, using the official NetBird cloud server. Only one VPN runs at a time due to NanoKVM Cube's 161MB RAM constraint. Backend: - NetBird service with install/uninstall/start/stop/restart/login/down - CLI wrapper with socket-based daemon communication and timeout handling - Bundled binary installation with version tracking - VPN preference service (/etc/kvm/vpn) for mutual exclusion Frontend: - NetBird settings tab mirroring Tailscale's state machine UI - Autostart toggle on both Tailscale and NetBird headers - Device flow login: Login button -> URL -> "I have logged in" - Device panel with enable switch, info rows, disconnect button Init scripts: - S99netbird init script with TUN device setup and GOMEMLIMIT=30MiB - S95nanokvm reads /etc/kvm/vpn preference, starts only selected VPN - Swap setup and OOM protection for NanoKVM-Server process --- kvmapp/system/init.d/S95nanokvm | 132 +++++++++- kvmapp/system/init.d/S99netbird | 80 ++++++ server/proto/netbird.go | 22 ++ server/proto/vpn.go | 9 + server/router/extensions.go | 17 ++ server/service/extensions/netbird/cli.go | 246 ++++++++++++++++++ server/service/extensions/netbird/install.go | 104 ++++++++ server/service/extensions/netbird/service.go | 216 +++++++++++++++ server/service/extensions/vpn/service.go | 95 +++++++ web/src/api/extensions/netbird.ts | 33 +++ web/src/api/extensions/vpn.ts | 9 + web/src/components/icons/netbird.tsx | 7 + web/src/i18n/locales/en.ts | 39 +++ web/src/pages/desktop/menu/settings/index.tsx | 7 + .../desktop/menu/settings/netbird/device.tsx | 112 ++++++++ .../menu/settings/netbird/error-help.tsx | 65 +++++ .../desktop/menu/settings/netbird/header.tsx | 136 ++++++++++ .../desktop/menu/settings/netbird/index.tsx | 83 ++++++ .../desktop/menu/settings/netbird/install.tsx | 57 ++++ .../desktop/menu/settings/netbird/login.tsx | 80 ++++++ .../desktop/menu/settings/netbird/run.tsx | 56 ++++ .../desktop/menu/settings/netbird/types.ts | 8 + .../menu/settings/tailscale/header.tsx | 54 +++- 23 files changed, 1651 insertions(+), 16 deletions(-) create mode 100644 kvmapp/system/init.d/S99netbird create mode 100644 server/proto/netbird.go create mode 100644 server/proto/vpn.go create mode 100644 server/service/extensions/netbird/cli.go create mode 100644 server/service/extensions/netbird/install.go create mode 100644 server/service/extensions/netbird/service.go create mode 100644 server/service/extensions/vpn/service.go create mode 100644 web/src/api/extensions/netbird.ts create mode 100644 web/src/api/extensions/vpn.ts create mode 100644 web/src/components/icons/netbird.tsx create mode 100644 web/src/pages/desktop/menu/settings/netbird/device.tsx create mode 100644 web/src/pages/desktop/menu/settings/netbird/error-help.tsx create mode 100644 web/src/pages/desktop/menu/settings/netbird/header.tsx create mode 100644 web/src/pages/desktop/menu/settings/netbird/index.tsx create mode 100644 web/src/pages/desktop/menu/settings/netbird/install.tsx create mode 100644 web/src/pages/desktop/menu/settings/netbird/login.tsx create mode 100644 web/src/pages/desktop/menu/settings/netbird/run.tsx create mode 100644 web/src/pages/desktop/menu/settings/netbird/types.ts diff --git a/kvmapp/system/init.d/S95nanokvm b/kvmapp/system/init.d/S95nanokvm index a40cc171..eb64e546 100755 --- a/kvmapp/system/init.d/S95nanokvm +++ b/kvmapp/system/init.d/S95nanokvm @@ -1,5 +1,56 @@ #!/bin/sh -# nanokvm Rev3.1 +# nanokvm Rev3.2 + +SWAPFILE="/data/swapfile" +SWAP_SIZE_MB=128 + +setup_swap() { + if [ -f "$SWAPFILE" ]; then + swapon "$SWAPFILE" 2>/dev/null + return + fi + + echo "Creating ${SWAP_SIZE_MB}MB swap file..." + dd if=/dev/zero of="$SWAPFILE" bs=1M count=$SWAP_SIZE_MB 2>/dev/null + chmod 600 "$SWAPFILE" + mkswap "$SWAPFILE" >/dev/null 2>&1 + swapon "$SWAPFILE" 2>/dev/null + echo "Swap enabled: $SWAPFILE" +} + +stop_tailscale() { + if [ -x /etc/init.d/S98tailscaled ]; then + /etc/init.d/S98tailscaled stop >/dev/null 2>&1 + fi +} + +stop_netbird() { + if [ -x /etc/init.d/S99netbird ]; then + /etc/init.d/S99netbird stop >/dev/null 2>&1 + fi + killall netbird 2>/dev/null +} + +start_tailscale() { + if [ -x /usr/sbin/tailscaled ] && [ -x /usr/bin/tailscale ] && [ -f /kvmapp/system/init.d/S98tailscaled ]; then + cp -f /kvmapp/system/init.d/S98tailscaled /etc/init.d/S98tailscaled + chmod 755 /etc/init.d/S98tailscaled + /etc/init.d/S98tailscaled start >/dev/null 2>&1 & + fi +} + +start_netbird() { + if [ -f /kvmapp/system/netbird/netbird ]; then + cp -f /kvmapp/system/netbird/netbird /usr/bin/netbird + chmod 755 /usr/bin/netbird + fi + + if [ -x /usr/bin/netbird ] && [ -f /kvmapp/system/init.d/S99netbird ]; then + cp -f /kvmapp/system/init.d/S99netbird /etc/init.d/S99netbird + chmod 755 /etc/init.d/S99netbird + /etc/init.d/S99netbird start >/dev/null 2>&1 & + fi +} case "$1" in start) @@ -16,6 +67,9 @@ case "$1" in echo "$first_uint$second_uint" > /device_key fi + # Enable swap before starting Go services + setup_swap + # Set iptables rules (skip if already present) iptables -C INPUT -i eth0 -p tcp --dport 80 -m state --state NEW,ESTABLISHED -j ACCEPT 2>/dev/null || \ iptables -A INPUT -i eth0 -p tcp --dport 80 -m state --state NEW,ESTABLISHED -j ACCEPT @@ -32,38 +86,92 @@ case "$1" in iptables -C OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP 2>/dev/null || \ iptables -A OUTPUT -o eth0 -p tcp --sport 8000 -m state --state ESTABLISHED -j DROP - # Start services + # Start services with GOMEMLIMIT cp -r /kvmapp/kvm_system /tmp/ /tmp/kvm_system/kvm_system & cp -r /kvmapp/server /tmp/ - /tmp/server/NanoKVM-Server & + GOMEMLIMIT=20MiB /tmp/server/NanoKVM-Server & + SERVER_PID=$! + + # Protect NanoKVM-Server from OOM killer + sleep 1 + if [ -d "/proc/$SERVER_PID" ]; then + echo -800 > /proc/$SERVER_PID/oom_score_adj 2>/dev/null + fi + + # Read VPN preference (default: tailscale) + VPN_PREF="tailscale" + if [ -f /etc/kvm/vpn ]; then + VPN_PREF=$(cat /etc/kvm/vpn) + fi + + case "$VPN_PREF" in + netbird) + rm -f /etc/init.d/S98tailscaled + start_netbird + ;; + *) + rm -f /etc/init.d/S99netbird + start_tailscale + ;; + esac ;; stop) - killall kvm_system - killall NanoKVM-Server - rm -r /tmp/kvm_system /tmp/server + stop_netbird + stop_tailscale + killall kvm_system 2>/dev/null + killall NanoKVM-Server 2>/dev/null + rm -rf /tmp/kvm_system /tmp/server echo "OK" ;; restart) - killall kvm_system - killall NanoKVM-Server - rm -r /tmp/kvm_system /tmp/server + stop_netbird + stop_tailscale + killall kvm_system 2>/dev/null + killall NanoKVM-Server 2>/dev/null + rm -rf /tmp/kvm_system /tmp/server + + # Ensure swap is active + setup_swap cp -r /kvmapp/kvm_system /tmp/ /tmp/kvm_system/kvm_system & cp -r /kvmapp/server /tmp/ - /tmp/server/NanoKVM-Server & + GOMEMLIMIT=20MiB /tmp/server/NanoKVM-Server & + SERVER_PID=$! + + sleep 1 + if [ -d "/proc/$SERVER_PID" ]; then + echo -800 > /proc/$SERVER_PID/oom_score_adj 2>/dev/null + fi + + # Read VPN preference (default: tailscale) + VPN_PREF="tailscale" + if [ -f /etc/kvm/vpn ]; then + VPN_PREF=$(cat /etc/kvm/vpn) + fi + + case "$VPN_PREF" in + netbird) + rm -f /etc/init.d/S98tailscaled + start_netbird + ;; + *) + rm -f /etc/init.d/S99netbird + start_tailscale + ;; + esac sync echo "OK" ;; - + *) echo "Usage: $0 {start|stop|restart}" exit 1 ;; -esac \ No newline at end of file +esac diff --git a/kvmapp/system/init.d/S99netbird b/kvmapp/system/init.d/S99netbird new file mode 100644 index 00000000..b71cafbb --- /dev/null +++ b/kvmapp/system/init.d/S99netbird @@ -0,0 +1,80 @@ +#!/bin/sh + +DAEMON="netbird" +DAEMON_PATH="/usr/bin/netbird" +PIDFILE="/var/run/$DAEMON.pid" +LOGDIR="/var/log/netbird" +LOGFILE="$LOGDIR/netbird.log" + +ensure_tun() { + [ -d /dev/net ] || mkdir -p /dev/net + if [ ! -c /dev/net/tun ]; then + [ -x /sbin/modprobe ] && modprobe tun 2>/dev/null || true + [ -f /lib/modules/tun.ko ] && insmod /lib/modules/tun.ko 2>/dev/null || true + mknod /dev/net/tun c 10 200 2>/dev/null || true + chmod 0755 /dev/net/tun 2>/dev/null || true + fi +} + +is_running() { + [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null +} + +case "$1" in + start) + [ -x "$DAEMON_PATH" ] || { + echo "$DAEMON_PATH not found" + exit 1 + } + + if is_running; then + echo "$DAEMON already running" + exit 0 + fi + + mkdir -p /var/run "$LOGDIR" + ensure_tun + + export GOMEMLIMIT=30MiB + + printf "Starting %s (GOMEMLIMIT=%s): " "$DAEMON" "$GOMEMLIMIT" + start-stop-daemon -S -bmq -p "$PIDFILE" -x "$DAEMON_PATH" -- service run >>"$LOGFILE" 2>&1 + if [ $? = 0 ]; then + echo "OK" + else + echo "FAIL" + exit 1 + fi + ;; + + stop) + printf "Stopping %s: " "$DAEMON" + if [ -f "$PIDFILE" ]; then + start-stop-daemon -K -p "$PIDFILE" >/dev/null 2>&1 + [ $? = 0 ] && (echo "OK"; rm -f "$PIDFILE") || (echo "FAIL"; exit 1) + else + echo "not running" + fi + ;; + + restart) + "$0" stop + "$0" start + ;; + + status) + if is_running; then + echo "$DAEMON is running" + exit 0 + fi + echo "$DAEMON is not running" + exit 1 + ;; + + *) + echo "Usage: $0 {start|stop|restart|status}" + exit 1 + ;; +esac + +exit 0 diff --git a/server/proto/netbird.go b/server/proto/netbird.go new file mode 100644 index 00000000..583aa915 --- /dev/null +++ b/server/proto/netbird.go @@ -0,0 +1,22 @@ +package proto + +type NetbirdState string + +const ( + NetbirdNotInstall NetbirdState = "notInstall" + NetbirdNotRunning NetbirdState = "notRunning" + NetbirdNotLogin NetbirdState = "notLogin" + NetbirdStopped NetbirdState = "stopped" + NetbirdRunning NetbirdState = "running" +) + +type GetNetbirdStatusRsp struct { + State NetbirdState `json:"state"` + Name string `json:"name"` + IP string `json:"ip"` + Version string `json:"version"` +} + +type LoginNetbirdRsp struct { + Url string `json:"url"` +} diff --git a/server/proto/vpn.go b/server/proto/vpn.go new file mode 100644 index 00000000..18290831 --- /dev/null +++ b/server/proto/vpn.go @@ -0,0 +1,9 @@ +package proto + +type GetVPNPreferenceRsp struct { + VPN string `json:"vpn"` // "tailscale" or "netbird" +} + +type SetVPNPreferenceReq struct { + VPN string `json:"vpn" form:"vpn" validate:"required"` +} diff --git a/server/router/extensions.go b/server/router/extensions.go index 696a94df..8ae3898a 100644 --- a/server/router/extensions.go +++ b/server/router/extensions.go @@ -2,7 +2,9 @@ package router import ( "NanoKVM-Server/middleware" + "NanoKVM-Server/service/extensions/netbird" "NanoKVM-Server/service/extensions/tailscale" + vpnService "NanoKVM-Server/service/extensions/vpn" "github.com/gin-gonic/gin" ) @@ -22,4 +24,19 @@ func extensionsRouter(r *gin.Engine) { api.POST("/tailscale/start", ts.Start) // tailscale start api.POST("/tailscale/stop", ts.Stop) // tailscale stop api.POST("/tailscale/restart", ts.Restart) // tailscale restart + + nb := netbird.NewService() + + api.POST("/netbird/install", nb.Install) // install netbird + api.POST("/netbird/uninstall", nb.Uninstall) // uninstall netbird + api.GET("/netbird/status", nb.GetStatus) // get netbird status + api.POST("/netbird/down", nb.Down) // run netbird down + api.POST("/netbird/login", nb.Login) // netbird login + api.POST("/netbird/start", nb.Start) // netbird start + api.POST("/netbird/stop", nb.Stop) // netbird stop + api.POST("/netbird/restart", nb.Restart) // netbird restart + + vpn := vpnService.NewService() + api.GET("/vpn/preference", vpn.GetPreference) // get VPN autostart preference + api.POST("/vpn/preference", vpn.SetPreference) // set VPN autostart preference } diff --git a/server/service/extensions/netbird/cli.go b/server/service/extensions/netbird/cli.go new file mode 100644 index 00000000..d7cbad93 --- /dev/null +++ b/server/service/extensions/netbird/cli.go @@ -0,0 +1,246 @@ +package netbird + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "os/exec" + "regexp" + "strings" + "time" +) + +const ( + ScriptPath = "/etc/init.d/S99netbird" + ScriptBackupPath = "/kvmapp/system/init.d/S99netbird" + CommandTimeout = 1 * time.Minute +) + +type Cli struct{} + +type NbStatus struct { + FQDN string `json:"fqdn"` + IP string `json:"netbirdIp"` + DaemonVersion string `json:"daemonVersion"` + + Management struct { + URL string `json:"url"` + Connected bool `json:"connected"` + Error string `json:"error"` + } `json:"management"` + + Signal struct { + URL string `json:"url"` + Connected bool `json:"connected"` + Error string `json:"error"` + } `json:"signal"` +} + +func NewCli() *Cli { + return &Cli{} +} + +func (c *Cli) Start() error { + commands := []string{ + fmt.Sprintf("cp -f %s %s", ScriptBackupPath, ScriptPath), + fmt.Sprintf("chmod 755 %s", ScriptPath), + fmt.Sprintf("%s start", ScriptPath), + } + return runCommand(strings.Join(commands, " && "), false) +} + +func (c *Cli) Restart() error { + commands := []string{ + fmt.Sprintf("cp -f %s %s", ScriptBackupPath, ScriptPath), + fmt.Sprintf("chmod 755 %s", ScriptPath), + fmt.Sprintf("%s restart", ScriptPath), + } + return runCommand(strings.Join(commands, " && "), false) +} + +func (c *Cli) Stop() error { + return runCommand(fmt.Sprintf("%s stop", ScriptPath), false) +} + +func (c *Cli) WaitForSocket(timeout time.Duration) error { + socketPath := "/var/run/netbird.sock" + deadline := time.Now().Add(timeout) + for time.Now().Before(deadline) { + if _, err := exec.Command("sh", "-c", "test -S "+socketPath).Output(); err == nil { + return nil + } + time.Sleep(500 * time.Millisecond) + } + return fmt.Errorf("netbird daemon socket not ready after %s", timeout) +} + +func (c *Cli) Login() (string, error) { + if err := c.WaitForSocket(10 * time.Second); err != nil { + return "", err + } + + command := "netbird up --daemon-addr unix:///var/run/netbird.sock --no-browser" + cmd := exec.Command("sh", "-c", command) + + stdout, err := cmd.StdoutPipe() + if err != nil { + return "", err + } + stderr, err := cmd.StderrPipe() + if err != nil { + return "", err + } + + if err := cmd.Start(); err != nil { + return "", err + } + + urlRe := regexp.MustCompile(`https://\S+`) + urlCh := make(chan string, 1) + + scanForURL := func(r *bufio.Reader) { + for { + line, err := r.ReadString('\n') + if match := urlRe.FindString(line); match != "" { + select { + case urlCh <- match: + default: + } + return + } + if err != nil { + return + } + } + } + + go scanForURL(bufio.NewReader(stdout)) + go scanForURL(bufio.NewReader(stderr)) + + // Wait for URL or command to finish + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + + select { + case url := <-urlCh: + return url, nil + case err := <-done: + // Command finished without producing a URL — already logged in + if err == nil { + return "", nil + } + return "", fmt.Errorf("netbird up failed: %w", err) + case <-time.After(60 * time.Second): + _ = cmd.Process.Kill() + return "", fmt.Errorf("timed out waiting for login URL") + } +} + +func (c *Cli) Down() error { + return runCommand("netbird down --daemon-addr unix:///var/run/netbird.sock", true) +} + +func (c *Cli) Status() (*NbStatus, error) { + output, err := runCommandWithOutput("netbird status --json --daemon-addr unix:///var/run/netbird.sock", true) + if err != nil { + return nil, err + } + + output = normalizeJSON(output) + if output == "" { + return nil, fmt.Errorf("invalid netbird status output") + } + + var status NbStatus + if err := json.Unmarshal([]byte(output), &status); err != nil { + return nil, fmt.Errorf("parse status failed: %w", err) + } + + return &status, nil +} + +func (c *Cli) ServiceRunning() (bool, error) { + output, err := runCommandWithOutput(fmt.Sprintf("%s status", ScriptPath), false) + if err == nil { + return strings.Contains(strings.ToLower(output), "running"), nil + } + + lower := strings.ToLower(output) + if strings.Contains(lower, "not running") || strings.Contains(lower, "stopped") { + return false, nil + } + + return false, err +} + +func runCommand(command string, restartOnTimeout bool) error { + _, err := runCommandWithOutput(command, restartOnTimeout) + return err +} + +func runCommandWithOutput(command string, restartOnTimeout bool) (string, error) { + output, err, timedOut := runCommandWithTimeout(command, CommandTimeout) + if err == nil { + return output, nil + } + + if !timedOut || !restartOnTimeout { + return output, err + } + + restartCommand := fmt.Sprintf("%s restart", ScriptPath) + restartOutput, restartErr, _ := runCommandWithTimeout(restartCommand, CommandTimeout) + if restartErr != nil { + return output, fmt.Errorf("command timed out, restart failed: %w", combineCommandError(restartErr, restartOutput)) + } + + retryOutput, retryErr, _ := runCommandWithTimeout(command, CommandTimeout) + if retryErr != nil { + return retryOutput, fmt.Errorf("command timed out, restart succeeded, retry failed: %w", combineCommandError(retryErr, retryOutput)) + } + + return retryOutput, nil +} + +func runCommandWithTimeout(command string, timeout time.Duration) (string, error, bool) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + cmd := exec.CommandContext(ctx, "sh", "-c", command) + output, err := cmd.CombinedOutput() + msg := strings.TrimSpace(string(output)) + if err == nil { + return msg, nil, false + } + + if ctx.Err() == context.DeadlineExceeded { + timeoutErr := fmt.Errorf("command timed out after %s", timeout) + return msg, combineCommandError(timeoutErr, msg), true + } + + return msg, combineCommandError(err, msg), false +} + +func combineCommandError(err error, output string) error { + msg := strings.TrimSpace(output) + if msg == "" { + return err + } + + return fmt.Errorf("%w: %s", err, msg) +} + +func normalizeJSON(s string) string { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "{") { + return s + } + + index := strings.Index(s, "{") + if index == -1 { + return "" + } + + return strings.TrimSpace(s[index:]) +} diff --git a/server/service/extensions/netbird/install.go b/server/service/extensions/netbird/install.go new file mode 100644 index 00000000..0a4f9a53 --- /dev/null +++ b/server/service/extensions/netbird/install.go @@ -0,0 +1,104 @@ +package netbird + +import ( + "fmt" + "io" + "os" + "path/filepath" + "strings" +) + +const ( + NetbirdPath = "/usr/bin/netbird" + BundledNetbirdPath = "/kvmapp/system/netbird/netbird" + BundledVersionPath = "/kvmapp/system/netbird/VERSION" + InstalledVersionPath = "/etc/kvm/netbird.version" +) + +func isInstalled() bool { + _, err := os.Stat(NetbirdPath) + return err == nil +} + +func install() error { + if _, err := os.Stat(BundledNetbirdPath); err != nil { + return fmt.Errorf("bundled netbird binary not found: %w", err) + } + + sourceVersion := getBundledVersion() + installedVersion := getInstalledVersion() + if isInstalled() && sourceVersion != "" && sourceVersion == installedVersion { + return nil + } + + if err := os.MkdirAll(filepath.Dir(NetbirdPath), 0o755); err != nil { + return fmt.Errorf("create netbird dir failed: %w", err) + } + + if err := copyFile(BundledNetbirdPath, NetbirdPath); err != nil { + return err + } + + if err := os.Chmod(NetbirdPath, 0o755); err != nil { + return fmt.Errorf("chmod netbird failed: %w", err) + } + + if sourceVersion != "" { + if err := os.MkdirAll(filepath.Dir(InstalledVersionPath), 0o755); err != nil { + return fmt.Errorf("create version dir failed: %w", err) + } + + if err := os.WriteFile(InstalledVersionPath, []byte(sourceVersion), 0o644); err != nil { + return fmt.Errorf("write version failed: %w", err) + } + } + + return nil +} + +func uninstall() error { + _ = os.Remove(NetbirdPath) + _ = os.Remove(InstalledVersionPath) + return nil +} + +func getBundledVersion() string { + return readVersion(BundledVersionPath) +} + +func getInstalledVersion() string { + return readVersion(InstalledVersionPath) +} + +func readVersion(path string) string { + content, err := os.ReadFile(path) + if err != nil { + return "" + } + + return strings.TrimSpace(string(content)) +} + +func copyFile(source string, destination string) error { + src, err := os.Open(source) + if err != nil { + return fmt.Errorf("open source failed: %w", err) + } + defer func() { + _ = src.Close() + }() + + dst, err := os.Create(destination) + if err != nil { + return fmt.Errorf("create destination failed: %w", err) + } + defer func() { + _ = dst.Close() + }() + + if _, err := io.Copy(dst, src); err != nil { + return fmt.Errorf("copy binary failed: %w", err) + } + + return nil +} diff --git a/server/service/extensions/netbird/service.go b/server/service/extensions/netbird/service.go new file mode 100644 index 00000000..5860c053 --- /dev/null +++ b/server/service/extensions/netbird/service.go @@ -0,0 +1,216 @@ +package netbird + +import ( + "NanoKVM-Server/proto" + "fmt" + "net" + "strings" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) Install(c *gin.Context) { + var rsp proto.Response + + if err := install(); err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("install failed: %v", err)) + log.Errorf("failed to install netbird: %s", err) + return + } + + if err := NewCli().Start(); err != nil { + rsp.ErrRsp(c, -2, fmt.Sprintf("start failed: %v", err)) + log.Errorf("failed to start netbird after install: %s", err) + return + } + + rsp.OkRsp(c) +} + +func (s *Service) Uninstall(c *gin.Context) { + var rsp proto.Response + + _ = NewCli().Stop() + + if err := uninstall(); err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("uninstall failed: %v", err)) + log.Errorf("failed to uninstall netbird: %s", err) + return + } + + rsp.OkRsp(c) +} + +func (s *Service) Start(c *gin.Context) { + var rsp proto.Response + + if !isInstalled() { + if err := install(); err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("start failed: %v", err)) + log.Errorf("failed to install netbird before start: %s", err) + return + } + } + + if err := NewCli().Start(); err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("start failed: %v", err)) + log.Errorf("failed to start netbird: %s", err) + return + } + + rsp.OkRsp(c) +} + +func (s *Service) Restart(c *gin.Context) { + var rsp proto.Response + + if err := NewCli().Restart(); err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("restart failed: %v", err)) + log.Errorf("failed to restart netbird: %s", err) + return + } + + rsp.OkRsp(c) +} + +func (s *Service) Stop(c *gin.Context) { + var rsp proto.Response + + if err := NewCli().Stop(); err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("stop failed: %v", err)) + log.Errorf("failed to stop netbird: %s", err) + return + } + + rsp.OkRsp(c) +} + +func (s *Service) Login(c *gin.Context) { + var rsp proto.Response + + cli := NewCli() + + url, err := cli.Login() + if err != nil { + log.Errorf("failed to run netbird login: %s", err) + rsp.ErrRsp(c, -2, fmt.Sprintf("login failed: %v", err)) + return + } + + rsp.OkRspWithData(c, &proto.LoginNetbirdRsp{ + Url: url, + }) + + log.Debugf("netbird login url: %s", url) +} + +func (s *Service) Down(c *gin.Context) { + var rsp proto.Response + + if err := NewCli().Down(); err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("netbird down failed: %v", err)) + log.Errorf("failed to run netbird down: %s", err) + return + } + + rsp.OkRsp(c) +} + +func (s *Service) GetStatus(c *gin.Context) { + var rsp proto.Response + + if !isInstalled() { + rsp.OkRspWithData(c, &proto.GetNetbirdStatusRsp{ + State: proto.NetbirdNotInstall, + Version: getBundledVersion(), + }) + return + } + + cli := NewCli() + running, err := cli.ServiceRunning() + if err != nil { + rsp.ErrRsp(c, -1, fmt.Sprintf("service status failed: %v", err)) + return + } + + if !running { + rsp.OkRspWithData(c, &proto.GetNetbirdStatusRsp{ + State: proto.NetbirdNotRunning, + Version: getInstalledVersion(), + }) + return + } + + status, err := cli.Status() + if err != nil { + lower := strings.ToLower(err.Error()) + state := proto.NetbirdStopped + if strings.Contains(lower, "run up command") || + strings.Contains(lower, "setup-key") || + strings.Contains(lower, "login") { + state = proto.NetbirdNotLogin + } + + rsp.OkRspWithData(c, &proto.GetNetbirdStatusRsp{ + State: state, + Version: getInstalledVersion(), + }) + return + } + + state := proto.NetbirdNotLogin + if status.Management.Connected && status.Signal.Connected { + state = proto.NetbirdRunning + } else if status.Management.URL != "" { + state = proto.NetbirdStopped + } + + rsp.OkRspWithData(c, &proto.GetNetbirdStatusRsp{ + State: state, + Name: status.FQDN, + IP: getIPv4(status.IP), + Version: firstNonEmpty(status.DaemonVersion, getInstalledVersion()), + }) +} + +func getIPv4(ip string) string { + if ip == "" { + return "" + } + + if addr, _, err := net.ParseCIDR(ip); err == nil && addr != nil { + if ipv4 := addr.To4(); ipv4 != nil { + return ipv4.String() + } + return addr.String() + } + + parsed := net.ParseIP(ip) + if parsed == nil { + return ip + } + + if parsed.To4() != nil { + return parsed.String() + } + + return "" +} + +func firstNonEmpty(items ...string) string { + for _, item := range items { + if item != "" { + return item + } + } + + return "" +} diff --git a/server/service/extensions/vpn/service.go b/server/service/extensions/vpn/service.go new file mode 100644 index 00000000..f9a9ed34 --- /dev/null +++ b/server/service/extensions/vpn/service.go @@ -0,0 +1,95 @@ +package vpn + +import ( + "NanoKVM-Server/proto" + "NanoKVM-Server/service/extensions/netbird" + "NanoKVM-Server/service/extensions/tailscale" + "fmt" + "os" + "strings" + + "github.com/gin-gonic/gin" + log "github.com/sirupsen/logrus" +) + +const VPNPreferenceFile = "/etc/kvm/vpn" + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) GetPreference(c *gin.Context) { + var rsp proto.Response + + vpn := readPreference() + rsp.OkRspWithData(c, &proto.GetVPNPreferenceRsp{VPN: vpn}) +} + +func (s *Service) SetPreference(c *gin.Context) { + var req proto.SetVPNPreferenceReq + var rsp proto.Response + + if err := proto.ParseFormRequest(c, &req); err != nil { + rsp.ErrRsp(c, -1, "invalid parameters") + return + } + + vpn := strings.TrimSpace(req.VPN) + if vpn != "tailscale" && vpn != "netbird" { + rsp.ErrRsp(c, -2, "vpn must be 'tailscale' or 'netbird'") + return + } + + current := readPreference() + if vpn == current { + rsp.OkRspWithData(c, &proto.GetVPNPreferenceRsp{VPN: vpn}) + return + } + + // Stop the old VPN and start the new one + switch vpn { + case "tailscale": + _ = netbird.NewCli().Stop() + if err := tailscale.NewCli().Start(); err != nil { + log.Errorf("failed to start tailscale: %s", err) + rsp.ErrRsp(c, -3, fmt.Sprintf("start tailscale failed: %v", err)) + return + } + case "netbird": + _ = tailscale.NewCli().Stop() + if err := netbird.NewCli().Start(); err != nil { + log.Errorf("failed to start netbird: %s", err) + rsp.ErrRsp(c, -3, fmt.Sprintf("start netbird failed: %v", err)) + return + } + } + + if err := writePreference(vpn); err != nil { + log.Errorf("failed to write VPN preference: %s", err) + rsp.ErrRsp(c, -4, fmt.Sprintf("write preference failed: %v", err)) + return + } + + log.Infof("VPN preference set to %s", vpn) + rsp.OkRspWithData(c, &proto.GetVPNPreferenceRsp{VPN: vpn}) +} + +func readPreference() string { + data, err := os.ReadFile(VPNPreferenceFile) + if err != nil { + return "tailscale" + } + + vpn := strings.TrimSpace(string(data)) + if vpn == "netbird" { + return "netbird" + } + + return "tailscale" +} + +func writePreference(vpn string) error { + return os.WriteFile(VPNPreferenceFile, []byte(vpn), 0o644) +} diff --git a/web/src/api/extensions/netbird.ts b/web/src/api/extensions/netbird.ts new file mode 100644 index 00000000..9855399a --- /dev/null +++ b/web/src/api/extensions/netbird.ts @@ -0,0 +1,33 @@ +import { http } from '@/lib/http.ts'; + +export function install() { + return http.post('/api/extensions/netbird/install'); +} + +export function uninstall() { + return http.post('/api/extensions/netbird/uninstall'); +} + +export function getStatus() { + return http.get('/api/extensions/netbird/status'); +} + +export function login() { + return http.post('/api/extensions/netbird/login'); +} + +export function start() { + return http.post('/api/extensions/netbird/start'); +} + +export function restart() { + return http.post('/api/extensions/netbird/restart'); +} + +export function stop() { + return http.post('/api/extensions/netbird/stop'); +} + +export function down() { + return http.post('/api/extensions/netbird/down'); +} diff --git a/web/src/api/extensions/vpn.ts b/web/src/api/extensions/vpn.ts new file mode 100644 index 00000000..8c15a602 --- /dev/null +++ b/web/src/api/extensions/vpn.ts @@ -0,0 +1,9 @@ +import { http } from '@/lib/http.ts'; + +export function getPreference() { + return http.get('/api/extensions/vpn/preference'); +} + +export function setPreference(vpn: string) { + return http.post('/api/extensions/vpn/preference', { vpn }); +} diff --git a/web/src/components/icons/netbird.tsx b/web/src/components/icons/netbird.tsx new file mode 100644 index 00000000..fb1eced7 --- /dev/null +++ b/web/src/components/icons/netbird.tsx @@ -0,0 +1,7 @@ +export const Netbird = () => { + return ( +
+ N +
+ ); +}; diff --git a/web/src/i18n/locales/en.ts b/web/src/i18n/locales/en.ts index a1e39cd2..a9cf50ac 100644 --- a/web/src/i18n/locales/en.ts +++ b/web/src/i18n/locales/en.ts @@ -367,6 +367,8 @@ const en = { }, tailscale: { title: 'Tailscale', + autostart: 'Autostart', + autostartConfirm: 'Switch autostart to Tailscale? NetBird will be stopped.', memory: { title: 'Memory optimization', tip: 'When memory usage exceeds the limit, garbage collection is performed more aggressively to attempt to free up memory. A Tailscale restart is required for the change to take effect.' @@ -408,6 +410,43 @@ const en = { okBtn: 'Yes', cancelBtn: 'No' }, + netbird: { + title: 'NetBird', + autostart: 'Autostart', + autostartConfirm: 'Switch autostart to NetBird? Tailscale will be stopped.', + restart: 'Restart NetBird?', + stop: 'Stop NetBird?', + stopDesc: 'Disconnect NetBird and disable automatic startup on boot.', + loading: 'Loading...', + notInstall: 'NetBird is not installed.', + install: 'Install', + installing: 'Installing', + notRunning: 'NetBird service is not running.', + run: 'Start', + notLogin: + 'The device has not been bound yet. Please login and bind this device to your account.', + urlPeriod: 'This url is valid for 10 minutes', + login: 'Login', + loginSuccess: 'I have logged in', + enable: 'Enable NetBird', + deviceName: 'Device Name', + deviceIP: 'Device IP', + version: 'Version', + disconnect: 'Disconnect', + disconnectConfirm: 'Are you sure you want to disconnect?', + okBtn: 'Yes', + cancelBtn: 'No', + error: { + title: 'NetBird operation failed', + intro: 'Error details:', + stepWait: '1. Wait 10-15 seconds and retry the action.', + stepRestartUI: '2. Click "Restart Service" below.', + stepRestartSSH: '3. If needed, run: /etc/init.d/S99netbird restart', + stepReboot: '4. Reboot NanoKVM only if the steps above do not help.', + restartButton: 'Restart Service', + refreshButton: 'Refresh Status' + } + }, update: { title: 'Check for Updates', queryFailed: 'Get version failed', diff --git a/web/src/pages/desktop/menu/settings/index.tsx b/web/src/pages/desktop/menu/settings/index.tsx index 8d59461c..7221b39b 100644 --- a/web/src/pages/desktop/menu/settings/index.tsx +++ b/web/src/pages/desktop/menu/settings/index.tsx @@ -17,6 +17,7 @@ import * as api from '@/api/application.ts'; import * as ls from '@/lib/localstorage.ts'; import { isKeyboardEnableAtom } from '@/jotai/keyboard.ts'; import { submenuOpenCountAtom } from '@/jotai/settings.ts'; +import { Netbird as NetbirdIcon } from '@/components/icons/netbird'; import { Tailscale as TailscaleIcon } from '@/components/icons/tailscale'; import { ScrollArea } from '@/components/ui/scroll-area'; @@ -24,6 +25,7 @@ import { About } from './about'; import { Account } from './account'; import { Appearance } from './appearance'; import { Device } from './device'; +import { Netbird } from './netbird'; import { Tailscale } from './tailscale'; import { Update } from './update'; @@ -47,6 +49,11 @@ export const Settings = () => { icon: , component: }, + { + id: 'netbird', + icon: , + component: + }, { id: 'update', icon: , diff --git a/web/src/pages/desktop/menu/settings/netbird/device.tsx b/web/src/pages/desktop/menu/settings/netbird/device.tsx new file mode 100644 index 00000000..53309295 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/netbird/device.tsx @@ -0,0 +1,112 @@ +import { useEffect, useState } from 'react'; +import { DisconnectOutlined } from '@ant-design/icons'; +import { Button, Divider, Popconfirm, Switch } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/extensions/netbird.ts'; + +import { ErrorHelp } from './error-help.tsx'; + +import { Status } from './types.ts'; + +type DeviceProps = { + status: Status; + onLogout: () => void; +}; + +export const Device = ({ status, onLogout }: DeviceProps) => { + const { t } = useTranslation(); + + const [isRunning, setIsRunning] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [isDisconnecting, setIsDisconnecting] = useState(false); + const [errMsg, setErrMsg] = useState(''); + + useEffect(() => { + setIsRunning(status.state === 'running'); + }, [status]); + + async function update() { + if (isUpdating) return; + setIsUpdating(true); + + try { + const rsp = isRunning ? await api.down() : await api.login(); + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + setIsRunning(!isRunning); + } finally { + setIsUpdating(false); + } + } + + async function disconnect() { + if (isDisconnecting) return; + setIsDisconnecting(true); + + api + .down() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + onLogout(); + }) + .finally(() => { + setIsDisconnecting(false); + }); + } + + return ( +
+
+ {t('settings.netbird.enable')} + +
+ +
+ {t('settings.netbird.deviceName')} + {status.name} +
+ +
+ {t('settings.netbird.deviceIP')} + {status.ip} +
+ +
+ {t('settings.netbird.version')} + {status.version} +
+ + +
+ + + +
+ + {errMsg && } +
+ ); +}; diff --git a/web/src/pages/desktop/menu/settings/netbird/error-help.tsx b/web/src/pages/desktop/menu/settings/netbird/error-help.tsx new file mode 100644 index 00000000..eb3ffd1f --- /dev/null +++ b/web/src/pages/desktop/menu/settings/netbird/error-help.tsx @@ -0,0 +1,65 @@ +import { useState } from 'react'; +import { Alert, Button, Space } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/extensions/netbird.ts'; + +type ErrorHelpProps = { + error: string; + onRefresh: () => void; +}; + +export const ErrorHelp = ({ error, onRefresh }: ErrorHelpProps) => { + const { t } = useTranslation(); + const [isRestarting, setIsRestarting] = useState(false); + const [actionError, setActionError] = useState(''); + + function restartService() { + if (isRestarting) return; + setIsRestarting(true); + setActionError(''); + + api + .restart() + .then((rsp) => { + if (rsp.code !== 0) { + setActionError(rsp.msg); + return; + } + + onRefresh(); + }) + .finally(() => { + setIsRestarting(false); + }); + } + + const details = [ + t('settings.netbird.error.stepWait'), + t('settings.netbird.error.stepRestartUI'), + t('settings.netbird.error.stepRestartSSH'), + t('settings.netbird.error.stepReboot') + ].join('\n'); + + const description = `${t('settings.netbird.error.intro')}\n${error}${actionError ? `\n${actionError}` : ''}\n\n${details}`; + + return ( +
+ {description}
+ } + /> + + + + + + + ); +}; diff --git a/web/src/pages/desktop/menu/settings/netbird/header.tsx b/web/src/pages/desktop/menu/settings/netbird/header.tsx new file mode 100644 index 00000000..595f16fc --- /dev/null +++ b/web/src/pages/desktop/menu/settings/netbird/header.tsx @@ -0,0 +1,136 @@ +import { useEffect, useState } from 'react'; +import { Popconfirm, Switch } from 'antd'; +import { CircleStopIcon, LoaderIcon, RotateCwIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/extensions/netbird.ts'; +import * as vpnApi from '@/api/extensions/vpn.ts'; + +import type { State } from './types.ts'; + +type HeaderProps = { + state: State | undefined; + onSuccess: () => void; +}; + +type Loading = '' | 'restarting' | 'stopping'; + +export const Header = ({ state, onSuccess }: HeaderProps) => { + const { t } = useTranslation(); + + const [loading, setLoading] = useState(''); + const [isAutostart, setIsAutostart] = useState(false); + const [autostartLoading, setAutostartLoading] = useState(false); + + useEffect(() => { + vpnApi.getPreference().then((rsp: any) => { + if (rsp.data?.vpn) { + setIsAutostart(rsp.data.vpn === 'netbird'); + } + }); + }, []); + + function handleAutostartChange(checked: boolean) { + if (!checked || autostartLoading) return; + setAutostartLoading(true); + + vpnApi + .setPreference('netbird') + .then(() => { + setIsAutostart(true); + onSuccess(); + }) + .finally(() => { + setAutostartLoading(false); + }); + } + + function restart() { + if (loading !== '') return; + setLoading('restarting'); + + api.restart().finally(() => { + setLoading(''); + onSuccess(); + }); + } + + function stop() { + if (loading !== '') return; + setLoading('stopping'); + + api.stop().finally(() => { + setLoading(''); + onSuccess(); + }); + } + + return ( +
+
+ {t('settings.netbird.title')} + + {state && state !== 'notInstall' && ( + handleAutostartChange(true)} + okText={t('settings.netbird.okBtn')} + cancelText={t('settings.netbird.cancelBtn')} + placement="bottom" + disabled={isAutostart} + > + + + )} +
+ +
+ {state && ['notRunning', 'notLogin', 'stopped', 'running'].includes(state) && ( + <> + {/* restart button */} + +
+ {loading === 'restarting' ? ( + + ) : ( + + )} +
+
+ + {/* stop button */} + +
+ {loading === 'stopping' ? ( + + ) : ( + + )} +
+
+ + )} +
+
+ ); +}; diff --git a/web/src/pages/desktop/menu/settings/netbird/index.tsx b/web/src/pages/desktop/menu/settings/netbird/index.tsx new file mode 100644 index 00000000..8b8944f4 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/netbird/index.tsx @@ -0,0 +1,83 @@ +import { useEffect, useState } from 'react'; +import { Divider } from 'antd'; +import { LoaderCircleIcon } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/extensions/netbird.ts'; + +import { Device } from './device.tsx'; +import { ErrorHelp } from './error-help.tsx'; +import { Header } from './header.tsx'; +import { Install } from './install.tsx'; +import { Login } from './login.tsx'; +import { Run } from './run.tsx'; +import type { Status } from './types.ts'; + +type NetbirdProps = { + setIsLocked: (isLocked: boolean) => void; +}; + +export const Netbird = ({ setIsLocked }: NetbirdProps) => { + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + const [status, setStatus] = useState(); + const [errMsg, setErrMsg] = useState(''); + + useEffect(() => { + getStatus(); + }, []); + + function getStatus() { + if (isLoading) return; + setIsLoading(true); + + api + .getStatus() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + setErrMsg(''); + setStatus(rsp.data); + }) + .catch((err) => { + setErrMsg(err.message || 'Failed to get status'); + }) + .finally(() => { + setIsLoading(false); + }); + } + + return ( + <> +
+ + + {isLoading ? ( +
+ + {t('settings.netbird.loading')} +
+ ) : ( + <> + {status?.state === 'notInstall' && ( + + )} + + {status?.state === 'notRunning' && } + + {status?.state === 'notLogin' && } + + {(status?.state === 'stopped' || status?.state === 'running') && ( + + )} + + {errMsg && } + + )} + + ); +}; diff --git a/web/src/pages/desktop/menu/settings/netbird/install.tsx b/web/src/pages/desktop/menu/settings/netbird/install.tsx new file mode 100644 index 00000000..2ed21069 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/netbird/install.tsx @@ -0,0 +1,57 @@ +import { useState } from 'react'; +import { DownloadOutlined } from '@ant-design/icons'; +import { Button, Card, Result } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/extensions/netbird.ts'; + +import { ErrorHelp } from './error-help.tsx'; + +type InstallProps = { + setIsLocked: (isLocked: boolean) => void; + onSuccess: () => void; +}; + +export const Install = ({ setIsLocked, onSuccess }: InstallProps) => { + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + const [errMsg, setErrMsg] = useState(''); + + function install() { + if (isLoading) return; + setIsLocked(true); + setIsLoading(true); + + api + .install() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + onSuccess(); + }) + .finally(() => { + setIsLoading(false); + setIsLocked(false); + }); + } + + return ( + + } + subTitle={t('settings.netbird.notInstall')} + extra={ + + } + /> + + {errMsg && } + + ); +}; diff --git a/web/src/pages/desktop/menu/settings/netbird/login.tsx b/web/src/pages/desktop/menu/settings/netbird/login.tsx new file mode 100644 index 00000000..4e39d7f3 --- /dev/null +++ b/web/src/pages/desktop/menu/settings/netbird/login.tsx @@ -0,0 +1,80 @@ +import { useState } from 'react'; +import { UserSwitchOutlined } from '@ant-design/icons'; +import { Button, Card } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/extensions/netbird.ts'; + +import { ErrorHelp } from './error-help.tsx'; + +type LoginProps = { + onSuccess: () => void; +}; + +export const Login = ({ onSuccess }: LoginProps) => { + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + const [loginUrl, setLoginUrl] = useState(''); + const [errMsg, setErrMsg] = useState(''); + + function login() { + if (isLoading) return; + setIsLoading(true); + + api + .login() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + const url = rsp.data.url; + if (!url) { + onSuccess(); + return; + } + + setLoginUrl(url); + window.open(url, '_blank'); + setTimeout(() => setLoginUrl(''), 10 * 60 * 1000); + }) + .finally(() => { + setIsLoading(false); + }); + } + + return ( +
+ {t('settings.netbird.notLogin')} + + {loginUrl === '' ? ( + + ) : ( +
+ + + {t('settings.netbird.urlPeriod')} + + +
+ )} + + {errMsg && } +
+ ); +}; diff --git a/web/src/pages/desktop/menu/settings/netbird/run.tsx b/web/src/pages/desktop/menu/settings/netbird/run.tsx new file mode 100644 index 00000000..d9bb092f --- /dev/null +++ b/web/src/pages/desktop/menu/settings/netbird/run.tsx @@ -0,0 +1,56 @@ +import { useState } from 'react'; +import { PauseCircleOutlined } from '@ant-design/icons'; +import { Button, Card, Result } from 'antd'; +import { useTranslation } from 'react-i18next'; + +import * as api from '@/api/extensions/netbird.ts'; + +import { ErrorHelp } from './error-help.tsx'; + +type RunProps = { + onSuccess: () => void; +}; + +export const Run = ({ onSuccess }: RunProps) => { + const { t } = useTranslation(); + + const [isLoading, setIsLoading] = useState(false); + const [errMsg, setErrMsg] = useState(''); + + function run() { + if (isLoading) return; + setIsLoading(true); + + api + .start() + .then((rsp) => { + if (rsp.code !== 0) { + setErrMsg(rsp.msg); + return; + } + + onSuccess(); + }) + .finally(() => { + setIsLoading(false); + }); + } + + return ( + + } + subTitle={t('settings.netbird.notRunning')} + extra={ + + } + /> + +
+ {errMsg && } +
+
+ ); +}; diff --git a/web/src/pages/desktop/menu/settings/netbird/types.ts b/web/src/pages/desktop/menu/settings/netbird/types.ts new file mode 100644 index 00000000..686cc23e --- /dev/null +++ b/web/src/pages/desktop/menu/settings/netbird/types.ts @@ -0,0 +1,8 @@ +export type State = 'notInstall' | 'notRunning' | 'notLogin' | 'stopped' | 'running'; + +export type Status = { + state: State; + name: string; + ip: string; + version: string; +}; diff --git a/web/src/pages/desktop/menu/settings/tailscale/header.tsx b/web/src/pages/desktop/menu/settings/tailscale/header.tsx index fd34ba44..0ac48cc3 100644 --- a/web/src/pages/desktop/menu/settings/tailscale/header.tsx +++ b/web/src/pages/desktop/menu/settings/tailscale/header.tsx @@ -1,9 +1,10 @@ -import { useState } from 'react'; -import { Popconfirm, Popover } from 'antd'; +import { useEffect, useState } from 'react'; +import { Popconfirm, Popover, Switch } from 'antd'; import { CircleStopIcon, EllipsisIcon, LoaderIcon, RotateCwIcon } from 'lucide-react'; import { useTranslation } from 'react-i18next'; import * as api from '@/api/extensions/tailscale.ts'; +import * as vpnApi from '@/api/extensions/vpn.ts'; import { Memory } from './memory.tsx'; import { Swap } from './swap.tsx'; @@ -21,6 +22,31 @@ export const Header = ({ state, onSuccess }: HeaderProps) => { const { t } = useTranslation(); const [loading, setLoading] = useState(''); + const [isAutostart, setIsAutostart] = useState(false); + const [autostartLoading, setAutostartLoading] = useState(false); + + useEffect(() => { + vpnApi.getPreference().then((rsp: any) => { + if (rsp.data?.vpn) { + setIsAutostart(rsp.data.vpn === 'tailscale'); + } + }); + }, []); + + function handleAutostartChange(checked: boolean) { + if (!checked || autostartLoading) return; + setAutostartLoading(true); + + vpnApi + .setPreference('tailscale') + .then(() => { + setIsAutostart(true); + onSuccess(); + }) + .finally(() => { + setAutostartLoading(false); + }); + } function restart() { if (loading !== '') return; @@ -44,10 +70,30 @@ export const Header = ({ state, onSuccess }: HeaderProps) => { return (
- {t('settings.tailscale.title')} +
+ {t('settings.tailscale.title')} + + {state && state !== 'notInstall' && ( + handleAutostartChange(true)} + okText={t('settings.tailscale.okBtn')} + cancelText={t('settings.tailscale.cancelBtn')} + placement="bottom" + disabled={isAutostart} + > + + + )} +
- {state && ['notLogin', 'stopped', 'running'].includes(state) && ( + {state && ['notRunning', 'notLogin', 'stopped', 'running'].includes(state) && ( <> {/* restart button */}