init
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
`
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) }
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user