Files
3x-ui-agent/internal/web/service/singbox_fragment.go
T
Rogue 29de9761c8
CI / go-test (push) Waiting to run
CI / postgres-durable-first (push) Waiting to run
CI / codegen (push) Waiting to run
CI / govulncheck (push) Waiting to run
CI / race (push) Waiting to run
CI / fuzz-smoke (push) Waiting to run
CI / golangci (push) Waiting to run
CI / frontend (push) Waiting to run
Docs CI / build (push) Waiting to run
Docs Deploy (GitHub Pages) / build (push) Waiting to run
Docs Deploy (GitHub Pages) / deploy (push) Blocked by required conditions
Release 3X-UI / build (386) (push) Waiting to run
Release 3X-UI / build (amd64) (push) Waiting to run
Release 3X-UI / build (arm64) (push) Waiting to run
Release 3X-UI / build (armv5) (push) Waiting to run
Release 3X-UI / build (armv6) (push) Waiting to run
Release 3X-UI / build (armv7) (push) Waiting to run
Release 3X-UI / build (s390x) (push) Waiting to run
Release 3X-UI / Build for Windows (amd64) (push) Waiting to run
Release 3X-UI / Publish rolling dev release (push) Blocked by required conditions
CodeQL Advanced / Analyze (go) (push) Waiting to run
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
V2.0.1: listen override fix, sing-box core fetch, probe settings page + fast report, spx removal, core mem env
- 0: sing-box gen overrides fragment listen/port from the inbound row
  (empty listen -> "::"), Update/Add recompute the stored fragment;
  non-loopback E2E against the real core binary.
- 1: agent fetches the sing-box core from the Gitea release when
  missing, self-checks it, and only then latches the switch.
- 2: node list shows the sing-box core version (heartbeat field,
  persisted on the node row), xray version as fallback.
- 3: public-page fallback returns 404 to anonymous visitors when the
  page is disabled.
- 4: settings restart hint bar removed, dead i18n keys cleaned.
- 5: two PUT calls missing JSON headers fixed (audit: only those two),
  batch probe-task creation endpoint + UI.
- 6: cf-probe retirement card and coverage pipeline removed.
- 7: generated vless links no longer carry spx (panel export + sub);
  parser still accepts spx; golden fixtures updated.
- probe batch: dedicated probe settings tab (public-page settings,
  tasks, per-node meta), retention_days editable, per-node traffic
  calibration + stat-interface pinning, runtime metrics split into a
  lightweight fast report (default 3s, 1-60s adjustable).
- 8: sing-box core processes start with GOMEMLIMIT=32MiB, GOGC=50.
- 9: dead /getTwoFactorEnable docs entries removed (frontend call and
  contract entry already gone at base; verified by grep).
2026-10-23 07:53:11 +00:00

134 lines
4.5 KiB
Go

package service
import (
"encoding/json"
"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/singbox"
)
// RecomputeSingboxFragment re-runs the sing-box converter over an
// inbound row's current panel material and stores the fresh fragment
// + preserved sidecar (V2.0.1 ⓪). Before this, the fragment was a
// one-shot migration snapshot: panel edits after the migration (a new
// Reality target, a changed listen/port, ...) never reached it, so
// the agent kept serving the migration-day material while the panel
// showed the edit as saved. The identity layer is read back after the
// caller's own sync, so the fragment's embedded users match what
// generation would project anyway; the row listen/port override is
// stamped on top exactly as generation applies it.
func (s *SingboxService) RecomputeSingboxFragment(inbound *model.Inbound) error {
attached, err := s.inboundService.clientService.ListForInbound(nil, inbound.Id)
if err != nil {
return err
}
clients := toSingboxClients(attached)
fillConverterOnlyClientFields(inbound, attached, clients)
fallbackMaps, err := s.inboundService.fallbackService.BuildFallbacksJSON(nil, inbound.Id)
if err != nil {
return err
}
fallbacks := make([]json.RawMessage, 0, len(fallbackMaps))
for _, fm := range fallbackMaps {
raw, err := json.Marshal(fm)
if err != nil {
return err
}
fallbacks = append(fallbacks, raw)
}
res, err := singbox.ConvertInbound(singbox.Inbound{
Listen: inbound.Listen,
Port: inbound.Port,
Protocol: string(inbound.Protocol),
Tag: inbound.Tag,
DisableFlow: inbound.DisableFlow,
Settings: json.RawMessage(inbound.Settings),
StreamSettings: json.RawMessage(inbound.StreamSettings),
Sniffing: json.RawMessage(inbound.Sniffing),
Fallbacks: fallbacks,
Clients: clients,
})
if err != nil {
return err
}
fragJSON, err := res.MarshalFragment()
if err != nil {
return err
}
if fragJSON != "" {
fragment := map[string]any{}
if err := json.Unmarshal([]byte(fragJSON), &fragment); err != nil {
return err
}
applyRowListenOverride(inbound, fragment)
raw, err := json.Marshal(fragment)
if err != nil {
return err
}
fragJSON = string(raw)
}
presJSON, err := res.MarshalPreserved()
if err != nil {
return err
}
return database.GetDB().Model(&model.Inbound{}).Where("id = ?", inbound.Id).Updates(map[string]any{
"singbox_config": fragJSON, "singbox_preserved": presJSON,
}).Error
}
// fillConverterOnlyClientFields restores the client fields the
// identity projection (toSingboxClients) does not carry but the
// converter reads: VMess alterId lives only in the settings.clients
// mirror (the identity layer has no column for it — the B1 migrator
// sourced it from the same mirror), and a VLESS client's reverse
// block reaches the identity projection as a parsed tag, re-rendered
// here in the converter's verbatim-JSON shape.
func fillConverterOnlyClientFields(inbound *model.Inbound, attached []model.Client, clients []singbox.Client) {
alterByEmail := map[string]int{}
var settings struct {
Clients []struct {
Email string `json:"email"`
AlterID int `json:"alterId"`
} `json:"clients"`
}
if err := json.Unmarshal([]byte(inbound.Settings), &settings); err == nil {
for _, c := range settings.Clients {
if c.AlterID != 0 {
alterByEmail[c.Email] = c.AlterID
}
}
}
reverseByEmail := map[string]string{}
for _, c := range attached {
if c.Reverse != nil && c.Reverse.Tag != "" {
reverseByEmail[c.Email] = c.Reverse.Tag
}
}
for i := range clients {
if alt, ok := alterByEmail[clients[i].Email]; ok {
clients[i].AlterID = alt
}
if tag, ok := reverseByEmail[clients[i].Email]; ok {
if raw, err := json.Marshal(map[string]string{"tag": tag}); err == nil {
clients[i].Reverse = raw
}
}
}
}
// recomputeSingboxFragmentLogged is the save-path hook: a failed
// recompute must not fail the panel save itself (the row is already
// persisted and generation still overrides listen/port from it), but
// it is logged loudly — a silent skip here is exactly how the
// V2.0.0 fragment drift stayed invisible.
func recomputeSingboxFragmentLogged(inbound *model.Inbound) {
svc := &SingboxService{inboundService: InboundService{}}
if err := svc.RecomputeSingboxFragment(inbound); err != nil {
logger.Warningf("sing-box fragment recompute for inbound %d (%s) failed: %v", inbound.Id, inbound.Tag, err)
}
}