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
- 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).
134 lines
4.5 KiB
Go
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)
|
|
}
|
|
}
|