feat: 添加服务端证书合法性验证(自定义CA/跳过验证/证书固定)
- 新增 internal/tlsconfig 包,集中构建 TLS 配置 - 修复 CDN 边缘 IP 故障转移时 HTTP 登录 TLS 验证失败的问题 - 支持自定义 CA 证书(内联 PEM + 文件路径,合并生效) - 支持 InsecureSkipVerify 跳过证书验证 - 支持证书固定(SHA-256 指纹校验) - TLS 验证错误设为不可恢复,避免无限重试 - Profile 编辑界面新增 TLS 设置区域,协议联动启用/禁用 - DB schema v4 迁移,新增 4 个 TLS 字段
This commit is contained in:
@@ -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)
|
||||
|
||||
+10
-1
@@ -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)
|
||||
|
||||
+14
-10
@@ -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())
|
||||
|
||||
+27
-1
@@ -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))
|
||||
|
||||
+19
-8
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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 = "服务器证书验证失败。"
|
||||
|
||||
+14
-10
@@ -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.
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
+92
-15
@@ -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
|
||||
|
||||
+21
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+45
-1
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user