Files
openvpn-manager/backend/internal/model/model.go
T
cnbugs 8be6add7bc Add cert+password dual-factor VPN authentication
OpenVPN now requires BOTH a valid client certificate AND a
username/password to establish a VPN connection.

Architecture:
  Client .ovpn has 'auth-user-pass' → prompts for credentials
  Server.conf has 'auth-user-pass-verify verify.sh via-env'
  verify.sh (bash+curl) calls POST /api/vpn/verify on the manager
  Manager verifies bcrypt hash via Go's golang.org/x/crypto/bcrypt
  Endpoint is localhost-only (127.0.0.1) for security

Model changes:
  Instance: new AuthMode field ('cert' | 'cert+password', default cert+password)
  VPNUser:  new PasswordHash (bcrypt) + Password (plaintext, transient)

Backend:
  model: AuthMode type, VPNUser.PasswordHash, VPNUser.Password (transient)
  store: ListUsers clears PasswordHash before returning
  service: CreateUser hashes password with bcrypt, enforces min 4 chars
  service: ResetVPNPassword for admin password reset
  service: UpdateUser preserves PasswordHash from old record
  service: CreateInstance defaults AuthMode=cert+password
  api: POST /api/vpn/verify (no JWT, localhost-only, bcrypt verify)
  api: POST /instances/:id/users/:uid/password (admin reset VPN pwd)
  openvpn: WriteVerifyScript generates bash+curl verify script
  openvpn: WriteServerConf adds script-security/auth-user-pass-verify
  openvpn: GenerateClientOVPN adds auth-user-pass directive

Frontend:
  Users.vue: password field on create form
  Users.vue: '重置密码' button in table + dialog
  Users.vue: '证书+密码' tag in auth column
  api: Inst.resetVPNPassword() method

Security:
  /api/vpn/verify rejects non-127.0.0.1 clients (403)
  PasswordHash never exposed via any API response
  verify.sh uses localhost curl (no external dependencies)
  bcrypt cost=10 (same as admin passwords)

Verified: correct pwd → 200, wrong pwd → 401, missing user → 401,
non-localhost → 403, .ovpn has auth-user-pass, server.conf has
script-security 2 + auth-user-pass-verify + verify-client-cert require.
2026-08-09 22:59:32 +08:00

135 lines
5.9 KiB
Go

package model
import "time"
// AccessMode 控制客户端的访问范围。
// - "open": 不限制,客户端可访问所有可达网段(默认)
// - "whitelist":白名单模式,仅允许访问 Instance 与 User 合并后的 allow_networks
// 列表中的网段,其他内网流量在服务端 FORWARD 链被丢弃
type AccessMode string
const (
AccessOpen AccessMode = "open"
AccessWhitelist AccessMode = "whitelist"
)
// AuthMode 控制客户端认证方式。
// - "cert": 仅证书认证(向后兼容)
// - "cert+password": 证书 + 用户名密码双因素认证(更安全)
type AuthMode string
const (
AuthCert AuthMode = "cert"
AuthCertPassword AuthMode = "cert+password"
)
// Instance 一个 OpenVPN 服务端实例。
// 每个实例使用独立端口与 PKI,运行在自己的 server.conf 下,
// 由 systemd 单元(或后台进程)托管,本服务通过 management 接口与之通信。
type Instance struct {
ID string `json:"id"`
Name string `json:"name"` // 唯一名,作为目录名
Port int `json:"port"` // openvpn 监听端口
Proto string `json:"proto"` // udp / tcp
Dev string `json:"dev"` // tun / tap
Subnet string `json:"subnet"` // 客户端子网,如 10.8.0.0/24
Cipher string `json:"cipher"` // 加密算法
AuthDigest string `json:"auth_digest"` // 摘要算法
PushDNS string `json:"push_dns"` // push "dhcp-option DNS x.x.x.x"
PushRoutes string `json:"push_routes"` // 多行
Extra string `json:"extra"` // 用户追加配置
AccessMode AccessMode `json:"access_mode"` // open | whitelist
AllowNetworks []string `json:"allow_networks"` // 实例级白名单 CIDR 列表
AuthMode AuthMode `json:"auth_mode"` // cert | cert+password
Status string `json:"status"` // running/stopped/error
PID int `json:"pid"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// VPNUser 一个 OpenVPN 客户端用户。
// 证书从对应实例的 PKI 中签发,可下载 .ovpn 客户端配置。
type VPNUser struct {
ID string `json:"id"`
InstanceID string `json:"instance_id"`
Username string `json:"username"` // 证书 CN, 同时也是 VPN 登录用户名
Password string `json:"password,omitempty"` // 明文密码(仅创建/重置时传入,不持久化)
PasswordHash string `json:"password_hash"` // bcrypt 哈希;store.ListUsers 返回时清空
RealName string `json:"real_name"` // 备注
Email string `json:"email"`
Enabled bool `json:"enabled"` // 是否启用, false 即吊销/禁用
StaticIP string `json:"static_ip"` // ccd 固定地址, 空表示动态
AllowNetworks []string `json:"allow_networks"` // 用户级白名单(在实例基础上叠加)
CreatedAt time.Time `json:"created_at"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
RevokedAt *time.Time `json:"revoked_at,omitempty"`
}
// AuditLog 操作审计日志
type AuditLog struct {
ID string `json:"id"`
Time time.Time `json:"time"`
User string `json:"user"` // 操作者(管理用户)
Action string `json:"action"` // create_instance, revoke_user, ...
Target string `json:"target"` // 目标对象
Result string `json:"result"` // ok / failed
Detail string `json:"detail"`
IP string `json:"ip"`
}
// AdminUser 管理控制台账号。
// 密码以 bcrypt 哈希存储 (cost=10)。
// - Role: "admin" = 全部权限 (含账号管理)
// "operator" = 仅运维操作(实例/用户/备份等),无账号管理
// - Status: "active" | "disabled"
// - MustChangePassword: 首次 seed 的默认账号(密码 = admin123)需要首次登录后改密
//
// 安全:
// * PasswordHash 字段在 db.json 中以 password_hash 持久化(必须!)
// * 但 store.ListAdmins 在返回前会清空 PasswordHash,
// 因此所有 API 响应里 hash 都是空字符串,绝不出网
type AdminUser struct {
ID string `json:"id"`
Username string `json:"username"`
PasswordHash string `json:"password_hash"` // 注意:store.ListAdmins 返回时会清空
Role string `json:"role"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
LastLoginIP string `json:"last_login_ip,omitempty"`
MustChangePassword bool `json:"must_change_password"` // 强制改密标志
}
// ConnectionLog 来自 OpenVPN status 的实时/历史连接记录。
// 周期由 OpenVPN 自身写入 status.log,本服务周期性读取解析后入库。
type ConnectionLog struct {
InstanceID string `json:"instance_id"`
CommonName string `json:"common_name"`
RealIP string `json:"real_ip"` // 客户端公网 IP
VPNIP string `json:"vpn_ip"` // 分配的 VPN 内网 IP
BytesIn int64 `json:"bytes_in"`
BytesOut int64 `json:"bytes_out"`
ConnectedAt time.Time `json:"connected_at"`
DisconnectedAt *time.Time `json:"disconnected_at,omitempty"`
}
// Backup 一份备份归档
type Backup struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
Size int64 `json:"size"`
Note string `json:"note"`
Filename string `json:"filename"`
Includes []string `json:"includes"`
}
// CertInfo 证书元数据(用于证书到期提醒)。
type CertInfo struct {
InstanceID string `json:"instance_id"`
Username string `json:"username"`
Subject string `json:"subject"`
NotBefore time.Time `json:"not_before"`
NotAfter time.Time `json:"not_after"`
DaysLeft int `json:"days_left"`
}