From b0aa386ee2591366c38a2c5c1a60cc7fb3f62072 Mon Sep 17 00:00:00 2001 From: kevin Date: Mon, 6 Jul 2026 17:46:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20daemon=20=E5=90=AF?= =?UTF-8?q?=E5=8A=A8=E9=97=AE=E9=A2=98=EF=BC=8C=E5=88=86=E7=A6=BB=20GUI=20?= =?UTF-8?q?=E5=92=8C=20daemon=20=E4=BA=8C=E8=BF=9B=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 分离二进制: cmd/lmvpn (GUI+Fyne) 和 cmd/lmvpnd (daemon, 无 Fyne) 消除 daemon 启动时的 Fyne locale 初始化错误 - 新增 daemon-launch 子命令: 用 syscall.SysProcAttr{Setsid: true} 替代 nohup, 解决 osascript 无 TTY 时的 'Inappropriate ioctl' 错误 - daemon 日志写到用户家目录: paths.SetUserHome() 覆盖 root 默认路径 - IPC socket chown 给用户: 解决 root:wheel 0660 导致 GUI 无法连接 - 修复 JWT→密码认证回退: 捕获 ServerError{auth_err} 而非仅 AuthError - 修复 token URL 编码: 用 url.QueryEscape 替代裸拼接 - 日志去重: LMVPN_DAEMON=1 时 daemon 不再 stderr 镜像到文件 - Makefile 构建双二进制并打包到 .app bundle --- Makefile | 21 +++-- cmd/lmvpn/main.go | 47 ++++++++++ cmd/lmvpnd/main.go | 34 +++++++ internal/daemon/daemon.go | 42 ++++++++- internal/daemon/launch.go | 152 ++++++++++++++++++++++++++++++++ internal/log/log.go | 24 ++++- internal/paths/paths.go | 9 ++ internal/paths/paths_darwin.go | 10 +++ internal/paths/paths_other.go | 6 ++ internal/transport/transport.go | 10 ++- internal/ui/daemon.go | 63 ++++++++++--- internal/vpn/session.go | 16 ++-- main.go | 30 ------- 13 files changed, 400 insertions(+), 64 deletions(-) create mode 100644 cmd/lmvpn/main.go create mode 100644 cmd/lmvpnd/main.go create mode 100644 internal/daemon/launch.go delete mode 100644 main.go diff --git a/Makefile b/Makefile index 01edfcf..df937cb 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,8 @@ APP_NAME = LMVPN BUNDLE_ID = com.lmvpn.client -BINARY = lmvpn +GUI_BIN = lmvpn +DAEMON_BIN = lmvpnd BUILD_DIR = build APP_BUNDLE = $(APP_NAME).app @@ -10,22 +11,24 @@ GO = go CGO_ENABLED = 1 LDFLAGS = -s -w -.PHONY: all build app run daemon clean vet tidy fmt +.PHONY: all build app run daemon clean vet tidy fmt icon ## all: build the .app bundle (default) all: app -## build: compile the binary +## build: compile both the GUI and daemon binaries build: mkdir -p $(BUILD_DIR) - CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(BINARY) . + CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(GUI_BIN) ./cmd/lmvpn + CGO_ENABLED=$(CGO_ENABLED) $(GO) build -ldflags "$(LDFLAGS)" -o $(BUILD_DIR)/$(DAEMON_BIN) ./cmd/lmvpnd -## app: build binary and assemble .app bundle +## app: build binaries and assemble .app bundle app: build rm -rf $(APP_BUNDLE) mkdir -p $(APP_BUNDLE)/Contents/MacOS mkdir -p $(APP_BUNDLE)/Contents/Resources - cp $(BUILD_DIR)/$(BINARY) $(APP_BUNDLE)/Contents/MacOS/$(BINARY) + cp $(BUILD_DIR)/$(GUI_BIN) $(APP_BUNDLE)/Contents/MacOS/$(GUI_BIN) + cp $(BUILD_DIR)/$(DAEMON_BIN) $(APP_BUNDLE)/Contents/MacOS/$(DAEMON_BIN) cp resources/Info.plist $(APP_BUNDLE)/Contents/Info.plist @if [ -f resources/icon.icns ]; then \ cp resources/icon.icns $(APP_BUNDLE)/Contents/Resources/icon.icns; \ @@ -50,11 +53,11 @@ icon: ## run: build and run the GUI run: build - ./$(BUILD_DIR)/$(BINARY) + ./$(BUILD_DIR)/$(GUI_BIN) -## daemon: run the daemon (needs root) +## daemon: run the daemon directly (needs root) daemon: build - sudo ./$(BUILD_DIR)/$(BINARY) daemon + sudo ./$(BUILD_DIR)/$(DAEMON_BIN) ## vet: run go vet vet: diff --git a/cmd/lmvpn/main.go b/cmd/lmvpn/main.go new file mode 100644 index 0000000..1497fe7 --- /dev/null +++ b/cmd/lmvpn/main.go @@ -0,0 +1,47 @@ +// Command lmvpn is the LMVPN GUI client application. +// +// It runs in two modes: +// +// lmvpn — GUI mode (default): Fyne desktop application +// lmvpn daemon-launch — launcher: forks the privileged daemon (lmvpnd) +// into a new session (Setsid) and exits immediately. +// Invoked by osascript as root; replaces fragile +// `nohup ... &` shell tricks that fail without a TTY. +// +// The privileged daemon itself is a separate binary, lmvpnd, to avoid +// loading Fyne (and its locale/font initialisation) in the root process. +package main + +import ( + "flag" + "fmt" + "os" + + "lmvpn/internal/daemon" + "lmvpn/internal/ui" +) + +func main() { + if len(os.Args) > 1 && os.Args[1] == "daemon-launch" { + runDaemonLaunch() + return + } + + ui.Run() +} + +// runDaemonLaunch forks the daemon binary (lmvpnd) into a new session +// and exits immediately. Invoked by osascript (as root). +func runDaemonLaunch() { + fs := flag.NewFlagSet("daemon-launch", flag.ExitOnError) + userHome := fs.String("user-home", "", "invoking user's home directory") + uid := fs.Int("uid", -1, "invoking user's UID for socket/log ownership") + gid := fs.Int("gid", -1, "invoking user's GID for socket/log ownership") + daemonBin := fs.String("daemon-bin", "", "path to the lmvpnd binary (auto-detected if empty)") + fs.Parse(os.Args[2:]) + + if err := daemon.Launch(*userHome, *uid, *gid, *daemonBin); err != nil { + fmt.Fprintf(os.Stderr, "daemon-launch: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/lmvpnd/main.go b/cmd/lmvpnd/main.go new file mode 100644 index 0000000..8a39997 --- /dev/null +++ b/cmd/lmvpnd/main.go @@ -0,0 +1,34 @@ +// Command lmvpnd is the privileged LMVPN daemon. It owns the WebSocket +// transport, TUN device, and routing. It receives commands from the GUI +// over an IPC unix socket and broadcasts state/stats events back. +// +// This is a separate binary from the GUI (lmvpn) to avoid loading Fyne +// (and its locale/font initialisation) in the root process. +// +// Daemon flags: +// +// --user-home invoking user's home dir (for log/data paths) +// --uid invoking user's UID (chown IPC socket + logs) +// --gid invoking user's GID +package main + +import ( + "flag" + "fmt" + "os" + + "lmvpn/internal/daemon" +) + +func main() { + fs := flag.NewFlagSet("lmvpnd", flag.ExitOnError) + userHome := fs.String("user-home", "", "invoking user's home directory") + uid := fs.Int("uid", -1, "invoking user's UID for socket/log ownership") + gid := fs.Int("gid", -1, "invoking user's GID for socket/log ownership") + fs.Parse(os.Args[1:]) + + if err := daemon.Run(*userHome, *uid, *gid); err != nil { + fmt.Fprintf(os.Stderr, "lmvpnd: %v\n", err) + os.Exit(1) + } +} diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index 2faf6eb..152e9e2 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -6,6 +6,11 @@ // The daemon is launched (as root) by the GUI via osascript. It holds // no persistent state — all configuration is provided by the GUI in // the Start command. +// +// The daemon accepts --user-home, --uid, and --gid flags so it can: +// - Write logs to the user's ~/Library/Logs/ (not /var/root) +// - Chown the IPC socket so the user can connect +// - Chown log files so the user can read them package daemon import ( @@ -26,9 +31,24 @@ import ( // Run starts the daemon and blocks until Shutdown is received or a // signal (SIGINT/SIGTERM) is delivered. -func Run() error { +// +// userHome, uid, gid are the invoking GUI user's home directory and +// IDs, used to place logs in the user's Library and chown the IPC +// socket so the non-root GUI can connect. +func Run(userHome string, uid, gid int) error { + // Override paths to use the user's home directory (not root's). + paths.SetUserHome(userHome) + if err := paths.EnsureDirs(); err != nil { + // Non-fatal: root can usually create these anyway. + fmt.Fprintf(os.Stderr, "ensure dirs: %v\n", err) + } + log.Init(log.RoleDaemon, paths.DaemonLogFile()) - log.L().Info("lmvpn daemon starting") + // Chown the daemon log file so the user can read it. + chownToUser(paths.DaemonLogFile(), uid, gid) + + log.L().Info("lmvpn daemon starting", + "user_home", userHome, "uid", uid, "gid", gid) server, err := ipc.NewServer() if err != nil { @@ -36,6 +56,12 @@ func Run() error { } defer server.Close() + // Chown the IPC socket so the non-root GUI process can connect. + // This is the critical fix: without it, the socket is owned by + // root:wheel with mode 0660, and the user cannot dial it. + chownToUser(paths.IPCSocketPath(), uid, gid) + log.L().Info("daemon listening", "socket", paths.IPCSocketPath()) + d := &daemon{server: server} // Signal handling for clean shutdown. @@ -49,10 +75,20 @@ func Run() error { os.Exit(0) }() - log.L().Info("daemon listening", "socket", paths.IPCSocketPath()) return server.Accept(d.handle) } +// chownToUser changes the ownership of a file to the given uid:gid. +// Errors are logged but not fatal (e.g. if uid is -1). +func chownToUser(path string, uid, gid int) { + if uid < 0 { + return + } + if err := os.Chown(path, uid, gid); err != nil { + log.L().Warn("chown failed", "path", path, "error", err) + } +} + type daemon struct { server *ipc.Server session *vpn.SessionManager diff --git a/internal/daemon/launch.go b/internal/daemon/launch.go new file mode 100644 index 0000000..77a1552 --- /dev/null +++ b/internal/daemon/launch.go @@ -0,0 +1,152 @@ +package daemon + +import ( + "fmt" + "os" + "path/filepath" + "syscall" + + "lmvpn/internal/paths" +) + +// Launch forks the daemon binary (lmvpnd) into a new session (detached +// from any controlling terminal) and returns immediately. +// +// This is the robust replacement for shell-level `nohup ... &` which +// fails inside osascript's `do shell script` (no TTY → ioctl error). +// +// The launcher itself is invoked by osascript as root. It: +// 1. Computes the daemon log path using the user's home directory +// 2. Opens the log file (append) and /dev/null +// 3. Starts the lmvpnd binary, redirecting the child's stdio to the +// log file and /dev/null +// 4. Sets SysProcAttr.Setsid = true so the child starts a new session +// (no controlling terminal, survives parent exit, no SIGHUP) +// 5. Returns immediately — the child continues reparented to PID 1 +// +// userHome/uid/gid are forwarded to the daemon so it can chown the IPC +// socket and place logs in the user's Library. +// +// daemonBin is the path to the lmvpnd binary. If empty, it is +// auto-detected relative to the launcher's executable path. +func Launch(userHome string, uid, gid int, daemonBin string) error { + // Use the user's home directory for log path computation. + paths.SetUserHome(userHome) + if err := paths.EnsureDirs(); err != nil { + // Non-fatal — root can usually create these. + fmt.Fprintf(os.Stderr, "ensure dirs: %v\n", err) + } + + // Resolve the daemon binary path if not given explicitly. + if daemonBin == "" { + var err error + daemonBin, err = resolveDaemonBinary() + if err != nil { + return err + } + } + + // Convert to absolute path — the child process runs with Dir="/" + // so a relative path would not resolve. + absBin, err := filepath.Abs(daemonBin) + if err != nil { + return fmt.Errorf("resolve absolute path for %s: %w", daemonBin, err) + } + daemonBin = absBin + + // Verify the daemon binary exists. + if _, err := os.Stat(daemonBin); err != nil { + return fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err) + } + + logPath := paths.DaemonLogFile() + // Pre-create/truncate-open the log file so the child inherits it. + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + return fmt.Errorf("open daemon log %s: %w", logPath, err) + } + // Chown so the user can read it. + if uid >= 0 { + _ = os.Chown(logPath, uid, gid) + } + + devNull, err := os.Open(os.DevNull) + if err != nil { + logFile.Close() + return fmt.Errorf("open /dev/null: %w", err) + } + + // Build the daemon command with the same flags. + args := []string{ + "--user-home", userHome, + "--uid", fmt.Sprintf("%d", uid), + "--gid", fmt.Sprintf("%d", gid), + } + + // Write a marker line so we can see the launch happened even if the + // daemon fails to start. + fmt.Fprintf(logFile, "=== launcher: starting daemon %s %v ===\n", daemonBin, args) + + // Set up the child process. + procAttr := &os.ProcAttr{ + Dir: "/", + Env: append(os.Environ(), + "LMVPN_DAEMON=1", + ), + Files: []*os.File{devNull, logFile, logFile}, + Sys: &syscall.SysProcAttr{ + // Setsid creates a new session, detaching the child from the + // controlling terminal. The child becomes a session leader + // and will not receive SIGHUP when the launcher exits. + Setsid: true, + }, + } + + pid, err := os.StartProcess(daemonBin, append([]string{daemonBin}, args...), procAttr) + // Close our handles to the files; the child has inherited them. + logFile.Close() + devNull.Close() + + if err != nil { + return fmt.Errorf("start daemon process: %w", err) + } + + // Release the child so it doesn't become a zombie when the launcher + // exits — it will be reparented to PID 1. + _ = pid.Release() + + // Print success to stderr (visible in osascript output if captured). + fmt.Fprintf(os.Stderr, "daemon launched (pid %d, bin %s)\n", pid.Pid, daemonBin) + return nil +} + +// resolveDaemonBinary locates the lmvpnd binary relative to the +// launcher's executable path. In a .app bundle, lmvpnd lives in the +// same Contents/MacOS/ directory as lmvpn. In development, it lives +// in the same build directory. +func resolveDaemonBinary() (string, error) { + exe, err := os.Executable() + if err != nil { + return "", fmt.Errorf("resolve launcher executable: %w", err) + } + + // Resolve any symlinks. + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return "", fmt.Errorf("resolve symlink: %w", err) + } + + dir := filepath.Dir(exe) + candidates := []string{ + filepath.Join(dir, "lmvpnd"), + filepath.Join(dir, "lmvpn-daemon"), + } + + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c, nil + } + } + + return "", fmt.Errorf("could not find lmvpnd binary near %s (tried %v)", exe, candidates) +} diff --git a/internal/log/log.go b/internal/log/log.go index 6f730a9..b63b612 100644 --- a/internal/log/log.go +++ b/internal/log/log.go @@ -1,6 +1,11 @@ // Package log configures structured logging (slog) to a rotating file // with a console mirror. Separate log files are used for the GUI and // the privileged daemon. +// +// When the daemon is launched via daemon-launch, its stderr is already +// redirected to the log file. To avoid duplicate log lines, the daemon +// detects the LMVPN_DAEMON=1 env var and writes only to the file (not +// stderr). The GUI always mirrors to stderr for console visibility. package log import ( @@ -23,7 +28,9 @@ const ( var logger *slog.Logger // Init configures the package-level logger writing to the given file -// path (rotated by size) and to stderr. +// path (rotated by size). For the GUI it also mirrors to stderr; for +// the forked daemon it writes only to the file (since stderr is +// already redirected to the file by the launcher). func Init(role Role, logFile string) *slog.Logger { if err := os.MkdirAll(filepath.Dir(logFile), 0o755); err != nil { // Fall back to stderr-only on failure. @@ -37,8 +44,19 @@ func Init(role Role, logFile string) *slog.Logger { MaxAge: 30, // days LocalTime: true, } - mw := io.MultiWriter(os.Stderr, w) - logger = slog.New(slog.NewTextHandler(mw, &slog.HandlerOptions{ + + // Determine the output writer. When the daemon was forked by + // daemon-launch, LMVPN_DAEMON=1 is set and stderr is already + // pointed at the log file — so write to the file only to avoid + // doubling every line. + var out io.Writer + if role == RoleDaemon && os.Getenv("LMVPN_DAEMON") == "1" { + out = w + } else { + out = io.MultiWriter(os.Stderr, w) + } + + logger = slog.New(slog.NewTextHandler(out, &slog.HandlerOptions{ Level: slog.LevelInfo, })) slog.SetDefault(logger) diff --git a/internal/paths/paths.go b/internal/paths/paths.go index 936e720..bc88bac 100644 --- a/internal/paths/paths.go +++ b/internal/paths/paths.go @@ -26,6 +26,15 @@ type Dirs struct { // Paths is the resolved directory set for the current platform. var Paths Dirs +// SetUserHome overrides the home directory used to compute Paths. +// This is used by the daemon (which runs as root) to write logs and +// data to the invoking user's Library folders instead of /var/root. +func SetUserHome(home string) { + if home != "" { + recomputePaths(home) + } +} + // EnsureDirs creates the application directories if they do not exist. func EnsureDirs() error { for _, d := range []string{Paths.Data, Paths.Cache, Paths.Log} { diff --git a/internal/paths/paths_darwin.go b/internal/paths/paths_darwin.go index 637b0bb..d397864 100644 --- a/internal/paths/paths_darwin.go +++ b/internal/paths/paths_darwin.go @@ -9,6 +9,16 @@ import ( func init() { home, _ := os.UserHomeDir() + recomputePaths(home) +} + +// recomputePaths sets Paths based on the given home directory. +// On macOS the layout is: +// +// /Library/Application Support// data +// /Library/Caches// cache +// /Library/Logs// logs +func recomputePaths(home string) { lib := filepath.Join(home, "Library") Paths = Dirs{ Data: filepath.Join(lib, "Application Support", BundleID), diff --git a/internal/paths/paths_other.go b/internal/paths/paths_other.go index 601c139..77e8c2a 100644 --- a/internal/paths/paths_other.go +++ b/internal/paths/paths_other.go @@ -9,6 +9,12 @@ import ( func init() { home, _ := os.UserHomeDir() + recomputePaths(home) +} + +// recomputePaths sets Paths based on the given home directory. +// Uses XDG-style layout on Linux/other platforms. +func recomputePaths(home string) { Paths = Dirs{ Data: filepath.Join(home, ".local", "share", BundleID), Cache: filepath.Join(home, ".cache", BundleID), diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 23f1f69..6138920 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -18,6 +18,7 @@ import ( "encoding/json" "fmt" "net/http" + "net/url" "sync" "time" @@ -255,13 +256,14 @@ func (e *ServerError) Error() string { return fmt.Sprintf("server %s: %s", e.Type, e.Message) } -// appendQuery appends a key=value parameter to a URL. -func appendQuery(url, key, value string) string { +// appendQuery appends a key=value parameter to a URL, with the value +// properly URL-escaped. +func appendQuery(rawURL, key, value string) string { sep := "?" - if contains(url, "?") { + if contains(rawURL, "?") { sep = "&" } - return url + sep + key + "=" + value + return rawURL + sep + key + "=" + url.QueryEscape(value) } func contains(s, sub string) bool { diff --git a/internal/ui/daemon.go b/internal/ui/daemon.go index 6d84242..9beebfb 100644 --- a/internal/ui/daemon.go +++ b/internal/ui/daemon.go @@ -1,12 +1,10 @@ -// Package ui contains the Fyne desktop GUI. It runs as the user, -// manages profiles and credentials (SQLite + Keychain), and controls -// the privileged daemon over IPC. package ui import ( "fmt" "os" "os/exec" + "path/filepath" "time" "lmvpn/internal/ipc" @@ -17,6 +15,15 @@ import ( // ensureDaemon checks if the daemon is running and launches it (as // root via osascript) if not. It blocks until the daemon is reachable // or times out. +// +// The daemon is launched via the `daemon-launch` subcommand of the GUI +// binary, which uses Go's syscall.SysProcAttr{Setsid: true} to fork +// the actual daemon binary (lmvpnd) into a new session. This is the +// robust replacement for shell-level `nohup ... &` which fails inside +// osascript's `do shell script` (no TTY → ioctl error). +// +// The --daemon-bin flag tells daemon-launch where to find lmvpnd. If +// not given, daemon-launch auto-detects it relative to its own path. func ensureDaemon() (*ipc.Client, error) { // Fast path: daemon already running. if c, err := ipc.Dial(); err == nil { @@ -27,24 +34,60 @@ func ensureDaemon() (*ipc.Client, error) { if err != nil { return nil, fmt.Errorf("resolve executable: %w", err) } + // Resolve symlinks (e.g. .app bundle's MacOS/lmvpn). + exe, err = filepath.EvalSymlinks(exe) + if err != nil { + return nil, fmt.Errorf("resolve symlink: %w", err) + } - // Launch daemon as root via osascript administrator prompt. + // Compute the daemon binary path: same directory as the GUI binary. + daemonBin := filepath.Join(filepath.Dir(exe), "lmvpnd") + if _, err := os.Stat(daemonBin); err != nil { + return nil, fmt.Errorf("daemon binary not found at %s: %w", daemonBin, err) + } + + home, _ := os.UserHomeDir() + uid := os.Getuid() + gid := os.Getgid() + + // Launch the daemon via osascript (prompts for admin password). + // The `daemon-launch` subcommand forks lmvpnd with Setsid and exits + // immediately, so osascript returns right away. script := fmt.Sprintf( - `do shell script "nohup %s daemon > /dev/null 2>&1 &" with administrator privileges`, - exe) + `do shell script %q with administrator privileges`, + fmt.Sprintf("%s daemon-launch --user-home %s --uid %d --gid %d --daemon-bin %s", + shellQuote(exe), shellQuote(home), uid, gid, shellQuote(daemonBin)), + ) cmd := exec.Command("osascript", "-e", script) if out, err := cmd.CombinedOutput(); err != nil { - return nil, fmt.Errorf("launch daemon: %w (%s)", err, string(out)) + return nil, fmt.Errorf("launch daemon: %w (output: %s)", err, string(out)) } - log.L().Info("daemon launched via osascript") + log.L().Info("daemon launched via osascript", + "uid", uid, "gid", gid, "home", home, "daemon_bin", daemonBin) // Wait for the daemon to become reachable. - for i := 0; i < 20; i++ { + logFile := paths.DaemonLogFile() + for i := 0; i < 40; i++ { time.Sleep(250 * time.Millisecond) if c, err := ipc.Dial(); err == nil { log.L().Info("daemon connected", "socket", paths.IPCSocketPath()) return c, nil } } - return nil, fmt.Errorf("daemon did not become reachable") + return nil, fmt.Errorf("daemon did not become reachable (check %s)", logFile) +} + +// shellQuote wraps a string in single quotes for shell safety. +// Embedded single quotes are escaped using the '\'' pattern. +func shellQuote(s string) string { + result := "'" + for _, r := range s { + if r == '\'' { + result += "'\\''" + } else { + result += string(r) + } + } + result += "'" + return result } diff --git a/internal/vpn/session.go b/internal/vpn/session.go index 0937991..4654654 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -188,11 +188,17 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig) er // Attempt JWT connection first; fall back to password on auth error. conn, err := transport.Connect(ctx, handshake) if err != nil { - var authErr *transport.AuthError - if errors.As(err, &authErr) && cfg.AuthMode == model.AuthModeBoth && token != "" { - // Retry with password auth (no token). - handshake.Token = "" - conn, err = transport.Connect(ctx, handshake) + if cfg.AuthMode == model.AuthModeBoth && token != "" { + // JWT failure can surface as AuthError (password-auth path) + // or ServerError with Type=auth_err (JWT rejected at /ws). + var authErr *transport.AuthError + var serverErr *transport.ServerError + if errors.As(err, &authErr) || + (errors.As(err, &serverErr) && serverErr.Type == protocol.TypeAuthErr) { + log.L().Info("JWT auth failed, falling back to password auth", "error", err) + handshake.Token = "" + conn, err = transport.Connect(ctx, handshake) + } } if err != nil { return err diff --git a/main.go b/main.go deleted file mode 100644 index 4356675..0000000 --- a/main.go +++ /dev/null @@ -1,30 +0,0 @@ -// Command lmvpn is the LMVPN client application. It runs in two modes: -// -// lmvpn — GUI mode (default): Fyne desktop application -// lmvpn daemon — privileged daemon: owns the TUN device and -// WebSocket transport, launched as root by the GUI -// -// The split architecture keeps the GUI running as the user (with -// Keychain access) while the daemon runs as root (with TUN/route -// privileges). They communicate over a unix domain socket. -package main - -import ( - "fmt" - "os" - - "lmvpn/internal/daemon" - "lmvpn/internal/ui" -) - -func main() { - if len(os.Args) > 1 && os.Args[1] == "daemon" { - if err := daemon.Run(); err != nil { - fmt.Fprintf(os.Stderr, "daemon: %v\n", err) - os.Exit(1) - } - return - } - - ui.Run() -}