This commit is contained in:
2026-07-06 16:21:06 +08:00
commit bb9f685221
41 changed files with 4333 additions and 0 deletions
+113
View File
@@ -0,0 +1,113 @@
// Package auth implements the HTTP login flow (POST /api/login) to
// obtain a JWT for WebSocket authentication.
//
// (server: internal/handler/auth.go:32-82, internal/router/router.go:17)
package auth
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
)
// LoginResult holds the response from a successful /api/login call.
type LoginResult struct {
Token string `json:"token"`
User LoginUser `json:"user"`
}
// LoginUser is the user object embedded in the login response.
type LoginUser struct {
ID uint `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
}
type loginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
type errorResponse struct {
Error string `json:"error"`
}
// Login performs an HTTP POST to /api/login and returns the JWT.
//
// baseURL should be the HTTP(S) origin derived from the WebSocket URL
// (e.g. "http://localhost:8080" for ws://, "https://vpn.example.com"
// for wss://). See WSURLToHTTP.
func Login(baseURL, username, password string) (*LoginResult, error) {
body, err := json.Marshal(loginRequest{Username: username, Password: password})
if err != nil {
return nil, err
}
url := strings.TrimRight(baseURL, "/") + "/api/login"
client := &http.Client{Timeout: 15 * time.Second}
resp, err := client.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("login request: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read login response: %w", err)
}
switch resp.StatusCode {
case http.StatusOK:
var result LoginResult
if err := json.Unmarshal(raw, &result); err != nil {
return nil, fmt.Errorf("parse login response: %w", err)
}
return &result, nil
case http.StatusBadRequest, http.StatusUnauthorized, http.StatusForbidden,
http.StatusTooManyRequests, http.StatusInternalServerError:
var e errorResponse
_ = json.Unmarshal(raw, &e)
return nil, &LoginError{Code: resp.StatusCode, Message: e.Error}
default:
return nil, &LoginError{Code: resp.StatusCode, Message: string(raw)}
}
}
// LoginError carries the HTTP status code and server error message.
type LoginError struct {
Code int
Message string
}
func (e *LoginError) Error() string {
return fmt.Sprintf("login failed (%d): %s", e.Code, e.Message)
}
// IsRateLimited reports whether the error is a 429 rate-limit response.
func (e *LoginError) IsRateLimited() bool { return e.Code == http.StatusTooManyRequests }
// WSURLToHTTP converts a WebSocket URL to its HTTP origin.
//
// ws://host:port/ws → http://host:port
// wss://host/ws → https://host
// ws://host:8080 → http://host:8080
func WSURLToHTTP(wsURL string) (string, error) {
u := wsURL
switch {
case strings.HasPrefix(u, "wss://"):
u = "https://" + u[len("wss://"):]
case strings.HasPrefix(u, "ws://"):
u = "http://" + u[len("ws://"):]
default:
return "", fmt.Errorf("invalid WebSocket URL: %s", wsURL)
}
// Strip the path (e.g. /ws) to get just the origin.
if idx := strings.IndexByte(u, '/'); idx > 8 { // keep "https://"
u = u[:idx]
}
return u, nil
}
+57
View File
@@ -0,0 +1,57 @@
// Package config manages the application-level configuration file
// (config.yml), distinct from per-profile server settings stored in
// the database.
package config
import (
"os"
"lmvpn/internal/paths"
"gopkg.in/yaml.v3"
)
// AppConfig holds application-wide settings.
type AppConfig struct {
AutoConnect bool `yaml:"auto_connect"`
MinimizeToTray bool `yaml:"minimize_to_tray"`
CloseToTray bool `yaml:"close_to_tray"`
DefaultProfileID int64 `yaml:"default_profile_id"`
LogLevel string `yaml:"log_level"` // debug, info, warn, error
}
// Default returns the default configuration.
func Default() AppConfig {
return AppConfig{
AutoConnect: false,
MinimizeToTray: true,
CloseToTray: true,
DefaultProfileID: 0,
LogLevel: "info",
}
}
// Load reads the config file, returning defaults if it does not exist.
func Load() (AppConfig, error) {
cfg := Default()
data, err := os.ReadFile(paths.ConfigPath())
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return cfg, err
}
if err := yaml.Unmarshal(data, &cfg); err != nil {
return Default(), err
}
return cfg, nil
}
// Save writes the config file with 0600 permissions.
func Save(cfg AppConfig) error {
data, err := yaml.Marshal(cfg)
if err != nil {
return err
}
return os.WriteFile(paths.ConfigPath(), data, 0o600)
}
+134
View File
@@ -0,0 +1,134 @@
// Package daemon implements the privileged daemon process that 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.
//
// 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.
package daemon
import (
"context"
"fmt"
"net"
"os"
"os/signal"
"syscall"
"lmvpn/internal/ipc"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/paths"
"lmvpn/internal/stats"
"lmvpn/internal/vpn"
)
// Run starts the daemon and blocks until Shutdown is received or a
// signal (SIGINT/SIGTERM) is delivered.
func Run() error {
log.Init(log.RoleDaemon, paths.DaemonLogFile())
log.L().Info("lmvpn daemon starting")
server, err := ipc.NewServer()
if err != nil {
return fmt.Errorf("ipc server: %w", err)
}
defer server.Close()
d := &daemon{server: server}
// Signal handling for clean shutdown.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigCh
log.L().Info("daemon received signal, shutting down")
d.stopSession()
server.Close()
os.Exit(0)
}()
log.L().Info("daemon listening", "socket", paths.IPCSocketPath())
return server.Accept(d.handle)
}
type daemon struct {
server *ipc.Server
session *vpn.SessionManager
cancel context.CancelFunc
}
func (d *daemon) handle(conn net.Conn, req ipc.Request) {
switch req.Cmd {
case ipc.CmdStart:
d.startSession(conn, req)
case ipc.CmdStop:
d.stopSession()
_ = ipc.WriteOK(conn)
case ipc.CmdShutdown:
d.stopSession()
_ = ipc.WriteOK(conn)
d.server.Close()
os.Exit(0)
case ipc.CmdStats:
if d.session != nil {
snap := d.session.Stats().Snapshot()
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &snap})
}
_ = ipc.WriteOK(conn)
default:
_ = ipc.WriteErr(conn, "unknown command: "+req.Cmd)
}
}
func (d *daemon) startSession(conn net.Conn, req ipc.Request) {
if req.Config == nil {
_ = ipc.WriteErr(conn, "missing config")
return
}
if d.session != nil {
d.stopSession()
}
cfg := vpn.SessionConfig{
ServerURL: req.Config.ServerURL,
Username: req.Config.Username,
Password: req.Config.Password,
Token: req.Config.Token,
AuthMode: model.AuthMode(req.Config.AuthMode),
RoutingMode: ipc.RoutingModeFromIPC(req.Config.RoutingMode),
CustomCIDRs: req.Config.CustomCIDRs,
MTUOverride: req.Config.MTUOverride,
}
ctx, cancel := context.WithCancel(context.Background())
d.cancel = cancel
d.session = vpn.New(
func(s stats.State) {
d.server.Broadcast(ipc.Event{Event: ipc.EvState, State: string(s)})
},
func(snap stats.Snapshot) {
s := snap
d.server.Broadcast(ipc.Event{Event: ipc.EvStats, Stats: &s})
},
)
if err := d.session.Connect(ctx, cfg); err != nil {
_ = ipc.WriteErr(conn, "connect: "+err.Error())
d.session = nil
return
}
_ = ipc.WriteOK(conn)
}
func (d *daemon) stopSession() {
if d.cancel != nil {
d.cancel()
d.cancel = nil
}
if d.session != nil {
d.session.Disconnect()
d.session = nil
}
}
+76
View File
@@ -0,0 +1,76 @@
// Package db manages the SQLite database connection, schema migrations,
// and data access for server profiles and connection logs.
//
// It uses modernc.org/sqlite (a pure-Go driver) so the binary has no
// CGO dependency and cross-compiles trivially.
package db
import (
"database/sql"
"fmt"
"lmvpn/internal/paths"
_ "modernc.org/sqlite"
)
// Store wraps the database handle and provides data access methods.
type Store struct {
db *sql.DB
}
// Open creates or opens the SQLite database and runs migrations.
func Open() (*Store, error) {
db, err := sql.Open("sqlite", paths.DBPath()+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)")
if err != nil {
return nil, fmt.Errorf("open db: %w", err)
}
db.SetMaxOpenConns(1) // sqlite serialises writes
s := &Store{db: db}
if err := s.migrate(); err != nil {
db.Close()
return nil, fmt.Errorf("migrate: %w", err)
}
return s, nil
}
// Close closes the database connection.
func (s *Store) Close() error {
return s.db.Close()
}
func (s *Store) migrate() error {
_, err := s.db.Exec(schema)
return err
}
const schema = `
CREATE TABLE IF NOT EXISTS server_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
server_url TEXT NOT NULL,
username TEXT NOT NULL,
auth_mode TEXT NOT NULL DEFAULT 'both',
routing_mode TEXT NOT NULL DEFAULT 'full',
custom_cidrs TEXT NOT NULL DEFAULT '',
mtu_override INTEGER NOT NULL DEFAULT 0,
auto_connect INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_connected_at DATETIME
);
CREATE TABLE IF NOT EXISTS connection_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL,
started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
ended_at DATETIME,
assigned_ip TEXT NOT NULL DEFAULT '',
rx_bytes INTEGER NOT NULL DEFAULT 0,
tx_bytes INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'connected',
error_msg TEXT NOT NULL DEFAULT '',
FOREIGN KEY (profile_id) REFERENCES server_profiles(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_logs_profile ON connection_logs(profile_id);
`
+64
View File
@@ -0,0 +1,64 @@
package db
import (
"fmt"
"time"
"lmvpn/internal/model"
)
// StartLog creates a connection log entry and returns its ID.
func (s *Store) StartLog(profileID int64) (int64, error) {
res, err := s.db.Exec(
`INSERT INTO connection_logs (profile_id, started_at, status)
VALUES (?, ?, 'connected')`,
profileID, time.Now())
if err != nil {
return 0, fmt.Errorf("start log: %w", err)
}
id, _ := res.LastInsertId()
return id, nil
}
// FinishLog finalises a connection log entry with final stats.
func (s *Store) FinishLog(id int64, status model.ConnectionStatus, assignedIP string, rxBytes, txBytes int64, errMsg string) error {
_, err := s.db.Exec(
`UPDATE connection_logs SET
ended_at = ?, assigned_ip = ?, rx_bytes = ?, tx_bytes = ?,
status = ?, error_msg = ?
WHERE id = ?`,
time.Now(), assignedIP, rxBytes, txBytes, status, errMsg, id)
if err != nil {
return fmt.Errorf("finish log %d: %w", id, err)
}
return nil
}
// RecentLogs returns the most recent N connection logs for a profile.
func (s *Store) RecentLogs(profileID int64, limit int) ([]model.ConnectionLog, error) {
rows, err := s.db.Query(
`SELECT id, profile_id, started_at, ended_at, assigned_ip,
rx_bytes, tx_bytes, status, error_msg
FROM connection_logs WHERE profile_id = ?
ORDER BY started_at DESC LIMIT ?`,
profileID, limit)
if err != nil {
return nil, fmt.Errorf("recent logs: %w", err)
}
defer rows.Close()
var out []model.ConnectionLog
for rows.Next() {
var l model.ConnectionLog
var ended interface{}
if err := rows.Scan(&l.ID, &l.ProfileID, &l.StartedAt, &ended,
&l.AssignedIP, &l.RxBytes, &l.TxBytes, &l.Status, &l.ErrorMsg); err != nil {
return nil, err
}
if t, ok := ended.(time.Time); ok {
l.EndedAt = &t
}
out = append(out, l)
}
return out, rows.Err()
}
+108
View File
@@ -0,0 +1,108 @@
package db
import (
"database/sql"
"fmt"
"time"
"lmvpn/internal/model"
)
// CreateProfile inserts a new server profile and returns its ID.
func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) {
res, err := s.db.Exec(
`INSERT INTO server_profiles
(name, server_url, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect)
VALUES (?,?,?,?,?,?,?,?)`,
p.Name, p.ServerURL, p.Username, p.AuthMode, p.RoutingMode,
p.CustomCIDRs, p.MTUOverride, p.AutoConnect,
)
if err != nil {
return 0, fmt.Errorf("insert profile: %w", err)
}
id, _ := res.LastInsertId()
p.ID = id
p.CreatedAt = time.Now()
return id, nil
}
// GetProfile returns a single profile by ID.
func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) {
p := &model.ServerProfile{}
var last sql.NullTime
err := s.db.QueryRow(
`SELECT id, name, server_url, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
FROM server_profiles WHERE id = ?`, id,
).Scan(&p.ID, &p.Name, &p.ServerURL, &p.Username, &p.AuthMode,
&p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect,
&p.CreatedAt, &last)
if err != nil {
return nil, fmt.Errorf("get profile %d: %w", id, err)
}
if last.Valid {
p.LastConnectedAt = &last.Time
}
return p, nil
}
// ListProfiles returns all saved profiles ordered by name.
func (s *Store) ListProfiles() ([]model.ServerProfile, error) {
rows, err := s.db.Query(
`SELECT id, name, server_url, username, auth_mode, routing_mode,
custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at
FROM server_profiles ORDER BY name`)
if err != nil {
return nil, fmt.Errorf("list profiles: %w", err)
}
defer rows.Close()
var out []model.ServerProfile
for rows.Next() {
var p model.ServerProfile
var last sql.NullTime
if err := rows.Scan(&p.ID, &p.Name, &p.ServerURL, &p.Username,
&p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride,
&p.AutoConnect, &p.CreatedAt, &last); err != nil {
return nil, err
}
if last.Valid {
p.LastConnectedAt = &last.Time
}
out = append(out, p)
}
return out, rows.Err()
}
// UpdateProfile updates an existing profile.
func (s *Store) UpdateProfile(p *model.ServerProfile) error {
_, err := s.db.Exec(
`UPDATE server_profiles SET
name = ?, server_url = ?, username = ?, auth_mode = ?,
routing_mode = ?, custom_cidrs = ?, mtu_override = ?, auto_connect = ?
WHERE id = ?`,
p.Name, p.ServerURL, p.Username, p.AuthMode,
p.RoutingMode, p.CustomCIDRs, p.MTUOverride, p.AutoConnect, p.ID)
if err != nil {
return fmt.Errorf("update profile %d: %w", p.ID, err)
}
return nil
}
// DeleteProfile removes a profile by ID.
func (s *Store) DeleteProfile(id int64) error {
_, err := s.db.Exec(`DELETE FROM server_profiles WHERE id = ?`, id)
if err != nil {
return fmt.Errorf("delete profile %d: %w", id, err)
}
return nil
}
// TouchLastConnected records the connection timestamp for a profile.
func (s *Store) TouchLastConnected(id int64) error {
_, err := s.db.Exec(
`UPDATE server_profiles SET last_connected_at = ? WHERE id = ?`,
time.Now(), id)
return err
}
+245
View File
@@ -0,0 +1,245 @@
// 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"
)
// 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"`
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"`
}
// 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"`
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.
func NewServer() (*Server, error) {
_ = os.Remove(paths.IPCSocketPath())
l, err := net.Listen("unix", paths.IPCSocketPath())
if err != nil {
return nil, fmt.Errorf("listen ipc: %w", err)
}
// Mode 0660 so group members (admin) can connect.
_ = os.Chmod(paths.IPCSocketPath(), 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("unix", paths.IPCSocketPath())
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"`
}
// 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})
}
// 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
}
}
+31
View File
@@ -0,0 +1,31 @@
// Package keychain stores and retrieves secrets (passwords, JWT tokens)
// in the macOS Keychain. On non-darwin platforms it falls back to an
// in-memory store (to be replaced with a platform-appropriate backend).
//
// Secrets are keyed by the server profile name. The Keychain service
// name is the application bundle identifier.
package keychain
import (
"fmt"
"lmvpn/internal/paths"
)
// ServiceName is the Keychain service under which all lmvpn secrets
// are stored.
const ServiceName = paths.BundleID
// ErrNotFound is returned when a secret is not present in the store.
var ErrNotFound = fmt.Errorf("secret not found")
// Store is the secret storage interface.
type Store interface {
SetPassword(profileName, password string) error
GetPassword(profileName string) (string, error)
DeletePassword(profileName string) error
SetToken(profileName, token string) error
GetToken(profileName string) (string, error)
DeleteToken(profileName string) error
DeleteAll(profileName string) error
}
+92
View File
@@ -0,0 +1,92 @@
//go:build darwin
package keychain
import (
"fmt"
"github.com/keybase/go-keychain"
)
// keyPrefixes distinguish password vs token entries in the keychain.
const (
passwordAccountPrefix = "password:"
tokenAccountPrefix = "token:"
)
// DarwinStore implements Store using the macOS Keychain.
type DarwinStore struct{}
// New returns a macOS Keychain-backed Store.
func New() Store {
return DarwinStore{}
}
func (DarwinStore) setItem(account, secret string) error {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
item.SetAccount(account)
item.SetData([]byte(secret))
item.SetAccessible(keychain.AccessibleAfterFirstUnlock)
// Delete any existing item first (Add fails on duplicates).
_ = keychain.DeleteItem(item)
if err := keychain.AddItem(item); err != nil {
return fmt.Errorf("keychain add %s: %w", account, err)
}
return nil
}
func (DarwinStore) getItem(account string) (string, error) {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
item.SetAccount(account)
item.SetMatchLimit(keychain.MatchLimitOne)
item.SetReturnData(true)
results, err := keychain.QueryItem(item)
if err != nil {
return "", fmt.Errorf("keychain query %s: %w", account, err)
}
if len(results) == 0 {
return "", ErrNotFound
}
return string(results[0].Data), nil
}
func (DarwinStore) deleteItem(account string) error {
item := keychain.NewItem()
item.SetSecClass(keychain.SecClassGenericPassword)
item.SetService(ServiceName)
item.SetAccount(account)
return keychain.DeleteItem(item)
}
func (s DarwinStore) SetPassword(profileName, password string) error {
return s.setItem(passwordAccountPrefix+profileName, password)
}
func (s DarwinStore) GetPassword(profileName string) (string, error) {
return s.getItem(passwordAccountPrefix + profileName)
}
func (s DarwinStore) DeletePassword(profileName string) error {
return s.deleteItem(passwordAccountPrefix + profileName)
}
func (s DarwinStore) SetToken(profileName, token string) error {
return s.setItem(tokenAccountPrefix+profileName, token)
}
func (s DarwinStore) GetToken(profileName string) (string, error) {
return s.getItem(tokenAccountPrefix + profileName)
}
func (s DarwinStore) DeleteToken(profileName string) error {
return s.deleteItem(tokenAccountPrefix + profileName)
}
func (s DarwinStore) DeleteAll(profileName string) error {
_ = s.DeletePassword(profileName)
return s.DeleteToken(profileName)
}
+49
View File
@@ -0,0 +1,49 @@
//go:build !darwin
package keychain
// MemStore is an in-memory fallback used on non-darwin platforms.
type MemStore struct {
data map[string]string
}
// New returns an in-memory Store (non-darwin fallback).
func New() Store {
return &MemStore{data: make(map[string]string)}
}
func (m *MemStore) SetPassword(profileName, password string) error {
m.data["password:"+profileName] = password
return nil
}
func (m *MemStore) GetPassword(profileName string) (string, error) {
v, ok := m.data["password:"+profileName]
if !ok {
return "", ErrNotFound
}
return v, nil
}
func (m *MemStore) DeletePassword(profileName string) error {
delete(m.data, "password:"+profileName)
return nil
}
func (m *MemStore) SetToken(profileName, token string) error {
m.data["token:"+profileName] = token
return nil
}
func (m *MemStore) GetToken(profileName string) (string, error) {
v, ok := m.data["token:"+profileName]
if !ok {
return "", ErrNotFound
}
return v, nil
}
func (m *MemStore) DeleteToken(profileName string) error {
delete(m.data, "token:"+profileName)
return nil
}
func (m *MemStore) DeleteAll(profileName string) error {
m.DeletePassword(profileName)
m.DeleteToken(profileName)
return nil
}
+55
View File
@@ -0,0 +1,55 @@
// 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.
package log
import (
"io"
"log/slog"
"os"
"path/filepath"
"gopkg.in/natefinch/lumberjack.v2"
)
// Role selects which log file to write to.
type Role string
const (
RoleGUI Role = "gui"
RoleDaemon Role = "daemon"
)
var logger *slog.Logger
// Init configures the package-level logger writing to the given file
// path (rotated by size) and to stderr.
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.
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
return logger
}
w := &lumberjack.Logger{
Filename: logFile,
MaxSize: 10, // MB
MaxBackups: 3,
MaxAge: 30, // days
LocalTime: true,
}
mw := io.MultiWriter(os.Stderr, w)
logger = slog.New(slog.NewTextHandler(mw, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
slog.SetDefault(logger)
return logger
}
// L returns the package-level logger. It returns a default logger if
// Init was not called.
func L() *slog.Logger {
if logger == nil {
logger = slog.New(slog.NewTextHandler(os.Stderr, nil))
}
return logger
}
+60
View File
@@ -0,0 +1,60 @@
// Package model defines the data structures persisted in SQLite and
// exchanged between application layers.
package model
import "time"
// AuthMode selects how the client authenticates to a server.
type AuthMode string
const (
AuthModeBoth AuthMode = "both" // try JWT, fall back to password
AuthModeJWT AuthMode = "jwt" // HTTP login then ?token=
AuthModePassword AuthMode = "password" // {type:auth} first message
)
// RoutingMode selects which traffic goes through the VPN tunnel.
type RoutingMode string
const (
RoutingFull RoutingMode = "full" // 0.0.0.0/0 via tunnel
RoutingSplit RoutingMode = "split" // only VPN subnet via tunnel
RoutingCustom RoutingMode = "custom" // user-specified CIDRs
)
// ServerProfile is a saved VPN server configuration.
type ServerProfile struct {
ID int64 `json:"id"`
Name string `json:"name"`
ServerURL string `json:"server_url"` // e.g. wss://vpn.example.com/ws
Username string `json:"username"`
AuthMode AuthMode `json:"auth_mode"`
RoutingMode RoutingMode `json:"routing_mode"`
CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom
MTUOverride int `json:"mtu_override"` // 0 = use server MTU
AutoConnect bool `json:"auto_connect"`
CreatedAt time.Time `json:"created_at"`
LastConnectedAt *time.Time `json:"last_connected_at"`
}
// ConnectionStatus records the outcome of a connection attempt.
type ConnectionStatus string
const (
StatusConnected ConnectionStatus = "connected"
StatusDisconnected ConnectionStatus = "disconnected"
StatusError ConnectionStatus = "error"
)
// ConnectionLog records a single VPN session.
type ConnectionLog struct {
ID int64 `json:"id"`
ProfileID int64 `json:"profile_id"`
StartedAt time.Time `json:"started_at"`
EndedAt *time.Time `json:"ended_at"`
AssignedIP string `json:"assigned_ip"`
RxBytes int64 `json:"rx_bytes"`
TxBytes int64 `json:"tx_bytes"`
Status ConnectionStatus `json:"status"`
ErrorMsg string `json:"error_msg"`
}
+53
View File
@@ -0,0 +1,53 @@
// Package paths resolves platform-specific application directories.
//
// Layout follows each platform's conventions. On macOS:
//
// ~/Library/Application Support/com.lmvpn.client/ user data, db, config
// ~/Library/Caches/com.lmvpn.client/ caches
// ~/Library/Logs/com.lmvpn.client/ logs
package paths
import "os"
// BundleID is the application bundle identifier used as the per-app
// subdirectory name under the platform library folders.
const BundleID = "com.lmvpn.client"
// AppName is the human-readable application name.
const AppName = "LMVPN"
// Dirs describes the resolved application directories.
type Dirs struct {
Data string // persistent user data (db, config)
Cache string // recreatable caches
Log string // log files
}
// Paths is the resolved directory set for the current platform.
var Paths Dirs
// EnsureDirs creates the application directories if they do not exist.
func EnsureDirs() error {
for _, d := range []string{Paths.Data, Paths.Cache, Paths.Log} {
if err := os.MkdirAll(d, 0o755); err != nil {
return err
}
}
return nil
}
// DBPath returns the path to the SQLite database file.
func DBPath() string { return Paths.Data + "/lmvpn.db" }
// ConfigPath returns the path to the application config file.
func ConfigPath() string { return Paths.Data + "/config.yml" }
// LogFile returns the path to the GUI log file.
func LogFile() string { return Paths.Log + "/lmvpn.log" }
// DaemonLogFile returns the path to the daemon log file.
func DaemonLogFile() string { return Paths.Log + "/lmvpn-daemon.log" }
// IPCSocketPath returns the path to the unix domain socket used for
// GUI <-> daemon communication.
func IPCSocketPath() string { return "/tmp/lmvpn.sock" }
+18
View File
@@ -0,0 +1,18 @@
//go:build darwin
package paths
import (
"os"
"path/filepath"
)
func init() {
home, _ := os.UserHomeDir()
lib := filepath.Join(home, "Library")
Paths = Dirs{
Data: filepath.Join(lib, "Application Support", BundleID),
Cache: filepath.Join(lib, "Caches", BundleID),
Log: filepath.Join(lib, "Logs", BundleID),
}
}
+17
View File
@@ -0,0 +1,17 @@
//go:build !darwin
package paths
import (
"os"
"path/filepath"
)
func init() {
home, _ := os.UserHomeDir()
Paths = Dirs{
Data: filepath.Join(home, ".local", "share", BundleID),
Cache: filepath.Join(home, ".cache", BundleID),
Log: filepath.Join(home, ".local", "state", BundleID, "log"),
}
}
+68
View File
@@ -0,0 +1,68 @@
// Package protocol defines the LMVPN wire protocol structures and
// constants. These mirror the server's definitions in
// lmvpn_server/internal/vpn/{protocol,auth,tunnel}.go exactly.
//
// Wire format:
// - Text frames = UTF-8 JSON control messages (with "type" field)
// - Binary frames = raw IPv4/IPv6 packets (no encapsulation header)
package protocol
import "time"
// Message type strings exchanged over the WebSocket.
const (
TypeAuth = "auth" // C→S: password credentials
TypeAuthOK = "auth_ok" // S→C: password auth succeeded
TypeAuthErr = "auth_err" // S→C: auth failed (then close)
TypeInit = "init" // S→C: tunnel parameters
TypeReady = "ready" // C→S: TUN configured, data plane ready
TypeError = "error" // S→C: handshake failure (then close)
)
// Timeout and limit constants matching the server (tunnel.go:16-23).
const (
ReadTimeout = 60 * time.Second // post-ready read deadline
WriteTimeout = 10 * time.Second // per-write deadline
ReadyTimeout = 30 * time.Second // client must send ready within this
PingPeriod = 30 * time.Second // server ping interval
MaxMessageSize = 1 << 20 // 1 MiB max WebSocket message
MaxConnsPerUser = 3 // per-user concurrent connection cap
TokenExpiry = 24 * time.Hour // JWT validity
)
// InitMessage is sent by the server after auth + pre-checks pass.
// (server: protocol.go:3-9, tunnel.go:126-137)
type InitMessage struct {
Type string `json:"type"`
IP string `json:"ip"` // assigned client IP (dotted-quad)
Prefix int `json:"prefix"` // subnet prefix length (e.g. 24)
MTU int `json:"mtu"` // TUN device MTU (e.g. 1420)
ServerIP string `json:"server_ip"` // server's tunnel IP (peer/gateway)
}
// ControlMessage is the generic text control message.
// (server: protocol.go:11-13)
type ControlMessage struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
}
// AuthMessage is sent by the client for password authentication.
// (server: auth.go:17-21)
type AuthMessage struct {
Type string `json:"type"`
Username string `json:"username"`
Password string `json:"password"`
}
// AuthResponse is the server's reply to an AuthMessage.
// (server: auth.go:23-26)
type AuthResponse struct {
Type string `json:"type"`
Message string `json:"message,omitempty"`
}
// IsError reports whether a control message type indicates failure.
func IsError(msgType string) bool {
return msgType == TypeAuthErr || msgType == TypeError
}
+178
View File
@@ -0,0 +1,178 @@
// Package route manages VPN routing on the client. It supports three
// modes:
//
// - Full tunnel: all traffic (0.0.0.0/0) via the TUN interface,
// with a bypass route for the server's public IP so
// the WebSocket connection stays on the physical NIC
// - Split tunnel: only the VPN virtual subnet via the TUN interface
// - Custom: user-specified CIDRs via the TUN interface
//
// All routes are tracked so they can be cleanly removed on disconnect.
package route
import (
"fmt"
"net"
"strings"
)
// Mode selects which traffic goes through the VPN tunnel.
type Mode string
const (
ModeFull Mode = "full"
ModeSplit Mode = "split"
ModeCustom Mode = "custom"
)
// Config describes the desired routing configuration.
type Config struct {
Mode Mode
InterfaceName string // e.g. "utun4"
VPNIP string // assigned tunnel IP, e.g. "192.168.77.5"
VPNPrefix int // subnet prefix, e.g. 24
ServerHost string // server hostname/IP (for full-tunnel bypass)
CustomCIDRs []string // for ModeCustom
}
// Manager applies and removes routes. It tracks all added routes so
// they can be cleaned up deterministically.
type Manager struct {
cfg Config
addedRoutes []string // route specs added, for deletion
serverBypass bool
originalGateway string
}
// NewManager creates a route manager for the given configuration.
func NewManager(cfg Config) *Manager {
return &Manager{cfg: cfg}
}
// Apply adds routes according to the configured mode.
func (m *Manager) Apply() error {
switch m.cfg.Mode {
case ModeFull:
return m.applyFull()
case ModeSplit:
return m.applySplit()
case ModeCustom:
return m.applyCustom()
default:
return fmt.Errorf("unknown routing mode: %s", m.cfg.Mode)
}
}
// Cleanup removes all routes that were added by Apply.
func (m *Manager) Cleanup() error {
var errs []string
for _, r := range m.addedRoutes {
if err := deleteRoute(r, m.cfg.InterfaceName); err != nil {
errs = append(errs, err.Error())
}
}
m.addedRoutes = nil
if m.serverBypass {
if err := m.deleteServerBypass(); err != nil {
errs = append(errs, err.Error())
}
m.serverBypass = false
}
if len(errs) > 0 {
return fmt.Errorf("cleanup errors: %s", strings.Join(errs, "; "))
}
return nil
}
func (m *Manager) applyFull() error {
// Capture the current default gateway before modifying routes.
gw, err := defaultGateway()
if err != nil {
return fmt.Errorf("get default gateway: %w", err)
}
m.originalGateway = gw
// Resolve server host to an IP for the bypass route.
serverIP, err := resolveHost(m.cfg.ServerHost)
if err != nil {
return fmt.Errorf("resolve server host %s: %w", m.cfg.ServerHost, err)
}
// Bypass: server's public IP via the original gateway (so the WS
// connection doesn't loop through the tunnel).
bypassSpec := serverIP + "/32"
if err := addRouteVia(bypassSpec, gw); err != nil {
return fmt.Errorf("add server bypass route: %w", err)
}
m.serverBypass = true
// Two /1 routes cover the entire IPv4 space and are more specific
// than the default route (0.0.0.0/0), so they take precedence
// without removing the original default.
for _, cidr := range []string{"0.0.0.0/1", "128.0.0.0/1"} {
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add route %s: %w", cidr, err)
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
return nil
}
func (m *Manager) applySplit() error {
subnet := vpnSubnet(m.cfg.VPNIP, m.cfg.VPNPrefix)
if err := addRoute(subnet, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add split route %s: %w", subnet, err)
}
m.addedRoutes = append(m.addedRoutes, subnet)
return nil
}
func (m *Manager) applyCustom() error {
for _, cidr := range m.cfg.CustomCIDRs {
cidr = strings.TrimSpace(cidr)
if cidr == "" {
continue
}
if err := addRoute(cidr, m.cfg.InterfaceName); err != nil {
return fmt.Errorf("add custom route %s: %w", cidr, err)
}
m.addedRoutes = append(m.addedRoutes, cidr)
}
return nil
}
func (m *Manager) deleteServerBypass() error {
serverIP, err := resolveHost(m.cfg.ServerHost)
if err != nil {
return nil // best-effort
}
return deleteRouteVia(serverIP+"/32", m.originalGateway)
}
// vpnSubnet computes the network CIDR from an IP and prefix.
func vpnSubnet(ipStr string, prefix int) string {
ip := net.ParseIP(ipStr)
if ip == nil {
return ipStr + "/" + fmt.Sprint(prefix)
}
mask := net.CIDRMask(prefix, 32)
network := ip.Mask(mask)
return fmt.Sprintf("%s/%d", network.String(), prefix)
}
// resolveHost resolves a hostname to an IP address. If already an IP,
// returns it directly.
func resolveHost(host string) (string, error) {
if net.ParseIP(host) != nil {
return host, nil
}
// Strip port if present.
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
ips, err := net.LookupIP(host)
if err != nil || len(ips) == 0 {
return "", fmt.Errorf("lookup %s: %w", host, err)
}
return ips[0].String(), nil
}
+59
View File
@@ -0,0 +1,59 @@
//go:build darwin
package route
import (
"fmt"
"os/exec"
"strings"
)
// addRoute adds a route via a network interface (macOS route command).
// route add -inet -net <cidr> -interface <iface>
func addRoute(cidr, iface string) error {
return runRoute("add", "-inet", "-net", cidr, "-interface", iface)
}
// deleteRoute removes a route via a network interface.
func deleteRoute(cidr, iface string) error {
return runRoute("delete", "-inet", "-net", cidr, "-interface", iface)
}
// addRouteVia adds a route via a gateway IP.
func addRouteVia(cidr, gateway string) error {
return runRoute("add", "-inet", "-net", cidr, gateway)
}
// deleteRouteVia removes a route via a gateway IP.
func deleteRouteVia(cidr, gateway string) error {
return runRoute("delete", "-inet", "-net", cidr, gateway)
}
// defaultGateway returns the current default gateway IP.
func defaultGateway() (string, error) {
out, err := exec.Command("route", "-n", "get", "default").Output()
if err != nil {
return "", err
}
for _, line := range strings.Split(string(out), "\n") {
line = strings.TrimSpace(line)
if strings.HasPrefix(line, "gateway:") {
parts := strings.SplitN(line, ":", 2)
if len(parts) == 2 {
gw := strings.TrimSpace(parts[1])
if gw != "" {
return gw, nil
}
}
}
}
return "", fmt.Errorf("no default gateway found")
}
func runRoute(args ...string) error {
cmd := exec.Command("route", args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("route %s: %w (%s)", strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
+47
View File
@@ -0,0 +1,47 @@
//go:build !darwin
package route
import (
"fmt"
"os/exec"
"strings"
)
func addRoute(cidr, iface string) error {
return runCmd("ip", "route", "add", cidr, "dev", iface)
}
func deleteRoute(cidr, iface string) error {
return runCmd("ip", "route", "del", cidr, "dev", iface)
}
func addRouteVia(cidr, gateway string) error {
return runCmd("ip", "route", "add", cidr, "via", gateway)
}
func deleteRouteVia(cidr, gateway string) error {
return runCmd("ip", "route", "del", cidr, "via", gateway)
}
func defaultGateway() (string, error) {
out, err := exec.Command("ip", "route", "show", "default").Output()
if err != nil {
return "", err
}
fields := strings.Fields(strings.TrimSpace(string(out)))
for i, f := range fields {
if f == "via" && i+1 < len(fields) {
return fields[i+1], nil
}
}
return "", fmt.Errorf("no default gateway found")
}
func runCmd(name string, args ...string) error {
cmd := exec.Command(name, args...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Errorf("%s %s: %w (%s)", name, strings.Join(args, " "), err, strings.TrimSpace(string(out)))
}
return nil
}
+85
View File
@@ -0,0 +1,85 @@
// Package stats provides atomic counters for VPN session statistics.
package stats
import (
"sync/atomic"
"time"
)
// State represents the current VPN session state.
type State string
const (
StateDisconnected State = "disconnected"
StateConnecting State = "connecting"
StateConnected State = "connected"
StateReconnecting State = "reconnecting"
StateError State = "error"
)
// Stats holds live session statistics. Counters are atomic for
// lock-free reads from the UI/IPC layer.
type Stats struct {
RxBytes atomic.Int64
TxBytes atomic.Int64
ConnectedAt atomic.Int64 // unix timestamp, 0 = not connected
state atomic.Value // State
assignedIP atomic.Value // string
}
// New creates a Stats instance initialised to the disconnected state.
func New() *Stats {
s := &Stats{}
s.state.Store(StateDisconnected)
s.assignedIP.Store("")
return s
}
// SetState updates the current state atomically.
func (s *Stats) SetState(st State) { s.state.Store(st) }
// State returns the current state.
func (s *Stats) State() State { return s.state.Load().(State) }
// SetConnected marks the session as connected, recording the time and IP.
func (s *Stats) SetConnected(ip string) {
s.ConnectedAt.Store(time.Now().Unix())
s.assignedIP.Store(ip)
s.state.Store(StateConnected)
}
// SetDisconnected clears the connection metadata.
func (s *Stats) SetDisconnected() {
s.ConnectedAt.Store(0)
s.assignedIP.Store("")
s.state.Store(StateDisconnected)
}
// AssignedIP returns the server-assigned tunnel IP.
func (s *Stats) AssignedIP() string { return s.assignedIP.Load().(string) }
// Snapshot returns a point-in-time copy of all counters.
type Snapshot struct {
RxBytes int64
TxBytes int64
ConnectedAt time.Time
AssignedIP string
State State
Uptime time.Duration
}
// Snapshot returns a point-in-time copy of the statistics.
func (s *Stats) Snapshot() Snapshot {
snap := Snapshot{
RxBytes: s.RxBytes.Load(),
TxBytes: s.TxBytes.Load(),
AssignedIP: s.AssignedIP(),
State: s.State(),
}
ts := s.ConnectedAt.Load()
if ts > 0 {
snap.ConnectedAt = time.Unix(ts, 0)
snap.Uptime = time.Since(snap.ConnectedAt)
}
return snap
}
+274
View File
@@ -0,0 +1,274 @@
// Package transport implements the LMVPN WebSocket client transport.
//
// It handles the full connection lifecycle:
// 1. Dial the WebSocket (no Origin header, per server's allow-empty rule)
// 2. Authenticate — JWT via ?token= query param, or password via first
// text message {type:auth}
// 3. Receive the {type:init} message with tunnel parameters
// 4. Call the OnInit callback (TUN configuration)
// 5. Send {type:ready}
// 6. Provide ReadPacket/WritePacket for raw IP binary frames
//
// Reconnection with exponential backoff is handled by the vpn package's
// SessionManager, not here. This type represents a single connection.
package transport
import (
"context"
"encoding/json"
"fmt"
"net/http"
"sync"
"time"
"lmvpn/internal/protocol"
"github.com/gorilla/websocket"
)
// HandshakeConfig configures a single connection attempt.
type HandshakeConfig struct {
ServerURL string // e.g. wss://vpn.example.com/ws
Token string // JWT; if non-empty, used via ?token= (method A)
Username string // for password auth (method B), or fallback
Password string // for password auth (method B), or fallback
OnInit func(protocol.InitMessage) error // configure TUN; nil = auto-ready
}
// Conn is an established VPN tunnel connection.
type Conn struct {
ws *websocket.Conn
init protocol.InitMessage
writeMu sync.Mutex
closed bool
mu sync.Mutex
}
// Connect dials, authenticates, and completes the tunnel handshake.
// It blocks until the connection is ready for data transfer or an
// error occurs.
func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) {
dialer := websocket.Dialer{
HandshakeTimeout: 15 * time.Second,
ReadBufferSize: 4096, // match server (handler.go:17)
WriteBufferSize: 4096, // match server (handler.go:18)
}
// Build URL: append ?token= for JWT auth.
url := cfg.ServerURL
if cfg.Token != "" {
url = appendQuery(url, "token", cfg.Token)
}
// Omit Origin header (server allows empty Origin for non-browser
// clients — handler.go:19-29).
header := http.Header{}
header.Set("Origin", "")
ws, resp, err := dialer.DialContext(ctx, url, header)
if err != nil {
if resp != nil {
resp.Body.Close()
}
return nil, fmt.Errorf("dial %s: %w", url, err)
}
defer resp.Body.Close()
ws.SetReadLimit(protocol.MaxMessageSize)
conn := &Conn{ws: ws}
// Step 2: authenticate (only for password mode; JWT is validated
// during the WS upgrade).
if cfg.Token == "" {
if err := conn.passwordAuth(cfg.Username, cfg.Password); err != nil {
ws.Close()
return nil, err
}
}
// Step 3: receive init (or error/auth_err).
initMsg, err := conn.readInit()
if err != nil {
ws.Close()
return nil, err
}
conn.init = initMsg
// Step 4: configure TUN via callback.
if cfg.OnInit != nil {
if err := cfg.OnInit(initMsg); err != nil {
ws.Close()
return nil, fmt.Errorf("tun configure: %w", err)
}
}
// Step 5: send ready.
if err := conn.sendReady(); err != nil {
ws.Close()
return nil, fmt.Errorf("send ready: %w", err)
}
// Step 6: set up keepalive — reset read deadline on each server
// ping, and auto-respond with pong (allowed via WriteControl in
// handler — see gorilla/websocket docs).
ws.SetReadDeadline(time.Now().Add(protocol.ReadTimeout))
ws.SetPingHandler(func(appData string) error {
ws.SetReadDeadline(time.Now().Add(protocol.ReadTimeout))
return ws.WriteControl(websocket.PongMessage, []byte(appData),
time.Now().Add(protocol.WriteTimeout))
})
return conn, nil
}
// passwordAuth sends the {type:auth} message and waits for auth_ok.
func (c *Conn) passwordAuth(username, password string) error {
c.ws.SetReadDeadline(time.Now().Add(protocol.ReadTimeout))
msg := protocol.AuthMessage{
Type: protocol.TypeAuth,
Username: username,
Password: password,
}
data, err := json.Marshal(msg)
if err != nil {
return err
}
if err := c.writeText(data); err != nil {
return fmt.Errorf("send auth: %w", err)
}
// Read auth response.
_, respData, err := c.ws.ReadMessage()
if err != nil {
return fmt.Errorf("read auth response: %w", err)
}
var resp protocol.AuthResponse
if err := json.Unmarshal(respData, &resp); err != nil {
return fmt.Errorf("parse auth response: %w", err)
}
if resp.Type == protocol.TypeAuthErr {
return &AuthError{Message: resp.Message}
}
if resp.Type != protocol.TypeAuthOK {
return fmt.Errorf("unexpected auth response type: %s", resp.Type)
}
return nil
}
// readInit reads the init message (or error/auth_err).
func (c *Conn) readInit() (protocol.InitMessage, error) {
c.ws.SetReadDeadline(time.Now().Add(protocol.ReadTimeout))
_, data, err := c.ws.ReadMessage()
if err != nil {
return protocol.InitMessage{}, fmt.Errorf("read init: %w", err)
}
// Try init first (most common path).
var initMsg protocol.InitMessage
if err := json.Unmarshal(data, &initMsg); err == nil && initMsg.Type == protocol.TypeInit {
return initMsg, nil
}
// Otherwise it's an error or auth_err control message.
var ctrl protocol.ControlMessage
if err := json.Unmarshal(data, &ctrl); err != nil {
return protocol.InitMessage{}, fmt.Errorf("parse init/error: %w (raw: %s)", err, data)
}
if ctrl.Type == protocol.TypeError || ctrl.Type == protocol.TypeAuthErr {
return protocol.InitMessage{}, &ServerError{Type: ctrl.Type, Message: ctrl.Message}
}
return protocol.InitMessage{}, fmt.Errorf("unexpected message type: %s", ctrl.Type)
}
// sendReady sends the {type:ready} control message.
func (c *Conn) sendReady() error {
data, _ := json.Marshal(protocol.ControlMessage{Type: protocol.TypeReady})
return c.writeText(data)
}
// writeText sends a text frame with the write deadline.
func (c *Conn) writeText(data []byte) error {
c.writeMu.Lock()
defer c.writeMu.Unlock()
c.ws.SetWriteDeadline(time.Now().Add(protocol.WriteTimeout))
return c.ws.WriteMessage(websocket.TextMessage, data)
}
// ReadPacket reads the next binary IP packet from the tunnel.
// It blocks until a packet is available or the connection breaks.
func (c *Conn) ReadPacket() ([]byte, error) {
for {
msgType, data, err := c.ws.ReadMessage()
if err != nil {
return nil, err
}
if msgType == websocket.BinaryMessage {
return data, nil
}
// Text messages after handshake are ignored (server shouldn't
// send any, but be resilient).
}
}
// WritePacket sends a raw IP packet as a binary frame.
func (c *Conn) WritePacket(data []byte) error {
if len(data) == 0 {
return nil
}
c.writeMu.Lock()
defer c.writeMu.Unlock()
c.ws.SetWriteDeadline(time.Now().Add(protocol.WriteTimeout))
return c.ws.WriteMessage(websocket.BinaryMessage, data)
}
// Init returns the init message received during handshake.
func (c *Conn) Init() protocol.InitMessage { return c.init }
// AssignedIP returns the IP assigned by the server.
func (c *Conn) AssignedIP() string { return c.init.IP }
// Close terminates the connection.
func (c *Conn) Close() error {
c.mu.Lock()
if c.closed {
c.mu.Unlock()
return nil
}
c.closed = true
c.mu.Unlock()
return c.ws.Close()
}
// AuthError indicates authentication failure (auth_err from server).
type AuthError struct{ Message string }
func (e *AuthError) Error() string { return "auth failed: " + e.Message }
// ServerError indicates a server-side rejection (error or auth_err).
type ServerError struct {
Type string
Message string
}
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 {
sep := "?"
if contains(url, "?") {
sep = "&"
}
return url + sep + key + "=" + value
}
func contains(s, sub string) bool {
for i := 0; i+len(sub) <= len(s); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+29
View File
@@ -0,0 +1,29 @@
// Package tun provides a cross-platform TUN virtual network card
// abstraction. Each platform implements the Device interface.
//
// On macOS the TUN device is a utunN interface created via the
// songgao/water library (same as the server). Configuration uses
// ifconfig/route commands and requires root privileges.
package tun
import "net"
// Device represents a TUN virtual network interface.
type Device interface {
// Name returns the OS-assigned interface name (e.g. utun4).
Name() string
// Read reads one IP packet from the TUN device.
Read(p []byte) (int, error)
// Write writes one IP packet to the TUN device.
Write(p []byte) (int, error)
// Configure sets the interface address, prefix, and peer IP.
Configure(localIP net.IP, prefix int, peerIP net.IP) error
// SetMTU sets the interface MTU.
SetMTU(mtu int) error
// Close destroys the TUN device.
Close() error
}
// Create creates a new TUN device. If name is empty, the OS assigns
// a name (utunN on macOS, tunN on Linux).
func Create(name string) (Device, error) { return createTUN(name) }
+60
View File
@@ -0,0 +1,60 @@
//go:build darwin
package tun
import (
"fmt"
"net"
"os"
"os/exec"
"strings"
"github.com/songgao/water"
)
type darwinDevice struct {
ifce *water.Interface
}
func createTUN(name string) (Device, error) {
cfg := water.Config{DeviceType: water.TUN}
cfg.Name = name
ifce, err := water.New(cfg)
if err != nil {
return nil, fmt.Errorf("create utun: %w", err)
}
return &darwinDevice{ifce: ifce}, nil
}
func (d *darwinDevice) Name() string { return d.ifce.Name() }
func (d *darwinDevice) Read(p []byte) (int, error) { return d.ifce.Read(p) }
func (d *darwinDevice) Write(p []byte) (int, error) { return d.ifce.Write(p) }
func (d *darwinDevice) Close() error { return d.ifce.Close() }
func (d *darwinDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error {
if localIP == nil {
return execCmd("ifconfig", d.Name(), "up")
}
inetType := "inet"
if localIP.To4() == nil {
inetType = "inet6"
}
localCidr := fmt.Sprintf("%s/%d", localIP.String(), prefix)
// ifconfig utunN inet <ip>/<prefix> <peer_ip> up
return execCmd("ifconfig", d.Name(), inetType, localCidr, peerIP.String(), "up")
}
func (d *darwinDevice) SetMTU(mtu int) error {
return execCmd("ifconfig", d.Name(), "mtu", fmt.Sprintf("%d", mtu))
}
// execCmd runs a command, forwarding stdout/stderr.
func execCmd(name string, arg ...string) error {
cmd := exec.Command(name, arg...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s %s: %w", name, strings.Join(arg, " "), err)
}
return nil
}
+61
View File
@@ -0,0 +1,61 @@
//go:build !darwin
package tun
import (
"fmt"
"net"
"os"
"os/exec"
"strings"
"github.com/songgao/water"
)
type linuxDevice struct {
ifce *water.Interface
}
func createTUN(name string) (Device, error) {
cfg := water.Config{DeviceType: water.TUN}
cfg.Name = name
ifce, err := water.New(cfg)
if err != nil {
return nil, fmt.Errorf("create tun: %w", err)
}
return &linuxDevice{ifce: ifce}, nil
}
func (d *linuxDevice) Name() string { return d.ifce.Name() }
func (d *linuxDevice) Read(p []byte) (int, error) { return d.ifce.Read(p) }
func (d *linuxDevice) Write(p []byte) (int, error) { return d.ifce.Write(p) }
func (d *linuxDevice) Close() error { return d.ifce.Close() }
func (d *linuxDevice) Configure(localIP net.IP, prefix int, peerIP net.IP) error {
if err := execCmd("ip", "link", "set", "dev", d.Name(), "up"); err != nil {
return err
}
if localIP == nil {
return nil
}
localCidr := fmt.Sprintf("%s/%d", localIP.String(), prefix)
if err := execCmd("ip", "addr", "add", "dev", d.Name(), localCidr, "peer", peerIP.String()); err != nil {
// Fall back without peer (some kernels).
return execCmd("ip", "addr", "add", "dev", d.Name(), localCidr)
}
return nil
}
func (d *linuxDevice) SetMTU(mtu int) error {
return execCmd("ip", "link", "set", "dev", d.Name(), "mtu", fmt.Sprintf("%d", mtu))
}
func execCmd(name string, arg ...string) error {
cmd := exec.Command(name, arg...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("%s %s: %w", name, strings.Join(arg, " "), err)
}
return nil
}
+170
View File
@@ -0,0 +1,170 @@
package ui
import (
"sync"
"lmvpn/internal/config"
"lmvpn/internal/db"
"lmvpn/internal/ipc"
"lmvpn/internal/keychain"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/paths"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/app"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// App is the GUI application controller.
type App struct {
fyneApp fyne.App
db *db.Store
kc keychain.Store
window fyne.Window
// UI widgets
profileSelect *widget.Select
stateLabel *widget.Label
ipLabel *widget.Label
uptimeLabel *widget.Label
rxLabel *widget.Label
txLabel *widget.Label
connectBtn *widget.Button
disconnectBtn *widget.Button
// State
mu sync.Mutex
ipcClient *ipc.Client
profiles []model.ServerProfile
currentProfile *model.ServerProfile
}
// Run initialises and starts the GUI application.
func Run() {
// Ensure platform directories exist.
if err := paths.EnsureDirs(); err != nil {
log.L().Error("ensure dirs", "error", err)
}
// Logging.
log.Init(log.RoleGUI, paths.LogFile())
// Database.
store, err := db.Open()
if err != nil {
log.L().Error("open db", "error", err)
}
// Load app config.
cfg, _ := config.Load()
a := &App{
fyneApp: app.NewWithID(paths.BundleID),
db: store,
kc: keychain.New(),
}
a.window = a.fyneApp.NewWindow("LMVPN")
a.window.SetContent(a.buildMainWindow())
a.window.Resize(fyne.NewSize(420, 480))
// Load profiles.
if store != nil {
a.loadProfiles()
}
// System tray.
a.setupTray()
// Auto-connect if configured.
if cfg.AutoConnect && a.currentProfile != nil {
go func() {
// Small delay to let window render.
a.onConnect()
}()
}
a.window.SetCloseIntercept(func() {
if cfg.CloseToTray {
a.window.Hide()
} else {
a.fyneApp.Quit()
}
})
a.window.ShowAndRun()
}
// loadProfiles loads all profiles from the database and populates the
// selector.
func (a *App) loadProfiles() {
profiles, err := a.db.ListProfiles()
if err != nil {
log.L().Error("list profiles", "error", err)
return
}
a.profiles = profiles
names := a.profileNames()
a.profileSelect.Options = names
if len(names) > 0 {
a.profileSelect.SetSelectedIndex(0)
a.selectProfileByName(names[0])
}
}
// profileNames returns the names of all loaded profiles.
func (a *App) profileNames() []string {
names := make([]string, len(a.profiles))
for i, p := range a.profiles {
names[i] = p.Name
}
return names
}
// selectProfileByName sets the current profile by name.
func (a *App) selectProfileByName(name string) {
for i := range a.profiles {
if a.profiles[i].Name == name {
a.currentProfile = &a.profiles[i]
return
}
}
}
// onAddProfile shows a dialog to create a new profile.
func (a *App) onAddProfile() {
a.showProfileDialog(nil)
}
// onEditProfile shows a dialog to edit the current profile.
func (a *App) onEditProfile() {
if a.currentProfile == nil {
dialog.ShowInformation("No Profile", "Select a profile to edit.", a.window)
return
}
p := *a.currentProfile
a.showProfileDialog(&p)
}
// onDeleteProfile deletes the current profile after confirmation.
func (a *App) onDeleteProfile() {
if a.currentProfile == nil {
return
}
name := a.currentProfile.Name
dialog.ShowConfirm("Delete Profile",
"Delete profile \""+name+"\" and its stored credentials?",
func(ok bool) {
if !ok {
return
}
if err := a.db.DeleteProfile(a.currentProfile.ID); err != nil {
showError("Error", err.Error(), a.window)
return
}
_ = a.kc.DeleteAll(name)
a.loadProfiles()
}, a.window)
}
+50
View File
@@ -0,0 +1,50 @@
// 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"
"time"
"lmvpn/internal/ipc"
"lmvpn/internal/log"
"lmvpn/internal/paths"
)
// 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.
func ensureDaemon() (*ipc.Client, error) {
// Fast path: daemon already running.
if c, err := ipc.Dial(); err == nil {
return c, nil
}
exe, err := os.Executable()
if err != nil {
return nil, fmt.Errorf("resolve executable: %w", err)
}
// Launch daemon as root via osascript administrator prompt.
script := fmt.Sprintf(
`do shell script "nohup %s daemon > /dev/null 2>&1 &" with administrator privileges`,
exe)
cmd := exec.Command("osascript", "-e", script)
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("launch daemon: %w (%s)", err, string(out))
}
log.L().Info("daemon launched via osascript")
// Wait for the daemon to become reachable.
for i := 0; i < 20; 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")
}
+134
View File
@@ -0,0 +1,134 @@
package ui
import (
"strconv"
"lmvpn/internal/model"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// showProfileDialog displays an add/edit dialog for a server profile.
// If editing is nil, a new profile is created.
func (a *App) showProfileDialog(editing *model.ServerProfile) {
isNew := editing == nil
nameEntry := widget.NewEntry()
serverEntry := widget.NewEntry()
userEntry := widget.NewEntry()
passEntry := widget.NewPasswordEntry()
authSelect := widget.NewSelect([]string{"both", "jwt", "password"}, nil)
routeSelect := widget.NewSelect([]string{"full", "split", "custom"}, nil)
cidrEntry := widget.NewMultiLineEntry()
cidrEntry.SetPlaceHolder("10.0.0.0/8, 172.16.0.0/12")
mtuEntry := widget.NewEntry()
mtuEntry.SetPlaceHolder("0 = use server MTU")
if !isNew {
nameEntry.SetText(editing.Name)
serverEntry.SetText(editing.ServerURL)
userEntry.SetText(editing.Username)
authSelect.SetSelected(string(editing.AuthMode))
routeSelect.SetSelected(string(editing.RoutingMode))
cidrEntry.SetText(editing.CustomCIDRs)
mtuEntry.SetText(fmtInt(editing.MTUOverride))
passEntry.SetPlaceHolder("(unchanged)")
} else {
authSelect.SetSelected(string(model.AuthModeBoth))
routeSelect.SetSelected(string(model.RoutingFull))
mtuEntry.SetText("0")
}
form := container.NewVBox(
widget.NewLabel("Name"), nameEntry,
widget.NewLabel("Server URL"), serverEntry,
widget.NewLabel("Username"), userEntry,
widget.NewLabel("Password"), passEntry,
widget.NewLabel("Auth Mode"), authSelect,
widget.NewLabel("Routing Mode"), routeSelect,
widget.NewLabel("Custom CIDRs (comma-separated)"), cidrEntry,
widget.NewLabel("MTU Override"), mtuEntry,
)
d := dialog.NewCustomConfirm("Profile", "Save", "Cancel", form, func(save bool) {
if !save {
return
}
a.saveProfile(editing, nameEntry.Text, serverEntry.Text,
userEntry.Text, passEntry.Text,
authSelect.Selected, routeSelect.Selected,
cidrEntry.Text, mtuEntry.Text, isNew)
}, a.window)
d.Resize(fyne.NewSize(400, 500))
d.Show()
}
// saveProfile creates or updates a profile and stores credentials.
func (a *App) saveProfile(editing *model.ServerProfile,
name, server, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) {
if name == "" || server == "" || user == "" {
showError("Validation", "Name, Server URL, and Username are required.", a.window)
return
}
mtu := parseIntDefault(mtuStr, 0)
if isNew {
p := &model.ServerProfile{
Name: name,
ServerURL: server,
Username: user,
AuthMode: model.AuthMode(authMode),
RoutingMode: model.RoutingMode(routeMode),
CustomCIDRs: cidrs,
MTUOverride: mtu,
}
id, err := a.db.CreateProfile(p)
if err != nil {
showError("Save Error", err.Error(), a.window)
return
}
_ = id
if password != "" {
if err := a.kc.SetPassword(name, password); err != nil {
showError("Keychain Error", err.Error(), a.window)
}
}
} else {
oldName := editing.Name
editing.Name = name
editing.ServerURL = server
editing.Username = user
editing.AuthMode = model.AuthMode(authMode)
editing.RoutingMode = model.RoutingMode(routeMode)
editing.CustomCIDRs = cidrs
editing.MTUOverride = mtu
if err := a.db.UpdateProfile(editing); err != nil {
showError("Save Error", err.Error(), a.window)
return
}
if password != "" {
_ = a.kc.DeleteAll(oldName)
if err := a.kc.SetPassword(name, password); err != nil {
showError("Keychain Error", err.Error(), a.window)
}
}
}
a.loadProfiles()
}
func fmtInt(n int) string {
return strconv.Itoa(n)
}
func parseIntDefault(s string, def int) int {
n, err := strconv.Atoi(s)
if err != nil {
return def
}
return n
}
+32
View File
@@ -0,0 +1,32 @@
package ui
import (
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/driver/desktop"
)
// setupTray configures the system tray menu (desktop only).
func (a *App) setupTray() {
deskApp, ok := a.fyneApp.(desktop.App)
if !ok {
return // not a desktop app, skip tray
}
menu := fyne.NewMenu("LMVPN",
fyne.NewMenuItem("Show Window", func() {
a.window.Show()
a.window.RequestFocus()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Connect", func() {
a.onConnect()
}),
fyne.NewMenuItem("Disconnect", func() {
a.onDisconnect()
}),
fyne.NewMenuItemSeparator(),
fyne.NewMenuItem("Quit", func() {
a.fyneApp.Quit()
}),
)
deskApp.SetSystemTrayMenu(menu)
}
+276
View File
@@ -0,0 +1,276 @@
package ui
import (
"fmt"
"time"
"lmvpn/internal/ipc"
"lmvpn/internal/stats"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
)
// showError displays a titled error dialog.
func showError(title, message string, parent fyne.Window) {
dialog.NewCustom(title, "OK", widget.NewLabel(message), parent).Show()
}
// buildMainWindow creates the main application window layout.
func (a *App) buildMainWindow() fyne.CanvasObject {
// Profile selector.
a.profileSelect = widget.NewSelect(a.profileNames(), func(sel string) {
a.selectProfileByName(sel)
})
// Status display.
a.stateLabel = widget.NewLabel("Disconnected")
a.stateLabel.TextStyle = fyne.TextStyle{Bold: true}
a.ipLabel = widget.NewLabel("IP: —")
a.uptimeLabel = widget.NewLabel("Uptime: —")
a.rxLabel = widget.NewLabel("↓ 0 B")
a.txLabel = widget.NewLabel("↑ 0 B")
statusCard := widget.NewCard("Status", "", container.NewVBox(
a.stateLabel,
a.ipLabel,
a.uptimeLabel,
container.NewHBox(a.rxLabel, a.txLabel),
))
// Buttons.
a.connectBtn = widget.NewButton("Connect", a.onConnect)
a.connectBtn.Importance = widget.HighImportance
a.disconnectBtn = widget.NewButton("Disconnect", a.onDisconnect)
a.disconnectBtn.Disable()
addBtn := widget.NewButton("Add Profile", a.onAddProfile)
editBtn := widget.NewButton("Edit", a.onEditProfile)
deleteBtn := widget.NewButton("Delete", a.onDeleteProfile)
buttons := container.NewGridWithColumns(2,
a.connectBtn, a.disconnectBtn,
)
profileButtons := container.NewGridWithColumns(3,
addBtn, editBtn, deleteBtn,
)
return container.NewVBox(
widget.NewLabel("Profile"),
a.profileSelect,
buttons,
profileButtons,
statusCard,
)
}
// onConnect handles the Connect button click.
func (a *App) onConnect() {
if a.currentProfile == nil {
showError("No Profile", "Please select or create a profile first.", a.window)
return
}
a.connectBtn.Disable()
a.stateLabel.SetText("Connecting...")
go func() {
// Ensure daemon is running.
client, err := ensureDaemon()
if err != nil {
fyne.Do(func() {
showError("Daemon Error", err.Error(), a.window)
a.stateLabel.SetText("Disconnected")
a.connectBtn.Enable()
})
return
}
a.mu.Lock()
a.ipcClient = client
a.mu.Unlock()
// Get password from keychain.
password, err := a.kc.GetPassword(a.currentProfile.Name)
if err != nil {
fyne.Do(func() {
showError("Credential Error",
"No password stored for this profile. Edit the profile to set it.",
a.window)
a.stateLabel.SetText("Disconnected")
a.connectBtn.Enable()
})
return
}
// Build and send the start command.
cfg := ipc.ClientConfig{
ServerURL: a.currentProfile.ServerURL,
Username: a.currentProfile.Username,
Password: password,
AuthMode: string(a.currentProfile.AuthMode),
RoutingMode: string(a.currentProfile.RoutingMode),
CustomCIDRs: splitCIDRs(a.currentProfile.CustomCIDRs),
MTUOverride: a.currentProfile.MTUOverride,
}
if err := ipc.SendStart(client, cfg); err != nil {
fyne.Do(func() {
showError("IPC Error", err.Error(), a.window)
a.stateLabel.SetText("Disconnected")
a.connectBtn.Enable()
})
return
}
// Start the event listener.
go a.eventLoop()
}()
}
// onDisconnect handles the Disconnect button click.
func (a *App) onDisconnect() {
a.mu.Lock()
client := a.ipcClient
a.mu.Unlock()
if client == nil {
return
}
_ = ipc.SendStop(client)
a.disconnectBtn.Disable()
a.connectBtn.Enable()
a.stateLabel.SetText("Disconnected")
}
// eventLoop reads IPC events from the daemon and updates the UI.
func (a *App) eventLoop() {
for {
a.mu.Lock()
client := a.ipcClient
a.mu.Unlock()
if client == nil {
return
}
ev, err := client.Recv()
if err != nil {
fyne.Do(func() {
a.stateLabel.SetText("Disconnected")
a.ipLabel.SetText("IP: —")
a.uptimeLabel.SetText("Uptime: —")
a.rxLabel.SetText("↓ 0 B")
a.txLabel.SetText("↑ 0 B")
a.connectBtn.Enable()
a.disconnectBtn.Disable()
})
return
}
switch ev.Event {
case ipc.EvState:
fyne.Do(func() {
a.applyState(ev.State)
})
case ipc.EvStats:
if ev.Stats != nil {
s := *ev.Stats
fyne.Do(func() {
a.applyStats(s)
})
}
case ipc.EvError:
fyne.Do(func() {
if ev.Message != "" {
showError("VPN Error", ev.Message, a.window)
}
})
}
}
}
// applyState updates UI elements for a state change.
func (a *App) applyState(state string) {
switch stats.State(state) {
case stats.StateConnected:
a.stateLabel.SetText("Connected")
a.connectBtn.Disable()
a.disconnectBtn.Enable()
case stats.StateConnecting:
a.stateLabel.SetText("Connecting...")
a.connectBtn.Disable()
a.disconnectBtn.Enable()
case stats.StateReconnecting:
a.stateLabel.SetText("Reconnecting...")
a.disconnectBtn.Enable()
case stats.StateDisconnected:
a.stateLabel.SetText("Disconnected")
a.ipLabel.SetText("IP: —")
a.connectBtn.Enable()
a.disconnectBtn.Disable()
case stats.StateError:
a.stateLabel.SetText("Error")
a.connectBtn.Enable()
a.disconnectBtn.Disable()
}
}
// applyStats updates the stats display.
func (a *App) applyStats(s stats.Snapshot) {
if s.AssignedIP != "" {
a.ipLabel.SetText("IP: " + s.AssignedIP)
}
if s.State == stats.StateConnected {
a.stateLabel.SetText("Connected")
}
a.rxLabel.SetText("↓ " + formatBytes(s.RxBytes))
a.txLabel.SetText("↑ " + formatBytes(s.TxBytes))
if s.Uptime > 0 {
a.uptimeLabel.SetText("Uptime: " + formatDuration(s.Uptime))
}
}
// formatBytes formats a byte count human-readably.
func formatBytes(b int64) string {
const unit = 1024
if b < unit {
return fmt.Sprintf("%d B", b)
}
div, exp := int64(unit), 0
for n := b / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %ciB", float64(b)/float64(div), "KMGTPE"[exp])
}
// formatDuration formats an uptime duration.
func formatDuration(d time.Duration) string {
d = d.Round(time.Second)
h := int(d.Hours())
m := int(d.Minutes()) % 60
s := int(d.Seconds()) % 60
if h > 0 {
return fmt.Sprintf("%dh %dm %ds", h, m, s)
}
if m > 0 {
return fmt.Sprintf("%dm %ds", m, s)
}
return fmt.Sprintf("%ds", s)
}
// splitCIDRs splits a comma-separated CIDR string into a slice.
func splitCIDRs(s string) []string {
if s == "" {
return nil
}
var out []string
start := 0
for i := 0; i <= len(s); i++ {
if i == len(s) || s[i] == ',' {
if i > start {
out = append(out, s[start:i])
}
start = i + 1
}
}
return out
}
+436
View File
@@ -0,0 +1,436 @@
// Package vpn orchestrates the full VPN session lifecycle: transport
// connection, TUN device setup, route management, the bidirectional
// packet pump, and automatic reconnection with exponential backoff.
//
// A SessionManager runs in its own goroutine once Connect is called.
// State changes and periodic stats are reported via callbacks, making
// it suitable for both the daemon (IPC) and headless use.
package vpn
import (
"context"
"errors"
"fmt"
"net"
"sync"
"time"
"lmvpn/internal/auth"
"lmvpn/internal/log"
"lmvpn/internal/model"
"lmvpn/internal/protocol"
"lmvpn/internal/route"
"lmvpn/internal/stats"
"lmvpn/internal/transport"
"lmvpn/internal/tun"
)
// SessionConfig describes how to connect to a VPN server.
type SessionConfig struct {
ServerURL string
Username string
Password string
AuthMode model.AuthMode
Token string // pre-obtained JWT (empty = fetch via HTTP login)
RoutingMode route.Mode
CustomCIDRs []string
MTUOverride int // 0 = use server MTU
}
// SessionManager manages a single VPN session with auto-reconnect.
type SessionManager struct {
stats *stats.Stats
onState func(stats.State)
onStats func(stats.Snapshot)
mu sync.Mutex
running bool
cancel context.CancelFunc
dev tun.Device
routeMgr *route.Manager
conn *transport.Conn
}
// New creates a SessionManager. The onState callback (if non-nil) is
// invoked on every state transition. The onStats callback (if non-nil)
// is invoked periodically while connected.
func New(onState func(stats.State), onStats func(stats.Snapshot)) *SessionManager {
return &SessionManager{
stats: stats.New(),
onState: onState,
onStats: onStats,
}
}
// Stats returns the live stats handle.
func (sm *SessionManager) Stats() *stats.Stats { return sm.stats }
// State returns the current session state.
func (sm *SessionManager) State() stats.State { return sm.stats.State() }
// Connect starts the VPN session. It returns immediately; the session
// runs in a background goroutine until Disconnect is called or the
// context is cancelled. If already running, it returns an error.
func (sm *SessionManager) Connect(ctx context.Context, cfg SessionConfig) error {
sm.mu.Lock()
if sm.running {
sm.mu.Unlock()
return errors.New("session already running")
}
ctx, cancel := context.WithCancel(ctx)
sm.cancel = cancel
sm.running = true
sm.mu.Unlock()
go sm.run(ctx, cfg)
return nil
}
// Disconnect stops the session and cleans up resources. It blocks
// until the session has fully shut down.
func (sm *SessionManager) Disconnect() {
sm.mu.Lock()
if !sm.running {
sm.mu.Unlock()
return
}
sm.running = false
cancel := sm.cancel
sm.mu.Unlock()
if cancel != nil {
cancel()
}
// Close the transport to unblock the packet pump.
sm.mu.Lock()
conn := sm.conn
sm.mu.Unlock()
if conn != nil {
conn.Close()
}
}
// run is the main session loop with exponential-backoff reconnection.
func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) {
defer sm.setState(stats.StateDisconnected)
backoff := time.Second
maxBackoff := 60 * time.Second
for {
if ctx.Err() != nil {
return
}
err := sm.connectOnce(ctx, cfg)
if ctx.Err() != nil {
sm.cleanup()
return
}
if err != nil {
log.L().Error("VPN connection failed", "error", err)
sm.setState(stats.StateReconnecting)
} else {
sm.setState(stats.StateReconnecting)
}
// Wait before reconnecting, unless cancelled.
select {
case <-ctx.Done():
sm.cleanup()
return
case <-time.After(backoff):
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
}
// connectOnce performs a single connection lifecycle: authenticate,
// handshake, configure TUN, apply routes, pump packets until failure.
func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig) error {
sm.setState(stats.StateConnecting)
// Determine auth strategy and obtain JWT if needed.
token := cfg.Token
if token == "" && (cfg.AuthMode == model.AuthModeJWT || cfg.AuthMode == model.AuthModeBoth) {
httpBase, err := auth.WSURLToHTTP(cfg.ServerURL)
if err != nil {
return fmt.Errorf("parse server URL: %w", err)
}
result, err := auth.Login(httpBase, cfg.Username, cfg.Password)
if err != nil {
if cfg.AuthMode == model.AuthModeBoth {
// Fall back to password auth.
token = ""
} else {
return fmt.Errorf("login: %w", err)
}
} else {
token = result.Token
}
}
// Prepare the TUN + route setup callback (called during handshake,
// between receiving init and sending ready).
handshake := transport.HandshakeConfig{
ServerURL: cfg.ServerURL,
Token: token,
Username: cfg.Username,
Password: cfg.Password,
OnInit: func(init protocol.InitMessage) error {
return sm.setupTUN(init, cfg)
},
}
// 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 err != nil {
return err
}
}
sm.mu.Lock()
sm.conn = conn
sm.mu.Unlock()
// If password auth was used (no token), the handshake already
// exchanged auth messages. For JWT, auth was implicit.
if token == "" {
// Password auth path already validated.
}
sm.stats.SetConnected(conn.Init().IP)
sm.setState(stats.StateConnected)
log.L().Info("VPN connected",
"ip", conn.Init().IP, "server_ip", conn.Init().ServerIP,
"mtu", conn.Init().MTU)
// Start stats reporter.
statsDone := make(chan struct{})
go sm.reportStats(statsDone, ctx)
// Run the packet pump (blocks until connection breaks).
sm.pumpPackets(ctx, conn)
close(statsDone)
sm.cleanup()
return nil
}
// setupTUN creates and configures the TUN device and applies routes.
// This is called by the transport during the handshake, between init
// and ready.
func (sm *SessionManager) setupTUN(init protocol.InitMessage, cfg SessionConfig) error {
dev, err := tun.Create("")
if err != nil {
return fmt.Errorf("create tun: %w", err)
}
sm.mu.Lock()
sm.dev = dev
sm.mu.Unlock()
localIP := net.ParseIP(init.IP)
peerIP := net.ParseIP(init.ServerIP)
if localIP == nil || peerIP == nil {
dev.Close()
return fmt.Errorf("invalid init IPs: %s / %s", init.IP, init.ServerIP)
}
if err := dev.Configure(localIP, init.Prefix, peerIP); err != nil {
dev.Close()
return fmt.Errorf("configure tun: %w", err)
}
mtu := init.MTU
if cfg.MTUOverride > 0 {
mtu = cfg.MTUOverride
}
if err := dev.SetMTU(mtu); err != nil {
dev.Close()
return fmt.Errorf("set mtu: %w", err)
}
// Apply routing.
routeCfg := route.Config{
Mode: cfg.RoutingMode,
InterfaceName: dev.Name(),
VPNIP: init.IP,
VPNPrefix: init.Prefix,
ServerHost: serverHostFromURL(cfg.ServerURL),
CustomCIDRs: cfg.CustomCIDRs,
}
sm.routeMgr = route.NewManager(routeCfg)
if err := sm.routeMgr.Apply(); err != nil {
log.L().Error("route apply failed (continuing)", "error", err)
}
log.L().Info("TUN configured",
"dev", dev.Name(), "ip", init.IP, "prefix", init.Prefix, "mtu", mtu)
return nil
}
// pumpPackets runs the bidirectional packet loop until the connection
// breaks.
func (sm *SessionManager) pumpPackets(ctx context.Context, conn *transport.Conn) {
var wg sync.WaitGroup
wg.Add(2)
// TUN → WebSocket
go func() {
defer wg.Done()
buf := make([]byte, 65536)
for {
select {
case <-ctx.Done():
return
default:
}
n, err := sm.readTUN(buf)
if err != nil {
if ctx.Err() == nil {
log.L().Error("tun read error", "error", err)
}
conn.Close()
return
}
if n == 0 {
continue
}
if err := conn.WritePacket(buf[:n]); err != nil {
if ctx.Err() == nil {
log.L().Error("ws write error", "error", err)
}
return
}
sm.stats.TxBytes.Add(int64(n))
}
}()
// WebSocket → TUN
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
default:
}
data, err := conn.ReadPacket()
if err != nil {
if ctx.Err() == nil {
log.L().Error("ws read error", "error", err)
}
return
}
if _, err := sm.writeTUN(data); err != nil {
if ctx.Err() == nil {
log.L().Error("tun write error", "error", err)
}
conn.Close()
return
}
sm.stats.RxBytes.Add(int64(len(data)))
}
}()
wg.Wait()
}
// cleanup tears down the TUN device and routes.
func (sm *SessionManager) cleanup() {
sm.mu.Lock()
dev := sm.dev
routeMgr := sm.routeMgr
conn := sm.conn
sm.dev = nil
sm.routeMgr = nil
sm.conn = nil
sm.mu.Unlock()
if routeMgr != nil {
if err := routeMgr.Cleanup(); err != nil {
log.L().Error("route cleanup error", "error", err)
}
}
if dev != nil {
dev.Close()
}
if conn != nil {
conn.Close()
}
sm.stats.SetDisconnected()
}
func (sm *SessionManager) readTUN(p []byte) (int, error) {
sm.mu.Lock()
dev := sm.dev
sm.mu.Unlock()
if dev == nil {
return 0, errors.New("tun device not available")
}
return dev.Read(p)
}
func (sm *SessionManager) writeTUN(p []byte) (int, error) {
sm.mu.Lock()
dev := sm.dev
sm.mu.Unlock()
if dev == nil {
return 0, errors.New("tun device not available")
}
return dev.Write(p)
}
func (sm *SessionManager) setState(s stats.State) {
sm.stats.SetState(s)
if sm.onState != nil {
sm.onState(s)
}
}
// reportStats periodically calls the onStats callback while connected.
func (sm *SessionManager) reportStats(done <-chan struct{}, ctx context.Context) {
ticker := time.NewTicker(time.Second)
defer ticker.Stop()
for {
select {
case <-done:
return
case <-ctx.Done():
return
case <-ticker.C:
if sm.onStats != nil {
sm.onStats(sm.stats.Snapshot())
}
}
}
}
// serverHostFromURL extracts the host portion from a WebSocket URL.
func serverHostFromURL(wsURL string) string {
u := wsURL
for _, p := range []string{"wss://", "ws://"} {
if len(u) > len(p) && u[:len(p)] == p {
u = u[len(p):]
break
}
}
// Strip path.
for i := 0; i < len(u); i++ {
if u[i] == '/' {
u = u[:i]
break
}
}
return u
}