mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 08:10:58 +00:00
930a0ed59d
* feat(inbound): add DisableFlow to opt an inbound out of auto XTLS Vision Adds an inbound-level DisableFlow flag so operators can suppress automatic xtls-rprx-vision injection on a specific inbound even when its transport is flow-capable — e.g. a tunneled/CDN-fronted XHTTP+vlessenc inbound where Vision is not wanted, while keeping it on the same client's Reality inbounds. When set, the inbound reports tlsFlowCapable=false, the write path clamps each attached client's flow to empty (so flow_override stores ""), and share links/subscriptions never carry the flow for it. The flag is panel-only metadata and is never sent to xray. Closes part of #5689. * feat(inbound): DisableFlow toggle in the inbound form (frontend) Wire the DisableFlow field through the form schema + adapters and add a VLESS-gated switch in the inbound form, plus en-US strings. tsc --noEmit and eslint pass. * fix(inbound): honor DisableFlow in all emitters + on toggle; regen OpenAPI Addresses review on #5690: - Clash (clash_service.go) and JSON (json_service.go) subscription emitters now also skip the flow for a DisableFlow inbound — previously only the raw share-link path was gated, so those two still advertised it (blocking 1). - UpdateInbound now strips any flow already stored on a DisableFlow inbound's clients (settings.clients[].flow + client_inbounds.flow_override) so xray and the subscription agree; otherwise toggling DisableFlow on an existing Vision client left xray expecting a flow the client no longer sends. - Regenerated the OpenAPI + zod/types/examples artifacts for the new field and added an example tag (blocking 2; make gen-check is clean). - Added Clash + JSON DisableFlow suppression tests alongside the raw-link one. * fix(inbound): make DisableFlow durable, clamp on create, guard live config Addresses the review + completeness audit on #5690: - UpdateInbound now persists inbound.DisableFlow onto the saved row. It was only read to branch strip-vs-restore, so toggling the flag on an existing inbound never stuck and MigrationRestoreVisionFlow re-injected the flow — the exact #5689 path (editing a multi-inbound client's inbound) self-reverted. - DBInbound (frontend) declares + initializes disableFlow so ObjectUtil .cloneProps carries the API value through; the edit Switch previously always read false and re-saving silently reverted the opt-out. - AddInbound strips client flow (settings + parsed clients) when DisableFlow is set, so a created-disabled inbound never persists a flow xray would expect. - GetXrayConfig forces flow="" for DisableFlow inbounds (VLESS + Trojan) as defense-in-depth, keeping the live config and the subscription in agreement. - genTrojanLink share link honors DisableFlow too. - Drop the dead explicit flow_override clear in UpdateInbound (SyncInbound rebuilds it from the stripped settings). - Clear disableFlow in the inbound form when switching to a non-VLESS protocol. - Add disableFlow/disableFlowHelp to the remaining 12 locales. Tests: stripClientFlows unit cases; DB-backed AddInbound clamp; UpdateInbound persist+strip+resist-restore regression (fails without the persist fix); frontend DBInbound + adapter round-trip (fails without the model field). * style(inbound): drop // line comments per repo CLAUDE.md The DisableFlow work followed the surrounding code's commenting style; the repo CLAUDE.md forbids // line comments in committed Go/TS. Remove the comments I added (Go + frontend + tests) and regenerate OpenAPI/schemas, which drops the generated field descriptions sourced from the Go doc comments. No behavior change; full go test (service+sub, CGO) + frontend typecheck/vitest green; golangci-lint clean on the changed files. * fix(runtime): propagate disableFlow to nodes Preserve the inbound DisableFlow flag when syncing inbounds across nodes and when recreating central records from remote traffic snapshots. This keeps multi-node deployments from reintroducing VLESS Vision flow in node configs and share links, and updates the related tests to cover the wired field and VLESS JSON generation.
329 lines
11 KiB
Go
329 lines
11 KiB
Go
package service
|
|
|
|
import (
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"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/xray"
|
|
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func (s *InboundService) MigrationRemoveOrphanedTraffics() {
|
|
db := database.GetDB()
|
|
query := fmt.Sprintf(
|
|
"DELETE FROM client_traffics WHERE email NOT IN (SELECT email FROM clients) AND email NOT IN (SELECT %s %s)",
|
|
database.JSONFieldText("client.value", "email"),
|
|
database.JSONClientsFromInbound(),
|
|
)
|
|
result := db.Exec(query)
|
|
if result.Error != nil {
|
|
logger.Warning("MigrationRemoveOrphanedTraffics failed:", result.Error)
|
|
return
|
|
}
|
|
if result.RowsAffected > 0 {
|
|
logger.Infof("MigrationRemoveOrphanedTraffics: removed %d orphaned client_traffics row(s)", result.RowsAffected)
|
|
}
|
|
}
|
|
|
|
func (s *InboundService) MigrationRequirements() (err error) {
|
|
db := database.GetDB()
|
|
tx := db.Begin()
|
|
defer func() {
|
|
if err == nil {
|
|
if commitErr := tx.Commit().Error; commitErr != nil {
|
|
err = commitErr
|
|
return
|
|
}
|
|
if !database.IsPostgres() {
|
|
if dbErr := db.Exec(`VACUUM "main"`).Error; dbErr != nil {
|
|
logger.Warningf("VACUUM failed: %v", dbErr)
|
|
}
|
|
}
|
|
} else {
|
|
tx.Rollback()
|
|
}
|
|
}()
|
|
|
|
if tx.Migrator().HasColumn(&model.Inbound{}, "all_time") {
|
|
if err = tx.Migrator().DropColumn(&model.Inbound{}, "all_time"); err != nil {
|
|
return
|
|
}
|
|
}
|
|
if tx.Migrator().HasColumn(&xray.ClientTraffic{}, "all_time") {
|
|
if err = tx.Migrator().DropColumn(&xray.ClientTraffic{}, "all_time"); err != nil {
|
|
return
|
|
}
|
|
}
|
|
if err = normalizeInboundShareAddressColumns(tx); err != nil {
|
|
return
|
|
}
|
|
|
|
// Normalize "enable" columns to boolean on Postgres. Legacy SQLite data
|
|
// (0/1 integers), partial migrations, or mixed write paths (public API
|
|
// inbound updates that flow through UpdateClientStat + client syncs, plus
|
|
// node traffic merge deltas) can leave the column as integer or with mixed
|
|
// interpretation. This (combined with the dialect-aware
|
|
// ClientTrafficEnableMergeExpr) prevents type problems in the node traffic
|
|
// sync merge (SetRemoteTraffic) and makes the sync robust even when
|
|
// inbounds are updated via the public API (incl. ones carrying
|
|
// externalProxy in streamSettings). The same expression is also safe on
|
|
// SQLite (no PG :: casts).
|
|
if database.IsPostgres() {
|
|
// Use DO block so it is idempotent and doesn't fail if already boolean.
|
|
normalizeBool := func(table, col string) error {
|
|
return tx.Exec(fmt.Sprintf(`
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1 FROM information_schema.columns
|
|
WHERE table_name = '%s' AND column_name = '%s'
|
|
AND data_type <> 'boolean'
|
|
) THEN
|
|
ALTER TABLE %s ALTER COLUMN %s
|
|
TYPE boolean USING (CASE WHEN %s::text IN ('1','true','t','yes') THEN true ELSE false END);
|
|
END IF;
|
|
END $$;`, table, col, table, col, col)).Error
|
|
}
|
|
for _, column := range [][2]string{{"inbounds", "enable"}, {"client_traffics", "enable"}, {"nodes", "enable"}, {"clients", "enable"}, {"api_tokens", "enabled"}, {"outbound_subscriptions", "enabled"}} {
|
|
if err = normalizeBool(column[0], column[1]); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fix inbounds based problems
|
|
var inbounds []*model.Inbound
|
|
err = tx.Model(model.Inbound{}).Where("protocol IN (?)", []string{"vmess", "vless", "trojan", "shadowsocks", "hysteria"}).Find(&inbounds).Error
|
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
|
return
|
|
}
|
|
for inbound_index := range inbounds {
|
|
settings := map[string]any{}
|
|
_ = json.Unmarshal([]byte(inbounds[inbound_index].Settings), &settings)
|
|
if raw, exists := settings["clients"]; exists && raw == nil {
|
|
settings["clients"] = []any{}
|
|
}
|
|
clients, ok := settings["clients"].([]any)
|
|
if ok {
|
|
// Fix Client configuration problems
|
|
newClients := make([]any, 0, len(clients))
|
|
hasVisionFlow := false
|
|
for client_index := range clients {
|
|
c := clients[client_index].(map[string]any)
|
|
|
|
// Add email='' if it is not exists
|
|
if _, ok := c["email"]; !ok {
|
|
c["email"] = ""
|
|
}
|
|
|
|
// Convert string tgId to int64
|
|
if _, ok := c["tgId"]; ok {
|
|
tgId := c["tgId"]
|
|
if tgIdStr, ok2 := tgId.(string); ok2 {
|
|
tgIdInt64, err := strconv.ParseInt(strings.ReplaceAll(tgIdStr, " ", ""), 10, 64)
|
|
if err == nil {
|
|
c["tgId"] = tgIdInt64
|
|
}
|
|
}
|
|
}
|
|
|
|
// Remove "flow": "xtls-rprx-direct"
|
|
if _, ok := c["flow"]; ok {
|
|
if c["flow"] == "xtls-rprx-direct" {
|
|
c["flow"] = ""
|
|
}
|
|
}
|
|
if flow, _ := c["flow"].(string); flow == "xtls-rprx-vision" {
|
|
hasVisionFlow = true
|
|
}
|
|
// Backfill created_at and updated_at
|
|
if _, ok := c["created_at"]; !ok {
|
|
c["created_at"] = time.Now().Unix() * 1000
|
|
}
|
|
c["updated_at"] = time.Now().Unix() * 1000
|
|
newClients = append(newClients, any(c))
|
|
}
|
|
settings["clients"] = newClients
|
|
|
|
// Drop orphaned testseed: VLESS-only field, only meaningful when at least
|
|
// one client uses the exact xtls-rprx-vision flow. Older versions saved it
|
|
// for any non-empty flow (including the UDP variant) or kept it after the
|
|
// flow was cleared from the client modal — clean those up here.
|
|
if inbounds[inbound_index].Protocol == model.VLESS && !hasVisionFlow {
|
|
delete(settings, "testseed")
|
|
}
|
|
|
|
var modifiedSettings []byte
|
|
modifiedSettings, err = json.MarshalIndent(settings, "", " ")
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
inbounds[inbound_index].Settings = string(modifiedSettings)
|
|
}
|
|
|
|
// Add client traffic row for all clients which has email
|
|
var modelClients []model.Client
|
|
modelClients, err = s.GetClients(inbounds[inbound_index])
|
|
if err != nil {
|
|
return
|
|
}
|
|
for _, modelClient := range modelClients {
|
|
if len(modelClient.Email) > 0 {
|
|
var count int64
|
|
if err = tx.Model(xray.ClientTraffic{}).Where("email = ?", modelClient.Email).Count(&count).Error; err != nil {
|
|
return
|
|
}
|
|
if count == 0 {
|
|
if err = s.AddClientStat(tx, inbounds[inbound_index].Id, &modelClient); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Heal clients table for installs where the one-shot seeder
|
|
// skipped clients due to a tgId-string unmarshal error.
|
|
if err = s.clientService.SyncInbound(tx, inbounds[inbound_index].Id, modelClients); err != nil {
|
|
return
|
|
}
|
|
}
|
|
if err = tx.Save(inbounds).Error; err != nil {
|
|
return
|
|
}
|
|
|
|
// Remove orphaned traffics
|
|
if err = tx.Where("inbound_id = 0").Delete(xray.ClientTraffic{}).Error; err != nil {
|
|
return
|
|
}
|
|
|
|
// Migrate old MultiDomain to External Proxy
|
|
var externalProxy []struct {
|
|
Id int
|
|
Port int
|
|
StreamSettings string // text column on both DBs; safer than []byte for cross-DB scan
|
|
}
|
|
externalProxyQuery := `select id, port, stream_settings
|
|
from inbounds
|
|
WHERE protocol in ('vmess','vless','trojan')
|
|
AND json_extract(stream_settings, '$.security') = 'tls'
|
|
AND json_extract(stream_settings, '$.tlsSettings.settings.domains') IS NOT NULL`
|
|
if database.IsPostgres() {
|
|
externalProxyQuery = `select id, port, stream_settings
|
|
from inbounds
|
|
WHERE protocol in ('vmess','vless','trojan')
|
|
AND NULLIF(stream_settings, '')::jsonb #>> '{security}' = 'tls'
|
|
AND NULLIF(stream_settings, '')::jsonb #> '{tlsSettings,settings,domains}' IS NOT NULL`
|
|
}
|
|
err = tx.Raw(externalProxyQuery).Scan(&externalProxy).Error
|
|
if err != nil || len(externalProxy) == 0 {
|
|
return
|
|
}
|
|
|
|
for _, ep := range externalProxy {
|
|
var reverses any
|
|
var stream map[string]any
|
|
_ = json.Unmarshal([]byte(ep.StreamSettings), &stream)
|
|
if tlsSettings, ok := stream["tlsSettings"].(map[string]any); ok {
|
|
if settings, ok := tlsSettings["settings"].(map[string]any); ok {
|
|
if domains, ok := settings["domains"].([]any); ok {
|
|
for _, domain := range domains {
|
|
if domainMap, ok := domain.(map[string]any); ok {
|
|
domainMap["forceTls"] = "same"
|
|
domainMap["port"] = ep.Port
|
|
domainMap["dest"] = domainMap["domain"].(string)
|
|
delete(domainMap, "domain")
|
|
}
|
|
}
|
|
}
|
|
reverses = settings["domains"]
|
|
delete(settings, "domains")
|
|
}
|
|
}
|
|
stream["externalProxy"] = reverses
|
|
newStream, marshalErr := json.MarshalIndent(stream, " ", " ")
|
|
if marshalErr != nil {
|
|
err = marshalErr
|
|
return
|
|
}
|
|
if err = tx.Model(model.Inbound{}).Where("id = ?", ep.Id).Update("stream_settings", newStream).Error; err != nil {
|
|
return
|
|
}
|
|
}
|
|
|
|
// Legacy tag cleanup for old auto-generated tags (e.g. "0.0.0.0:443-...").
|
|
// Must be cross-DB: INSTR/REPLACE work on SQLite; Postgres needs position().
|
|
tagCleanup := `UPDATE inbounds
|
|
SET tag = REPLACE(tag, '0.0.0.0:', '')
|
|
WHERE INSTR(tag, '0.0.0.0:') > 0;`
|
|
if database.IsPostgres() {
|
|
tagCleanup = `UPDATE inbounds
|
|
SET tag = REPLACE(tag, '0.0.0.0:', '')
|
|
WHERE position('0.0.0.0:' in tag) > 0;`
|
|
}
|
|
err = tx.Exec(tagCleanup).Error
|
|
if err != nil {
|
|
return
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *InboundService) MigrateDB() {
|
|
if err := s.MigrationRequirements(); err != nil {
|
|
logger.Errorf("MigrationRequirements failed: %v", err)
|
|
}
|
|
s.MigrationRemoveOrphanedTraffics()
|
|
s.MigrationRestoreVisionFlow()
|
|
}
|
|
|
|
// MigrationRestoreVisionFlow repairs VLESS inbounds whose clients lost their
|
|
// XTLS Vision flow because the inbound was not flow-eligible when the client was
|
|
// written (e.g. an XHTTP inbound whose vlessenc encryption was enabled only
|
|
// later). For each now-eligible inbound it restores flow=xtls-rprx-vision on
|
|
// clients whose intended flow (their flow_override on a sibling inbound) is
|
|
// Vision. Idempotent: once a client carries the flow it is skipped, so this is a
|
|
// no-op on healthy installs and on subsequent boots.
|
|
func (s *InboundService) MigrationRestoreVisionFlow() {
|
|
db := database.GetDB()
|
|
var inbounds []*model.Inbound
|
|
if err := db.Model(&model.Inbound{}).
|
|
Where("protocol = ?", model.VLESS).
|
|
Find(&inbounds).Error; err != nil {
|
|
logger.Warning("MigrationRestoreVisionFlow: load inbounds failed:", err)
|
|
return
|
|
}
|
|
for _, ib := range inbounds {
|
|
if ib.DisableFlow {
|
|
continue
|
|
}
|
|
restored, changed := s.restoreVisionFlowForEligibleInbound(nil, ib.Settings, ib.StreamSettings, ib.Protocol)
|
|
if !changed {
|
|
continue
|
|
}
|
|
clients, err := s.GetClients(&model.Inbound{Settings: restored})
|
|
if err != nil {
|
|
logger.Warning("MigrationRestoreVisionFlow: parse clients for inbound", ib.Id, "failed:", err)
|
|
continue
|
|
}
|
|
err = db.Transaction(func(tx *gorm.DB) error {
|
|
if e := tx.Model(&model.Inbound{}).Where("id = ?", ib.Id).Update("settings", restored).Error; e != nil {
|
|
return e
|
|
}
|
|
return s.clientService.SyncInbound(tx, ib.Id, clients)
|
|
})
|
|
if err != nil {
|
|
logger.Warning("MigrationRestoreVisionFlow: update inbound", ib.Id, "failed:", err)
|
|
continue
|
|
}
|
|
logger.Info("MigrationRestoreVisionFlow: restored XTLS Vision flow on inbound", ib.Id)
|
|
}
|
|
}
|