diff --git a/Makefile b/Makefile index 6446f00..8e07c54 100644 --- a/Makefile +++ b/Makefile @@ -11,7 +11,7 @@ GO = go CGO_ENABLED = 1 WINDRES ?= x86_64-w64-mingw32-windres MINGW_CC ?= x86_64-w64-mingw32-gcc -SEMVER ?= 0.3.9 +SEMVER ?= 0.4.1 GIT_HASH = $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) VERSION = $(SEMVER)-$(GIT_HASH) LDFLAGS = -s -w -X lmvpn/internal/version.Version=$(VERSION) diff --git a/internal/auth/auth.go b/internal/auth/auth.go index 1839765..1a3b5dc 100644 --- a/internal/auth/auth.go +++ b/internal/auth/auth.go @@ -6,6 +6,7 @@ package auth import ( "bytes" + "crypto/tls" "encoding/json" "fmt" "io" @@ -41,7 +42,12 @@ type errorResponse struct { // 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) { +// +// tlsCfg, if non-nil, is used as the TLS configuration for the HTTP +// client. This is essential when connecting via CDN edge IPs: the URL +// host will be an IP address, but the certificate must be verified +// against the real hostname (set tlsCfg.ServerName). +func Login(baseURL, username, password string, tlsCfg *tls.Config) (*LoginResult, error) { body, err := json.Marshal(loginRequest{Username: username, Password: password}) if err != nil { return nil, err @@ -49,6 +55,9 @@ func Login(baseURL, username, password string) (*LoginResult, error) { url := strings.TrimRight(baseURL, "/") + "/api/login" client := &http.Client{Timeout: 15 * time.Second} + if tlsCfg != nil { + client.Transport = &http.Transport{TLSClientConfig: tlsCfg} + } resp, err := client.Post(url, "application/json", bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("login request: %w", err) diff --git a/internal/daemon/daemon.go b/internal/daemon/daemon.go index e6e0e2d..47da857 100644 --- a/internal/daemon/daemon.go +++ b/internal/daemon/daemon.go @@ -131,16 +131,20 @@ func (d *daemon) startSession(conn net.Conn, req ipc.Request) { } cfg := vpn.SessionConfig{ - ServerURL: req.Config.ServerURL, - SNIHost: req.Config.SNIHost, - ServerIPs: req.Config.ServerIPs, - 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, + ServerURL: req.Config.ServerURL, + SNIHost: req.Config.SNIHost, + ServerIPs: req.Config.ServerIPs, + 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, + TLSCACert: req.Config.TLSCACert, + TLSCAPath: req.Config.TLSCAPath, + TLSInsecure: req.Config.TLSInsecure, + TLSPinnedHash: req.Config.TLSPinnedHash, } ctx, cancel := context.WithCancel(context.Background()) diff --git a/internal/db/db.go b/internal/db/db.go index cad5532..80509d8 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -48,7 +48,10 @@ func (s *Store) migrate() error { if err := s.migrateV2(); err != nil { return err } - return s.migrateV3() + if err := s.migrateV3(); err != nil { + return err + } + return s.migrateV4() } func (s *Store) migrateV2() error { @@ -181,6 +184,29 @@ func (s *Store) migrateV3() error { return nil } +// migrateV4 adds TLS certificate verification columns to +// server_profiles for custom CA, insecure mode, and cert pinning. +// Idempotent: skips columns that already exist. +func (s *Store) migrateV4() error { + cols := []struct { + name string + sql string + }{ + {"tls_ca_cert", "ALTER TABLE server_profiles ADD COLUMN tls_ca_cert TEXT NOT NULL DEFAULT ''"}, + {"tls_ca_path", "ALTER TABLE server_profiles ADD COLUMN tls_ca_path TEXT NOT NULL DEFAULT ''"}, + {"tls_insecure", "ALTER TABLE server_profiles ADD COLUMN tls_insecure INTEGER NOT NULL DEFAULT 0"}, + {"tls_pinned_hash", "ALTER TABLE server_profiles ADD COLUMN tls_pinned_hash TEXT NOT NULL DEFAULT ''"}, + } + for _, c := range cols { + if !columnExists(s.db, "server_profiles", c.name) { + if _, err := s.db.Exec(c.sql); err != nil { + return fmt.Errorf("migrate v4 add %s: %w", c.name, err) + } + } + } + return nil +} + // columnExists reports whether a column exists on a table. func columnExists(db *sql.DB, table, column string) bool { rows, err := db.Query(fmt.Sprintf("PRAGMA table_info(%s)", table)) diff --git a/internal/db/profile.go b/internal/db/profile.go index 5bdad66..7262e2c 100644 --- a/internal/db/profile.go +++ b/internal/db/profile.go @@ -14,11 +14,13 @@ func (s *Store) CreateProfile(p *model.ServerProfile) (int64, error) { `INSERT INTO server_profiles (name, protocol, host, server_ips, port, path, username, auth_mode, routing_mode, - custom_cidrs, mtu_override, auto_connect) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, + custom_cidrs, mtu_override, auto_connect, + tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Username, p.AuthMode, p.RoutingMode, p.CustomCIDRs, p.MTUOverride, p.AutoConnect, + p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, ) if err != nil { return 0, fmt.Errorf("insert profile: %w", err) @@ -36,11 +38,14 @@ func (s *Store) GetProfile(id int64) (*model.ServerProfile, error) { err := s.db.QueryRow( `SELECT id, name, protocol, host, server_ips, port, path, username, auth_mode, routing_mode, - custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at + custom_cidrs, mtu_override, auto_connect, + tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, + created_at, last_connected_at FROM server_profiles WHERE id = ?`, id, ).Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path, &p.Username, &p.AuthMode, &p.RoutingMode, &p.CustomCIDRs, &p.MTUOverride, - &p.AutoConnect, &p.CreatedAt, &last) + &p.AutoConnect, &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, + &p.CreatedAt, &last) if err != nil { return nil, fmt.Errorf("get profile %d: %w", id, err) } @@ -55,7 +60,9 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) { rows, err := s.db.Query( `SELECT id, name, protocol, host, server_ips, port, path, username, auth_mode, routing_mode, - custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at + custom_cidrs, mtu_override, auto_connect, + tls_ca_cert, tls_ca_path, tls_insecure, tls_pinned_hash, + created_at, last_connected_at FROM server_profiles ORDER BY name`) if err != nil { return nil, fmt.Errorf("list profiles: %w", err) @@ -68,7 +75,9 @@ func (s *Store) ListProfiles() ([]model.ServerProfile, error) { var last sql.NullTime if err := rows.Scan(&p.ID, &p.Name, &p.Protocol, &p.Host, &p.ServerIPs, &p.Port, &p.Path, &p.Username, &p.AuthMode, &p.RoutingMode, - &p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect, &p.CreatedAt, &last); err != nil { + &p.CustomCIDRs, &p.MTUOverride, &p.AutoConnect, + &p.TLSCACert, &p.TLSCAPath, &p.TLSInsecure, &p.TLSPinnedHash, + &p.CreatedAt, &last); err != nil { return nil, err } if last.Valid { @@ -85,11 +94,13 @@ func (s *Store) UpdateProfile(p *model.ServerProfile) error { `UPDATE server_profiles SET name = ?, protocol = ?, host = ?, server_ips = ?, port = ?, path = ?, username = ?, auth_mode = ?, routing_mode = ?, - custom_cidrs = ?, mtu_override = ?, auto_connect = ? + custom_cidrs = ?, mtu_override = ?, auto_connect = ?, + tls_ca_cert = ?, tls_ca_path = ?, tls_insecure = ?, tls_pinned_hash = ? WHERE id = ?`, p.Name, p.Protocol, p.Host, p.ServerIPs, p.Port, p.Path, p.Username, p.AuthMode, p.RoutingMode, - p.CustomCIDRs, p.MTUOverride, p.AutoConnect, p.ID) + p.CustomCIDRs, p.MTUOverride, p.AutoConnect, + p.TLSCACert, p.TLSCAPath, p.TLSInsecure, p.TLSPinnedHash, p.ID) if err != nil { return fmt.Errorf("update profile %d: %w", p.ID, err) } diff --git a/internal/i18n/en.toml b/internal/i18n/en.toml index 5bf47f6..b597cf0 100644 --- a/internal/i18n/en.toml +++ b/internal/i18n/en.toml @@ -102,3 +102,16 @@ AuthModePassword = "Password" RoutingModeFull = "Full Tunnel" RoutingModeSplit = "Split Tunnel" RoutingModeCustom = "Custom" + +FieldTLS = "TLS Certificate" +FieldTLSCACert = "CA Certificate (PEM)" +FieldTLSCAPath = "CA Certificate Path" +HintTLSCAReplacesSystem = "Setting a custom CA replaces system root certificates. Public CA servers (e.g. Let's Encrypt) will fail verification." +FieldTLSInsecure = "Skip Verification (Insecure)" +FieldTLSPinnedHash = "Certificate Pin (SHA-256)" +PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..." +PlaceholderTLSCAPath = "/path/to/ca.crt" +PlaceholderTLSPinnedHash = "sha256:a1b2c3..." +BtnBrowse = "Browse..." +DlgTLSError = "TLS Certificate Error" +TLSErrorVerification = "Server certificate verification failed." diff --git a/internal/i18n/zh-Hans.toml b/internal/i18n/zh-Hans.toml index d1e14df..fbdcb7b 100644 --- a/internal/i18n/zh-Hans.toml +++ b/internal/i18n/zh-Hans.toml @@ -102,3 +102,16 @@ AuthModePassword = "密码" RoutingModeFull = "全隧道" RoutingModeSplit = "分离隧道" RoutingModeCustom = "自定义" + +FieldTLS = "TLS 证书" +FieldTLSCACert = "CA 证书 (PEM)" +FieldTLSCAPath = "CA 证书路径" +HintTLSCAReplacesSystem = "设置自定义 CA 将替换系统根证书,使用公共 CA(如 Let's Encrypt)的服务器将验证失败。" +FieldTLSInsecure = "跳过验证 (不安全)" +FieldTLSPinnedHash = "证书固定 (SHA-256)" +PlaceholderTLSCACert = "-----BEGIN CERTIFICATE-----..." +PlaceholderTLSCAPath = "/path/to/ca.crt" +PlaceholderTLSPinnedHash = "sha256:a1b2c3..." +BtnBrowse = "浏览..." +DlgTLSError = "TLS 证书错误" +TLSErrorVerification = "服务器证书验证失败。" diff --git a/internal/ipc/ipc.go b/internal/ipc/ipc.go index b3b5499..332c994 100644 --- a/internal/ipc/ipc.go +++ b/internal/ipc/ipc.go @@ -49,16 +49,20 @@ type Request struct { // vpn.SessionConfig but is kept separate to avoid importing the vpn // package (which needs root-only TUN) into the GUI. type ClientConfig struct { - ServerURL string `json:"server_url"` - SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN - ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover - Username string `json:"username"` - Password string `json:"password"` - Token string `json:"token"` - AuthMode string `json:"auth_mode"` - RoutingMode string `json:"routing_mode"` - CustomCIDRs []string `json:"custom_cidrs"` - MTUOverride int `json:"mtu_override"` + ServerURL string `json:"server_url"` + SNIHost string `json:"sni_host"` // TLS SNI hostname for CDN + ServerIPs []string `json:"server_ips"` // CDN edge IP list for failover + Username string `json:"username"` + Password string `json:"password"` + Token string `json:"token"` + AuthMode string `json:"auth_mode"` + RoutingMode string `json:"routing_mode"` + CustomCIDRs []string `json:"custom_cidrs"` + MTUOverride int `json:"mtu_override"` + TLSCACert string `json:"tls_ca_cert"` // inline CA cert PEM (wss only) + TLSCAPath string `json:"tls_ca_path"` // CA cert file path (wss only) + TLSInsecure bool `json:"tls_insecure"` // skip cert verification (wss only) + TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 cert pin (wss only) } // Event is a notification from the daemon to the GUI. diff --git a/internal/model/model.go b/internal/model/model.go index 6237674..e0b84a2 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -41,6 +41,10 @@ type ServerProfile struct { CustomCIDRs string `json:"custom_cidrs"` // comma-separated, for RoutingCustom MTUOverride int `json:"mtu_override"` // 0 = use server MTU AutoConnect bool `json:"auto_connect"` + TLSCACert string `json:"tls_ca_cert"` // inline CA certificate PEM (wss only) + TLSCAPath string `json:"tls_ca_path"` // path to CA certificate file (wss only) + TLSInsecure bool `json:"tls_insecure"` // skip certificate verification (wss only) + TLSPinnedHash string `json:"tls_pinned_hash"` // SHA-256 fingerprint of server leaf cert (wss only) CreatedAt time.Time `json:"created_at"` LastConnectedAt *time.Time `json:"last_connected_at"` } diff --git a/internal/tlsconfig/tlsconfig.go b/internal/tlsconfig/tlsconfig.go new file mode 100644 index 0000000..3075fb8 --- /dev/null +++ b/internal/tlsconfig/tlsconfig.go @@ -0,0 +1,157 @@ +// Package tlsconfig builds *tls.Config instances from user-supplied +// verification settings (custom CA, insecure mode, certificate +// pinning). It is shared by the WebSocket transport and the HTTP +// login client so both paths enforce identical TLS policy. +package tlsconfig + +import ( + "bytes" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "encoding/hex" + "errors" + "fmt" + "os" + "strings" +) + +// Config holds TLS verification settings for a server connection. +type Config struct { + ServerName string // TLS SNI hostname (for CDN edge IP connections) + CACertPEM string // inline CA certificate PEM content + CACertPath string // path to a CA certificate file + InsecureSkipVerify bool // skip chain verification entirely + PinnedCertHash string // SHA-256 fingerprint of the leaf cert (hex, optional "sha256:" prefix) +} + +// Build creates a *tls.Config from the given settings. +// +// Verification behaviour matrix: +// +// Pin set | Insecure | Behaviour +// --------+----------+------------------------------------------- +// no | no | Normal chain + hostname verification (Go default) +// no | yes | All verification skipped +// yes | no | Normal chain verification, then pin check +// yes | yes | Chain skipped, pin check only +func Build(cfg Config) (*tls.Config, error) { + tlsCfg := &tls.Config{ + ServerName: cfg.ServerName, + } + + if cfg.CACertPEM != "" || cfg.CACertPath != "" { + pool, err := buildCertPool(cfg.CACertPEM, cfg.CACertPath) + if err != nil { + return nil, fmt.Errorf("load CA cert: %w", err) + } + tlsCfg.RootCAs = pool + } + + var pin []byte + if cfg.PinnedCertHash != "" { + var err error + pin, err = decodePin(cfg.PinnedCertHash) + if err != nil { + return nil, fmt.Errorf("invalid pinned cert hash: %w", err) + } + } + + if pin != nil { + if cfg.InsecureSkipVerify { + tlsCfg.InsecureSkipVerify = true + } + tlsCfg.VerifyPeerCertificate = func(rawCerts [][]byte, _ [][]*x509.Certificate) error { + return verifyPin(rawCerts, pin) + } + } else if cfg.InsecureSkipVerify { + tlsCfg.InsecureSkipVerify = true + } + + return tlsCfg, nil +} + +// PinMismatchError indicates the server leaf certificate does not +// match the pinned SHA-256 fingerprint. +type PinMismatchError struct { + Expected []byte + Got []byte +} + +func (e *PinMismatchError) Error() string { + return fmt.Sprintf("certificate pin mismatch: expected %x, got %x", e.Expected, e.Got) +} + +// IsTLSError reports whether err represents a TLS certificate +// verification failure that will not resolve on retry. +func IsTLSError(err error) bool { + var certInvalid *x509.CertificateInvalidError + if errors.As(err, &certInvalid) { + return true + } + var hostErr *x509.HostnameError + if errors.As(err, &hostErr) { + return true + } + var unknownAuth *x509.UnknownAuthorityError + if errors.As(err, &unknownAuth) { + return true + } + var verifyErr *tls.CertificateVerificationError + if errors.As(err, &verifyErr) { + return true + } + var pinErr *PinMismatchError + if errors.As(err, &pinErr) { + return true + } + return false +} + +func buildCertPool(pemData, path string) (*x509.CertPool, error) { + pool := x509.NewCertPool() + + if pemData != "" { + if !pool.AppendCertsFromPEM([]byte(pemData)) { + return nil, errors.New("failed to parse inline CA certificate PEM") + } + } + + if path != "" { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read CA cert file %s: %w", path, err) + } + if !pool.AppendCertsFromPEM(data) { + return nil, errors.New("failed to parse CA certificate file") + } + } + + return pool, nil +} + +func decodePin(s string) ([]byte, error) { + s = strings.TrimSpace(s) + if strings.HasPrefix(s, "sha256:") { + s = s[len("sha256:"):] + } + pin, err := hex.DecodeString(s) + if err != nil { + return nil, fmt.Errorf("invalid hex: %w", err) + } + if len(pin) != sha256.Size { + return nil, fmt.Errorf("expected %d bytes, got %d", sha256.Size, len(pin)) + } + return pin, nil +} + +func verifyPin(rawCerts [][]byte, pin []byte) error { + if len(rawCerts) == 0 { + return errors.New("no server certificate provided") + } + h := sha256.Sum256(rawCerts[0]) + if !bytes.Equal(h[:], pin) { + return &PinMismatchError{Expected: pin, Got: h[:]} + } + return nil +} diff --git a/internal/transport/transport.go b/internal/transport/transport.go index 0e5dcf8..0bedd94 100644 --- a/internal/transport/transport.go +++ b/internal/transport/transport.go @@ -36,6 +36,7 @@ type HandshakeConfig struct { 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 + TLSConfig *tls.Config // pre-built TLS config (nil = derive from SNIHost) } // Conn is an established VPN tunnel connection. @@ -57,7 +58,9 @@ func Connect(ctx context.Context, cfg HandshakeConfig) (*Conn, error) { WriteBufferSize: 4096, // match server (handler.go:18) } - if cfg.SNIHost != "" { + if cfg.TLSConfig != nil { + dialer.TLSClientConfig = cfg.TLSConfig + } else if cfg.SNIHost != "" { dialer.TLSClientConfig = &tls.Config{ ServerName: cfg.SNIHost, } diff --git a/internal/ui/profile.go b/internal/ui/profile.go index 65c9196..8692ee8 100644 --- a/internal/ui/profile.go +++ b/internal/ui/profile.go @@ -108,6 +108,52 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { mtuEntry.Scroll = container.ScrollNone mtuEntry.SetPlaceHolder(i18n.T("PlaceholderMTU")) + tlsCaPEMEntry := widget.NewMultiLineEntry() + tlsCaPEMEntry.Wrapping = fyne.TextWrapOff + tlsCaPEMEntry.Scroll = container.ScrollNone + tlsCaPEMEntry.SetMinRowsVisible(4) + tlsCaPEMEntry.SetPlaceHolder(i18n.T("PlaceholderTLSCACert")) + + tlsCaPathEntry := widget.NewEntry() + tlsCaPathEntry.Wrapping = fyne.TextWrapOff + tlsCaPathEntry.Scroll = container.ScrollNone + tlsCaPathEntry.SetPlaceHolder(i18n.T("PlaceholderTLSCAPath")) + + tlsInsecureCheck := widget.NewCheck(i18n.T("FieldTLSInsecure"), nil) + + tlsPinnedHashEntry := widget.NewEntry() + tlsPinnedHashEntry.Wrapping = fyne.TextWrapOff + tlsPinnedHashEntry.Scroll = container.ScrollNone + tlsPinnedHashEntry.SetPlaceHolder(i18n.T("PlaceholderTLSPinnedHash")) + + var profileWin fyne.Window + + browseBtn := widget.NewButton(i18n.T("BtnBrowse"), func() { + dialog.NewFileOpen(func(reader fyne.URIReadCloser, err error) { + if err != nil || reader == nil { + return + } + defer reader.Close() + tlsCaPathEntry.SetText(reader.URI().Path()) + }, profileWin).Show() + }) + + setTLSEnabled := func(enabled bool) { + if enabled { + tlsCaPEMEntry.Enable() + tlsCaPathEntry.Enable() + browseBtn.Enable() + tlsInsecureCheck.Enable() + tlsPinnedHashEntry.Enable() + } else { + tlsCaPEMEntry.Disable() + tlsCaPathEntry.Disable() + browseBtn.Disable() + tlsInsecureCheck.Disable() + tlsPinnedHashEntry.Disable() + } + } + if !isNew { nameEntry.SetText(editing.Name) protoSelect.SetSelectedIndex(codeIndex(protoCodes, editing.Protocol)) @@ -122,6 +168,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { routeSelect.SetSelectedIndex(codeIndex(routeCodes, string(editing.RoutingMode))) cidrEntry.SetText(editing.CustomCIDRs) mtuEntry.SetText(fmtInt(editing.MTUOverride)) + tlsCaPEMEntry.SetText(editing.TLSCACert) + tlsCaPathEntry.SetText(editing.TLSCAPath) + tlsInsecureCheck.SetChecked(editing.TLSInsecure) + tlsPinnedHashEntry.SetText(editing.TLSPinnedHash) passEntry.SetPlaceHolder(i18n.T("PlaceholderPasswordUnchanged")) } else { protoSelect.SetSelectedIndex(0) // wss @@ -132,6 +182,11 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { mtuEntry.SetText("0") } + protoSelect.OnChanged = func(value string) { + setTLSEnabled(value == "wss") + } + setTLSEnabled(selectedCode(protoCodes, protoSelect.SelectedIndex()) == "wss") + form := container.NewVBox( widget.NewLabel(i18n.T("FieldName")), nameEntry, @@ -159,9 +214,19 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { widget.NewLabel(i18n.T("FieldCustomCIDRs")), cidrEntry, widget.NewLabel(i18n.T("FieldMTUOverride")), mtuEntry, + + widget.NewLabel(i18n.T("FieldTLS")), + widget.NewLabel(i18n.T("FieldTLSCAPath")), + container.NewBorder(nil, nil, nil, browseBtn, tlsCaPathEntry), + widget.NewLabel(i18n.T("FieldTLSCACert")), + tlsCaPEMEntry, + widget.NewLabel(i18n.T("HintTLSCAReplacesSystem")), + tlsInsecureCheck, + widget.NewLabel(i18n.T("FieldTLSPinnedHash")), + tlsPinnedHashEntry, ) - profileWin := a.fyneApp.NewWindow(i18n.T("DlgProfileTitle")) + profileWin = a.fyneApp.NewWindow(i18n.T("DlgProfileTitle")) a.profileWindow = profileWin profileWin.SetOnClosed(func() { @@ -177,7 +242,10 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { userEntry.Text, passEntry.Text, selectedCode(authCodes, authSelect.SelectedIndex()), selectedCode(routeCodes, routeSelect.SelectedIndex()), - cidrEntry.Text, mtuEntry.Text, isNew) { + cidrEntry.Text, mtuEntry.Text, + tlsCaPEMEntry.Text, tlsCaPathEntry.Text, + tlsPinnedHashEntry.Text, tlsInsecureCheck.Checked, + isNew) { profileWin.Close() } }) @@ -188,14 +256,15 @@ func (a *App) showProfileDialog(editing *model.ServerProfile) { }) profileWin.SetContent(container.NewBorder(nil, container.NewHBox(saveBtn, cancelBtn), nil, nil, container.NewVScroll(form))) - profileWin.Resize(fyne.NewSize(460, 560)) + profileWin.Resize(fyne.NewSize(460, 760)) profileWin.Show() } // saveProfile creates or updates a profile and stores credentials. // Returns true on success, false if validation or DB operation failed. func (a *App) saveProfile(editing *model.ServerProfile, - name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, cidrs, mtuStr string, isNew bool) bool { + name, protocol, host, ips, portStr, pathStr, user, password, authMode, routeMode, cidrs, mtuStr, + tlsCaPEM, tlsCaPath, tlsPinnedHash string, tlsInsecure, isNew bool) bool { if name == "" || host == "" || user == "" { showError(i18n.T("DlgValidationTitle"), i18n.T("DlgValidationMsg"), a.window) return false @@ -206,17 +275,21 @@ func (a *App) saveProfile(editing *model.ServerProfile, if isNew { p := &model.ServerProfile{ - Name: name, - Protocol: protocol, - Host: host, - ServerIPs: ips, - Port: port, - Path: pathStr, - Username: user, - AuthMode: model.AuthMode(authMode), - RoutingMode: model.RoutingMode(routeMode), - CustomCIDRs: cidrs, - MTUOverride: mtu, + Name: name, + Protocol: protocol, + Host: host, + ServerIPs: ips, + Port: port, + Path: pathStr, + Username: user, + AuthMode: model.AuthMode(authMode), + RoutingMode: model.RoutingMode(routeMode), + CustomCIDRs: cidrs, + MTUOverride: mtu, + TLSCACert: tlsCaPEM, + TLSCAPath: tlsCaPath, + TLSInsecure: tlsInsecure, + TLSPinnedHash: tlsPinnedHash, } id, err := a.db.CreateProfile(p) if err != nil { @@ -242,6 +315,10 @@ func (a *App) saveProfile(editing *model.ServerProfile, editing.RoutingMode = model.RoutingMode(routeMode) editing.CustomCIDRs = cidrs editing.MTUOverride = mtu + editing.TLSCACert = tlsCaPEM + editing.TLSCAPath = tlsCaPath + editing.TLSInsecure = tlsInsecure + editing.TLSPinnedHash = tlsPinnedHash if err := a.db.UpdateProfile(editing); err != nil { showError(i18n.T("DlgSaveError"), err.Error(), a.window) return false diff --git a/internal/ui/view.go b/internal/ui/view.go index b0f3a4b..34c792d 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -158,15 +158,19 @@ func (a *App) onConnect() { // Build and send the start command. cfg := ipc.ClientConfig{ - ServerURL: serverURL, - SNIHost: sniHost, - ServerIPs: serverIPs, - Username: p.Username, - Password: password, - AuthMode: string(p.AuthMode), - RoutingMode: string(p.RoutingMode), - CustomCIDRs: splitCIDRs(p.CustomCIDRs), - MTUOverride: p.MTUOverride, + ServerURL: serverURL, + SNIHost: sniHost, + ServerIPs: serverIPs, + Username: p.Username, + Password: password, + AuthMode: string(p.AuthMode), + RoutingMode: string(p.RoutingMode), + CustomCIDRs: splitCIDRs(p.CustomCIDRs), + MTUOverride: p.MTUOverride, + TLSCACert: p.TLSCACert, + TLSCAPath: p.TLSCAPath, + TLSInsecure: p.TLSInsecure, + TLSPinnedHash: p.TLSPinnedHash, } if err := ipc.SendStart(client, cfg); err != nil { fyne.Do(func() { @@ -241,9 +245,14 @@ func (a *App) eventLoop() { } case ipc.EvError: fyne.Do(func() { - msg := authErrorMessage(ev.Code, ev.Message) - if msg != "" { - showError(i18n.T("DlgAuthError"), msg, a.window) + if ev.Code == "tls_error" { + showError(i18n.T("DlgTLSError"), + i18n.T("TLSErrorVerification")+"\n\n"+ev.Message, a.window) + } else { + msg := authErrorMessage(ev.Code, ev.Message) + if msg != "" { + showError(i18n.T("DlgAuthError"), msg, a.window) + } } }) } diff --git a/internal/vpn/session.go b/internal/vpn/session.go index 34177bc..c57a542 100644 --- a/internal/vpn/session.go +++ b/internal/vpn/session.go @@ -9,10 +9,12 @@ package vpn import ( "context" + "crypto/tls" "errors" "fmt" "net" "net/http" + "strings" "sync" "time" @@ -22,6 +24,7 @@ import ( "lmvpn/internal/protocol" "lmvpn/internal/route" "lmvpn/internal/stats" + "lmvpn/internal/tlsconfig" "lmvpn/internal/transport" "lmvpn/internal/tun" ) @@ -38,6 +41,10 @@ type SessionConfig struct { RoutingMode route.Mode CustomCIDRs []string MTUOverride int // 0 = use server MTU + TLSCACert string // inline CA cert PEM (wss only) + TLSCAPath string // CA cert file path (wss only) + TLSInsecure bool // skip cert verification (wss only) + TLSPinnedHash string // SHA-256 cert pin (wss only) } // SessionManager manages a single VPN session with auto-reconnect. @@ -164,6 +171,20 @@ func (sm *SessionManager) run(ctx context.Context, cfg SessionConfig) { if err != nil { log.L().Error("VPN connection failed", "error", err) + // A TLS certificate verification failure is not retryable: + // the cert won't change between attempts, so stop the + // loop and surface the reason to the user. + if tlsconfig.IsTLSError(err) { + log.L().Warn("fatal TLS error, stopping reconnect", "error", err) + sm.setState(stats.StateError) + if sm.onError != nil { + sm.onError("tls_error", err.Error()) + } + fatal = true + sm.cleanup() + return + } + // A fatal authentication failure (wrong password, disabled // account, expired token, rate limit) is not retryable: // stop the loop and surface the reason to the user instead @@ -254,6 +275,28 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta serverURL = replaceHost(cfg.ServerURL, targetIP) } + // Build TLS config for wss:// connections. For ws:// there is no + // TLS layer, so tlsCfg remains nil and both the HTTP client and + // the WebSocket dialer use their default (plaintext) behaviour. + var tlsCfg *tls.Config + if strings.HasPrefix(serverURL, "wss://") { + serverName := cfg.SNIHost + if serverName == "" { + serverName = serverHostFromURL(cfg.ServerURL) + } + var err error + tlsCfg, err = tlsconfig.Build(tlsconfig.Config{ + ServerName: serverName, + CACertPEM: cfg.TLSCACert, + CACertPath: cfg.TLSCAPath, + InsecureSkipVerify: cfg.TLSInsecure, + PinnedCertHash: cfg.TLSPinnedHash, + }) + if err != nil { + return fmt.Errorf("tls config: %w", err) + } + } + // Determine auth strategy and obtain JWT if needed. token := cfg.Token if token == "" && (cfg.AuthMode == model.AuthModeJWT || cfg.AuthMode == model.AuthModeBoth) { @@ -261,7 +304,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta if err != nil { return fmt.Errorf("parse server URL: %w", err) } - result, err := auth.Login(httpBase, cfg.Username, cfg.Password) + result, err := auth.Login(httpBase, cfg.Username, cfg.Password, tlsCfg) if err != nil { if cfg.AuthMode == model.AuthModeBoth { // Fall back to password auth. @@ -285,6 +328,7 @@ func (sm *SessionManager) connectOnce(ctx context.Context, cfg SessionConfig, ta OnInit: func(init protocol.InitMessage) error { return sm.setupTUN(init, cfg) }, + TLSConfig: tlsCfg, } // Attempt JWT connection first; fall back to password on auth error.