From 5b58369af24340b28b180b7b4776067163db14be Mon Sep 17 00:00:00 2001 From: Kuzz007 Date: Mon, 27 Jul 2026 23:36:00 +0300 Subject: [PATCH] feat(logs): show which AmneziaWG client an access-log line belongs to The dokodemo-door TPROXY bridge every AmneziaWG peer's traffic is routed through has no per-user identity, so Xray's own access log never carries an "email:" token for these lines -- the Access Logs modal showed a blank Email column for every in-*-udp row, even though every other protocol's rows show the client normally. The peer's decapsulated tunnel IP does survive as the log's "from" address, and that IP deterministically maps to exactly one configured peer. Builds a "|" -> email index from the same AmneziaWG inbounds already parsed elsewhere (amneziawg.InstanceFromInbound), and fills in Email from it whenever the raw log line didn't have one. --- internal/web/service/access_log_parse_test.go | 43 ++++++++++++++++++- internal/web/service/server.go | 42 ++++++++++++++++++ 2 files changed, 84 insertions(+), 1 deletion(-) diff --git a/internal/web/service/access_log_parse_test.go b/internal/web/service/access_log_parse_test.go index 2d57da48e..be04c1833 100644 --- a/internal/web/service/access_log_parse_test.go +++ b/internal/web/service/access_log_parse_test.go @@ -1,6 +1,12 @@ package service -import "testing" +import ( + "encoding/json" + "testing" + + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" +) func TestParseAccessLogFields(t *testing.T) { malformed := []string{ @@ -43,3 +49,38 @@ func TestParseAccessLogFields(t *testing.T) { t.Error("DateTime was not parsed from a well-formed line") } } + +func TestAmneziawgEmailIndex(t *testing.T) { + settings, err := json.Marshal(amneziawg.InboundSettings{ + Server: &amneziawg.ServerSettings{ + PrivateKey: "priv", + PublicKey: "pub", + SubnetIP: "10.8.1.0", + SubnetCIDR: 24, + }, + Clients: []model.Client{ + {Email: "router@x", Enable: true, PublicKey: "pubA", AllowedIPs: []string{"10.8.1.3/32"}}, + {Email: "disabled@x", Enable: false, PublicKey: "pubB", AllowedIPs: []string{"10.8.1.4/32"}}, + }, + }) + if err != nil { + t.Fatalf("marshal settings: %v", err) + } + + inbounds := []*model.Inbound{ + {Id: 10, Tag: "in-443-udp", Protocol: model.AmneziaWG, Port: 443, Settings: string(settings)}, + {Id: 11, Tag: "in-443-tcp", Protocol: model.VLESS, Port: 443, Settings: "{}"}, + } + + index := amneziawgEmailIndex(inbounds) + + if got := index["in-443-udp|10.8.1.3"]; got != "router@x" { + t.Errorf(`index["in-443-udp|10.8.1.3"] = %q, want "router@x"`, got) + } + if _, ok := index["in-443-udp|10.8.1.4"]; ok { + t.Error("a disabled peer must not appear in the index") + } + if len(index) != 1 { + t.Errorf("expected exactly 1 entry (non-amneziawg inbound and disabled peer excluded), got %d: %+v", len(index), index) + } +} diff --git a/internal/web/service/server.go b/internal/web/service/server.go index 7afa144ca..c661a51de 100644 --- a/internal/web/service/server.go +++ b/internal/web/service/server.go @@ -28,8 +28,10 @@ import ( "sync" "time" + "github.com/mhsanaei/3x-ui/v3/internal/amneziawg" "github.com/mhsanaei/3x-ui/v3/internal/config" "github.com/mhsanaei/3x-ui/v3/internal/database" + "github.com/mhsanaei/3x-ui/v3/internal/database/model" "github.com/mhsanaei/3x-ui/v3/internal/logger" "github.com/mhsanaei/3x-ui/v3/internal/util/common" "github.com/mhsanaei/3x-ui/v3/internal/util/sys" @@ -1166,6 +1168,33 @@ func parseAccessLogFields(line string) LogEntry { return entry } +// amneziawgEmailIndex maps "|" to that peer's +// email, for every AmneziaWG inbound in inbounds. dokodemo-door (the +// TPROXY bridge every AmneziaWG peer's traffic is routed through, tagged +// with the AmneziaWG inbound's own tag) has no per-user identity, so Xray's +// access log never has an "email:" token for these lines -- only the +// decapsulated peer's own tunnel IP survives as the log's "from" address. +// This lets GetXrayLogs recover which client that was, the same way it's +// already shown for every authenticated protocol. +func amneziawgEmailIndex(inbounds []*model.Inbound) map[string]string { + index := make(map[string]string) + for _, ib := range inbounds { + instance, ok := amneziawg.InstanceFromInbound(ib) + if !ok { + continue + } + for _, peer := range instance.Peers { + if peer.Email == "" { + continue + } + if ip := amneziawg.FirstIPv4(peer.AllowedIPs); ip != "" { + index[ib.Tag+"|"+ip] = peer.Email + } + } + } + return index +} + func (s *ServerService) GetXrayLogs( count string, filter string, @@ -1184,6 +1213,11 @@ func (s *ServerService) GetXrayLogs( countInt, _ := strconv.Atoi(count) var entries []LogEntry + var amneziawgEmails map[string]string + if inbounds, err := s.inboundService.GetAllInbounds(); err == nil { + amneziawgEmails = amneziawgEmailIndex(inbounds) + } + pathToAccessLog, err := xray.GetAccessLogPath() if err != nil { return nil @@ -1212,6 +1246,14 @@ func (s *ServerService) GetXrayLogs( entry := parseAccessLogFields(line) + if entry.Email == "" && len(amneziawgEmails) > 0 { + if ip, _, splitErr := stdnet.SplitHostPort(entry.FromAddress); splitErr == nil { + if email, ok := amneziawgEmails[entry.Inbound+"|"+ip]; ok { + entry.Email = email + } + } + } + if logEntryContains(line, freedoms) { if showDirect == "false" { continue