Bind Feishu identity to OpenVPN sessions and refine management UI

This commit is contained in:
opsx committed 2026-10-22 23:24:41 +08:00
1 parent 347799cb1e
commit 8fd8c0cd73
27 files changed
+1556 -1011

No files matched your search

@@ -212,6 +212,10 @@ func (p *Provider) FeishuCallback(c *gin.Context) {
oidcError(c, 403)
return
}
if err := saveProfile(base, cfg, user); err != nil {
oidcError(c, 503)
return
}
code, err := randomToken()
if err != nil {
oidcError(c, 503)
@@ -223,7 +227,8 @@ func (p *Provider) FeishuCallback(c *gin.Context) {
return
}
// A successful user-info lookup confirms this application's browser scope.
// Do not store user identifiers; reset this evidence when credentials change.
// This timestamp contains no identity; the verified subject and display name
// are stored separately in IdentityProfile. Reset the evidence when credentials change.
_ = store.DB.Model(&model.IdentityProvider{}).Where("id = ? AND app_id = ? AND tenant_key = ?", cfg.ID, cfg.AppID, cfg.TenantKey).Update("user_scope_verified_at", time.Now()).Error
u, _ := url.Parse(state.Redirect)
q := u.Query()
@@ -249,6 +254,9 @@ func signIDToken(key *rsa.PrivateKey, kid, base string, cfg model.IdentityProvid
now := time.Now().Unix()
header, _ := json.Marshal(gin.H{"alg": "RS256", "typ": "JWT", "kid": kid})
claims := gin.H{"iss": base, "aud": cfg.ClientID, "sub": grant.Subject, "iat": now, "exp": now + 600, "auth_time": now}
if grant.Name != "" {
claims["name"] = grant.Name
}
if grant.Nonce != "" {
claims["nonce"] = grant.Nonce
}
@@ -0,0 +1,40 @@
package identityprovider
import (
"gorm.io/gorm/clause"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
)
func saveProfile(base string, cfg model.IdentityProvider, user accessData) error {
return store.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "issuer"}, {Name: "app_id"}, {Name: "subject"}},
DoUpdates: clause.AssignmentColumns([]string{"name", "updated_at"}),
}).Create(&model.IdentityProfile{Issuer: base, AppID: cfg.AppID, Subject: user.Subject, Name: user.Name}).Error
}
// ProfileNames returns verified display names only for the current built-in
// application and exact issuer. Never resolve an identity by IP or name.
func ProfileNames(tunnel *model.Tunnel, subjects []string) map[string]string {
names := map[string]string{}
if store.DB == nil || len(subjects) == 0 || tunnel.Protocol != "openvpn" {
return names
}
cfg, err := store.TunnelConfig(tunnel)
if err != nil || service.Str(cfg, "auth_mode") != "oidc" || service.Str(cfg, "oidc_provider") != "builtin" {
return names
}
idp, err := current()
if err != nil || !idp.Enabled {
return names
}
var profiles []model.IdentityProfile
if store.DB.Where("issuer = ? AND app_id = ? AND subject IN ?", service.Str(cfg, "oidc_issuer"), idp.AppID, subjects).Find(&profiles).Error != nil {
return names
}
for _, profile := range profiles {
names[profile.Subject] = profile.Name
}
return names
}
@@ -0,0 +1,55 @@
package identityprovider
import (
"testing"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/service"
"vpn-platform/backend/internal/store"
)
func TestProfileNamesRequireMatchingIssuerAppAndSubject(t *testing.T) {
previous := store.DB
t.Cleanup(func() {
if store.DB != nil {
if db, err := store.DB.DB(); err == nil {
_ = db.Close()
}
}
store.DB = previous
})
if err := store.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
idp := model.IdentityProvider{ID: 1, Enabled: true, AppID: "cli_current"}
if err := store.DB.Create(&idp).Error; err != nil {
t.Fatal(err)
}
issuer := "https://vpn.example.com/id"
subject := "tenant:ou_123"
if err := saveProfile(issuer, idp, accessData{Subject: subject, Name: "First name"}); err != nil {
t.Fatal(err)
}
if err := saveProfile(issuer, idp, accessData{Subject: subject, Name: "Updated name"}); err != nil {
t.Fatal(err)
}
tunnel := &model.Tunnel{Protocol: "openvpn", Config: service.ConfigJSON(map[string]interface{}{
"auth_mode": "oidc", "oidc_provider": "builtin", "oidc_issuer": issuer,
})}
if names := ProfileNames(tunnel, []string{subject, "tenant:ou_other"}); len(names) != 1 || names[subject] != "Updated name" {
t.Fatalf("profile did not follow stable subject: %v", names)
}
other := *tunnel
other.Config = service.ConfigJSON(map[string]interface{}{
"auth_mode": "oidc", "oidc_provider": "builtin", "oidc_issuer": "https://other.example.com/id",
})
if names := ProfileNames(&other, []string{subject}); len(names) != 0 {
t.Fatalf("profile leaked across issuer: %v", names)
}
if err := store.DB.Model(&idp).Update("app_id", "cli_other").Error; err != nil {
t.Fatal(err)
}
if names := ProfileNames(tunnel, []string{subject}); len(names) != 0 {
t.Fatalf("profile leaked across app: %v", names)
}
}
@@ -47,7 +47,7 @@ func TestFeishuExchangeUsesJSONAndRejectsUnexpectedIdentity(t *testing.T) {
t.Fatalf("exchange: %q %v", token, err)
}
user, tenant, err := fetchFeishuUser(context.Background(), token)
if err != nil || tenant != "tenant_1" || user.Subject != "tenant_1:ou_1" {
if err != nil || tenant != "tenant_1" || user.Subject != "tenant_1:ou_1" || user.Name != "Alice" {
t.Fatalf("identity: %+v %s %v", user, tenant, err)
}
}
@@ -86,7 +86,7 @@ func TestGrantSingleUseAndSignedToken(t *testing.T) {
if err != nil {
t.Fatal(err)
}
grant := codeData{requestState: requestState{ClientID: "opsx-vpn", Redirect: "https://vpn.example.com/oauth2/callback", Nonce: "nonce-1"}, Subject: "tenant:ou_1"}
grant := codeData{requestState: requestState{ClientID: "opsx-vpn", Redirect: "https://vpn.example.com/oauth2/callback", Nonce: "nonce-1"}, Subject: "tenant:ou_1", Name: "Alice"}
if err := saveGrant("code", code, grant, time.Minute); err != nil {
t.Fatal(err)
}
@@ -122,7 +122,7 @@ func TestGrantSingleUseAndSignedToken(t *testing.T) {
t.Fatal(err)
}
var claims map[string]any
if json.Unmarshal(claimsRaw, &claims) != nil || claims["sub"] != "tenant:ou_1" || claims["nonce"] != "nonce-1" || claims["aud"] != "opsx-vpn" {
if json.Unmarshal(claimsRaw, &claims) != nil || claims["sub"] != "tenant:ou_1" || claims["name"] != "Alice" || claims["nonce"] != "nonce-1" || claims["aud"] != "opsx-vpn" {
t.Fatalf("unexpected claims: %v", claims)
}
if info, err := filepath.Glob(filepath.Join(dir, "identity", "*.pem")); err != nil || len(info) != 1 {
@@ -130,6 +130,33 @@ func TestGrantSingleUseAndSignedToken(t *testing.T) {
}
}
func TestIDTokenOmitsMissingDisplayName(t *testing.T) {
t.Setenv("VPN_DATA_DIR", t.TempDir())
key, kid, err := signingKey()
if err != nil {
t.Fatal(err)
}
token, err := signIDToken(key, kid, "https://vpn.example.com/id", model.IdentityProvider{ClientID: "opsx-vpn"}, codeData{Subject: "tenant:ou_1"})
if err != nil {
t.Fatal(err)
}
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatal("invalid JWT")
}
body, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatal(err)
}
var claims map[string]any
if json.Unmarshal(body, &claims) != nil || claims["sub"] != "tenant:ou_1" {
t.Fatalf("stable identity missing: %v", claims)
}
if _, present := claims["name"]; present {
t.Fatal("empty display name should not be added to ID token")
}
}
func TestRedirectIsExactAndNoOpenRedirect(t *testing.T) {
if !validateRedirect("https://vpn.example.com/oauth2/callback", "vpn.example.com") {
t.Fatal("valid callback rejected")
+13
View File
@@ -28,6 +28,17 @@ type IdentityGrant struct {
Consumed bool `gorm:"default:false"`
}
// IdentityProfile is display metadata from a verified Feishu user-info response.
// It grants no VPN permissions and contains no upstream access token.
type IdentityProfile struct {
ID uint `gorm:"primaryKey"`
Issuer string `gorm:"uniqueIndex:idx_identity_profile;size:256"`
AppID string `gorm:"uniqueIndex:idx_identity_profile;size:128"`
Subject string `gorm:"uniqueIndex:idx_identity_profile;size:256"`
Name string `gorm:"size:256"`
UpdatedAt time.Time
}
// TrafficSample 流量时间序列采样(每 60 秒一次,用于趋势图) // Logan.Li
type TrafficSample struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -175,6 +186,7 @@ type AuditLog struct {
// VPNSession 在线会话(跨协议统一记录拨号用户的连接状态) // Logan.Li
type VPNSession struct {
DisplayName string `gorm:"size:256" json:"display_name,omitempty"`
ID uint `gorm:"primaryKey" json:"id"`
TunnelID uint `gorm:"index" json:"tunnel_id"`
VPNUserID uint `gorm:"index" json:"vpn_user_id"`
@@ -191,6 +203,7 @@ type VPNSession struct {
// ConnectionLog 连接审计日志(连接/断开/认证失败/配额超额/异常流量) // Logan.Li
type ConnectionLog struct {
DisplayName string `gorm:"size:256" json:"display_name,omitempty"`
ID uint `gorm:"primaryKey" json:"id"`
TunnelID uint `gorm:"index" json:"tunnel_id"`
Username string `gorm:"size:128" json:"username"`
+14 -10
View File
@@ -2,16 +2,20 @@ package openvpn
// This page is intentionally outcome-neutral: the auth component can render its
// end template for different outcomes. Only the VPN client knows connection state.
// Never reflect callback parameters or user identity into this page.
const authResultPage = `<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<meta name="color-scheme" content="light"><title>返回 VPN 客户端 · OPSX VPN</title>
<meta name="color-scheme" content="light"><meta name="referrer" content="no-referrer"><title>返回 VPN 客户端 · OPSX VPN</title>
<style>
*{box-sizing:border-box}body{margin:0;min-height:100vh;display:grid;place-items:center;padding:24px;background:radial-gradient(circle at 50% 0%,#e8f2ff 0%,#f5f8fc 48%,#f3f6fa 100%);color:#1c334e;font:16px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
.card{width:min(100%,440px);padding:44px 36px 38px;background:#fff;border:1px solid #e3eaf3;border-radius:22px;box-shadow:0 22px 65px rgba(29,68,113,.09);text-align:center}
.brand{color:#3975bb;letter-spacing:.16em;font-size:12px;font-weight:800}.mark{display:grid;place-items:center;width:72px;height:72px;margin:28px auto 22px;background:#ebf4ff;border-radius:22px;color:#3479c8}
h1{font-size:25px;line-height:1.3;margin:0 0 12px}p{margin:0;color:#62758a}.tip{margin-top:30px;padding:14px;background:#f4f8fd;border-radius:12px;font-size:14px;color:#526a84}
@media(max-width:480px){.card{padding:36px 24px}}
</style></head><body><main class="card"><div class="brand">OPSX VPN</div>
<div class="mark" aria-hidden="true"><svg width="38" height="38" viewBox="0 0 38 38" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M19 3 33 9v9c0 9-5.8 14.6-14 17C10.8 32.6 5 27 5 18V9L19 3Z" stroke="currentColor" stroke-width="2.5"/><path d="m12.5 19 4.5 4.5 8.5-9" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/></svg></div>
<h1>请返回 VPN 客户端</h1><p>浏览器认证步骤已结束。连接状态请以客户端显示为准。</p>
<div class="tip">此页面可以关闭</div></main></body></html>`
*{box-sizing:border-box}body{margin:0;min-height:100vh;min-height:100svh;display:grid;place-items:center;padding:28px 20px;background:radial-gradient(ellipse 70% 50% at 50% 0%,#e5f1ff 0%,transparent 100%),#f5f8fc;color:#1c314a;font:15px/1.65 -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif}
main{width:min(100%,480px)}.brand{display:flex;align-items:center;justify-content:center;gap:10px;margin-bottom:26px;color:#244363;font-size:15px;font-weight:750;letter-spacing:.035em}.brand-icon{display:grid;place-items:center;width:30px;height:30px;border-radius:9px;background:#2878d0;color:#fff}
.card{padding:42px 38px 36px;background:#fff;border:1px solid #e5edf6;border-radius:22px;box-shadow:0 22px 55px rgba(34,77,126,.08),0 2px 8px rgba(34,77,126,.025);text-align:center}
.mark{display:grid;place-items:center;width:68px;height:68px;margin:0 auto 24px;background:#eef6ff;border:1px solid #dcecff;border-radius:20px;color:#2477d0}
h1{margin:0;color:#193550;font-size:25px;line-height:1.35;letter-spacing:-.025em}p{max-width:340px;margin:14px auto 0;color:#60758c;line-height:1.75}
.next{display:flex;align-items:flex-start;gap:13px;margin-top:32px;padding:17px 18px;border:1px solid #e8eff8;border-radius:14px;background:#f7faff;text-align:left}.next-icon{display:grid;flex:none;place-items:center;width:28px;height:28px;border-radius:9px;background:#e3f0ff;color:#2878d0}.next strong{display:block;color:#25415d;font-size:14px;line-height:1.5}.next span{display:block;margin-top:2px;color:#6b8095;font-size:13px;line-height:1.55}
footer{margin-top:22px;color:#8a9bad;font-size:12px;text-align:center}@media(max-width:480px){.card{padding:34px 23px 28px;border-radius:18px}h1{font-size:23px}.brand{margin-bottom:22px}}
</style></head><body><main><div class="brand"><span class="brand-icon" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none"><path d="M12 2 20 5.5v6c0 5-3 8.4-8 10.5-5-2.1-8-5.5-8-10.5v-6L12 2Z" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/></svg></span>OPSX VPN</div>
<section class="card" aria-labelledby="title"><div class="mark" aria-hidden="true"><svg width="35" height="35" viewBox="0 0 36 36" fill="none"><rect x="6" y="8" width="24" height="19" rx="3" stroke="currentColor" stroke-width="2"/><path d="M12 13h12M12 18h7M14 31h8M18 27v4" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg></div>
<h1 id="title">请返回 VPN 客户端</h1><p>浏览器认证步骤已结束,请在 VPN 客户端查看连接状态。</p>
<div class="next"><span class="next-icon" aria-hidden="true"><svg width="17" height="17" viewBox="0 0 20 20" fill="none"><path d="M4 10h12m-5-5 5 5-5 5" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></svg></span><div><strong>下一步:切回 VPN 客户端</strong><span>如客户端提示失败,请按提示重试或联系管理员。</span></div></div></section>
<footer>此浏览器页面可以关闭</footer></main></body></html>`
+6 -1
View File
@@ -62,11 +62,16 @@ func yamlString(s string) string {
func oidcConfig(t *model.Tunnel, cfg map[string]interface{}) string {
dir := service.InstanceDir(t)
return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\n template: %s\noauth2:\n issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n addr: %s\n password: %s\n auth-token-user: true\n",
usernameConfig := ""
if service.Str(cfg, "oidc_provider") == "builtin" {
usernameConfig = " openvpn-username: \"string(token.claims.sub)\"\n"
}
return fmt.Sprintf("http:\n baseurl: %s\n listen: %s\n secret: %s\n template: %s\noauth2:\n%s issuer: %s\n client:\n id: %s\n secret: %s\nopenvpn:\n addr: %s\n password: %s\n auth-token-user: true\n",
yamlString(service.Str(cfg, "oidc_base_url")),
yamlString(fmt.Sprintf("127.0.0.1:%d", int(service.Num(cfg, "oidc_http_port", 19090)))),
yamlString(service.Str(cfg, "oidc_http_secret")),
yamlString(filepath.Join(dir, "auth-result.html")),
usernameConfig,
yamlString(service.Str(cfg, "oidc_issuer")),
yamlString(service.Str(cfg, "oidc_client_id")),
yamlString(service.Str(cfg, "oidc_client_secret")),
+29 -6
View File
@@ -969,17 +969,24 @@ func (s *Service) collectTraffic(t *model.Tunnel) (int64, int64) {
continue
}
cn := f[1]
username := cn
if len(f) > 9 && f[9] != "" && f[9] != "UNDEF" {
username = f[9]
}
username := statusUsername(f)
realAddr := f[2]
virtualAddr := ""
if len(f) > 4 {
virtualAddr = f[3]
}
key := username + "|" + realAddr
old := snap[key]
keyName := username
if keyName == "" {
// Preserve the old traffic snapshot key for anonymous OIDC peers.
keyName = cn
}
key := keyName + "|" + realAddr
old, found := snap[key]
if !found && username != "" {
// A WebAuth peer can gain a verified username after its initial
// UNDEF snapshot. Keep counting from the same socket's prior value.
old = snap["UNDEF|"+realAddr]
}
deltaRx, deltaTx := rx-old.Rx, tx-old.Tx
if deltaRx < 0 {
deltaRx = rx
@@ -1015,6 +1022,22 @@ func splitStatusFields(line string) []string {
return strings.Split(line, ",")
}
// OpenVPN writes UNDEF when certificate-free WebAuth has not supplied a
// username. It is a protocol placeholder, never a verified Feishu identity.
func statusUsername(fields []string) string {
if len(fields) > 9 {
if username := strings.TrimSpace(fields[9]); username != "" && !strings.EqualFold(username, "UNDEF") {
return username
}
}
if len(fields) > 1 {
if cn := strings.TrimSpace(fields[1]); cn != "" && !strings.EqualFold(cn, "UNDEF") {
return cn
}
}
return ""
}
func (s *Service) Delete(t *model.Tunnel) error { return s.Stop(t) }
// RunAuthHelper is called by the same opsx-vpn binary from OpenVPN's
@@ -2,6 +2,7 @@ package openvpn
import (
"encoding/base64"
"fmt"
"os"
"path/filepath"
"strings"
@@ -52,6 +53,19 @@ func TestOIDCConfigAndSecrets(t *testing.T) {
if stat, err := os.Stat(path); err != nil || stat.Mode().Perm() != 0o600 {
t.Fatalf("auth configuration must be 0600: %v %v", stat, err)
}
pagePath := filepath.Join(service.InstanceDir(tunnel), "auth-result.html")
page, err := os.ReadFile(pagePath)
if err != nil {
t.Fatal(err)
}
if stat, err := os.Stat(pagePath); err != nil || stat.Mode().Perm() != 0o600 {
t.Fatalf("auth result template must be 0600: %v %v", stat, err)
}
for _, want := range []string{"name=\"viewport\"", "请返回 VPN 客户端", "请在 VPN 客户端查看连接状态"} {
if !strings.Contains(string(page), want) {
t.Fatalf("auth result template missing %q", want)
}
}
passwordPath := filepath.Join(service.InstanceDir(tunnel), "management-password.txt")
if stat, err := os.Stat(passwordPath); err != nil || stat.Mode().Perm() != 0o600 {
t.Fatalf("management password must be 0600: %v %v", stat, err)
@@ -79,6 +93,21 @@ func TestOIDCConfigAndSecrets(t *testing.T) {
}
}
func TestBuiltInOIDCUsesStableSubjectAsOpenVPNUsername(t *testing.T) {
tunnel := &model.Tunnel{ID: 44, Protocol: "openvpn"}
config := map[string]interface{}{
"oidc_provider": "builtin", "oidc_issuer": "https://vpn.example.com/id",
}
generated := oidcConfig(tunnel, config)
if !strings.Contains(generated, `openvpn-username: "string(token.claims.sub)"`) {
t.Fatalf("built-in issuer must use verified subject as username: %s", generated)
}
config["oidc_provider"] = "external"
if generated := oidcConfig(tunnel, config); strings.Contains(generated, "openvpn-username:") {
t.Fatalf("external issuer username behavior must remain unchanged: %s", generated)
}
}
func TestOIDCRejectsUnsafeURLs(t *testing.T) {
base := map[string]interface{}{
"auth_mode": "oidc", "oidc_issuer": "https://idp.example.com/realms/a",
@@ -335,3 +364,85 @@ func TestSplitStatusFieldsSupportsOpenVPNStatusV3(t *testing.T) {
t.Fatalf("status-version 3 fields parsed incorrectly: %#v", fields)
}
}
func TestStatusUsernameDoesNotTreatUNDEFAsIdentity(t *testing.T) {
for _, tc := range []struct {
name, cn, username, want string
}{
{"password", "admin", "admin", "admin"},
{"authenticated username", "UNDEF", "tenant:ou_123", "tenant:ou_123"},
{"certificate", "client-cert", "UNDEF", "client-cert"},
{"webauth without username", "UNDEF", "UNDEF", ""},
{"webauth empty username", "UNDEF", "", ""},
{"webauth lowercase placeholder", "undef", "UnDeF", ""},
} {
t.Run(tc.name, func(t *testing.T) {
fields := splitStatusFields("CLIENT_LIST\t" + tc.cn + "\t203.0.113.1:1234\t10.8.0.2\t\t12\t34\t2026-10-22\t1234\t" + tc.username)
if got := statusUsername(fields); got != tc.want {
t.Fatalf("statusUsername() = %q, want %q", got, tc.want)
}
})
}
}
func TestWebAuthSessionIdentityAndTrafficTransition(t *testing.T) {
dir := t.TempDir()
t.Setenv("VPN_DATA_DIR", dir)
previousDB := store.DB
t.Cleanup(func() {
if store.DB != nil {
if sqlDB, err := store.DB.DB(); err == nil {
_ = sqlDB.Close()
}
}
store.DB = previousDB
})
if err := store.Init(dir); err != nil {
t.Fatal(err)
}
tunnel := &model.Tunnel{ID: 502, Name: "webauth-test", Protocol: "openvpn"}
if err := store.DB.Create(tunnel).Error; err != nil {
t.Fatal(err)
}
for _, username := range []string{"UNDEF", "tenant:ou_123"} {
if err := store.DB.Create(&model.VPNUser{TunnelID: tunnel.ID, Username: username, Enabled: true}).Error; err != nil {
t.Fatal(err)
}
}
instanceDir := service.InstanceDir(tunnel)
if err := os.MkdirAll(instanceDir, 0o700); err != nil {
t.Fatal(err)
}
writeStatus := func(username string, rx, tx int64) {
t.Helper()
line := fmt.Sprintf("CLIENT_LIST\tUNDEF\t203.0.113.1:1234\t10.8.0.2\t\t%d\t%d\t2026-10-22\t1234\t%s\n", rx, tx, username)
if err := os.WriteFile(filepath.Join(instanceDir, "openvpn-status.log"), []byte(line), 0o600); err != nil {
t.Fatal(err)
}
}
writeStatus("UNDEF", 150, 230)
svc := New()
if rx, tx := svc.collectTraffic(tunnel); rx != 150 || tx != 230 {
t.Fatalf("initial traffic = %d/%d", rx, tx)
}
var unknown model.VPNSession
if err := store.DB.Where("tunnel_id = ? AND status = ?", tunnel.ID, "active").First(&unknown).Error; err != nil || unknown.Username != "" || unknown.VPNUserID != 0 {
t.Fatalf("unknown peer must not be attributed: %+v, %v", unknown, err)
}
var placeholder model.VPNUser
store.DB.Where("tunnel_id = ? AND username = ?", tunnel.ID, "UNDEF").First(&placeholder)
if placeholder.RxBytes != 0 || placeholder.TxBytes != 0 {
t.Fatal("UNDEF placeholder received VPN user traffic")
}
if rx, tx := svc.collectTraffic(tunnel); rx != 0 || tx != 0 {
t.Fatalf("unchanged snapshot counted twice: %d/%d", rx, tx)
}
writeStatus("tenant:ou_123", 160, 260)
if rx, tx := svc.collectTraffic(tunnel); rx != 10 || tx != 30 {
t.Fatalf("authenticated snapshot delta = %d/%d, want 10/30", rx, tx)
}
var identified model.VPNSession
if err := store.DB.Where("tunnel_id = ? AND status = ?", tunnel.ID, "active").First(&identified).Error; err != nil || identified.Username != "tenant:ou_123" || identified.VPNUserID == 0 {
t.Fatalf("verified username should link its own user: %+v, %v", identified, err)
}
}
+20 -16
View File
@@ -7,6 +7,7 @@ import (
"time"
"vpn-platform/backend/internal/core"
"vpn-platform/backend/internal/identityprovider"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
@@ -28,6 +29,13 @@ func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
return
}
now := time.Now()
subjects := make([]string, 0, len(clients))
for _, client := range clients {
subjects = append(subjects, client.Username)
}
var tunnel model.Tunnel
store.DB.First(&tunnel, tunnelID)
names := identityprovider.ProfileNames(&tunnel, subjects)
// 1. 收集当前在线的标识 key(username|realAddress)
currentKeys := map[string]bool{}
@@ -44,10 +52,12 @@ func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
// 已有会话 → 更新流量
store.DB.Model(&existing).Updates(map[string]interface{}{
"rx_bytes": c.RxBytes, "tx_bytes": c.TxBytes,
"display_name": names[c.Username],
})
} else {
// 新连接 → 创建会话 + 审计日志
session := model.VPNSession{
DisplayName: names[c.Username],
TunnelID: tunnelID,
Username: c.Username,
Protocol: protocol,
@@ -75,7 +85,7 @@ func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
key := s.Username + "|" + s.RealAddress
if !currentKeys[key] {
store.DB.Model(&s).Updates(map[string]interface{}{
"status": "disconnected",
"status": "disconnected",
"disconnected_at": now,
})
duration := now.Sub(s.ConnectedAt).Round(time.Second)
@@ -84,16 +94,6 @@ func SyncSessions(tunnelID uint, protocol string, clients []ClientInfo) {
}
}
// 3. 如果 clients 为空(隧道无在线用户),标记该隧道所有 active 会话为 disconnected
if len(clients) == 0 {
for _, s := range activeSessions {
store.DB.Model(&s).Updates(map[string]interface{}{
"status": "disconnected",
"disconnected_at": now,
})
LogConnection(tunnelID, s.Username, "disconnect", s.RealAddress, "隧道无在线用户")
}
}
}
// SyncFromPeers 从 core.Status.Peers 同步(WireGuard 用)
@@ -132,12 +132,16 @@ func LogConnection(tunnelID uint, username, event, ip, detail string) {
if store.DB == nil {
return
}
var tunnel model.Tunnel
store.DB.First(&tunnel, tunnelID)
names := identityprovider.ProfileNames(&tunnel, []string{username})
store.DB.Create(&model.ConnectionLog{
TunnelID: tunnelID,
Username: username,
Event: event,
IPAddress: ip,
Detail: detail,
DisplayName: names[username],
TunnelID: tunnelID,
Username: username,
Event: event,
IPAddress: ip,
Detail: detail,
})
}
@@ -0,0 +1,38 @@
package sessionmgr
import (
"testing"
"vpn-platform/backend/internal/model"
"vpn-platform/backend/internal/store"
)
func TestDisconnectLoggedOnceWhenTunnelHasNoClients(t *testing.T) {
previous := store.DB
t.Cleanup(func() {
if store.DB != nil {
if db, err := store.DB.DB(); err == nil {
_ = db.Close()
}
}
store.DB = previous
})
if err := store.Init(t.TempDir()); err != nil {
t.Fatal(err)
}
tunnel := model.Tunnel{Name: "audit-test", Protocol: "openvpn"}
if err := store.DB.Create(&tunnel).Error; err != nil {
t.Fatal(err)
}
client := ClientInfo{Username: "tenant:ou_123", RealAddress: "203.0.113.1:1234", RxBytes: 42, TxBytes: 84}
SyncSessions(tunnel.ID, "openvpn", []ClientInfo{client})
SyncSessions(tunnel.ID, "openvpn", nil)
SyncSessions(tunnel.ID, "openvpn", nil)
var logs []model.ConnectionLog
if err := store.DB.Where("tunnel_id = ?", tunnel.ID).Order("id").Find(&logs).Error; err != nil {
t.Fatal(err)
}
if len(logs) != 2 || logs[0].Event != "connect" || logs[1].Event != "disconnect" || logs[1].Username != client.Username {
t.Fatalf("expected one connect and one disconnect with stable subject, got %+v", logs)
}
}
+1 -1
View File
@@ -44,7 +44,7 @@ func Init(dataDir string) error {
}
// 兼容旧库:VPNUser 唯一约束曾是 username 单列全局唯一
db.Exec("DROP INDEX IF EXISTS idx_vpn_user")
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}, &model.IdentityProvider{}, &model.IdentityGrant{}); err != nil {
if err := db.AutoMigrate(&model.User{}, &model.LDAPConfig{}, &model.MFADevice{}, &model.VPNUser{}, &model.Tunnel{}, &model.Node{}, &model.Plugin{}, &model.AuditLog{}, &model.VPNSession{}, &model.ConnectionLog{}, &model.TrafficPolicy{}, &model.TrafficSample{}, &model.ClientDevice{}, &model.ClientSession{}, &model.IdentityProvider{}, &model.IdentityGrant{}, &model.IdentityProfile{}); err != nil {
return err
}
DB = db