Files
openvpn-manager/backend/cmd/server/main.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

89 lines
1.9 KiB
Go

package main
import (
"flag"
"log"
"os"
"path/filepath"
"openvpn-manager/internal/api"
"openvpn-manager/internal/config"
"openvpn-manager/internal/service"
"openvpn-manager/internal/store"
"openvpn-manager/pkg/openvpn"
)
func main() {
dataDir := flag.String("data", "", "data directory (default $OVPNMGR_DATA or ./data)")
distDir := flag.String("dist", "", "frontend dist directory (default $OVPNMGR_DIST or ./dist)")
flag.Parse()
if *dataDir == "" {
*dataDir = os.Getenv("OVPNMGR_DATA")
}
if *dataDir == "" {
*dataDir = "./data"
}
if *distDir == "" {
*distDir = os.Getenv("OVPNMGR_DIST")
}
if *distDir == "" {
*distDir = "./dist"
}
abs, _ := filepath.Abs(*dataDir)
cfg := config.Load(abs)
if err := os.MkdirAll(cfg.PKIDir(), 0o700); err != nil {
log.Fatalf("mkdir pki: %v", err)
}
if err := os.MkdirAll(cfg.InstancesDir(), 0o755); err != nil {
log.Fatalf("mkdir instances: %v", err)
}
if err := os.MkdirAll(cfg.ClientsDir(), 0o755); err != nil {
log.Fatalf("mkdir clients: %v", err)
}
if err := os.MkdirAll(cfg.BackupsDir(), 0o755); err != nil {
log.Fatalf("mkdir backups: %v", err)
}
st, err := store.Open(cfg.DBFile())
if err != nil {
log.Fatalf("open store: %v", err)
}
ovm := openvpn.NewManager(cfg.OpenVPNBin, cfg.DataDir)
ovm.SetPort(cfg.Port) // 让 verify.sh 知道回调哪个端口
if err := ovm.EnsureCA(); err != nil {
log.Printf("warn: ensure CA: %v", err)
}
svc := service.New(cfg, st, ovm)
addr := cfg.Host + ":" + itoa(cfg.Port)
log.Printf("openvpn-manager listening on %s, data=%s, dist=%s", addr, cfg.DataDir, *distDir)
srv := api.NewServer(cfg, svc)
if err := srv.Router(*distDir).Run(addr); err != nil {
log.Fatal(err)
}
}
func itoa(n int) string {
if n == 0 {
return "0"
}
neg := false
if n < 0 {
neg = true
n = -n
}
var buf [20]byte
i := len(buf)
for n > 0 {
i--
buf[i] = byte('0' + n%10)
n /= 10
}
if neg {
i--
buf[i] = '-'
}
return string(buf[i:])
}