Files
lmvpn_client/internal/db/db.go
T
kevin bf4744bb1d
Release / build-macos (push) Canceled after 0s
Release / build-windows (push) Canceled after 0s
Release / release (push) Canceled after 0s
feat: 添加服务端证书合法性验证(自定义CA/跳过验证/证书固定)
- 新增 internal/tlsconfig 包,集中构建 TLS 配置
- 修复 CDN 边缘 IP 故障转移时 HTTP 登录 TLS 验证失败的问题
- 支持自定义 CA 证书(内联 PEM + 文件路径,合并生效)
- 支持 InsecureSkipVerify 跳过证书验证
- 支持证书固定(SHA-256 指纹校验)
- TLS 验证错误设为不可恢复,避免无限重试
- Profile 编辑界面新增 TLS 设置区域,协议联动启用/禁用
- DB schema v4 迁移,新增 4 个 TLS 字段
2026-07-08 11:33:36 +08:00

267 lines
7.6 KiB
Go

// 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"
"strings"
"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(schemaV2)
if err != nil {
return err
}
if err := s.migrateV2(); err != nil {
return err
}
if err := s.migrateV3(); err != nil {
return err
}
return s.migrateV4()
}
func (s *Store) migrateV2() error {
// Detect if migration is needed by checking if old server_url column
// still exists. If protocol column is present, v2 is already in place.
_, err := s.db.Exec(`SELECT protocol, host, server_ips, port, path FROM server_profiles LIMIT 0`)
if err == nil {
return nil // already v2
}
// Check if old table exists.
var count int
err = s.db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='server_profiles' AND sql LIKE '%server_url%'`).Scan(&count)
if err != nil || count == 0 {
return nil // nothing to migrate
}
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("migrate v2 begin: %w", err)
}
defer tx.Rollback()
_, err = tx.Exec(`ALTER TABLE server_profiles RENAME TO server_profiles_old`)
if err != nil {
return fmt.Errorf("migrate v2 rename: %w", err)
}
_, err = tx.Exec(schemaV2)
if err != nil {
return fmt.Errorf("migrate v2 create new: %w", err)
}
rows, err := tx.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_old`)
if err != nil {
return fmt.Errorf("migrate v2 read old: %w", err)
}
defer rows.Close()
insert, err := tx.Prepare(`INSERT INTO server_profiles (id, name, protocol, host, server_ips, port, path, username, auth_mode, routing_mode, custom_cidrs, mtu_override, auto_connect, created_at, last_connected_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
return fmt.Errorf("migrate v2 prepare insert: %w", err)
}
defer insert.Close()
for rows.Next() {
var id int64
var name, serverURL, username, authMode, routingMode, customCIDRs string
var mtuOverride int
var autoConnect int
var createdAt, lastConnectedAt sql.NullString
if err := rows.Scan(&id, &name, &serverURL, &username, &authMode, &routingMode, &customCIDRs, &mtuOverride, &autoConnect, &createdAt, &lastConnectedAt); err != nil {
return fmt.Errorf("migrate v2 scan: %w", err)
}
protocol, host, ips, port, path := parseOldURL(serverURL)
_, err = insert.Exec(id, name, protocol, host, ips, port, path, username, authMode, routingMode, customCIDRs, mtuOverride, autoConnect, nullStr(createdAt), nullStr(lastConnectedAt))
if err != nil {
return fmt.Errorf("migrate v2 insert: %w", err)
}
}
if err := rows.Err(); err != nil {
return err
}
_, err = tx.Exec(`DROP TABLE server_profiles_old`)
if err != nil {
return fmt.Errorf("migrate v2 drop old: %w", err)
}
return tx.Commit()
}
func parseOldURL(raw string) (protocol, host, ips, path string, port int) {
u := raw
switch {
case strings.HasPrefix(u, "wss://"):
protocol = "wss"
u = u[6:]
case strings.HasPrefix(u, "ws://"):
protocol = "ws"
u = u[5:]
default:
protocol = "wss"
}
path = "/ws"
if i := strings.IndexByte(u, '/'); i >= 0 {
path = u[i:]
u = u[:i]
}
port = 443
if i := strings.LastIndexByte(u, ':'); i >= 0 {
if p, err := stringToInt(u[i+1:]); err == nil {
port = p
u = u[:i]
}
}
host = u
return
}
func stringToInt(s string) (int, error) {
var n int
for _, c := range s {
if c < '0' || c > '9' {
return 0, fmt.Errorf("not a number: %s", s)
}
n = n*10 + int(c-'0')
}
return n, nil
}
func nullStr(s sql.NullString) sql.NullString {
return s
}
// migrateV3 adds the assigned_ip6 column to connection_logs for IPv6
// dual-stack support. Idempotent: skips if the column already exists.
func (s *Store) migrateV3() error {
if columnExists(s.db, "connection_logs", "assigned_ip6") {
return nil
}
_, err := s.db.Exec(`ALTER TABLE connection_logs ADD COLUMN assigned_ip6 TEXT NOT NULL DEFAULT ''`)
if err != nil {
return fmt.Errorf("migrate v3 add assigned_ip6: %w", err)
}
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))
if err != nil {
return false
}
defer rows.Close()
for rows.Next() {
var cid int
var name, ctype string
var notnull, pk int
var dflt sql.NullString
if err := rows.Scan(&cid, &name, &ctype, &notnull, &dflt, &pk); err != nil {
return false
}
if name == column {
return true
}
}
return false
}
const schemaV2 = `
CREATE TABLE IF NOT EXISTS server_profiles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
protocol TEXT NOT NULL DEFAULT 'wss',
host TEXT NOT NULL,
server_ips TEXT NOT NULL DEFAULT '',
port INTEGER NOT NULL DEFAULT 443,
path TEXT NOT NULL DEFAULT '/ws',
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 '',
assigned_ip6 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);
`