From 8fd8c0cd738f809cdacd954d69c12578561235bf Mon Sep 17 00:00:00 2001 From: opsx Date: Thu, 22 Oct 2026 23:24:41 +0800 Subject: [PATCH] Bind Feishu identity to OpenVPN sessions and refine management UI --- backend/internal/identityprovider/handlers.go | 10 +- backend/internal/identityprovider/profile.go | 40 ++ .../internal/identityprovider/profile_test.go | 55 +++ .../identityprovider/provider_test.go | 33 +- backend/internal/model/model.go | 13 + .../internal/service/openvpn/auth_result.go | 24 +- backend/internal/service/openvpn/oidc.go | 7 +- backend/internal/service/openvpn/openvpn.go | 35 +- .../internal/service/openvpn/openvpn_test.go | 111 +++++ backend/internal/sessionmgr/sessionmgr.go | 36 +- .../internal/sessionmgr/sessionmgr_test.go | 38 ++ backend/internal/store/store.go | 2 +- docs/deploy.md | 5 +- docs/specs/SDD-007-built-in-feishu-issuer.md | 28 ++ docs/specs/SDD-008-web-ui-guide-alignment.md | 6 +- docs/specs/SDD-009-feishu-session-identity.md | 55 +++ frontend/src/locales/en-US.json | 1 + frontend/src/locales/zh-CN.json | 1 + frontend/src/views/Sessions.vue | 18 +- frontend/src/views/Settings.vue | 378 +---------------- frontend/src/views/Tunnels.vue | 380 +----------------- frontend/src/views/VPNUsers.vue | 228 +---------- .../settings/components/GatewaySettings.vue | 243 +++++++++++ .../settings/components/IdentitySettings.vue | 161 ++++++++ .../tunnels/components/TunnelDetailDrawer.vue | 337 ++++++++++++++++ .../tunnels/components/TunnelLogDialog.vue | 84 ++++ .../vpn-users/components/VpnUserMfaDialog.vue | 238 +++++++++++ 27 files changed, 1556 insertions(+), 1011 deletions(-) create mode 100644 backend/internal/identityprovider/profile.go create mode 100644 backend/internal/identityprovider/profile_test.go create mode 100644 backend/internal/sessionmgr/sessionmgr_test.go create mode 100644 docs/specs/SDD-009-feishu-session-identity.md create mode 100644 frontend/src/views/settings/components/GatewaySettings.vue create mode 100644 frontend/src/views/settings/components/IdentitySettings.vue create mode 100644 frontend/src/views/tunnels/components/TunnelDetailDrawer.vue create mode 100644 frontend/src/views/tunnels/components/TunnelLogDialog.vue create mode 100644 frontend/src/views/vpn-users/components/VpnUserMfaDialog.vue diff --git a/backend/internal/identityprovider/handlers.go b/backend/internal/identityprovider/handlers.go index 96a9b58..48073be 100644 --- a/backend/internal/identityprovider/handlers.go +++ b/backend/internal/identityprovider/handlers.go @@ -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 } diff --git a/backend/internal/identityprovider/profile.go b/backend/internal/identityprovider/profile.go new file mode 100644 index 0000000..4cec374 --- /dev/null +++ b/backend/internal/identityprovider/profile.go @@ -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 +} diff --git a/backend/internal/identityprovider/profile_test.go b/backend/internal/identityprovider/profile_test.go new file mode 100644 index 0000000..c6840f0 --- /dev/null +++ b/backend/internal/identityprovider/profile_test.go @@ -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) + } +} diff --git a/backend/internal/identityprovider/provider_test.go b/backend/internal/identityprovider/provider_test.go index 4cb2a07..6d4adb4 100644 --- a/backend/internal/identityprovider/provider_test.go +++ b/backend/internal/identityprovider/provider_test.go @@ -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") diff --git a/backend/internal/model/model.go b/backend/internal/model/model.go index c775603..cbe990a 100644 --- a/backend/internal/model/model.go +++ b/backend/internal/model/model.go @@ -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"` diff --git a/backend/internal/service/openvpn/auth_result.go b/backend/internal/service/openvpn/auth_result.go index 07a66eb..05615bf 100644 --- a/backend/internal/service/openvpn/auth_result.go +++ b/backend/internal/service/openvpn/auth_result.go @@ -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 = ` -返回 VPN 客户端 · OPSX VPN +返回 VPN 客户端 · OPSX VPN
OPSX VPN
- -

请返回 VPN 客户端

浏览器认证步骤已结束。连接状态请以客户端显示为准。

-
此页面可以关闭
` +*{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}} +
OPSX VPN
+
+

请返回 VPN 客户端

浏览器认证步骤已结束,请在 VPN 客户端查看连接状态。

+
+
此浏览器页面可以关闭
` diff --git a/backend/internal/service/openvpn/oidc.go b/backend/internal/service/openvpn/oidc.go index cb9a9d7..af5019c 100644 --- a/backend/internal/service/openvpn/oidc.go +++ b/backend/internal/service/openvpn/oidc.go @@ -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")), diff --git a/backend/internal/service/openvpn/openvpn.go b/backend/internal/service/openvpn/openvpn.go index 2de76db..78fa45c 100644 --- a/backend/internal/service/openvpn/openvpn.go +++ b/backend/internal/service/openvpn/openvpn.go @@ -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 diff --git a/backend/internal/service/openvpn/openvpn_test.go b/backend/internal/service/openvpn/openvpn_test.go index 906d422..4b0f6ad 100644 --- a/backend/internal/service/openvpn/openvpn_test.go +++ b/backend/internal/service/openvpn/openvpn_test.go @@ -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) + } +} diff --git a/backend/internal/sessionmgr/sessionmgr.go b/backend/internal/sessionmgr/sessionmgr.go index 34e3137..2a9c6ae 100644 --- a/backend/internal/sessionmgr/sessionmgr.go +++ b/backend/internal/sessionmgr/sessionmgr.go @@ -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, }) } diff --git a/backend/internal/sessionmgr/sessionmgr_test.go b/backend/internal/sessionmgr/sessionmgr_test.go new file mode 100644 index 0000000..1f22998 --- /dev/null +++ b/backend/internal/sessionmgr/sessionmgr_test.go @@ -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) + } +} diff --git a/backend/internal/store/store.go b/backend/internal/store/store.go index fc3b0cc..41e0af5 100644 --- a/backend/internal/store/store.go +++ b/backend/internal/store/store.go @@ -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 diff --git a/docs/deploy.md b/docs/deploy.md index 17ada31..0942cc5 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -29,7 +29,8 @@ curl --fail http://127.0.0.1:18080/api/health cd /home/ubuntu/opsx-vpn git status --short curl --fail http://127.0.0.1:18080/api/health -sudo cp -p opsx-vpn "opsx-vpn.bak-$(date +%Y%m%d-%H%M%S)" +sudo install -d -m 700 /tmp/opsx-vpn-backups +sudo cp -p opsx-vpn "/tmp/opsx-vpn-backups/opsx-vpn.bak-$(date +%Y%m%d-%H%M%S)" git pull --ff-only origin master cd frontend && npm run build && cd .. go build -ldflags="-s -w" -o opsx-vpn.new ./backend @@ -47,7 +48,7 @@ curl --fail http://127.0.0.1:18080/api/health sudo ss -tlnp | grep ':18080' ``` -再检查页面静态资源、`/tmp/opsx-vpn.log` 和原有 VPN 实例状态。若新版本无法启动,停掉新管理进程,复制最近的 `opsx-vpn.bak-*` 到 `opsx-vpn` 并按上面的方式启动;如涉及数据模型变更,先依据对应变更的迁移说明处理数据库,不能直接覆盖 `data/`。 +再检查页面静态资源、`/tmp/opsx-vpn.log` 和原有 VPN 实例状态。备份只放在 `/tmp/opsx-vpn-backups`,验证成功后仅保留最新两份。若新版本无法启动,停掉新管理进程,复制该目录中最近的 `opsx-vpn.bak-*` 到 `opsx-vpn` 并按上面的方式启动;如涉及数据模型变更,先依据对应变更的迁移说明处理数据库,不能直接覆盖 `data/`。 ## 飞书 / OIDC 登录入口 diff --git a/docs/specs/SDD-007-built-in-feishu-issuer.md b/docs/specs/SDD-007-built-in-feishu-issuer.md index f920d42..f8e543c 100644 --- a/docs/specs/SDD-007-built-in-feishu-issuer.md +++ b/docs/specs/SDD-007-built-in-feishu-issuer.md @@ -93,3 +93,31 @@ 真实飞书授权及 OpenVPN 建链现已在实例日志中验证。配置页输入 App ID/Secret 后延迟调用已有 HTTPS 管理员查询接口:成功读取企业信息才显示 `tenant:tenant:readonly` 已验证,并自动填入 tenant_key;失败仅展示脱敏错误并允许重试。`contact:user.base:readonly` 是用户授权范围,不能仅靠应用凭据证明;首次成功换取用户资料并校验租户后记录无用户标识的验证时间,换应用凭据或租户时清除,设置页只展示是否曾通过。服务端启动或页面刷新不重复发起 OAuth,也不把手填 tenant_key 当作验证通过。密钥只经 HTTPS 管理接口传输,页面异步请求需忽略被新输入覆盖的旧响应。 认证组件通过原生 `http.template` 加载每实例的 0600 HTML 页面;页面只提示返回客户端查看连接状态,不在可能复用的终态模板上错误宣称 VPN 已连接。不额外放宽 Nginx 路径。验收:凭据变更后状态清零,查询正确/错误与旧响应均不误报;首次真实登录后状态可见;插件能正常加载模板并保持浏览器认证、OpenVPN 建链。发布时先构建测试,再替换二进制并重启受影响的 OIDC 实例以应用新模板;若插件不兼容模板,回退之前的配置与二进制、重启实例,旧认证仍可使用。数据库新列由 AutoMigrate 添加,回退版本忽略该列。 + +## 12. 在线会话的未知身份(S1) + +OpenVPN `status-version 3` 的第 1 列 Common Name 与第 9 列 Username,在无客户端证书的 WebAuth 首次连接时都可能为 `UNDEF`。目前会话同步把占位字符串当作拨号用户名,误写入在线会话及审计。优先使用有效 Username,其次使用有效证书 CN;两列均为 `UNDEF` 时留空,并在在线会话及连接审计页面明确显示“身份未识别”。保持原流量快照键以避免部署后的首次轮询重复计数;不根据公网 IP、姓名或认证时间猜测身份,更不把“LG”这样的显示名自动绑定 VPNUser。已有历史日志保留原值,仅页面标记其身份未识别。 + +验收:密码与证书连接仍展示其用户名/CN;无身份的 WebAuth 状态行不会记录 `UNDEF` 为 VPNUser,也不会误关联飞书人员;在线会话保留连接地址与流量。若同一连接随后获得可信 Username,沿用此前 `UNDEF` 流量快照计算增量,不把旧流量重复累计;单元测试覆盖未知身份、重复轮询和后续获得稳定 subject 的过程。真正按人展示还需验证 `openvpn-auth-oauth2` 是否能向 OpenVPN 提供带稳定 subject 的 Username;如插件不传递,须在认证阶段增加可信映射,不能用会话 IP 推断。此修复不改变认证权限或 Nginx;发布前运行 OpenVPN 单元测试及前端构建,发布后检查真实状态文件第 1/9 列与在线会话,失败则恢复旧管理二进制,保留数据库和专线进程。 + +## 13. 授权身份传递与回调结果页(S2) + +现有 `fetchFeishuUser` 从飞书 `/authen/v1/user_info` 读取 `open_id`、`tenant_key`、`name`,`/id/userinfo` 输出稳定 `sub` 与 `name`;但 ID Token 仅包含 `sub`。浏览器 `/oauth2/callback` 是认证组件消费授权码的端点,地址栏的 code 不能代表用户资料。将已获取的非空姓名作为标准 OIDC `name` claim 加入 ID Token;`sub` 继续由 `tenant_key:open_id` 组成,不能以可变姓名替代。结果模板改为简洁蓝白灰页面,继续保持结果中性,不能输出令牌、姓名或根据地址栏宣称 VPN 已连通。 + +FR-13-01:模拟飞书换码与用户信息返回时,服务端传递稳定标识与姓名,签发的 ID Token 在非空时包含 `name`,`/id/userinfo` 保留原字段;空姓名不写入空 claim。FR-13-02:同一 0600 模板通过 `http.template` 用于回调终态,无论认证结果如何都只提示切回客户端检查状态。 + +AC-13-01:模拟上游含 `open_id`、`tenant_key`、`name` 的响应,验签并检查 ID Token 的 `sub`/`name`,验证名称为空时不增加 claim;运行身份模块测试。AC-13-02:读取生成的模板,核对页面标题、移动端视口及不出现回调参数/用户资料;运行 OpenVPN 模块测试。真实账号授权和 OpenVPN 状态第 1/9 列另需在线联调,单元模拟不等同于线上已显示真人姓名。 + +风险:姓名属于个人信息,只放入面向已认证机密客户端的短时 ID Token 与现有 UserInfo,不写浏览器结果页或日志;上游未提供姓名时维持稳定 `sub`。兼容性:已有依赖 `sub` 的客户端行为不变;不增公网路由或数据库列。发布先构建并验证,再部署管理二进制、重启受影响的 OIDC 专线以生成新模板,检查 HTTPS 认证流程;失败回退二进制并重启该专线,不删除身份数据或签名密钥。 + +## 14. WebAuth 身份进入 OpenVPN 会话(S3,待组件核实) + +现状证据:飞书回调读取 `tenant_key`、`open_id`、`name`,受限 OIDC Token 返回稳定 `sub`;实例配置 `openvpn.auth-token-user: true`,但服务端 `status-version 3` 的 Username 和 CN 仍可能都是 `UNDEF`。`auth-token-user` 是推送给客户端以后重新认证使用的用户名,不能证明服务端首次连接状态已绑定 OIDC subject。会话同步只信任 OpenVPN 状态文件,不应按公网 IP、浏览器回调时间或显示名匹配人。旧版代码把 `UNDEF` 直接写入审计,本地 S1 修正正在避免新增这类伪用户;历史审计保持原样。 + +目标链路:飞书应用确认用户和租户 → OIDC 使用 `tenant_key:open_id` 作为稳定 subject、姓名只用于显示 → 认证组件验证令牌与 pending CID/KID 的绑定 → 在同一条管理接口 `client-auth` 的客户端配置中设置 `override-username <稳定 subject>` → OpenVPN 状态文件 Username 出现该 subject → 平台在线会话与审计使用这个 subject。OpenVPN 本地源码文档 `doc/man-sections/server-options.rst` 明确 `override-username` 会被状态输出采用;但须先核实当前 `openvpn-auth-oauth2` 版本是否支持下发此配置,若不支持,应修改/封装认证组件并在集成测试验证,不能在 `server.conf` 中静态设置一个共用用户名。将来展示飞书姓名需按 subject 保存受控身份资料或提供受限查询,不得将姓名当成授权键或自动关联同名 VPNUser。 + +FR-14-01:每个成功认证的 OpenVPN CID/KID 必须绑定经验证的非空稳定 subject;缺失身份则拒绝授权,不得以 `UNDEF`、IP 或可变姓名补全。FR-14-02:状态文件的 Username 必须与该 subject 一致,平台在线列表和连接/断开审计使用该值;密码和证书专线保留原行为。FR-14-03:部署不得扩大 Nginx、管理 socket 或飞书资料接口的权限;失败时能回退认证组件与配置。 + +AC-14-01:模拟两个并发 WebAuth 请求及其不同 CID/KID,证明不能交叉赋值,令牌验证失败、租户不符和空 subject 均拒绝;检查管理命令仅含安全编码的 subject。AC-14-02:测试专线真实授权后读取 `openvpn-status.log` 的 CN/Username、在线会话和连接/断开审计,三处能追溯同一稳定 subject;重复认证和重连无误绑定。AC-14-03:测试旧密码/证书连接、认证失败、组件崩溃恢复,并检查失败时无匿名会话被错误归到已认证用户。 + +待决:当前运行的 `openvpn-auth-oauth2` 对 `client-auth` 配置和令牌 `sub` 的实际处理,以及线上状态文件原始列值;在没有组件源码/运行输出验证前,不声明此节已实现。发布顺序为先验证并编译组件、测试专线联调,再更新管理服务及组件,最后观察线上状态与审计;回退为恢复先前组件及配置并重启测试专线,保留密钥、身份数据和历史审计。 diff --git a/docs/specs/SDD-008-web-ui-guide-alignment.md b/docs/specs/SDD-008-web-ui-guide-alignment.md index 6aee399..640eb76 100644 --- a/docs/specs/SDD-008-web-ui-guide-alignment.md +++ b/docs/specs/SDD-008-web-ui-guide-alignment.md @@ -22,6 +22,8 @@ 风险:菜单 meta 遗漏导致入口消失;颜色对比、窄屏表格或图表实例切换出错;登录错误处理误跳转;大文件 CSV 进入浏览器内存。验收:所有原有入口均可从对应角色看到,子页可返回专线;中文/英文页面可构建;桌面与小屏下菜单和操作按钮可点;登录 401 留在登录表单,业务 401 只触发一次跳转;接口错误时有重试且不伪造数据;日志导出请求 URL 不含 token,HTTP Authorization 携带凭据并成功下载 CSV。旧版后端接口保持兼容。提交前执行前端构建与 `git diff --check`,生产发布需另行确认可访问性与当前在线 VPN 连接;回退只需切回上一版前端嵌入二进制。 -## 后续拆分点 +## 第二轮:页面拆分与发布 -`Settings.vue`、`Tunnels.vue`、`VPNUsers.vue` 目前超过指南的 400 行视图上限。下一轮按业务边界分别将身份/网关设置、专线详情/日志、拨号用户的 MFA 弹层移至页面私有组件和 composable。避免本次视觉统一同时改变这些敏感业务表单的状态及 API 调用顺序。 +`Settings.vue` 的飞书身份与公网入口各自负责原有请求、加载状态及校验;`Tunnels.vue` 的专线详情(流量策略、WireGuard Peer、二维码)与实时日志各自管理原有弹层;`VPNUsers.vue` 的 MFA 管理弹层负责原有绑定、PIN、解锁与备用码操作,操作完成通知父页刷新列表。路由、接口 URL、鉴权头、表单字段和权限入口不变,页面私有组件放在各自的 `views/<域>/components/`。实时日志在关闭、切换和组件销毁时结束请求,以免后台持续读取。 + +风险:拆分状态后子组件遗漏初始化、刷新或清理;身份凭据与 MFA 的错误处理不能退化;二维码仍需在列表与详情两处可用。验收:前端构建、差异检查;管理端登录后验证设置页四个标签、专线详情/日志及拨号用户 MFA 操作;服务器确认 HTTPS 健康、OIDC 发现文档、Nginx 配置与原有 VPN 进程。生产发布只替换管理服务的内嵌前端二进制,不重启专线进程;先在 `/tmp/opsx-vpn-backups` 保留旧二进制,最多留两份。若健康检查或静态资源异常,恢复旧二进制并重启管理进程;保留现有数据库、证书和专线配置。 diff --git a/docs/specs/SDD-009-feishu-session-identity.md b/docs/specs/SDD-009-feishu-session-identity.md new file mode 100644 index 0000000..572b5e6 --- /dev/null +++ b/docs/specs/SDD-009-feishu-session-identity.md @@ -0,0 +1,55 @@ +# SDD-009:飞书认证会话身份 + +| 字段 | 内容 | +|---|---| +| 版本 / 变更级别 | 0.1 / S3 | +| 设计状态 | 已形成 | +| 实现状态 | 未开始 | +| 负责人 / 日期 | Codex / 2026-10-22 | +| 关联需求 | 飞书登录后在线会话与连接审计显示具体用户 | +| 批准状态 | 用户授权排查修复;尚未发布 | + +## 1. 背景、目标与范围 + +线上 2.3.0 认证组件日志显示授权成功且 sub 非空,但 user_username 为空;OpenVPN 状态中 CN、Username 均为 UNDEF。`identityprovider/handlers.go` 已从飞书 user_info 获取 tenant_key、open_id、name,现有短期 grant 未形成持久姓名关联。`openvpn/oidc.go` 没有配置用户名表达式。 + +目标:内置飞书专线以已验证的 sub 传递用户身份,并展示飞书姓名。保留外部 OIDC、密码、证书模式和现有用户权限;不按 IP 或时间推断历史身份,不自动创建或授权 VPNUser,不查询整个企业通讯录。 + +## 2. 需求与约束 + +- FR-01:内置飞书认证组件使用 token.claims.sub 作为用户名来源,外部配置保持原行为。 +- FR-02:仅成功获取飞书资料并通过企业校验后,保存应用、issuer、subject、姓名和更新时间;不保存上游访问令牌。 +- FR-03:在线会话与连接审计以稳定 subject 为身份,额外展示同应用同 issuer 的飞书姓名。未知身份保持未知;同名用户不合并。 +- NFR-01:姓名仅用于展示,不参与权限判断;资料写入失败时不发放本次授权码;已有记录不回填猜测身份。 + +## 3. 架构与方案 + +回调校验企业后 upsert 独立身份资料表;内置专线生成明确 CEL 用户名表达式。会话采集按当前内置配置及 subject 查资料,保存展示姓名快照;连接审计同步保存姓名快照。前端显示姓名与稳定标识,旧 UNDEF 显示未知。 + +## 4. 接口、数据与配置契约 + +新增身份资料表,issuer、app_id、subject 为联合唯一键;VPNSession 与 ConnectionLog 增加可选 display_name。现有 username 字段语义不变。自动迁移仅新增表与列。内置组件配置增加 oauth2.openvpn-username,要求支持该选项(线上 2.3.0 已通过 --help 核对);外部 OIDC 不增加此配置。 + +## 5. 风险与验收 + +| 风险 | 防护 / 恢复 | 可观察信号 | +|---|---|---| +| 姓名或应用混淆 | 同 issuer、应用、subject 精确查询;姓名非身份键 | 同名、跨应用测试 | +| 新组件配置不兼容 | 发布前核对组件参数,恢复原配置 | 启动日志与监听 | +| 旧连接仍无身份 | 用户重新发起 WebAuth;不关联旧 UNDEF | 状态文件用户名 | +| 数据写入失败 | 本次回调 503,不发码 | 负向测试 | + +| AC | 对应需求 | 操作与预期 | 验证方式 | 结果 | +|---|---|---|---|---| +| AC-01 | FR-01 | 内置生成 sub 表达式,外部不改变 | Go 测试 | 未执行 | +| AC-02 | FR-02、NFR-01 | 保存及更新同一身份,应用隔离;不保存令牌 | Go 测试 | 未执行 | +| AC-03 | FR-03 | 会话与审计保留 subject 并显示姓名,未知无误归属 | Go 测试、前端构建 | 未执行 | +| AC-04 | FR-01、FR-03 | 真实用户重连后状态用户名非 UNDEF,后台显示姓名 | 远程联调 | 未执行 | + +## 6. 发布与恢复 + +先完成测试及前端构建,备份旧二进制及认证配置,再更新管理二进制;不覆盖数据库或密钥。认证配置在专线下次启动时生成;现有连接不自动中断,重启测试专线须明确告知影响。验证健康、认证监听、实际重连和会话展示。失败恢复旧二进制、旧认证配置及原启动方式;新增表列可保留,旧版忽略,不做删除迁移。没有不可逆删除。 + +## 7. 待决问题 + +真实用户重连需用户本人完成飞书认证;不阻塞本地实现,阻塞端到端验收。历史 UNDEF 无可信身份关联,保持原始记录。 diff --git a/frontend/src/locales/en-US.json b/frontend/src/locales/en-US.json index 6cb55c4..2ff3d05 100644 --- a/frontend/src/locales/en-US.json +++ b/frontend/src/locales/en-US.json @@ -531,6 +531,7 @@ "connectedDuration": "Duration", "kickOffline": "Kick", "noOnlineUsers": "No online users", + "identityUnknown": "Identity unknown", "trafficAlert": "⚠ Abnormal Traffic Alert", "userTunnel": "User/Tunnel", "alertType": "Type", diff --git a/frontend/src/locales/zh-CN.json b/frontend/src/locales/zh-CN.json index 902043d..664717e 100644 --- a/frontend/src/locales/zh-CN.json +++ b/frontend/src/locales/zh-CN.json @@ -531,6 +531,7 @@ "connectedDuration": "已连接时长", "kickOffline": "踢下线", "noOnlineUsers": "当前无在线用户", + "identityUnknown": "身份未识别", "trafficAlert": "⚠ 异常流量告警", "userTunnel": "用户/隧道", "alertType": "类型", diff --git a/frontend/src/views/Sessions.vue b/frontend/src/views/Sessions.vue index a1ea9d4..8d1925b 100644 --- a/frontend/src/views/Sessions.vue +++ b/frontend/src/views/Sessions.vue @@ -13,7 +13,9 @@ - + + +