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
+1550 -1005

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)
}
}
+14 -10
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,
@@ -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,7 +132,11 @@ 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{
DisplayName: names[username],
TunnelID: tunnelID,
Username: username,
Event: event,
@@ -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
+3 -2
View File
@@ -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 登录入口
@@ -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` 的实际处理,以及线上状态文件原始列值;在没有组件源码/运行输出验证前,不声明此节已实现。发布顺序为先验证并编译组件、测试专线联调,再更新管理服务及组件,最后观察线上状态与审计;回退为恢复先前组件及配置并重启测试专线,保留密钥、身份数据和历史审计。
+4 -2
View File
@@ -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` 保留旧二进制,最多留两份。若健康检查或静态资源异常,恢复旧二进制并重启管理进程;保留现有数据库、证书和专线配置。
@@ -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 无可信身份关联,保持原始记录。
+1
View File
@@ -531,6 +531,7 @@
"connectedDuration": "Duration",
"kickOffline": "Kick",
"noOnlineUsers": "No online users",
"identityUnknown": "Identity unknown",
"trafficAlert": "⚠ Abnormal Traffic Alert",
"userTunnel": "User/Tunnel",
"alertType": "Type",
+1
View File
@@ -531,6 +531,7 @@
"connectedDuration": "已连接时长",
"kickOffline": "踢下线",
"noOnlineUsers": "当前无在线用户",
"identityUnknown": "身份未识别",
"trafficAlert": "⚠ 异常流量告警",
"userTunnel": "用户/隧道",
"alertType": "类型",
+14 -4
View File
@@ -13,7 +13,9 @@
</div>
</template>
<el-table :data="sessions" size="default" v-loading="loading">
<el-table-column prop="username" :label="$t('common.username')" min-width="120" />
<el-table-column :label="$t('common.username')" min-width="120">
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
</el-table-column>
<el-table-column :label="$t('common.protocol')" width="100">
<template #default="{ row }">
<span class="protocol-tag" :style="{ color: protocolColor(row.protocol) }">{{ $t(protocolDisplay(row.protocol)) }}</span>
@@ -64,7 +66,9 @@
<el-table-column prop="created_at" :label="$t('common.time')" width="170">
<template #default="{ row }">{{ new Date(row.created_at).toLocaleString() }}</template>
</el-table-column>
<el-table-column prop="username" :label="$t('common.username')" width="100" />
<el-table-column :label="$t('common.username')" min-width="180">
<template #default="{ row }"><span :title="row.username">{{ row.display_name || displayUsername(row.username) }}</span><div v-if="row.display_name" class="identity-subject">{{ row.username }}</div></template>
</el-table-column>
<el-table-column :label="$t('sessions.event')" width="100">
<template #default="{ row }">
<el-tag :type="eventTagType(row.event)" size="small">{{ eventLabel(row.event) }}</el-tag>
@@ -92,6 +96,12 @@ const alerts = ref([])
const loading = ref(false)
const loadError = ref('')
function displayUsername(value) {
return typeof value === 'string' && value.trim() && value.trim().toUpperCase() !== 'UNDEF'
? value
: t('sessions.identityUnknown')
}
function fmtDuration(dur) {
if (!dur) return '-'
const sec = Math.floor((Date.now() - new Date(dur).getTime()) / 1000)
@@ -125,7 +135,7 @@ async function load() {
async function disconnect(row) {
try {
await ElMessageBox.confirm(t('sessions.kickConfirm', { username: row.username }), t('common.confirm'), { type: 'warning' })
await ElMessageBox.confirm(t('sessions.kickConfirm', { username: row.display_name || displayUsername(row.username) }), t('common.confirm'), { type: 'warning' })
await api.post(`/sessions/${row.id}/disconnect`)
ElMessage.success(t('sessions.kicked'))
load()
@@ -137,4 +147,4 @@ onMounted(() => { load(); timer = setInterval(load, 15000) })
onUnmounted(() => clearInterval(timer))
</script>
<style scoped>.session-error { margin-bottom: var(--space-4); }</style>
<style scoped>.identity-subject { font-size: 11px; color: var(--el-text-color-secondary); overflow-wrap: anywhere; } .session-error { margin-bottom: var(--space-4); }</style>
+6 -372
View File
@@ -38,122 +38,10 @@
</div>
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('identity.tab')" name="identity">
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('identity.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
<div class="identity-guide">
<b>{{ $t('identity.guideTitle') }}</b>
<p>{{ $t('identity.guideApp') }}</p>
<p>{{ $t('identity.guidePublish') }}</p>
<p>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></p>
</div>
<el-alert v-if="!identitySecure" type="warning" :closable="false" :title="$t('identity.httpsOnly')" style="margin-bottom:16px" />
<el-button v-if="!identitySecure && identityHttpsUrl" type="primary" tag="a" :href="identityHttpsUrl" style="margin-bottom:16px">{{ $t('identity.openHttps') }}</el-button>
<el-form v-if="identitySecure" label-position="top" @submit.prevent="saveIdentity">
<el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item>
<el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item>
<div class="identity-checks" aria-live="polite">
<div><span>{{ $t('identity.tenantPermission') }}</span><el-tag :type="tenantError ? 'danger' : tenantLoading ? 'info' : tenantChecked ? 'success' : 'info'" size="small">{{ tenantError ? $t('identity.checkFailed') : tenantLoading ? $t('identity.checking') : tenantChecked ? $t('identity.checked') : $t('identity.checkPending') }}</el-tag></div>
<div><span>{{ $t('identity.userPermission') }}</span><el-tag :type="userScopeVerified ? 'success' : 'info'" size="small">{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}</el-tag></div>
</div>
<el-form-item :label="$t('identity.tenant')">
<el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" />
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant(false)">{{ $t('identity.fetchTenant') }}</el-button>
<span v-if="tenantName" class="tls-hint" style="margin-left:10px">{{ tenantName }}</span>
<span v-if="tenantLoading" class="tenant-progress" role="status" aria-live="polite"><el-icon class="is-loading"><Loading /></el-icon>{{ $t('identity.fetchingTenant', { seconds: tenantSeconds }) }}</span>
</el-form-item>
<el-alert v-if="tenantError" type="warning" :closable="false" :title="tenantError" style="margin-bottom:16px" />
<el-form-item :label="$t('identity.enabled')"><el-switch v-model="identityForm.enabled" :disabled="!identityStatus?.tls_ready" /></el-form-item>
<el-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
</el-form>
<el-alert v-if="!identityStatus?.tls_ready" type="warning" :closable="false" :title="$t('identity.httpsRequired')" style="margin-top:16px" />
<el-alert v-if="identityStatus?.enabled && !identityStatus?.gateway_ready" type="warning" :closable="false" :title="$t('identity.gatewayPending')" style="margin-top:16px" />
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('identity.addresses') }}</b></template>
<el-descriptions :column="1" border>
<el-descriptions-item :label="$t('identity.issuer')">{{ identityStatus?.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.feishuCallback')">{{ identityStatus?.feishu_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.vpnCallback')">{{ identityStatus?.vpn_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item>
</el-descriptions>
<p class="tls-hint">{{ $t('identity.routeHint') }}</p>
<el-button :loading="identityGatewayLoading" :disabled="!identitySecure || !identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
</el-card>
</div>
<IdentitySettings />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.gatewayTab')" name="gateway">
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('tls.description')" style="margin-bottom:16px" />
<el-steps :active="nginxSetup?.site_stage === 'https' ? 3 : nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
<el-step :title="$t('tls.setupStepCheck')" />
<el-step :title="$t('tls.setupStepInstall')" />
<el-step :title="$t('tls.setupStepConfigure')" />
</el-steps>
<el-card shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.setupTitle') }}</b></template>
<template v-if="nginxSetup">
<p class="tls-hint">{{ $t('tls.detectedSystem') }}:{{ nginxSetup.os }} {{ nginxSetup.version }} · {{ nginxSetup.package_manager || '—' }}</p>
<p class="tls-hint">Nginx:{{ nginxSetup.nginx_installed ? '✓' : '—' }} · Certbot:{{ nginxSetup.certbot_installed ? '✓' : '—' }} · HTTP-01 webroot:{{ nginxSetup.webroot_ready ? '✓' : '—' }}</p>
<p class="tls-hint">{{ $t('tls.siteStage') }}:{{ $t(`tls.stage_${nginxSetup.site_stage || 'none'}`) }}</p>
<el-alert v-if="!nginxSetup.supported || !nginxSetup.root" type="warning" :closable="false" :title="$t('tls.setupUnsupported')" style="margin-bottom:12px" />
</template>
<el-alert type="info" :closable="false" :title="$t('tls.setupScope')" style="margin-bottom:12px" />
<el-button :loading="nginxSetupLoading" @click="loadNginxSetup">{{ $t('tls.refresh') }}</el-button>
<el-button type="primary" :loading="nginxInstalling" :disabled="!nginxSetup?.supported || !nginxSetup?.root || nginxSetup?.running || (nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready)" @click="initializeNginx">{{ $t('tls.setupButton') }}</el-button>
<pre v-if="nginxInstallLog" ref="nginxLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxInstallLog }}</pre>
</el-card>
<el-form @submit.prevent="saveTLSDomain" label-position="top">
<el-form-item :label="$t('tls.domain')">
<el-input v-model.trim="tlsDomain" placeholder="test-link.appx365.com" maxlength="253" />
</el-form-item>
<el-button type="primary" :loading="tlsSaving" @click="saveTLSDomain">{{ $t('tls.saveDomain') }}</el-button>
</el-form>
<el-form label-position="top" style="margin-top:16px" @submit.prevent="saveTLSMode">
<el-form-item :label="$t('tls.mode')">
<el-select v-model="tlsMode" style="width:100%">
<el-option value="autocert" :label="$t('tls.goMode')" />
<el-option value="nginx" :label="$t('tls.nginxMode')" />
</el-select>
</el-form-item>
<el-button :loading="tlsSavingMode" @click="saveTLSMode">{{ $t('tls.saveMode') }}</el-button>
</el-form>
<el-alert v-if="tlsMode === 'nginx'" type="warning" :closable="false" :title="$t('tls.nginxHint')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.management_direct_public" type="warning" :closable="false" :title="$t('tls.directPortWarning')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin:12px 0" />
<el-descriptions :column="1" size="small" border v-if="tlsStatus">
<el-descriptions-item :label="$t('tls.domain')">{{ tlsStatus.configured_domain || $t('tls.notConfigured') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.mode')">{{ tlsStatus.mode === 'nginx' ? $t('tls.nginxMode') : $t('tls.goMode') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.activeDomain')">{{ tlsStatus.mode === 'nginx' && nginxSetup?.site_stage !== 'https' ? $t('tls.notConfigured') : tlsStatus.domain || $t('tls.notConfigured') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.dns')">{{ tlsStatus.dns?.join(', ') || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.certificate')">
<el-tag :type="tlsStatus.certificate_valid ? 'success' : 'warning'">{{ tlsStatus.certificate_valid ? $t('tls.valid') : tlsStatus.enabled ? $t('tls.pending') : $t('tls.notConfigured') }}</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.issuer')">{{ tlsStatus.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.expires')">{{ new Date(tlsStatus.not_after).toLocaleString() }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.renewal')">{{ tlsStatus.enabled ? $t('tls.automatic') : tlsStatus.mode === 'nginx' ? $t('tls.externalRenewal') : '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.ports')">{{ tlsStatus.mode === 'nginx' ? $t('tls.externalPorts') : `${tlsStatus.http_address || ':80'} / ${tlsStatus.https_address || ':443'}` }}</el-descriptions-item>
</el-descriptions>
<p class="tls-hint">{{ $t('tls.setupHint') }}</p>
<el-button :loading="tlsLoading" @click="loadTLS">{{ $t('tls.refresh') }}</el-button>
<el-button v-if="tlsStatus?.enabled || (tlsStatus?.certificate_valid && nginxSetup?.site_stage === 'https')" type="primary" tag="a" :href="`https://${tlsStatus.domain}/api/health`" target="_blank" rel="noopener noreferrer">{{ $t('tls.openHttps') }}</el-button>
<el-button type="primary" :loading="nginxActivating" :disabled="!nginxSetup?.root || !nginxSetup?.nginx_installed || !nginxSetup?.certbot_installed || !nginxSetup?.webroot_ready || nginxSetup?.running || tlsStatus?.mode !== 'nginx' || tlsStatus?.restart_required || !tlsStatus?.configured_domain" @click="activateNginx">{{ $t('tls.activateSite') }}</el-button>
<p class="tls-hint">{{ $t('tls.activateHint') }}</p>
<pre v-if="nginxActivateLog" ref="nginxActivateLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxActivateLog }}</pre>
<el-button :loading="nginxLoading" :disabled="!tlsStatus?.configured_domain" @click="loadNginxPreview">{{ $t('tls.previewNginx') }}</el-button>
<template v-if="nginxPreview">
<p class="tls-hint">{{ $t('tls.previewOnly') }}</p>
<p class="tls-hint">{{ $t('tls.httpSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.http_site }}</pre>
<p class="tls-hint">{{ $t('tls.httpsSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.https_site }}</pre>
</template>
</el-card>
</div>
<GatewaySettings />
</el-tab-pane>
<el-tab-pane v-if="auth.isAdmin()" :label="$t('settings.hostTab')" name="host">
<div class="settings-column settings-column-wide">
@@ -198,12 +86,13 @@
</template>
<script setup>
import { computed, ref, onMounted, nextTick, watch, onUnmounted } from 'vue'
import { ref, onMounted, nextTick, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import { Loading } from '@element-plus/icons-vue'
import IdentitySettings from './settings/components/IdentitySettings.vue'
import GatewaySettings from './settings/components/GatewaySettings.vue'
import api, { auth } from '../api'
const { t } = useI18n()
@@ -216,109 +105,12 @@ function changeTab(name) {
if (route.query.tab !== name) router.replace({ path: '/settings', query: name === 'security' ? {} : { tab: name } })
}
const pwd = ref({ old_password: '', new_password: '', confirm: '' })
const identityStatus = ref(null)
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
const identitySecure = window.location.protocol === 'https:'
const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/settings?tab=identity` : '')
const identityLoading = ref(false)
const identitySaving = ref(false)
const identityGatewayLoading = ref(false)
const tenantLoading = ref(false)
const tenantName = ref('')
const tenantError = ref('')
const tenantChecked = ref(false)
const userScopeVerified = computed(() => !!identityStatus.value?.user_scope_verified && identityForm.value.app_id === identityStatus.value?.app_id && !identityForm.value.app_secret && identityForm.value.tenant_key === identityStatus.value?.tenant_key)
const tenantSeconds = ref(0)
let tenantTimer
let identityCheckTimer
let tenantRequest = 0
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); tenantRequest++ })
watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => {
if (appId === oldId && secret === oldSecret) return
tenantRequest++
clearInterval(tenantTimer)
tenantLoading.value = false
tenantChecked.value = false
tenantError.value = ''
clearTimeout(identityCheckTimer)
if ((appId !== oldId && appId !== identityStatus.value?.app_id) || (secret && secret !== oldSecret)) {
identityForm.value.tenant_key = ''
tenantName.value = ''
}
if (identitySecure && appId && (secret || (appId === identityStatus.value?.app_id && identityStatus.value?.app_secret_configured))) {
identityCheckTimer = setTimeout(() => fetchIdentityTenant(true), 700)
}
})
async function fetchIdentityTenant(silent = false) {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
const request = ++tenantRequest
clearInterval(tenantTimer)
tenantError.value = ''
tenantLoading.value = true
tenantSeconds.value = 0
const started = Date.now()
tenantTimer = setInterval(() => { tenantSeconds.value = Math.floor((Date.now() - started) / 1000) }, 1000)
try {
const appId = identityForm.value.app_id
const secret = identityForm.value.app_secret
const { data } = await api.post('/system/identity/tenant', { app_id: appId, app_secret: secret })
if (request === tenantRequest && identityForm.value.app_id === appId && identityForm.value.app_secret === secret) {
identityForm.value.tenant_key = data.tenant_key
tenantName.value = data.name || ''
tenantChecked.value = true
if (!silent) ElMessage.success(t('identity.tenantFetched'))
}
} catch (e) { if (request === tenantRequest) tenantError.value = e.response?.data?.error || t('identity.tenantFailed') }
finally { if (request === tenantRequest) { clearInterval(tenantTimer); tenantLoading.value = false } }
}
async function loadIdentity() {
tenantRequest++
clearInterval(tenantTimer)
tenantLoading.value = false
identityLoading.value = true
try {
const { data } = await api.get('/system/identity')
identityStatus.value = data
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
tenantName.value = ''
tenantError.value = ''
tenantChecked.value = false
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
finally { identityLoading.value = false }
}
async function saveIdentity() {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
identitySaving.value = true
try {
await api.put('/system/identity', identityForm.value)
await loadIdentity()
ElMessage.success(t('identity.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.saveFailed')) }
finally { identitySaving.value = false }
}
async function syncIdentityGateway() {
identityGatewayLoading.value = true
try {
await api.post('/system/identity/gateway')
await loadIdentity()
ElMessage.success(t('identity.gatewaySynced'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.gatewayFailed')) }
finally { identityGatewayLoading.value = false }
}
const pwdLoading = ref(false)
const mfa = ref({ enabled: false, uri: '', code: '' })
const mfaCanvas = ref(null)
const mfaLoading = ref(false)
const backupVisible = ref(false)
const backupCodes = ref([])
const tlsStatus = ref(null)
const tlsLoading = ref(false)
const tlsSaving = ref(false)
const tlsDomain = ref('')
const tlsMode = ref('autocert')
const tlsSavingMode = ref(false)
const nginxLoading = ref(false)
const nginxPreview = ref(null)
const hostReadiness = ref(null)
const hostLoading = ref(false)
const hostFixing = ref(false)
@@ -341,152 +133,6 @@ async function enableForwarding() {
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { hostFixing.value = false }
}
const nginxSetup = ref(null)
const nginxSetupLoading = ref(false)
const nginxInstalling = ref(false)
const nginxInstallLog = ref('')
const nginxLogView = ref(null)
const nginxActivating = ref(false)
const nginxActivateLog = ref('')
const nginxActivateLogView = ref(null)
async function activateNginx() {
nginxActivating.value = true
nginxActivateLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-activate', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxActivateLog.value = (nginxActivateLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxActivateLogView.value) nginxActivateLogView.value.scrollTop = nginxActivateLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.siteReady'))
} catch (e) {
nginxActivateLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.siteFailed'))
} finally {
nginxActivating.value = false
await Promise.all([loadNginxSetup(), loadTLS()])
}
}
async function loadNginxSetup() {
nginxSetupLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-setup')
nginxSetup.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxSetupLoading.value = false }
}
async function initializeNginx() {
nginxInstalling.value = true
nginxInstallLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-setup', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxInstallLog.value = (nginxInstallLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxLogView.value) nginxLogView.value.scrollTop = nginxLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.setupComplete'))
} catch (e) {
nginxInstallLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.setupFailed'))
} finally {
nginxInstalling.value = false
await loadNginxSetup()
}
}
async function loadTLS() {
tlsLoading.value = true
try {
const { data } = await api.get('/system/tls')
tlsStatus.value = data
tlsDomain.value = data.configured_domain || data.domain || ''
tlsMode.value = data.configured_mode || data.mode || 'autocert'
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { tlsLoading.value = false }
}
async function saveTLSMode() {
tlsSavingMode.value = true
try {
const { data } = await api.put('/system/tls/mode', { mode: tlsMode.value })
tlsStatus.value = data.status
nginxPreview.value = null
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSavingMode.value = false }
}
async function loadNginxPreview() {
nginxLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-preview')
nginxPreview.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxLoading.value = false }
}
async function saveTLSDomain() {
tlsSaving.value = true
try {
const { data } = await api.put('/system/tls/domain', { domain: tlsDomain.value })
tlsStatus.value = data.status
nginxPreview.value = null
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSaving.value = false }
}
async function changePwd() {
if (pwd.value.new_password !== pwd.value.confirm) return ElMessage.warning(t('settings.passwordMismatch'))
if (pwd.value.new_password.length < 8) return ElMessage.warning(t('settings.passwordMin8'))
@@ -502,7 +148,7 @@ async function changePwd() {
}
onMounted(async () => {
if (auth.isAdmin() && !auth.mustChange) { loadTLS(); loadNginxSetup(); loadHostReadiness(); loadIdentity() }
if (auth.isAdmin() && !auth.mustChange) { loadHostReadiness() }
try {
const status = await api.get('/mfa/status')
mfa.value.enabled = status.data.enabled
@@ -554,12 +200,6 @@ function copyBackup() {
.settings-column-wide { max-width: 850px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.identity-guide { padding: 16px 18px; margin-bottom: 20px; border: 1px solid #e2ebf6; border-radius: 12px; background: #f7faff; font-size: 13px; color: #536780; }
.identity-guide b { display: block; margin-bottom: 6px; color: #244368; }
.identity-guide p { margin: 4px 0; line-height: 1.6; overflow-wrap: anywhere; }
.identity-guide code { color: #246cb7; }
.identity-checks { display: grid; gap: 9px; margin: 0 0 18px; }
.identity-checks > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid #e6edf6; border-radius: 10px; font-size: 13px; color: #435b75; }
@media (max-width: 640px) { .settings-tabs { padding: 0 14px 16px; } .settings-tabs :deep(.el-tabs__item) { font-size: 12px; padding: 0 10px; } }
.backup-codes {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 8px; padding: 12px;
@@ -571,10 +211,4 @@ function copyBackup() {
background: var(--bg-card); border-radius: 4px;
}
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
.identity-guide { margin-bottom: 16px; }
.identity-guide ol { margin: 0; padding-left: 22px; line-height: 1.8; color: var(--text-secondary); font-size: 13px; }
.identity-guide li + li { margin-top: 6px; }
.identity-guide code { display: inline-block; padding: 1px 5px; overflow-wrap: anywhere; color: var(--el-color-primary); background: var(--el-fill-color-light); border-radius: 4px; }
.tenant-progress { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 0 12px; color: var(--el-color-primary); font-size: 13px; }
.nginx-preview { max-height: 300px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; }
</style>
+9 -371
View File
@@ -106,132 +106,10 @@
</template>
</el-table>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
<template v-if="detail">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.authMethod')">{{ authMethod(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
</el-descriptions>
<h4>{{ $t('tunnels.runningParams') }}</h4>
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
<h4>{{ $t('tunnels.clientConfig') }}</h4>
<pre class="mono conf-box">{{ detail.client_config || $t('tunnels.noClientConfig') }}</pre>
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">{{ $t('tunnels.downloadClientConfig') }}</el-button>
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
@click="$router.push('/ipsec-peer')">{{ $t('tunnels.ipsecParamCheck') }}</el-button>
<!-- 流量策略 -->
<h4>{{ $t('tunnels.trafficPolicy') }}</h4>
<el-form label-width="90px" size="small" v-loading="policyLoading">
<el-form-item :label="$t('tunnels.quotaGB')">
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.quotaUnlimited', { used: fmtBytes(policy.used_bytes || 0) }) }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.rateLimitMbps')">
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.rateUnlimited') }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.exceedAction')">
<el-select v-model="policy.action_on_exceed" style="width:100%">
<el-option value="warn" :label="$t('tunnels.actionWarn')" />
<el-option value="block" :label="$t('tunnels.actionBlock')" />
<el-option value="throttle" :label="$t('tunnels.actionThrottle')" />
</el-select>
</el-form-item>
<el-form-item :label="$t('tunnels.resetCycle')">
<el-select v-model="policy.reset_cycle" style="width:100%">
<el-option value="none" :label="$t('tunnels.resetNone')" />
<el-option value="daily" :label="$t('tunnels.resetDaily')" />
<el-option value="monthly" :label="$t('tunnels.resetMonthly')" />
</el-select>
</el-form-item>
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">{{ $t('tunnels.savePolicy') }}</el-button>
</el-form>
<!-- WireGuard Peer 管理 -->
<template v-if="detail.tunnel.protocol === 'wireguard'">
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
<h4 style="margin:0">{{ $t('tunnels.peerList') }}</h4>
<el-button type="primary" size="small" @click="openAddPeer">{{ $t('tunnels.addPeer') }}</el-button>
</div>
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="name" :label="$t('common.name')" width="100" />
<el-table-column prop="client_address" :label="$t('tunnels.address')" min-width="110" />
<el-table-column :label="$t('common.status')" width="80">
<template #default="{ row }">
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
{{ peerOnline(row) ? $t('tunnels.peerOnline') : $t('tunnels.peerOffline') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="150">
<template #default="{ row }">
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">{{ $t('tunnels.config') }}</el-button>
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">{{ $t('tunnels.qrCode') }}</el-button>
<el-button type="danger" link size="small" @click="removePeer(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- 流量趋势图 -->
<TrafficChart v-if="detail.tunnel.id" :tunnel-id="detail.tunnel.id" />
</template>
</el-drawer>
<!-- WireGuard 二维码 -->
<el-dialog v-model="qrVisible" :title="$t('tunnels.scanQrTitle')" width="360px" align-center>
<div style="text-align:center">
<canvas ref="qrCanvas"></canvas>
<p style="color:#909399;font-size:12px">{{ $t('tunnels.scanQrHint') }}</p>
</div>
</el-dialog>
<!-- 添加 Peer -->
<el-dialog v-model="peerDialog" :title="$t('tunnels.addPeerTitle')" width="440px">
<el-form label-width="110px">
<el-form-item :label="$t('tunnels.peerId')">
<el-input v-model="peerForm.id" :placeholder="$t('tunnels.autoGenId')" />
</el-form-item>
<el-form-item :label="$t('common.name')">
<el-input v-model="peerForm.name" :placeholder="$t('tunnels.peerNamePlaceholder')" />
</el-form-item>
<el-form-item :label="$t('tunnels.clientAddress')" required>
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
</el-form-item>
<el-form-item label="AllowedIPs">
<el-input v-model="peerForm.allowed_ips" :placeholder="$t('tunnels.defaultAllowedIPs')" />
</el-form-item>
<el-form-item :label="$t('tunnels.externalPublicKey')">
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
:placeholder="$t('tunnels.externalKeyHint')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="peerDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="peerSaving" @click="addPeer">{{ $t('common.add') }}</el-button>
</template>
</el-dialog>
<TunnelDetailDrawer ref="detailDrawer" @updated="load" />
</el-card>
<!-- 日志实时查看弹窗 -->
<el-dialog v-model="logVisible" :title="$t('tunnels.logTitle', { name: logTunnel?.name || '' })" width="780px" align-center
@close="closeLogStream">
<div class="log-toolbar">
<el-tag size="small" :type="logStreaming ? 'success' : 'info'">
{{ logStreaming ? $t('tunnels.liveOutput') : $t('tunnels.logStopped') }}
</el-tag>
<el-button v-if="logStreaming" size="small" @click="stopLogStream">{{ $t('tunnels.pause') }}</el-button>
<el-button v-else size="small" type="primary" @click="resumeLogStream">{{ $t('tunnels.resume') }}</el-button>
<el-button size="small" @click="logContent = ''">{{ $t('tunnels.clearScreen') }}</el-button>
</div>
<div class="log-view mono" ref="logView">{{ logContent || $t('tunnels.waitingLog') }}</div>
</el-dialog>
<TunnelLogDialog ref="logDialog" />
<!-- 批量任务进度 -->
<TaskProgress ref="taskProgressRef" />
@@ -242,13 +120,13 @@ import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { Plus, InfoFilled, ArrowDown, Refresh } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import { useRouter } from 'vue-router'
import api from '../api'
import { fmtBytes } from '../utils/format'
import ProtocolIcon from '../components/ProtocolIcon.vue'
import TaskProgress from '../components/TaskProgress.vue'
import TrafficChart from '../components/TrafficChart.vue'
import TunnelDetailDrawer from './tunnels/components/TunnelDetailDrawer.vue'
import TunnelLogDialog from './tunnels/components/TunnelLogDialog.vue'
import { protocolDisplay, protocolColor } from '../protocols'
const { t } = useI18n()
@@ -262,24 +140,11 @@ const keyword = ref('')
const protocolFilter = ref('')
const stateFilter = ref('')
const protocolOptions = ref([])
const detailVisible = ref(false)
const detail = ref(null)
const qrVisible = ref(false)
// 流量策略
const policy = ref({ quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 })
const policyLoading = ref(false)
const policySaving = ref(false)
const qrCanvas = ref(null)
const wgPeers = ref([])
const runtimePeers = ref([])
const peersLoading = ref(false)
const peerDialog = ref(false)
const peerSaving = ref(false)
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
// 批量操作
const selectedRows = ref([])
const taskProgressRef = ref(null)
const logDialog = ref(null)
const detailDrawer = ref(null)
function handleSelectionChange(rows) {
selectedRows.value = rows
@@ -328,14 +193,6 @@ function authMethod(row) {
}
return t('tunnels.authUnknown')
}
function prettyConfig(c) {
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
}
function peerOnline(row) {
const rt = runtimePeers.value.find(p => p.id === row.id || p.public_key === row.public_key)
return !!rt?.connected
}
async function load() {
const revision = ++loadRevision
loading.value = true
@@ -353,109 +210,12 @@ async function load() {
// 更多操作下拉命令分发
function onMoreCommand(cmd, row) {
if (cmd === 'detail') showDetail(row)
if (cmd === 'detail') detailDrawer.value?.open(row)
else if (cmd === 'delete') remove(row)
else if (cmd === 'download') download(row)
else if (cmd === 'qrcode') showQr(row)
else if (cmd === 'qrcode') detailDrawer.value?.showQr(row)
else if (cmd === 'ipsec-peer') router.push('/ipsec-peer')
else if (cmd === 'logs') openLogStream(row)
}
// ---- 实时日志流(SSE via fetch + ReadableStream,避免 token 暴露在 URL)----
const logVisible = ref(false)
const logTunnel = ref(null)
const logContent = ref('')
const logStreaming = ref(false)
const logView = ref(null)
let logAbortController = null
function openLogStream(row) {
logTunnel.value = row
logContent.value = ''
logVisible.value = true
resumeLogStream()
}
async function resumeLogStream() {
if (!logTunnel.value) return
stopLogStream()
const token = localStorage.getItem('vpn_token')
logAbortController = new AbortController()
logStreaming.value = true
try {
const resp = await fetch(`/api/tunnels/${logTunnel.value.id}/logs`, {
headers: { 'Authorization': `Bearer ${token}` },
signal: logAbortController.signal
})
if (!resp.ok) { logStreaming.value = false; return }
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data: ')) {
logContent.value += line.slice(6) + '\n'
}
}
nextTick(() => {
if (logView.value) logView.value.scrollTop = logView.value.scrollHeight
})
if (logContent.value.length > 100000) {
logContent.value = logContent.value.slice(-50000)
}
}
} catch (e) {
if (e.name !== 'AbortError') logContent.value += `\n${t('common.loadFailed')}\n`
}
logStreaming.value = false
}
function stopLogStream() {
logStreaming.value = false
logAbortController?.abort()
logAbortController = null
}
function closeLogStream() {
stopLogStream()
logContent.value = ''
}
// ---- 流量策略 ----
async function loadPolicy(tunnelId) {
policyLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/policy`)
policy.value = {
quota_gb: data.quota_bytes > 0 ? +(data.quota_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0,
rate_limit_mbps: data.rate_limit_mbps || 0,
action_on_exceed: data.action_on_exceed || 'block',
reset_cycle: data.reset_cycle || 'none',
used_bytes: data.used_bytes || 0,
}
} catch { policy.value = { quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 } }
finally { policyLoading.value = false }
}
async function savePolicy(tunnelId) {
policySaving.value = true
try {
const quotaBytes = Math.round((policy.value.quota_gb || 0) * 1024 * 1024 * 1024)
await api.put(`/tunnels/${tunnelId}/policy`, {
quota_bytes: quotaBytes,
rate_limit_mbps: policy.value.rate_limit_mbps,
action_on_exceed: policy.value.action_on_exceed,
reset_cycle: policy.value.reset_cycle,
})
ElMessage.success(t('tunnels.policySaved'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
} finally { policySaving.value = false }
else if (cmd === 'logs') logDialog.value?.open(row)
}
async function changeState(row, action) {
@@ -491,110 +251,6 @@ async function remove(row) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
async function showDetail(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
detail.value = data
detailVisible.value = true
if (row.protocol === 'wireguard') {
await loadPeers(row.id)
} else {
wgPeers.value = []
runtimePeers.value = []
}
loadPolicy(row.id)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
}
}
async function loadPeers(tunnelId) {
peersLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/wireguard/peers`)
wgPeers.value = data.peers || []
runtimePeers.value = data.runtime_peers || []
} catch {
wgPeers.value = []
} finally {
peersLoading.value = false
}
}
function openAddPeer() {
peerForm.value = { id: '', name: '', client_address: '', allowed_ips: '', public_key: '' }
peerDialog.value = true
}
async function addPeer() {
if (!peerForm.value.client_address) return ElMessage.warning(t('tunnels.fillClientAddress'))
peerSaving.value = true
try {
const body = { ...peerForm.value }
if (!body.id) delete body.id
if (!body.public_key) delete body.public_key
if (!body.allowed_ips) delete body.allowed_ips
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
ElMessage.success(t('tunnels.peerAdded'))
peerDialog.value = false
await loadPeers(detail.value.tunnel.id)
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
detail.value = data
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.addFailed'))
} finally {
peerSaving.value = false
}
}
async function removePeer(row) {
await ElMessageBox.confirm(t('tunnels.deletePeerConfirm', { id: row.id }), t('common.confirm'), { type: 'warning' })
try {
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
ElMessage.success(t('common.deleteSuccess'))
await loadPeers(detail.value.tunnel.id)
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
function downloadPeer(row) {
fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
}).then(async r => {
if (!r.ok) {
const d = await r.json()
throw new Error(d.error)
}
return r.blob()
}).then(b => {
const a = document.createElement('a')
a.href = URL.createObjectURL(b)
a.download = `wireguard-${row.id}.conf`
a.click()
}).catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function qrPeer(row) {
try {
const res = await fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
})
if (!res.ok) {
const d = await res.json()
throw new Error(d.error)
}
const conf = await res.text()
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.message || t('tunnels.qrFailed'))
}
}
function download(row) {
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.blob() })
@@ -609,18 +265,6 @@ function download(row) {
})
.catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function showQr(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.qrFailed'))
}
}
let timer
onMounted(async () => {
load()
@@ -643,12 +287,6 @@ onUnmounted(() => clearInterval(timer))
@media (max-width: 640px) { .tunnel-search { flex: 1 1 100%; width: 100%; } .tunnel-filter { flex: 1 1 135px; width: auto; } .tunnel-actions { width: 100%; justify-content: flex-end; } }
.remark { font-size: 12px; color: var(--text-secondary); }
.proto-cell { display: inline-flex; align-items: center; gap: 6px; font-weight: 600; }
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.log-view {
background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px;
height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all;
}
.batch-bar {
display: flex; align-items: center; gap: 10px;
padding: 10px 16px; margin-bottom: 12px;
+6 -222
View File
@@ -123,57 +123,21 @@
</template>
</el-dialog>
<!-- OTP 双因素认证管理 -->
<el-dialog v-model="otpVisible" width="620px" class="mfa-dialog" destroy-on-close>
<template #header>
<div class="mfa-dialog-title">
<div class="mfa-title-icon"><el-icon><Key /></el-icon></div>
<div><div class="mfa-title-text">{{ $t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' }) }}</div><div class="mfa-title-subtitle">{{ tunnelName(otpUser?.tunnel_id) }}</div></div>
</div>
</template>
<div v-if="otpStatus" class="mfa-content">
<div class="mfa-status-strip" :class="{ enabled: otpStatus.mfa_enabled }">
<el-icon class="mfa-status-icon"><CircleCheckFilled v-if="otpStatus.mfa_enabled" /><Key v-else /></el-icon>
<div class="mfa-status-copy"><strong>{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.mfaNotEnabled') }}</strong><span>{{ otpStatus.mfa_enabled ? $t('vpnUsers.mfaEnabledHint') : $t('vpnUsers.mfaDisabledHint') }}</span></div>
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" effect="plain">{{ otpStatus.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}</el-tag>
</div>
<template v-if="!otpStatus.mfa_enabled">
<div v-if="!bindStep" class="mfa-empty-state"><div class="mfa-empty-icon"><el-icon><Key /></el-icon></div><p>{{ $t('vpnUsers.mfaSetupIntro') }}</p><el-button type="primary" size="large" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button></div>
<div v-else class="mfa-enrollment">
<div class="mfa-qr-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.scanHint') }}</div><div class="mfa-qr"><canvas ref="mfaCanvas"></canvas></div><el-collapse v-if="mfaSecret" v-model="manualSecretOpen" class="mfa-manual-secret"><el-collapse-item :title="$t('vpnUsers.manualSetup')" name="secret"><code>{{ mfaSecret }}</code></el-collapse-item></el-collapse></div>
<div class="mfa-verify-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.verifySetup') }}</div><p>{{ $t('vpnUsers.verifySetupHint') }}</p><el-input v-model="mfaCode" inputmode="numeric" maxlength="6" :placeholder="$t('vpnUsers.input6DigitCode')" /><div class="mfa-action-row"><el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button><el-button @click="cancelMFASetup">{{ $t('common.cancel') }}</el-button></div></div>
</div>
</template>
<template v-else>
<div class="mfa-metrics"><div class="mfa-metric"><span>{{ $t('vpnUsers.staticPin') }}</span><strong>{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lockStatus') }}</span><strong :class="{ danger: otpStatus.mfa_locked }">{{ otpStatus.mfa_locked ? $t('vpnUsers.locked') : $t('common.no') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lastOtpSuccess') }}</span><strong>{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}</strong></div></div>
<div class="mfa-actions"><el-button @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button><el-button @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button><el-button v-if="otpStatus.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button><el-button @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button><el-button type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button></div>
<el-collapse class="mfa-details"><el-collapse-item :title="$t('vpnUsers.securityDetails')" name="details"><div class="mfa-detail-line"><span>{{ $t('vpnUsers.failCount') }}</span><b>{{ otpStatus.mfa_fail_count || 0 }}</b></div><div class="mfa-detail-line"><span>{{ $t('vpnUsers.compatible') }}</span><b>{{ otpStatus.compatible }}</b></div></el-collapse-item></el-collapse>
</template>
</div>
</el-dialog>
<el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
:title="$t('vpnUsers.pinHint')" />
<el-input v-model="pinValue" type="password" show-password :placeholder="$t('vpnUsers.pinPlaceholder')" />
<template #footer>
<el-button @click="pinVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="savePin">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
<VpnUserMfaDialog ref="mfaDialog" :tunnels="tunnels" @updated="load" />
</template>
<script setup>
import { computed, nextTick, onMounted, ref } from 'vue'
import { computed, onMounted, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckFilled, Key, Refresh, ArrowDown } from '@element-plus/icons-vue'
import { Refresh, ArrowDown } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import VpnUserMfaDialog from './vpn-users/components/VpnUserMfaDialog.vue'
import api from '../api'
import { fmtBytes } from '../utils/format'
import { protocolDisplay } from '../protocols'
const { t } = useI18n()
const mfaDialog = ref(null)
const users = ref([])
const tunnels = ref([])
const loading = ref(false)
@@ -192,24 +156,11 @@ const platformUsers = ref([])
const platformSelected = ref([])
const importing = ref(false)
const otpVisible = ref(false)
const otpUser = ref(null)
const otpStatus = ref(null)
const bindStep = ref(false)
const mfaCode = ref('')
const mfaSecret = ref('')
const mfaCanvas = ref(null)
const mfaSaving = ref(false)
const manualSecretOpen = ref([])
const pinVisible = ref(false)
const pinValue = ref('')
const tunnelName = id => tunnels.value.find(t => t.id === id)?.name || `#${id}`
const tunnelProto = id => {
const tun = tunnels.value.find(x => x.id === id)
return tun ? t(protocolDisplay(tun.protocol)) : ''
}
const isOpenVPN = id => tunnels.value.find(x => x.id === id)?.protocol === 'openvpn'
const supportsMFA = id => ['openvpn', 'wireguard'].includes(tunnels.value.find(x => x.id === id)?.protocol)
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
const filteredUsers = computed(() => users.value.filter(user => {
@@ -230,7 +181,7 @@ async function load() {
}
function userCommand(command, row) {
if (command === 'otp') openOTPManager(row)
if (command === 'otp') mfaDialog.value?.open(row)
else if (command === 'toggle') toggle(row)
else if (command === 'delete') remove(row)
}
@@ -329,143 +280,10 @@ async function download(row) {
}
}
async function openOTPManager(row) {
otpUser.value = row
bindStep.value = false
manualSecretOpen.value = []
otpVisible.value = true
await refreshOTPStatus()
}
async function refreshOTPStatus() {
if (!otpUser.value) return
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa`)
otpStatus.value = data
} catch (e) {
otpStatus.value = {
mfa_enabled: otpUser.value.mfa_enabled,
mfa_has_pin: otpUser.value.mfa_has_pin,
mfa_fail_count: otpUser.value.mfa_fail_count || 0,
mfa_locked: isLocked(otpUser.value),
mfa_locked_until: otpUser.value.mfa_locked_until,
mfa_last_used_at: otpUser.value.mfa_last_used_at,
compatible: t('vpnUsers.totpCompatible')
}
}
}
async function setupMFA() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`)
mfaCode.value = ''
mfaSecret.value = data.secret || ''
manualSecretOpen.value = []
bindStep.value = true
await nextTick()
if (mfaCanvas.value && data.otpauth_url) {
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 200, margin: 1 })
}
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.mfaSetupFailed'))
}
}
function cancelMFASetup() {
bindStep.value = false
mfaCode.value = ''
mfaSecret.value = ''
manualSecretOpen.value = []
}
async function enableMFA() {
if (!mfaCode.value) return ElMessage.warning(t('vpnUsers.inputCode'))
mfaSaving.value = true
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/enable`, { code: mfaCode.value })
bindStep.value = false
const codes = (data.backup_codes || []).join(' ')
ElMessageBox.alert(
t('vpnUsers.mfaEnabledMsg', { codes }),
t('vpnUsers.bindSuccess'),
{ type: 'success' }
)
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.verifyFailed'))
} finally {
mfaSaving.value = false
}
}
async function disableMFA() {
await ElMessageBox.confirm(t('vpnUsers.disableMfaConfirm', { username: otpUser.value.username }), t('common.confirm'), { type: 'warning' })
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/disable`)
ElMessage.success(t('vpnUsers.mfaClosed'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
}
}
async function regenBackup() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/backup-codes`)
ElMessageBox.alert(t('vpnUsers.newBackupCodes', { codes: (data.backup_codes || []).join(' ') }), t('vpnUsers.backupCodesTitle'), { type: 'success' })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.generateFailed'))
}
}
function showPinDialog() {
pinValue.value = ''
pinVisible.value = true
}
async function savePin() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/pin`, { pin: pinValue.value })
pinVisible.value = false
ElMessage.success(pinValue.value ? t('vpnUsers.pinSetSuccess') : t('vpnUsers.pinCleared'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
}
}
async function unlockMFA() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/unlock`)
ElMessage.success(t('vpnUsers.unlocked'))
await refreshOTPStatus()
load()
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.unlockFailed'))
}
}
async function exportOtpSecrets() {
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa/otp-secrets`)
ElMessageBox.alert(
t('vpnUsers.otpSecretsMsg', { line: data.line, format: data.format }),
t('vpnUsers.exportKeysTitle'),
{ type: 'warning' }
)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.exportFailed'))
}
}
onMounted(load)
</script>
<style scoped>
.mfa-dialog-title { display: flex; align-items: center; gap: 12px; }
.vpn-header, .vpn-header-actions, .vpn-toolbar, .vpn-batch-bar { display:flex; align-items:center; gap:8px; }
.vpn-header { justify-content:space-between; flex-wrap:wrap; }
.vpn-header-actions { flex-wrap:wrap; }
@@ -473,38 +291,4 @@ onMounted(load)
.vpn-load-error { margin-bottom: var(--space-4); }
@media (max-width: 640px) { .vpn-toolbar { flex-wrap: wrap; } .vpn-toolbar > * { flex: 1 1 180px; max-width: 100%; } }
.vpn-batch-bar { justify-content:space-between; margin: 0 0 10px; padding: 8px 10px; background:#fef3c7; color:#92400e; border:1px solid #fde68a; font-size:13px; }
.mfa-title-icon, .mfa-empty-icon { display: grid; place-items: center; color: #2563eb; background: #eff6ff; }
.mfa-title-icon { width: 36px; height: 36px; border-radius: 6px; font-size: 19px; }
.mfa-title-text { font-size: 16px; font-weight: 650; color: #1f2937; }
.mfa-title-subtitle { margin-top: 2px; color: #6b7280; font-size: 12px; }
.mfa-content { display: grid; gap: 16px; }
.mfa-status-strip { display: flex; align-items: center; gap: 12px; padding: 13px 14px; border: 1px solid #d1d5db; background: #f9fafb; }
.mfa-status-strip.enabled { border-color: #a7f3d0; background: #ecfdf5; }
.mfa-status-icon { color: #64748b; font-size: 22px; }
.mfa-status-strip.enabled .mfa-status-icon { color: #16a34a; }
.mfa-status-copy { display: grid; gap: 2px; flex: 1; }
.mfa-status-copy strong { color: #1f2937; font-size: 14px; }
.mfa-status-copy span { color: #6b7280; font-size: 12px; }
.mfa-empty-state { display: grid; justify-items: center; gap: 12px; padding: 26px 16px 20px; text-align: center; }
.mfa-empty-icon { width: 48px; height: 48px; border-radius: 50%; font-size: 22px; }
.mfa-empty-state p { margin: 0; color: #4b5563; font-size: 13px; }
.mfa-enrollment { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); border: 1px solid #e5e7eb; }
.mfa-qr-panel, .mfa-verify-panel { padding: 18px; }
.mfa-qr-panel { display: grid; justify-items: center; border-right: 1px solid #e5e7eb; }
.mfa-panel-label { color: #374151; font-weight: 600; font-size: 13px; }
.mfa-qr { margin: 14px 0 8px; padding: 10px; background: #fff; border: 1px solid #e5e7eb; }
.mfa-manual-secret { width: 100%; }
.mfa-manual-secret code { display: block; overflow-wrap: anywhere; color: #334155; font-size: 12px; }
.mfa-verify-panel { display: grid; align-content: center; gap: 12px; }
.mfa-verify-panel p { margin: 0; color: #6b7280; font-size: 12px; line-height: 1.55; }
.mfa-action-row, .mfa-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mfa-metrics { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid #e5e7eb; }
.mfa-metric { display: grid; gap: 5px; padding: 13px; border-right: 1px solid #e5e7eb; }
.mfa-metric:last-child { border-right: 0; }
.mfa-metric span { color: #6b7280; font-size: 12px; }
.mfa-metric strong { color: #1f2937; font-size: 13px; word-break: break-word; }
.mfa-metric .danger { color: #dc2626; }
.mfa-details { margin-top: -4px; }
.mfa-detail-line { display: flex; justify-content: space-between; padding: 4px 0; color: #4b5563; font-size: 13px; }
@media (max-width: 640px) { .mfa-enrollment { grid-template-columns: 1fr; } .mfa-qr-panel { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metrics { grid-template-columns: 1fr; } .mfa-metric { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metric:last-child { border-bottom: 0; } }
</style>
@@ -0,0 +1,243 @@
<template>
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('tls.description')" style="margin-bottom:16px" />
<el-steps :active="nginxSetup?.site_stage === 'https' ? 3 : nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready ? 2 : 1" finish-status="success" style="margin-bottom:16px">
<el-step :title="$t('tls.setupStepCheck')" />
<el-step :title="$t('tls.setupStepInstall')" />
<el-step :title="$t('tls.setupStepConfigure')" />
</el-steps>
<el-card shadow="never" style="margin-bottom:16px">
<template #header><b>{{ $t('tls.setupTitle') }}</b></template>
<template v-if="nginxSetup">
<p class="tls-hint">{{ $t('tls.detectedSystem') }}:{{ nginxSetup.os }} {{ nginxSetup.version }} · {{ nginxSetup.package_manager || '—' }}</p>
<p class="tls-hint">Nginx:{{ nginxSetup.nginx_installed ? '✓' : '—' }} · Certbot:{{ nginxSetup.certbot_installed ? '✓' : '—' }} · HTTP-01 webroot:{{ nginxSetup.webroot_ready ? '✓' : '—' }}</p>
<p class="tls-hint">{{ $t('tls.siteStage') }}:{{ $t(`tls.stage_${nginxSetup.site_stage || 'none'}`) }}</p>
<el-alert v-if="!nginxSetup.supported || !nginxSetup.root" type="warning" :closable="false" :title="$t('tls.setupUnsupported')" style="margin-bottom:12px" />
</template>
<el-alert type="info" :closable="false" :title="$t('tls.setupScope')" style="margin-bottom:12px" />
<el-button :loading="nginxSetupLoading" @click="loadNginxSetup">{{ $t('tls.refresh') }}</el-button>
<el-button type="primary" :loading="nginxInstalling" :disabled="!nginxSetup?.supported || !nginxSetup?.root || nginxSetup?.running || (nginxSetup?.nginx_installed && nginxSetup?.certbot_installed && nginxSetup?.webroot_ready)" @click="initializeNginx">{{ $t('tls.setupButton') }}</el-button>
<pre v-if="nginxInstallLog" ref="nginxLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxInstallLog }}</pre>
</el-card>
<el-form @submit.prevent="saveTLSDomain" label-position="top">
<el-form-item :label="$t('tls.domain')">
<el-input v-model.trim="tlsDomain" placeholder="test-link.appx365.com" maxlength="253" />
</el-form-item>
<el-button type="primary" :loading="tlsSaving" @click="saveTLSDomain">{{ $t('tls.saveDomain') }}</el-button>
</el-form>
<el-form label-position="top" style="margin-top:16px" @submit.prevent="saveTLSMode">
<el-form-item :label="$t('tls.mode')">
<el-select v-model="tlsMode" style="width:100%">
<el-option value="autocert" :label="$t('tls.goMode')" />
<el-option value="nginx" :label="$t('tls.nginxMode')" />
</el-select>
</el-form-item>
<el-button :loading="tlsSavingMode" @click="saveTLSMode">{{ $t('tls.saveMode') }}</el-button>
</el-form>
<el-alert v-if="tlsMode === 'nginx'" type="warning" :closable="false" :title="$t('tls.nginxHint')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.management_direct_public" type="warning" :closable="false" :title="$t('tls.directPortWarning')" style="margin:12px 0" />
<el-alert v-if="tlsStatus?.restart_required" type="warning" :closable="false" :title="$t('tls.restartRequired')" style="margin:12px 0" />
<el-descriptions :column="1" size="small" border v-if="tlsStatus">
<el-descriptions-item :label="$t('tls.domain')">{{ tlsStatus.configured_domain || $t('tls.notConfigured') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.mode')">{{ tlsStatus.mode === 'nginx' ? $t('tls.nginxMode') : $t('tls.goMode') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.activeDomain')">{{ tlsStatus.mode === 'nginx' && nginxSetup?.site_stage !== 'https' ? $t('tls.notConfigured') : tlsStatus.domain || $t('tls.notConfigured') }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.dns')">{{ tlsStatus.dns?.join(', ') || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.certificate')">
<el-tag :type="tlsStatus.certificate_valid ? 'success' : 'warning'">{{ tlsStatus.certificate_valid ? $t('tls.valid') : tlsStatus.enabled ? $t('tls.pending') : $t('tls.notConfigured') }}</el-tag>
</el-descriptions-item>
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.issuer')">{{ tlsStatus.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item v-if="tlsStatus.certificate_cached" :label="$t('tls.expires')">{{ new Date(tlsStatus.not_after).toLocaleString() }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.renewal')">{{ tlsStatus.enabled ? $t('tls.automatic') : tlsStatus.mode === 'nginx' ? $t('tls.externalRenewal') : '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('tls.ports')">{{ tlsStatus.mode === 'nginx' ? $t('tls.externalPorts') : `${tlsStatus.http_address || ':80'} / ${tlsStatus.https_address || ':443'}` }}</el-descriptions-item>
</el-descriptions>
<p class="tls-hint">{{ $t('tls.setupHint') }}</p>
<el-button :loading="tlsLoading" @click="loadTLS">{{ $t('tls.refresh') }}</el-button>
<el-button v-if="tlsStatus?.enabled || (tlsStatus?.certificate_valid && nginxSetup?.site_stage === 'https')" type="primary" tag="a" :href="`https://${tlsStatus.domain}/api/health`" target="_blank" rel="noopener noreferrer">{{ $t('tls.openHttps') }}</el-button>
<el-button type="primary" :loading="nginxActivating" :disabled="!nginxSetup?.root || !nginxSetup?.nginx_installed || !nginxSetup?.certbot_installed || !nginxSetup?.webroot_ready || nginxSetup?.running || tlsStatus?.mode !== 'nginx' || tlsStatus?.restart_required || !tlsStatus?.configured_domain" @click="activateNginx">{{ $t('tls.activateSite') }}</el-button>
<p class="tls-hint">{{ $t('tls.activateHint') }}</p>
<pre v-if="nginxActivateLog" ref="nginxActivateLogView" class="nginx-preview" role="log" aria-live="polite">{{ nginxActivateLog }}</pre>
<el-button :loading="nginxLoading" :disabled="!tlsStatus?.configured_domain" @click="loadNginxPreview">{{ $t('tls.previewNginx') }}</el-button>
<template v-if="nginxPreview">
<p class="tls-hint">{{ $t('tls.previewOnly') }}</p>
<p class="tls-hint">{{ $t('tls.httpSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.http_site }}</pre>
<p class="tls-hint">{{ $t('tls.httpsSite') }}</p>
<pre class="nginx-preview">{{ nginxPreview.https_site }}</pre>
</template>
</el-card>
</div>
</template>
<script setup>
import { nextTick, onMounted, ref } from 'vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import api, { auth } from '../../../api'
const { t } = useI18n()
const tlsStatus = ref(null)
const tlsLoading = ref(false)
const tlsSaving = ref(false)
const tlsDomain = ref('')
const tlsMode = ref('autocert')
const tlsSavingMode = ref(false)
const nginxLoading = ref(false)
const nginxPreview = ref(null)
const nginxSetup = ref(null)
const nginxSetupLoading = ref(false)
const nginxInstalling = ref(false)
const nginxInstallLog = ref('')
const nginxLogView = ref(null)
const nginxActivating = ref(false)
const nginxActivateLog = ref('')
const nginxActivateLogView = ref(null)
async function activateNginx() {
nginxActivating.value = true
nginxActivateLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-activate', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxActivateLog.value = (nginxActivateLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxActivateLogView.value) nginxActivateLogView.value.scrollTop = nginxActivateLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.siteReady'))
} catch (e) {
nginxActivateLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.siteFailed'))
} finally {
nginxActivating.value = false
await Promise.all([loadNginxSetup(), loadTLS()])
}
}
async function loadNginxSetup() {
nginxSetupLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-setup')
nginxSetup.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxSetupLoading.value = false }
}
async function initializeNginx() {
nginxInstalling.value = true
nginxInstallLog.value = ''
let completed = false
try {
const response = await fetch('/api/system/tls/nginx-setup', {
method: 'POST', headers: { Authorization: `Bearer ${auth.token}` }
})
if (!response.ok) {
const body = await response.json().catch(() => ({}))
throw new Error(body.error || `${response.status} ${response.statusText}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
const show = (line) => {
if (!line) return
const event = JSON.parse(line)
nginxInstallLog.value = (nginxInstallLog.value + (event.message || '')).slice(-100000)
if (event.type === 'done') completed = true
if (event.type === 'error') throw new Error(event.message)
nextTick(() => { if (nginxLogView.value) nginxLogView.value.scrollTop = nginxLogView.value.scrollHeight })
}
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) show(line)
}
if (buffer) show(buffer)
if (!completed) throw new Error(t('tls.setupInterrupted'))
ElMessage.success(t('tls.setupComplete'))
} catch (e) {
nginxInstallLog.value += `\n${e.message}\n`
ElMessage.error(t('tls.setupFailed'))
} finally {
nginxInstalling.value = false
await loadNginxSetup()
}
}
async function loadTLS() {
tlsLoading.value = true
try {
const { data } = await api.get('/system/tls')
tlsStatus.value = data
tlsDomain.value = data.configured_domain || data.domain || ''
tlsMode.value = data.configured_mode || data.mode || 'autocert'
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { tlsLoading.value = false }
}
async function saveTLSMode() {
tlsSavingMode.value = true
try {
const { data } = await api.put('/system/tls/mode', { mode: tlsMode.value })
tlsStatus.value = data.status
nginxPreview.value = null
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSavingMode.value = false }
}
async function loadNginxPreview() {
nginxLoading.value = true
try {
const { data } = await api.get('/system/tls/nginx-preview')
nginxPreview.value = data
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.loadFailed')) }
finally { nginxLoading.value = false }
}
async function saveTLSDomain() {
tlsSaving.value = true
try {
const { data } = await api.put('/system/tls/domain', { domain: tlsDomain.value })
tlsStatus.value = data.status
nginxPreview.value = null
ElMessage.success(data.restart_required ? t('tls.restartRequired') : t('tls.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('tls.saveFailed')) }
finally { tlsSaving.value = false }
}
onMounted(() => { if (!auth.mustChange) { loadTLS(); loadNginxSetup() } })
</script>
<style scoped>
.settings-column { max-width: 850px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
.nginx-preview { max-height: 300px; overflow: auto; background: #172239; color: #e7eefc; border-radius: 8px; padding: 14px; font-size: 12px; line-height: 1.5; }
</style>
@@ -0,0 +1,161 @@
<template>
<div class="settings-column settings-column-wide">
<el-card class="page-card" shadow="never">
<template #header><b>{{ $t('identity.title') }}</b></template>
<el-alert type="info" :closable="false" :title="$t('identity.description')" style="margin-bottom:16px" />
<div class="identity-guide">
<b>{{ $t('identity.guideTitle') }}</b>
<p>{{ $t('identity.guideApp') }}</p>
<p>{{ $t('identity.guidePublish') }}</p>
<p>{{ $t('identity.guideCallback') }} <code v-if="identityStatus?.feishu_callback">{{ identityStatus.feishu_callback }}</code><span v-else>{{ $t('identity.guideCallbackPending') }}</span></p>
</div>
<el-alert v-if="!identitySecure" type="warning" :closable="false" :title="$t('identity.httpsOnly')" style="margin-bottom:16px" />
<el-button v-if="!identitySecure && identityHttpsUrl" type="primary" tag="a" :href="identityHttpsUrl" style="margin-bottom:16px">{{ $t('identity.openHttps') }}</el-button>
<el-form v-if="identitySecure" label-position="top" @submit.prevent="saveIdentity">
<el-form-item :label="$t('identity.appId')"><el-input v-model.trim="identityForm.app_id" placeholder="cli_..." maxlength="128" /></el-form-item>
<el-form-item :label="$t('identity.appSecret')"><el-input v-model="identityForm.app_secret" type="password" show-password autocomplete="new-password" :placeholder="$t('identity.secretPlaceholder')" /></el-form-item>
<div class="identity-checks" aria-live="polite">
<div><span>{{ $t('identity.tenantPermission') }}</span><el-tag :type="tenantError ? 'danger' : tenantLoading ? 'info' : tenantChecked ? 'success' : 'info'" size="small">{{ tenantError ? $t('identity.checkFailed') : tenantLoading ? $t('identity.checking') : tenantChecked ? $t('identity.checked') : $t('identity.checkPending') }}</el-tag></div>
<div><span>{{ $t('identity.userPermission') }}</span><el-tag :type="userScopeVerified ? 'success' : 'info'" size="small">{{ userScopeVerified ? $t('identity.checkedByLogin') : $t('identity.loginPending') }}</el-tag></div>
</div>
<el-form-item :label="$t('identity.tenant')">
<el-input v-model.trim="identityForm.tenant_key" :placeholder="$t('identity.tenantPlaceholder')" maxlength="128" />
<el-button :loading="tenantLoading" :disabled="!identityForm.app_id" style="margin-top:8px" @click="fetchIdentityTenant(false)">{{ $t('identity.fetchTenant') }}</el-button>
<span v-if="tenantName" class="tls-hint" style="margin-left:10px">{{ tenantName }}</span>
<span v-if="tenantLoading" class="tenant-progress" role="status" aria-live="polite"><el-icon class="is-loading"><Loading /></el-icon>{{ $t('identity.fetchingTenant', { seconds: tenantSeconds }) }}</span>
</el-form-item>
<el-alert v-if="tenantError" type="warning" :closable="false" :title="tenantError" style="margin-bottom:16px" />
<el-form-item :label="$t('identity.enabled')"><el-switch v-model="identityForm.enabled" :disabled="!identityStatus?.tls_ready" /></el-form-item>
<el-button type="primary" :loading="identitySaving" @click="saveIdentity">{{ $t('common.save') }}</el-button>
<el-button :loading="identityLoading" @click="loadIdentity">{{ $t('tls.refresh') }}</el-button>
</el-form>
<el-alert v-if="!identityStatus?.tls_ready" type="warning" :closable="false" :title="$t('identity.httpsRequired')" style="margin-top:16px" />
<el-alert v-if="identityStatus?.enabled && !identityStatus?.gateway_ready" type="warning" :closable="false" :title="$t('identity.gatewayPending')" style="margin-top:16px" />
</el-card>
<el-card class="page-card" shadow="never" style="margin-top:16px">
<template #header><b>{{ $t('identity.addresses') }}</b></template>
<el-descriptions :column="1" border>
<el-descriptions-item :label="$t('identity.issuer')">{{ identityStatus?.issuer || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.feishuCallback')">{{ identityStatus?.feishu_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.vpnCallback')">{{ identityStatus?.vpn_callback || '—' }}</el-descriptions-item>
<el-descriptions-item :label="$t('identity.clientId')">{{ identityStatus?.client_id || '—' }}</el-descriptions-item>
</el-descriptions>
<p class="tls-hint">{{ $t('identity.routeHint') }}</p>
<el-button :loading="identityGatewayLoading" :disabled="!identitySecure || !identityStatus?.tls_ready" @click="syncIdentityGateway">{{ $t('identity.syncGateway') }}</el-button>
</el-card>
</div>
</template>
<script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Loading } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import api, { auth } from '../../../api'
const { t } = useI18n()
const identityStatus = ref(null)
const identityForm = ref({ enabled: false, app_id: '', app_secret: '', tenant_key: '' })
const identitySecure = window.location.protocol === 'https:'
const identityHttpsUrl = computed(() => identityStatus.value?.issuer ? `${identityStatus.value.issuer.replace(/\/id$/, '')}/settings?tab=identity` : '')
const identityLoading = ref(false)
const identitySaving = ref(false)
const identityGatewayLoading = ref(false)
const tenantLoading = ref(false)
const tenantName = ref('')
const tenantError = ref('')
const tenantChecked = ref(false)
const userScopeVerified = computed(() => !!identityStatus.value?.user_scope_verified && identityForm.value.app_id === identityStatus.value?.app_id && !identityForm.value.app_secret && identityForm.value.tenant_key === identityStatus.value?.tenant_key)
const tenantSeconds = ref(0)
let tenantTimer
let identityCheckTimer
let tenantRequest = 0
onUnmounted(() => { clearInterval(tenantTimer); clearTimeout(identityCheckTimer); tenantRequest++ })
watch(() => [identityForm.value.app_id, identityForm.value.app_secret], ([appId, secret], [oldId, oldSecret] = []) => {
if (appId === oldId && secret === oldSecret) return
tenantRequest++
clearInterval(tenantTimer)
tenantLoading.value = false
tenantChecked.value = false
tenantError.value = ''
clearTimeout(identityCheckTimer)
if ((appId !== oldId && appId !== identityStatus.value?.app_id) || (secret && secret !== oldSecret)) {
identityForm.value.tenant_key = ''
tenantName.value = ''
}
if (identitySecure && appId && (secret || (appId === identityStatus.value?.app_id && identityStatus.value?.app_secret_configured))) {
identityCheckTimer = setTimeout(() => fetchIdentityTenant(true), 700)
}
})
async function fetchIdentityTenant(silent = false) {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
const request = ++tenantRequest
clearInterval(tenantTimer)
tenantError.value = ''
tenantLoading.value = true
tenantSeconds.value = 0
const started = Date.now()
tenantTimer = setInterval(() => { tenantSeconds.value = Math.floor((Date.now() - started) / 1000) }, 1000)
try {
const appId = identityForm.value.app_id
const secret = identityForm.value.app_secret
const { data } = await api.post('/system/identity/tenant', { app_id: appId, app_secret: secret })
if (request === tenantRequest && identityForm.value.app_id === appId && identityForm.value.app_secret === secret) {
identityForm.value.tenant_key = data.tenant_key
tenantName.value = data.name || ''
tenantChecked.value = true
if (!silent) ElMessage.success(t('identity.tenantFetched'))
}
} catch (e) { if (request === tenantRequest) tenantError.value = e.response?.data?.error || t('identity.tenantFailed') }
finally { if (request === tenantRequest) { clearInterval(tenantTimer); tenantLoading.value = false } }
}
async function loadIdentity() {
tenantRequest++
clearInterval(tenantTimer)
tenantLoading.value = false
identityLoading.value = true
try {
const { data } = await api.get('/system/identity')
identityStatus.value = data
identityForm.value = { enabled: data.enabled, app_id: data.app_id || '', app_secret: '', tenant_key: data.tenant_key || '' }
tenantName.value = ''
tenantError.value = ''
tenantChecked.value = false
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.loadFailed')) }
finally { identityLoading.value = false }
}
async function saveIdentity() {
if (!identitySecure) return ElMessage.warning(t('identity.httpsOnly'))
identitySaving.value = true
try {
await api.put('/system/identity', identityForm.value)
await loadIdentity()
ElMessage.success(t('identity.saved'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.saveFailed')) }
finally { identitySaving.value = false }
}
async function syncIdentityGateway() {
identityGatewayLoading.value = true
try {
await api.post('/system/identity/gateway')
await loadIdentity()
ElMessage.success(t('identity.gatewaySynced'))
} catch (e) { ElMessage.error(e.response?.data?.error || t('identity.gatewayFailed')) }
finally { identityGatewayLoading.value = false }
}
onMounted(() => { if (!auth.mustChange) loadIdentity() })
</script>
<style scoped>
.settings-column { max-width: 850px; }
.settings-column :deep(.page-card) { border-color: #e6ecf3; box-shadow: none; }
.settings-column :deep(.el-card__header) { background: #f9fbfe; color: #21344e; }
.identity-guide { padding: 16px 18px; margin-bottom: 16px; border: 1px solid #e2ebf6; border-radius: 12px; background: #f7faff; font-size: 13px; color: #536780; }
.identity-guide b { display: block; margin-bottom: 6px; color: #244368; }
.identity-guide p { margin: 4px 0; line-height: 1.6; overflow-wrap: anywhere; }
.identity-guide code { display: inline-block; padding: 1px 5px; overflow-wrap: anywhere; color: var(--el-color-primary); background: var(--el-fill-color-light); border-radius: 4px; }
.identity-checks { display: grid; gap: 9px; margin: 0 0 18px; }
.identity-checks > div { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 10px 14px; border: 1px solid #e6edf6; border-radius: 10px; font-size: 13px; color: #435b75; }
.tls-hint { color: var(--text-secondary); line-height: 1.6; font-size: 13px; }
.tenant-progress { display: inline-flex; align-items: center; gap: 6px; margin: 8px 0 0 12px; color: var(--el-color-primary); font-size: 13px; }
</style>
@@ -0,0 +1,337 @@
<template>
<div>
<!-- 详情抽屉 -->
<el-drawer v-model="detailVisible" :title="detail?.tunnel?.name" size="560px">
<template v-if="detail">
<el-descriptions :column="1" border size="small">
<el-descriptions-item :label="$t('common.protocol')">{{ $t(protocolDisplay(detail.tunnel.protocol)) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.authMethod')">{{ authMethod(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('common.status')">{{ stateText(detail.tunnel) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.createTime')">{{ fmtTime(detail.tunnel.created_at) }}</el-descriptions-item>
<el-descriptions-item :label="$t('tunnels.autoStart')">{{ detail.tunnel.auto_start ? $t('common.yes') : $t('common.no') }}</el-descriptions-item>
</el-descriptions>
<h4>{{ $t('tunnels.runningParams') }}</h4>
<pre class="mono conf-box">{{ prettyConfig(detail.tunnel.config) }}</pre>
<h4>{{ $t('tunnels.clientConfig') }}</h4>
<pre class="mono conf-box">{{ detail.client_config || $t('tunnels.noClientConfig') }}</pre>
<el-button type="primary" style="width:100%;margin-bottom:8px" @click="download(detail.tunnel)">{{ $t('tunnels.downloadClientConfig') }}</el-button>
<el-button v-if="detail.tunnel.protocol === 'ipsec'" type="warning" plain style="width:100%;margin-bottom:16px"
@click="router.push('/ipsec-peer')">{{ $t('tunnels.ipsecParamCheck') }}</el-button>
<!-- 流量策略 -->
<h4>{{ $t('tunnels.trafficPolicy') }}</h4>
<el-form label-width="90px" size="small" v-loading="policyLoading">
<el-form-item :label="$t('tunnels.quotaGB')">
<el-input-number v-model="policy.quota_gb" :min="0" :precision="1" :step="10" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.quotaUnlimited', { used: fmtBytes(policy.used_bytes || 0) }) }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.rateLimitMbps')">
<el-input-number v-model="policy.rate_limit_mbps" :min="0" :max="10000" style="width:100%" />
<span style="font-size:12px;color:var(--text-secondary);margin-left:8px">{{ $t('tunnels.rateUnlimited') }}</span>
</el-form-item>
<el-form-item :label="$t('tunnels.exceedAction')">
<el-select v-model="policy.action_on_exceed" style="width:100%">
<el-option value="warn" :label="$t('tunnels.actionWarn')" />
<el-option value="block" :label="$t('tunnels.actionBlock')" />
<el-option value="throttle" :label="$t('tunnels.actionThrottle')" />
</el-select>
</el-form-item>
<el-form-item :label="$t('tunnels.resetCycle')">
<el-select v-model="policy.reset_cycle" style="width:100%">
<el-option value="none" :label="$t('tunnels.resetNone')" />
<el-option value="daily" :label="$t('tunnels.resetDaily')" />
<el-option value="monthly" :label="$t('tunnels.resetMonthly')" />
</el-select>
</el-form-item>
<el-button type="primary" size="small" :loading="policySaving" @click="savePolicy(detail.tunnel.id)">{{ $t('tunnels.savePolicy') }}</el-button>
</el-form>
<!-- WireGuard Peer 管理 -->
<template v-if="detail.tunnel.protocol === 'wireguard'">
<div style="display:flex;justify-content:space-between;align-items:center;margin:8px 0">
<h4 style="margin:0">{{ $t('tunnels.peerList') }}</h4>
<el-button type="primary" size="small" @click="openAddPeer">{{ $t('tunnels.addPeer') }}</el-button>
</div>
<el-table :data="wgPeers" size="small" v-loading="peersLoading">
<el-table-column prop="id" label="ID" width="100" />
<el-table-column prop="name" :label="$t('common.name')" width="100" />
<el-table-column prop="client_address" :label="$t('tunnels.address')" min-width="110" />
<el-table-column :label="$t('common.status')" width="80">
<template #default="{ row }">
<el-tag size="small" :type="peerOnline(row) ? 'success' : 'info'">
{{ peerOnline(row) ? $t('tunnels.peerOnline') : $t('tunnels.peerOffline') }}
</el-tag>
</template>
</el-table-column>
<el-table-column :label="$t('common.operation')" width="150">
<template #default="{ row }">
<el-button v-if="row.managed !== false" link size="small" @click="downloadPeer(row)">{{ $t('tunnels.config') }}</el-button>
<el-button v-if="row.managed !== false" link size="small" @click="qrPeer(row)">{{ $t('tunnels.qrCode') }}</el-button>
<el-button type="danger" link size="small" @click="removePeer(row)">{{ $t('common.delete') }}</el-button>
</template>
</el-table-column>
</el-table>
</template>
<!-- 流量趋势图 -->
<TrafficChart v-if="detail.tunnel.id" :tunnel-id="detail.tunnel.id" />
</template>
</el-drawer>
<!-- WireGuard 二维码 -->
<el-dialog v-model="qrVisible" :title="$t('tunnels.scanQrTitle')" width="360px" align-center>
<div style="text-align:center">
<canvas ref="qrCanvas"></canvas>
<p style="color:#909399;font-size:12px">{{ $t('tunnels.scanQrHint') }}</p>
</div>
</el-dialog>
<!-- 添加 Peer -->
<el-dialog v-model="peerDialog" :title="$t('tunnels.addPeerTitle')" width="440px">
<el-form label-width="110px">
<el-form-item :label="$t('tunnels.peerId')">
<el-input v-model="peerForm.id" :placeholder="$t('tunnels.autoGenId')" />
</el-form-item>
<el-form-item :label="$t('common.name')">
<el-input v-model="peerForm.name" :placeholder="$t('tunnels.peerNamePlaceholder')" />
</el-form-item>
<el-form-item :label="$t('tunnels.clientAddress')" required>
<el-input v-model="peerForm.client_address" placeholder="10.8.0.3/32" />
</el-form-item>
<el-form-item label="AllowedIPs">
<el-input v-model="peerForm.allowed_ips" :placeholder="$t('tunnels.defaultAllowedIPs')" />
</el-form-item>
<el-form-item :label="$t('tunnels.externalPublicKey')">
<el-input v-model="peerForm.public_key" type="textarea" :rows="2"
:placeholder="$t('tunnels.externalKeyHint')" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="peerDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="peerSaving" @click="addPeer">{{ $t('common.add') }}</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { nextTick, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import QRCode from 'qrcode'
import api from '../../../api'
import { fmtBytes } from '../../../utils/format'
import { protocolDisplay } from '../../../protocols'
import TrafficChart from '../../../components/TrafficChart.vue'
const { t } = useI18n()
const router = useRouter()
const emit = defineEmits(['updated'])
const detailVisible = ref(false)
const detail = ref(null)
const qrVisible = ref(false)
// 流量策略
const policy = ref({ quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 })
const policyLoading = ref(false)
const policySaving = ref(false)
const qrCanvas = ref(null)
const wgPeers = ref([])
const runtimePeers = ref([])
const peersLoading = ref(false)
const peerDialog = ref(false)
const peerSaving = ref(false)
const peerForm = ref({ id: '', name: '', client_address: '', allowed_ips: '', public_key: '' })
function fmtTime(time) { return time ? new Date(time).toLocaleString() : '-' }
function stateText(row) {
return { running: t('tunnels.stateRunning'), stopped: t('tunnels.stateStopped'), error: t('tunnels.stateError'), undeployed: t('tunnels.stateUndeployed') }[stateOf(row)] || stateOf(row)
}
function stateOf(row) { return row.runtime?.state || row.state }
function authMethod(row) {
let config = {}
try { config = typeof row.config === 'string' ? JSON.parse(row.config) : (row.config || {}) } catch { /* old malformed config */ }
if (row.protocol === 'wireguard') return !config.auth_mode || config.auth_mode === 'password' ? t('tunnels.wgAuthPassword') : t('tunnels.authUnknown')
if (row.protocol === 'openvpn') {
const mode = config.auth_mode || 'password'
const key = `protocols.openvpn.fields.auth_mode.options.${mode}`
return ['certificate', 'password', 'certificate+password', 'oidc'].includes(mode) ? t(key) : t('tunnels.authUnknown')
}
return t('tunnels.authUnknown')
}
function prettyConfig(c) {
try { return JSON.stringify(JSON.parse(c), null, 2) } catch { return c }
}
function peerOnline(row) {
const rt = runtimePeers.value.find(p => p.id === row.id || p.public_key === row.public_key)
return !!rt?.connected
}
// ---- 流量策略 ----
async function loadPolicy(tunnelId) {
policyLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/policy`)
policy.value = {
quota_gb: data.quota_bytes > 0 ? +(data.quota_bytes / (1024 * 1024 * 1024)).toFixed(2) : 0,
rate_limit_mbps: data.rate_limit_mbps || 0,
action_on_exceed: data.action_on_exceed || 'block',
reset_cycle: data.reset_cycle || 'none',
used_bytes: data.used_bytes || 0,
}
} catch { policy.value = { quota_gb: 0, rate_limit_mbps: 0, action_on_exceed: 'block', reset_cycle: 'none', used_bytes: 0 } }
finally { policyLoading.value = false }
}
async function savePolicy(tunnelId) {
policySaving.value = true
try {
const quotaBytes = Math.round((policy.value.quota_gb || 0) * 1024 * 1024 * 1024)
await api.put(`/tunnels/${tunnelId}/policy`, {
quota_bytes: quotaBytes,
rate_limit_mbps: policy.value.rate_limit_mbps,
action_on_exceed: policy.value.action_on_exceed,
reset_cycle: policy.value.reset_cycle,
})
ElMessage.success(t('tunnels.policySaved'))
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
} finally { policySaving.value = false }
}
async function showDetail(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
detail.value = data
detailVisible.value = true
if (row.protocol === 'wireguard') {
await loadPeers(row.id)
} else {
wgPeers.value = []
runtimePeers.value = []
}
loadPolicy(row.id)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.loadFailed'))
}
}
async function loadPeers(tunnelId) {
peersLoading.value = true
try {
const { data } = await api.get(`/tunnels/${tunnelId}/wireguard/peers`)
wgPeers.value = data.peers || []
runtimePeers.value = data.runtime_peers || []
} catch {
wgPeers.value = []
} finally {
peersLoading.value = false
}
}
function openAddPeer() {
peerForm.value = { id: '', name: '', client_address: '', allowed_ips: '', public_key: '' }
peerDialog.value = true
}
async function addPeer() {
if (!peerForm.value.client_address) return ElMessage.warning(t('tunnels.fillClientAddress'))
peerSaving.value = true
try {
const body = { ...peerForm.value }
if (!body.id) delete body.id
if (!body.public_key) delete body.public_key
if (!body.allowed_ips) delete body.allowed_ips
await api.post(`/tunnels/${detail.value.tunnel.id}/wireguard/peers`, body)
ElMessage.success(t('tunnels.peerAdded'))
peerDialog.value = false
await loadPeers(detail.value.tunnel.id)
const { data } = await api.get(`/tunnels/${detail.value.tunnel.id}`)
detail.value = data
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.addFailed'))
} finally {
peerSaving.value = false
}
}
async function removePeer(row) {
await ElMessageBox.confirm(t('tunnels.deletePeerConfirm', { id: row.id }), t('common.confirm'), { type: 'warning' })
try {
await api.delete(`/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}`)
ElMessage.success(t('common.deleteSuccess'))
await loadPeers(detail.value.tunnel.id)
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.deleteFailed'))
}
}
function downloadPeer(row) {
fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
}).then(async r => {
if (!r.ok) {
const d = await r.json()
throw new Error(d.error)
}
return r.blob()
}).then(b => {
const a = document.createElement('a')
a.href = URL.createObjectURL(b)
a.download = `wireguard-${row.id}.conf`
a.click()
}).catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function qrPeer(row) {
try {
const res = await fetch(`/api/tunnels/${detail.value.tunnel.id}/wireguard/peers/${row.id}/config`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` }
})
if (!res.ok) {
const d = await res.json()
throw new Error(d.error)
}
const conf = await res.text()
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, conf, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.message || t('tunnels.qrFailed'))
}
}
function download(row) {
fetch(`/api/tunnels/${row.id}/config`, { headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` } })
.then(r => { if (!r.ok) throw new Error(r.statusText); return r.blob() })
.then(b => {
const a = document.createElement('a')
const url = URL.createObjectURL(b)
a.href = url
const ext = row.protocol === 'openvpn' ? 'ovpn' : 'conf'
a.download = `${row.protocol}-${row.name}.${ext}`
a.click()
URL.revokeObjectURL(url)
})
.catch(e => ElMessage.error(e.message || t('tunnels.downloadFailed')))
}
async function showQr(row) {
try {
const { data } = await api.get(`/tunnels/${row.id}`)
if (!data.client_config) return ElMessage.warning(t('tunnels.noClientConfigWarn'))
qrVisible.value = true
await nextTick()
QRCode.toCanvas(qrCanvas.value, data.client_config, { width: 280, margin: 1 })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('tunnels.qrFailed'))
}
}
defineExpose({ open: showDetail, showQr })
</script>
<style scoped>
.conf-box { background: var(--bg-inset); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 12px; max-height: 260px; overflow: auto; }
</style>
@@ -0,0 +1,84 @@
<template>
<el-dialog v-model="visible" :title="$t('tunnels.logTitle', { name: tunnel?.name || '' })" width="780px" align-center @close="closeStream">
<div class="log-toolbar">
<el-tag size="small" :type="streaming ? 'success' : 'info'">{{ streaming ? $t('tunnels.liveOutput') : $t('tunnels.logStopped') }}</el-tag>
<el-button v-if="streaming" size="small" @click="stopStream">{{ $t('tunnels.pause') }}</el-button>
<el-button v-else size="small" type="primary" @click="resumeStream">{{ $t('tunnels.resume') }}</el-button>
<el-button size="small" @click="content = ''">{{ $t('tunnels.clearScreen') }}</el-button>
</div>
<div ref="logView" class="log-view mono">{{ content || $t('tunnels.waitingLog') }}</div>
</el-dialog>
</template>
<script setup>
import { nextTick, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const visible = ref(false)
const tunnel = ref(null)
const content = ref('')
const streaming = ref(false)
const logView = ref(null)
let controller = null
function open(row) {
tunnel.value = row
content.value = ''
visible.value = true
resumeStream()
}
async function resumeStream() {
if (!tunnel.value) return
stopStream()
const current = new AbortController()
controller = current
streaming.value = true
try {
const resp = await fetch(`/api/tunnels/${tunnel.value.id}/logs`, {
headers: { Authorization: `Bearer ${localStorage.getItem('vpn_token')}` },
signal: current.signal
})
if (!resp.ok) return
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop()
for (const line of lines) {
if (line.startsWith('data: ')) content.value += line.slice(6) + '\n'
}
nextTick(() => { if (logView.value) logView.value.scrollTop = logView.value.scrollHeight })
if (content.value.length > 100000) content.value = content.value.slice(-50000)
}
} catch (e) {
if (e.name !== 'AbortError') content.value += `\n${t('common.loadFailed')}\n`
} finally {
if (controller === current) streaming.value = false
}
}
function stopStream() {
streaming.value = false
controller?.abort()
controller = null
}
function closeStream() {
stopStream()
content.value = ''
}
onUnmounted(stopStream)
defineExpose({ open })
</script>
<style scoped>
.log-toolbar { display: flex; align-items: center; gap: 8px; margin-bottom: 12px; }
.log-view { background: #1e1e1e; color: #d4d4d4; border-radius: var(--radius-sm); padding: 16px; height: 400px; overflow-y: auto; font-size: 13px; line-height: 1.6; white-space: pre-wrap; word-break: break-all; }
</style>
@@ -0,0 +1,238 @@
<template>
<!-- OTP 双因素认证管理 -->
<el-dialog v-model="otpVisible" width="620px" class="mfa-dialog" destroy-on-close>
<template #header>
<div class="mfa-dialog-title">
<div class="mfa-title-icon"><el-icon><Key /></el-icon></div>
<div><div class="mfa-title-text">{{ $t('vpnUsers.otpDialogTitle', { username: otpUser?.username || '' }) }}</div><div class="mfa-title-subtitle">{{ tunnelName(otpUser?.tunnel_id) }}</div></div>
</div>
</template>
<div v-if="otpStatus" class="mfa-content">
<div class="mfa-status-strip" :class="{ enabled: otpStatus.mfa_enabled }">
<el-icon class="mfa-status-icon"><CircleCheckFilled v-if="otpStatus.mfa_enabled" /><Key v-else /></el-icon>
<div class="mfa-status-copy"><strong>{{ otpStatus.mfa_enabled ? $t('vpnUsers.boundTotp') : $t('vpnUsers.mfaNotEnabled') }}</strong><span>{{ otpStatus.mfa_enabled ? $t('vpnUsers.mfaEnabledHint') : $t('vpnUsers.mfaDisabledHint') }}</span></div>
<el-tag :type="otpStatus.mfa_enabled ? 'success' : 'info'" effect="plain">{{ otpStatus.mfa_enabled ? $t('vpnUsers.bound') : $t('vpnUsers.unbound') }}</el-tag>
</div>
<template v-if="!otpStatus.mfa_enabled">
<div v-if="!bindStep" class="mfa-empty-state"><div class="mfa-empty-icon"><el-icon><Key /></el-icon></div><p>{{ $t('vpnUsers.mfaSetupIntro') }}</p><el-button type="primary" size="large" @click="setupMFA">{{ $t('vpnUsers.bindAuthenticator') }}</el-button></div>
<div v-else class="mfa-enrollment">
<div class="mfa-qr-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.scanHint') }}</div><div class="mfa-qr"><canvas ref="mfaCanvas"></canvas></div><el-collapse v-if="mfaSecret" v-model="manualSecretOpen" class="mfa-manual-secret"><el-collapse-item :title="$t('vpnUsers.manualSetup')" name="secret"><code>{{ mfaSecret }}</code></el-collapse-item></el-collapse></div>
<div class="mfa-verify-panel"><div class="mfa-panel-label">{{ $t('vpnUsers.verifySetup') }}</div><p>{{ $t('vpnUsers.verifySetupHint') }}</p><el-input v-model="mfaCode" inputmode="numeric" maxlength="6" :placeholder="$t('vpnUsers.input6DigitCode')" /><div class="mfa-action-row"><el-button type="primary" :loading="mfaSaving" @click="enableMFA">{{ $t('vpnUsers.verifyAndEnable') }}</el-button><el-button @click="cancelMFASetup">{{ $t('common.cancel') }}</el-button></div></div>
</div>
</template>
<template v-else>
<div class="mfa-metrics"><div class="mfa-metric"><span>{{ $t('vpnUsers.staticPin') }}</span><strong>{{ otpStatus.mfa_has_pin ? $t('vpnUsers.pinSet') : $t('vpnUsers.pinNotSet') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lockStatus') }}</span><strong :class="{ danger: otpStatus.mfa_locked }">{{ otpStatus.mfa_locked ? $t('vpnUsers.locked') : $t('common.no') }}</strong></div><div class="mfa-metric"><span>{{ $t('vpnUsers.lastOtpSuccess') }}</span><strong>{{ otpStatus.mfa_last_used_at ? new Date(otpStatus.mfa_last_used_at).toLocaleString() : '—' }}</strong></div></div>
<div class="mfa-actions"><el-button @click="showPinDialog">{{ $t('vpnUsers.setClearPin') }}</el-button><el-button @click="regenBackup">{{ $t('vpnUsers.regenBackupCodes') }}</el-button><el-button v-if="otpStatus.mfa_locked" type="warning" @click="unlockMFA">{{ $t('vpnUsers.unlock') }}</el-button><el-button @click="exportOtpSecrets">{{ $t('vpnUsers.exportKeys') }}</el-button><el-button type="danger" plain @click="disableMFA">{{ $t('vpnUsers.disableMfa') }}</el-button></div>
<el-collapse class="mfa-details"><el-collapse-item :title="$t('vpnUsers.securityDetails')" name="details"><div class="mfa-detail-line"><span>{{ $t('vpnUsers.failCount') }}</span><b>{{ otpStatus.mfa_fail_count || 0 }}</b></div><div class="mfa-detail-line"><span>{{ $t('vpnUsers.compatible') }}</span><b>{{ otpStatus.compatible }}</b></div></el-collapse-item></el-collapse>
</template>
</div>
</el-dialog>
<el-dialog v-model="pinVisible" :title="$t('vpnUsers.setPinTitle')" width="400px">
<el-alert type="info" :closable="false" style="margin-bottom:12px"
:title="$t('vpnUsers.pinHint')" />
<el-input v-model="pinValue" type="password" show-password :placeholder="$t('vpnUsers.pinPlaceholder')" />
<template #footer>
<el-button @click="pinVisible = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" @click="savePin">{{ $t('common.save') }}</el-button>
</template>
</el-dialog>
</template>
<script setup>
import { nextTick, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { CircleCheckFilled, Key } from '@element-plus/icons-vue'
import { useI18n } from 'vue-i18n'
import QRCode from 'qrcode'
import api from '../../../api'
const props = defineProps({ tunnels: { type: Array, required: true } })
const emit = defineEmits(['updated'])
const { t } = useI18n()
const otpVisible = ref(false)
const otpUser = ref(null)
const otpStatus = ref(null)
const bindStep = ref(false)
const mfaCode = ref('')
const mfaSecret = ref('')
const mfaCanvas = ref(null)
const mfaSaving = ref(false)
const manualSecretOpen = ref([])
const pinVisible = ref(false)
const pinValue = ref('')
const tunnelName = id => props.tunnels.find(tunnel => tunnel.id === id)?.name || `#${id}`
const isLocked = row => row.mfa_locked_until && new Date(row.mfa_locked_until) > new Date()
async function open(row) {
otpUser.value = row
bindStep.value = false
manualSecretOpen.value = []
otpVisible.value = true
await refreshOTPStatus()
}
async function refreshOTPStatus() {
if (!otpUser.value) return
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa`)
otpStatus.value = data
} catch (e) {
otpStatus.value = {
mfa_enabled: otpUser.value.mfa_enabled,
mfa_has_pin: otpUser.value.mfa_has_pin,
mfa_fail_count: otpUser.value.mfa_fail_count || 0,
mfa_locked: isLocked(otpUser.value),
mfa_locked_until: otpUser.value.mfa_locked_until,
mfa_last_used_at: otpUser.value.mfa_last_used_at,
compatible: t('vpnUsers.totpCompatible')
}
}
}
async function setupMFA() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/setup`)
mfaCode.value = ''
mfaSecret.value = data.secret || ''
manualSecretOpen.value = []
bindStep.value = true
await nextTick()
if (mfaCanvas.value && data.otpauth_url) {
QRCode.toCanvas(mfaCanvas.value, data.otpauth_url, { width: 200, margin: 1 })
}
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.mfaSetupFailed'))
}
}
function cancelMFASetup() {
bindStep.value = false
mfaCode.value = ''
mfaSecret.value = ''
manualSecretOpen.value = []
}
async function enableMFA() {
if (!mfaCode.value) return ElMessage.warning(t('vpnUsers.inputCode'))
mfaSaving.value = true
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/enable`, { code: mfaCode.value })
bindStep.value = false
const codes = (data.backup_codes || []).join(' ')
ElMessageBox.alert(
t('vpnUsers.mfaEnabledMsg', { codes }),
t('vpnUsers.bindSuccess'),
{ type: 'success' }
)
await refreshOTPStatus()
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.verifyFailed'))
} finally {
mfaSaving.value = false
}
}
async function disableMFA() {
await ElMessageBox.confirm(t('vpnUsers.disableMfaConfirm', { username: otpUser.value.username }), t('common.confirm'), { type: 'warning' })
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/disable`)
ElMessage.success(t('vpnUsers.mfaClosed'))
await refreshOTPStatus()
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.operationFailed'))
}
}
async function regenBackup() {
try {
const { data } = await api.post(`/vpn-users/${otpUser.value.id}/mfa/backup-codes`)
ElMessageBox.alert(t('vpnUsers.newBackupCodes', { codes: (data.backup_codes || []).join(' ') }), t('vpnUsers.backupCodesTitle'), { type: 'success' })
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.generateFailed'))
}
}
function showPinDialog() {
pinValue.value = ''
pinVisible.value = true
}
async function savePin() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/pin`, { pin: pinValue.value })
pinVisible.value = false
ElMessage.success(pinValue.value ? t('vpnUsers.pinSetSuccess') : t('vpnUsers.pinCleared'))
await refreshOTPStatus()
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('common.saveFailed'))
}
}
async function unlockMFA() {
try {
await api.post(`/vpn-users/${otpUser.value.id}/mfa/unlock`)
ElMessage.success(t('vpnUsers.unlocked'))
await refreshOTPStatus()
emit('updated')
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.unlockFailed'))
}
}
async function exportOtpSecrets() {
try {
const { data } = await api.get(`/vpn-users/${otpUser.value.id}/mfa/otp-secrets`)
ElMessageBox.alert(
t('vpnUsers.otpSecretsMsg', { line: data.line, format: data.format }),
t('vpnUsers.exportKeysTitle'),
{ type: 'warning' }
)
} catch (e) {
ElMessage.error(e.response?.data?.error || t('vpnUsers.exportFailed'))
}
}
defineExpose({ open })
</script>
<style scoped>
.mfa-dialog-title { display: flex; align-items: center; gap: 12px; }
.mfa-title-icon, .mfa-empty-icon { display: grid; place-items: center; color: #2563eb; background: #eff6ff; }
.mfa-title-icon { width: 36px; height: 36px; border-radius: 6px; font-size: 19px; }
.mfa-title-text { font-size: 16px; font-weight: 650; color: #1f2937; }
.mfa-title-subtitle { margin-top: 2px; color: #6b7280; font-size: 12px; }
.mfa-content { display: grid; gap: 16px; }
.mfa-status-strip { display: flex; align-items: center; gap: 12px; padding: 13px 14px; border: 1px solid #d1d5db; background: #f9fafb; }
.mfa-status-strip.enabled { border-color: #a7f3d0; background: #ecfdf5; }
.mfa-status-icon { color: #64748b; font-size: 22px; }
.mfa-status-strip.enabled .mfa-status-icon { color: #16a34a; }
.mfa-status-copy { display: grid; gap: 2px; flex: 1; }
.mfa-status-copy strong { color: #1f2937; font-size: 14px; }
.mfa-status-copy span { color: #6b7280; font-size: 12px; }
.mfa-empty-state { display: grid; justify-items: center; gap: 12px; padding: 26px 16px 20px; text-align: center; }
.mfa-empty-icon { width: 48px; height: 48px; border-radius: 50%; font-size: 22px; }
.mfa-empty-state p { margin: 0; color: #4b5563; font-size: 13px; }
.mfa-enrollment { display: grid; grid-template-columns: minmax(220px, 1fr) minmax(220px, 1fr); border: 1px solid #e5e7eb; }
.mfa-qr-panel, .mfa-verify-panel { padding: 18px; }
.mfa-qr-panel { display: grid; justify-items: center; border-right: 1px solid #e5e7eb; }
.mfa-panel-label { color: #374151; font-weight: 600; font-size: 13px; }
.mfa-qr { margin: 14px 0 8px; padding: 10px; background: #fff; border: 1px solid #e5e7eb; }
.mfa-manual-secret { width: 100%; }
.mfa-manual-secret code { display: block; overflow-wrap: anywhere; color: #334155; font-size: 12px; }
.mfa-verify-panel { display: grid; align-content: center; gap: 12px; }
.mfa-verify-panel p { margin: 0; color: #6b7280; font-size: 12px; line-height: 1.55; }
.mfa-action-row, .mfa-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.mfa-metrics { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid #e5e7eb; }
.mfa-metric { display: grid; gap: 5px; padding: 13px; border-right: 1px solid #e5e7eb; }
.mfa-metric:last-child { border-right: 0; }
.mfa-metric span { color: #6b7280; font-size: 12px; }
.mfa-metric strong { color: #1f2937; font-size: 13px; word-break: break-word; }
.mfa-metric .danger { color: #dc2626; }
.mfa-details { margin-top: -4px; }
.mfa-detail-line { display: flex; justify-content: space-between; padding: 4px 0; color: #4b5563; font-size: 13px; }
@media (max-width: 640px) { .mfa-enrollment { grid-template-columns: 1fr; } .mfa-qr-panel { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metrics { grid-template-columns: 1fr; } .mfa-metric { border-right: 0; border-bottom: 1px solid #e5e7eb; } .mfa-metric:last-child { border-bottom: 0; } }
</style>