diff --git a/Makefile b/Makefile index e6c4c40..72f254e 100644 --- a/Makefile +++ b/Makefile @@ -10,8 +10,8 @@ APP_BUNDLE = $(APP_NAME).app GO = go CGO_ENABLED = 1 GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) -VERSION = 0.2.5-$(GIT_HASH) -LDFLAGS = -s -w -X lmvpn/internal/ui.Version=$(VERSION) +VERSION = 0.2.6-$(GIT_HASH) +LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) .PHONY: all build app run daemon clean vet tidy fmt icon diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index cc86c8b..f567e40 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -26,6 +26,7 @@ import ( "lmvpn/internal/model" "lmvpn/internal/paths" "lmvpn/internal/stats" + "lmvpn/internal/version" "lmvpn/internal/vpn" ) @@ -113,6 +114,8 @@ func (d *daemon) handle(conn net.Conn, req ipc.Request) { d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap}) } _ = ipc.WriteOK(conn) + case ipc.CmdVersion: + _ = ipc.WriteVersion(conn, version.Version) default: _ = ipc.WriteErr(conn, "unknown command: "+req.Cmd) } diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index 78b6cb3..7fddacb 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -29,6 +29,7 @@ const ( CmdStop = "stop" CmdShutdown = "shutdown" CmdStats = "stats" + CmdVersion = "version" // query daemon build version ) // Event types sent from daemon to GUI. @@ -49,8 +50,8 @@ type Request struct { // package (which needs root-only TUN) into the GUI. type ClientConfig struct { ServerURL string `json:"server_url"` - SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN - ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover + SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN + ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover Username string `json:"username"` Password string `json:"password"` Token string `json:"token"` @@ -203,8 +204,9 @@ func (c *Client) Close() error { return c.conn.Close() } // Response is a simple ack/error reply to a command. type Response struct { - OK bool `json:"ok"` - Error string `json:"error,omitempty"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + Version string `json:"version,omitempty"` // set for CmdVersion replies } // WriteOK sends a success response to a connection. @@ -232,6 +234,28 @@ func SendShutdown(c *Client) error { return c.Send(Request{Cmd: CmdShutdown}) } +// WriteVersion sends a version response to a connection. +func WriteVersion(conn net.Conn, ver string) error { + return writeMsg(conn, Response{OK: true, Version: ver}) +} + +// QueryVersion sends a CmdVersion request and reads the daemon's +// build version. It must be called before any VPN session starts (so +// no Event messages are in flight on the connection). +func (c *Client) QueryVersion() (string, error) { + if err := c.Send(Request{Cmd: CmdVersion}); err != nil { + return "", err + } + var resp Response + if err := readMsg(c.r, &resp); err != nil { + return "", err + } + if !resp.OK { + return "", fmt.Errorf("version query failed: %s", resp.Error) + } + return resp.Version, nil +} + // RoutingModeFromIPC converts an IPC routing mode string to route.Mode. func RoutingModeFromIPC(s string) route.Mode { switch s { diff --git a/internal/ui/daemon.go b/internal/ui/daemon.go index 9beebfb..e4b5543 100644 --- a/internal/ui/daemon.go +++ b/internal/ui/daemon.go @@ -10,6 +10,7 @@ import ( "lmvpn/internal/ipc" "lmvpn/internal/log" "lmvpn/internal/paths" + "lmvpn/internal/version" ) // ensureDaemon checks if the daemon is running and launches it (as @@ -25,9 +26,27 @@ import ( // 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. + // Fast path: a daemon is already running. Verify its build version + // matches ours; if not, it's a stale process from a previous build + // and must be restarted so the new code takes effect. if c, err := ipc.Dial(); err == nil { - return c, nil + if ver, qerr := c.QueryVersion(); qerr == nil && ver == version.Version { + return c, nil + } else { + log.L().Info("stale daemon detected, restarting", + "got", ver, "expected", version.Version, "query_err", qerr) + _ = ipc.SendShutdown(c) + c.Close() + // Wait for the old daemon to exit and release the socket. + for i := 0; i < 30; i++ { + time.Sleep(100 * time.Millisecond) + if cc, err := ipc.Dial(); err == nil { + cc.Close() + } else { + break // socket gone + } + } + } } exe, err := os.Executable() @@ -78,7 +97,7 @@ func ensureDaemon() (*ipc.Client, error) { } // shellQuote wraps a string in single quotes for shell safety. -// Embedded single quotes are escaped using the '\'' pattern. +// Embedded single quotes are escaped using the '\” pattern. func shellQuote(s string) string { result := "'" for _, r := range s { diff --git a/internal/ui/version.go b/internal/ui/version.go index 0402693..dfcf9b1 100644 --- a/internal/ui/version.go +++ b/internal/ui/version.go @@ -1,3 +1,7 @@ package ui -var Version = "dev" +import "lmvpn/internal/version" + +// Version is the application version, sourced from the shared version +// package so the GUI and daemon report the same string. +var Version = version.Version diff --git a/internal/version/version.go b/internal/version/version.go new file mode 100644 index 0000000..c038d64 --- /dev/null +++ b/internal/version/version.go @@ -0,0 +1,15 @@ +// Package version holds the build version string shared by the GUI +// (lmvpn) and daemon (lmvpnd) binaries. The value is injected at link +// time via ldflags: +// +// -X lmvpn/internal/version.Version=$(VERSION) +// +// Both binaries are built with the same LDFLAGS, so a version mismatch +// between the running daemon and the GUI indicates a stale daemon +// process that predates the current build. +package version + +// Version is the application version. Overridden at link time; "dev" +// when running an untagged build (e.g. go run / go build without +// ldflags). +var Version = "dev"