- GUI 单实例锁: 新增 internal/instance 包,首实例监听专用端点, 第二实例发送 focus 信号后静默退出 (Unix: gui.sock, Win: 18924) - 守护进程弱锁修复: NewServer() 在 os.Remove 前先 net.Dial 探测, 有活进程时拒绝启动,避免静默抢占 socket 导致孤儿进程 - macOS .app 重开恢复: 通过 class_addMethod 给 GLFWApplicationDelegate 注册 applicationShouldHandleReopen:hasVisibleWindows: 回调, 绕过 GLFW 直接用 Cocoa makeKeyAndOrderFront: 显示窗口 - 三平台覆盖: macOS/Linux 用 unix socket, Windows 用 TCP 端口
296 lines
7.7 KiB
Go
296 lines
7.7 KiB
Go
// Package ipc implements the communication protocol between the GUI
|
|
// process (user) and the privileged daemon (root) over a unix domain
|
|
// socket.
|
|
//
|
|
// Protocol: newline-delimited JSON. Each message is one JSON object
|
|
// followed by '\n'.
|
|
//
|
|
// GUI → daemon: Request (start, stop, shutdown, stats)
|
|
// daemon → GUI: Event (state, stats, error)
|
|
package ipc
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"sync"
|
|
|
|
"lmvpn/internal/paths"
|
|
"lmvpn/internal/route"
|
|
"lmvpn/internal/stats"
|
|
)
|
|
|
|
// Command types sent from GUI to daemon.
|
|
const (
|
|
CmdStart = "start"
|
|
CmdStop = "stop"
|
|
CmdShutdown = "shutdown"
|
|
CmdStats = "stats"
|
|
CmdVersion = "version" // query daemon build version
|
|
)
|
|
|
|
// Event types sent from daemon to GUI.
|
|
const (
|
|
EvState = "state"
|
|
EvStats = "stats"
|
|
EvError = "error"
|
|
)
|
|
|
|
// Request is a command from the GUI to the daemon.
|
|
type Request struct {
|
|
Cmd string `json:"cmd"`
|
|
Config *ClientConfig `json:"config,omitempty"`
|
|
}
|
|
|
|
// ClientConfig is the session configuration sent over IPC. It mirrors
|
|
// vpn.SessionConfig but is kept separate to avoid importing the vpn
|
|
// 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
|
|
Username string `json:"username"`
|
|
Password string `json:"password"`
|
|
Token string `json:"token"`
|
|
AuthMode string `json:"auth_mode"`
|
|
RoutingMode string `json:"routing_mode"`
|
|
CustomCIDRs []string `json:"custom_cidrs"`
|
|
MTUOverride int `json:"mtu_override"`
|
|
TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only)
|
|
TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only)
|
|
TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only)
|
|
TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only)
|
|
}
|
|
|
|
// Event is a notification from the daemon to the GUI.
|
|
type Event struct {
|
|
Event string `json:"event"`
|
|
State string `json:"state,omitempty"`
|
|
Stats *stats.Snapshot `json:"stats,omitempty"`
|
|
Code string `json:"code,omitempty"` // stable auth-error code for EvError
|
|
Message string `json:"message,omitempty"`
|
|
}
|
|
|
|
// --- Wire helpers ---
|
|
|
|
func writeMsg(w io.Writer, v interface{}) error {
|
|
data, err := json.Marshal(v)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
data = append(data, '\n')
|
|
_, err = w.Write(data)
|
|
return err
|
|
}
|
|
|
|
func readMsg(r *bufio.Reader, v interface{}) error {
|
|
line, err := r.ReadString('\n')
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return json.Unmarshal([]byte(line), v)
|
|
}
|
|
|
|
// --- Server (daemon side) ---
|
|
|
|
// Server listens on the IPC socket and manages connected clients.
|
|
type Server struct {
|
|
listener net.Listener
|
|
mu sync.Mutex
|
|
clients map[net.Conn]bool
|
|
}
|
|
|
|
// NewServer creates (but does not start) the IPC server. It removes
|
|
// any stale socket file first (unix only).
|
|
func NewServer() (*Server, error) {
|
|
netType := paths.IPCNetwork()
|
|
addr := paths.IPCAddress()
|
|
|
|
// Guard against a second daemon instance on unix: probe the
|
|
// socket before removing it. If the dial succeeds, another
|
|
// daemon is alive and owns the socket - refuse to start so we
|
|
// don't silently orphan it. Only remove the file when the dial
|
|
// fails (stale socket from a crashed process).
|
|
if netType == "unix" {
|
|
if c, derr := net.Dial(netType, addr); derr == nil {
|
|
c.Close()
|
|
return nil, fmt.Errorf("daemon already running on %s", addr)
|
|
}
|
|
_ = os.Remove(addr)
|
|
}
|
|
|
|
l, err := net.Listen(netType, addr)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("listen ipc: %w", err)
|
|
}
|
|
|
|
// Set socket permissions so group members can connect (unix only).
|
|
if netType == "unix" {
|
|
_ = os.Chmod(addr, 0o660)
|
|
}
|
|
|
|
return &Server{listener: l, clients: make(map[net.Conn]bool)}, nil
|
|
}
|
|
|
|
// Accept runs the accept loop. Each connection is handled in a
|
|
// goroutine; the handler callback receives each Request.
|
|
func (s *Server) Accept(handle func(net.Conn, Request)) error {
|
|
for {
|
|
conn, err := s.listener.Accept()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
s.clients[conn] = true
|
|
s.mu.Unlock()
|
|
go s.serve(conn, handle)
|
|
}
|
|
}
|
|
|
|
func (s *Server) serve(conn net.Conn, handle func(net.Conn, Request)) {
|
|
defer func() {
|
|
s.mu.Lock()
|
|
delete(s.clients, conn)
|
|
s.mu.Unlock()
|
|
conn.Close()
|
|
}()
|
|
r := bufio.NewReader(conn)
|
|
for {
|
|
var req Request
|
|
if err := readMsg(r, &req); err != nil {
|
|
return
|
|
}
|
|
handle(conn, req)
|
|
}
|
|
}
|
|
|
|
// Broadcast sends an event to all connected clients.
|
|
func (s *Server) Broadcast(ev Event) {
|
|
data, _ := json.Marshal(ev)
|
|
data = append(data, '\n')
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for c := range s.clients {
|
|
_, _ = c.Write(data)
|
|
}
|
|
}
|
|
|
|
// Close stops the server and closes all connections.
|
|
func (s *Server) Close() {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for c := range s.clients {
|
|
c.Close()
|
|
}
|
|
s.clients = nil
|
|
if s.listener != nil {
|
|
s.listener.Close()
|
|
}
|
|
}
|
|
|
|
// --- Client (GUI side) ---
|
|
|
|
// Client connects to the daemon's IPC socket.
|
|
type Client struct {
|
|
conn net.Conn
|
|
r *bufio.Reader
|
|
}
|
|
|
|
// Dial connects to the daemon.
|
|
func Dial() (*Client, error) {
|
|
conn, err := net.Dial(paths.IPCNetwork(), paths.IPCAddress())
|
|
if err != nil {
|
|
return nil, fmt.Errorf("dial daemon: %w", err)
|
|
}
|
|
return &Client{conn: conn, r: bufio.NewReader(conn)}, nil
|
|
}
|
|
|
|
// Send sends a request to the daemon.
|
|
func (c *Client) Send(req Request) error {
|
|
return writeMsg(c.conn, &req)
|
|
}
|
|
|
|
// Recv reads the next event from the daemon. Blocks until an event is
|
|
// available or the connection breaks.
|
|
func (c *Client) Recv() (Event, error) {
|
|
var ev Event
|
|
if err := readMsg(c.r, &ev); err != nil {
|
|
return ev, err
|
|
}
|
|
return ev, nil
|
|
}
|
|
|
|
// Close closes the connection.
|
|
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"`
|
|
Version string `json:"version,omitempty"` // set for CmdVersion replies
|
|
}
|
|
|
|
// WriteOK sends a success response to a connection.
|
|
func WriteOK(conn net.Conn) error {
|
|
return writeMsg(conn, Response{OK: true})
|
|
}
|
|
|
|
// WriteErr sends an error response to a connection.
|
|
func WriteErr(conn net.Conn, msg string) error {
|
|
return writeMsg(conn, Response{OK: false, Error: msg})
|
|
}
|
|
|
|
// SendStart is a convenience helper for sending a start command.
|
|
func SendStart(c *Client, cfg ClientConfig) error {
|
|
return c.Send(Request{Cmd: CmdStart, Config: &cfg})
|
|
}
|
|
|
|
// SendStop is a convenience helper for sending a stop command.
|
|
func SendStop(c *Client) error {
|
|
return c.Send(Request{Cmd: CmdStop})
|
|
}
|
|
|
|
// SendShutdown is a convenience helper for sending a shutdown command.
|
|
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 {
|
|
case "full":
|
|
return route.ModeFull
|
|
case "split":
|
|
return route.ModeSplit
|
|
case "custom":
|
|
return route.ModeCustom
|
|
default:
|
|
return route.ModeFull
|
|
}
|
|
}
|