8be6add7bc
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.
397 lines
8.7 KiB
Go
397 lines
8.7 KiB
Go
package store
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sync"
|
|
"time"
|
|
|
|
"openvpn-manager/internal/model"
|
|
)
|
|
|
|
// Store 简易 JSON 文件存储:
|
|
// 适合中小规模运维工具,无需引入数据库。所有变更通过 RWMutex 保护。
|
|
type Store struct {
|
|
mu sync.RWMutex
|
|
path string
|
|
data Data
|
|
writeCh chan struct{}
|
|
}
|
|
|
|
type Data struct {
|
|
Instances []model.Instance `json:"instances"`
|
|
Users []model.VPNUser `json:"users"`
|
|
Audits []model.AuditLog `json:"audits"`
|
|
ConnLogs []model.ConnectionLog `json:"conn_logs"`
|
|
Backups []model.Backup `json:"backups"`
|
|
Admins []model.AdminUser `json:"admins"`
|
|
}
|
|
|
|
func Open(path string) (*Store, error) {
|
|
s := &Store{path: path}
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, err := os.Stat(path); os.IsNotExist(err) {
|
|
s.data = Data{}
|
|
if err := s.flush(); err != nil {
|
|
return nil, err
|
|
}
|
|
return s, nil
|
|
}
|
|
b, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(b) == 0 {
|
|
s.data = Data{}
|
|
return s, nil
|
|
}
|
|
if err := json.Unmarshal(b, &s.data); err != nil {
|
|
return nil, fmt.Errorf("parse db: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
func (s *Store) flush() error {
|
|
b, err := json.MarshalIndent(s.data, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmp := s.path + ".tmp"
|
|
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
|
return err
|
|
}
|
|
return os.Rename(tmp, s.path)
|
|
}
|
|
|
|
// ---- Instances ----
|
|
|
|
func (s *Store) ListInstances() []model.Instance {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]model.Instance, len(s.data.Instances))
|
|
copy(out, s.data.Instances)
|
|
return out
|
|
}
|
|
|
|
func (s *Store) GetInstance(id string) (*model.Instance, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := range s.data.Instances {
|
|
if s.data.Instances[i].ID == id {
|
|
in := s.data.Instances[i]
|
|
return &in, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("instance %s not found", id)
|
|
}
|
|
|
|
func (s *Store) GetInstanceByName(name string) (*model.Instance, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := range s.data.Instances {
|
|
if s.data.Instances[i].Name == name {
|
|
in := s.data.Instances[i]
|
|
return &in, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("instance %s not found", name)
|
|
}
|
|
|
|
func (s *Store) UpsertInstance(in model.Instance) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.data.Instances {
|
|
if s.data.Instances[i].ID == in.ID {
|
|
s.data.Instances[i] = in
|
|
return s.flush()
|
|
}
|
|
}
|
|
s.data.Instances = append(s.data.Instances, in)
|
|
return s.flush()
|
|
}
|
|
|
|
func (s *Store) DeleteInstance(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.data.Instances {
|
|
if s.data.Instances[i].ID == id {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return fmt.Errorf("instance %s not found", id)
|
|
}
|
|
s.data.Instances = append(s.data.Instances[:idx], s.data.Instances[idx+1:]...)
|
|
// 同步删除其用户
|
|
users := s.data.Users[:0]
|
|
for _, u := range s.data.Users {
|
|
if u.InstanceID != id {
|
|
users = append(users, u)
|
|
}
|
|
}
|
|
s.data.Users = users
|
|
return s.flush()
|
|
}
|
|
|
|
// ---- Users ----
|
|
|
|
func (s *Store) ListUsers(instanceID string) []model.VPNUser {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := []model.VPNUser{}
|
|
for _, u := range s.data.Users {
|
|
if instanceID == "" || u.InstanceID == instanceID {
|
|
u.PasswordHash = "" // 不向外暴露 hash
|
|
out = append(out, u)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) GetUser(id string) (*model.VPNUser, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := range s.data.Users {
|
|
if s.data.Users[i].ID == id {
|
|
u := s.data.Users[i]
|
|
return &u, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("user %s not found", id)
|
|
}
|
|
|
|
func (s *Store) GetUserByCN(instanceID, cn string) (*model.VPNUser, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := range s.data.Users {
|
|
if s.data.Users[i].InstanceID == instanceID && s.data.Users[i].Username == cn {
|
|
u := s.data.Users[i]
|
|
return &u, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("user %s/%s not found", instanceID, cn)
|
|
}
|
|
|
|
func (s *Store) UpsertUser(u model.VPNUser) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.data.Users {
|
|
if s.data.Users[i].ID == u.ID {
|
|
s.data.Users[i] = u
|
|
return s.flush()
|
|
}
|
|
}
|
|
s.data.Users = append(s.data.Users, u)
|
|
return s.flush()
|
|
}
|
|
|
|
func (s *Store) DeleteUser(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.data.Users {
|
|
if s.data.Users[i].ID == id {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return fmt.Errorf("user %s not found", id)
|
|
}
|
|
s.data.Users = append(s.data.Users[:idx], s.data.Users[idx+1:]...)
|
|
return s.flush()
|
|
}
|
|
|
|
// ---- Audit ----
|
|
|
|
func (s *Store) AppendAudit(a model.AuditLog) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.data.Audits = append(s.data.Audits, a)
|
|
// 仅保留最近 5000 条
|
|
if len(s.data.Audits) > 5000 {
|
|
s.data.Audits = s.data.Audits[len(s.data.Audits)-5000:]
|
|
}
|
|
return s.flush()
|
|
}
|
|
|
|
func (s *Store) ListAudits(limit int) []model.AuditLog {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
if limit <= 0 || limit > len(s.data.Audits) {
|
|
limit = len(s.data.Audits)
|
|
}
|
|
out := make([]model.AuditLog, limit)
|
|
copy(out, s.data.Audits[len(s.data.Audits)-limit:])
|
|
// 倒序
|
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
|
out[i], out[j] = out[j], out[i]
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ---- Connection Logs ----
|
|
|
|
func (s *Store) AppendConnLog(c model.ConnectionLog) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.data.ConnLogs = append(s.data.ConnLogs, c)
|
|
if len(s.data.ConnLogs) > 20000 {
|
|
s.data.ConnLogs = s.data.ConnLogs[len(s.data.ConnLogs)-20000:]
|
|
}
|
|
return s.flush()
|
|
}
|
|
|
|
func (s *Store) ListConnLogs(instanceID string, limit int) []model.ConnectionLog {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := []model.ConnectionLog{}
|
|
for i := len(s.data.ConnLogs) - 1; i >= 0 && len(out) < limit; i-- {
|
|
c := s.data.ConnLogs[i]
|
|
if instanceID == "" || c.InstanceID == instanceID {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) FindActiveConn(instanceID, commonName string) *model.ConnectionLog {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
|
|
c := s.data.ConnLogs[i]
|
|
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
|
|
cc := c
|
|
return &cc
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) CloseActiveConn(instanceID, commonName string, at time.Time) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := len(s.data.ConnLogs) - 1; i >= 0; i-- {
|
|
c := &s.data.ConnLogs[i]
|
|
if c.InstanceID == instanceID && c.CommonName == commonName && c.DisconnectedAt == nil {
|
|
c.DisconnectedAt = &at
|
|
return s.flush()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- Backups ----
|
|
|
|
func (s *Store) ListBackups() []model.Backup {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]model.Backup, len(s.data.Backups))
|
|
copy(out, s.data.Backups)
|
|
return out
|
|
}
|
|
|
|
func (s *Store) AddBackup(b model.Backup) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.data.Backups = append(s.data.Backups, b)
|
|
return s.flush()
|
|
}
|
|
|
|
func (s *Store) DeleteBackup(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.data.Backups {
|
|
if s.data.Backups[i].ID == id {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return fmt.Errorf("backup %s not found", id)
|
|
}
|
|
s.data.Backups = append(s.data.Backups[:idx], s.data.Backups[idx+1:]...)
|
|
return s.flush()
|
|
}
|
|
|
|
// ---- Admins ----
|
|
|
|
func (s *Store) ListAdmins() []model.AdminUser {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
out := make([]model.AdminUser, len(s.data.Admins))
|
|
copy(out, s.data.Admins)
|
|
// 不返回哈希,调用方负责清空 PasswordHash
|
|
for i := range out {
|
|
out[i].PasswordHash = ""
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (s *Store) GetAdmin(id string) (*model.AdminUser, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := range s.data.Admins {
|
|
if s.data.Admins[i].ID == id {
|
|
a := s.data.Admins[i]
|
|
return &a, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("admin %s not found", id)
|
|
}
|
|
|
|
// GetAdminByUsername 包含哈希的内部副本。仅供登录校验使用。
|
|
func (s *Store) GetAdminByUsername(username string) (*model.AdminUser, error) {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
for i := range s.data.Admins {
|
|
if s.data.Admins[i].Username == username {
|
|
a := s.data.Admins[i]
|
|
return &a, nil
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("admin %s not found", username)
|
|
}
|
|
|
|
func (s *Store) UpsertAdmin(a model.AdminUser) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
for i := range s.data.Admins {
|
|
if s.data.Admins[i].ID == a.ID {
|
|
s.data.Admins[i] = a
|
|
return s.flush()
|
|
}
|
|
}
|
|
s.data.Admins = append(s.data.Admins, a)
|
|
return s.flush()
|
|
}
|
|
|
|
func (s *Store) DeleteAdmin(id string) error {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
idx := -1
|
|
for i := range s.data.Admins {
|
|
if s.data.Admins[i].ID == id {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return fmt.Errorf("admin %s not found", id)
|
|
}
|
|
s.data.Admins = append(s.data.Admins[:idx], s.data.Admins[idx+1:]...)
|
|
return s.flush()
|
|
}
|
|
|
|
func (s *Store) AdminCount() int {
|
|
s.mu.RLock()
|
|
defer s.mu.RUnlock()
|
|
return len(s.data.Admins)
|
|
} |