fix(xray): stop a lone dns qType 0 from matching every query

The core reads a dns rule's qType as a PortList, which drops a bare numeric
0 (infra/conf/common.go: `if number != 0`), and a rule with no qTypes
matches every query. A stored `"qType": 0` therefore does not target query
type 0: it drops, refuses or hijacks all DNS through that outbound.

A qType the panel writes has to be read by the core as exactly the query
types it names. Four writers broke that:

- DNSOutboundLegacyKeysFix rewrote a lone blockTypes [0] into "qType": 0,
  so "block type 0" became "block everything" on upgrade.
- That seeder shipped in v3.8.0 and is recorded as done, so fixing it does
  not reach installs that already ran it. DNSOutboundQTypeZeroFix spells
  any stored numeric qType 0 as "0" once, protocol id matched like the core.
- The outbound form adapter turned a typed "0" into the number 0.
- The Xray template editor saves raw JSON past that adapter; the save now
  applies the same rewrite.

Each writer is pinned by a test that fails without its part. The rewrite
and the repair compare policies as the pinned core builds them, and the
repair runs through runSeeders over a database whose legacy-keys seeder
already ran, on SQLite and PostgreSQL 16.
This commit is contained in:
Sanaei
2026-09-15 15:45:01 +02:00
parent ac3fc12077
commit bc424f0968
6 changed files with 231 additions and 4 deletions
+76 -3
View File
@@ -1254,7 +1254,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
@@ -1383,6 +1383,12 @@ func runSeeders(isUsersEmpty bool) error {
}
}
if !slices.Contains(seedersHistory, "DNSOutboundQTypeZeroFix") {
if err := migrateDNSOutboundQTypeZero(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "NodeInboundsAdopted") {
if err := seedNodeInboundsAdopted(); err != nil {
return err
@@ -1920,9 +1926,10 @@ func legacyDNSOutboundRules(mode string, blockTypes []int) []any {
return append(rules, fallback)
}
// dnsQTypeValue keeps a lone qType a number, the way the core marshals one.
// dnsQTypeValue keeps a lone qType a number the way the core marshals one, except
// 0: the core drops a numeric 0, and a rule with no qTypes matches every query.
func dnsQTypeValue(blockTypes []int) any {
if len(blockTypes) == 1 {
if len(blockTypes) == 1 && blockTypes[0] != 0 {
return blockTypes[0]
}
parts := make([]string, 0, len(blockTypes))
@@ -1932,6 +1939,72 @@ func dnsQTypeValue(blockTypes []int) any {
return strings.Join(parts, ",")
}
// migrateDNSOutboundQTypeZero repairs the numeric qType 0 that 3.8.0's legacy-keys
// seeder stored, which that seeder's own history row keeps it from revisiting.
func migrateDNSOutboundQTypeZero() error {
var setting model.Setting
err := db.Model(model.Setting{}).Where("key = ?", "xrayTemplateConfig").First(&setting).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
}
if err != nil {
return err
}
updated, changed, rErr := RewriteDNSOutboundQTypeZero(setting.Value)
if rErr != nil {
log.Printf("DNSOutboundQTypeZeroFix: skip (invalid xrayTemplateConfig json): %v", rErr)
return db.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
}
return db.Transaction(func(tx *gorm.DB) error {
if changed {
if err := tx.Model(&model.Setting{}).Where("key = ?", "xrayTemplateConfig").
Update("value", updated).Error; err != nil {
return err
}
}
return tx.Create(&model.HistoryOfSeeders{SeederName: "DNSOutboundQTypeZeroFix"}).Error
})
}
// RewriteDNSOutboundQTypeZero spells a dns rule's numeric qType 0 as "0", the one
// form the core reads as query type 0 rather than as every query.
func RewriteDNSOutboundQTypeZero(raw string) (string, bool, error) {
if strings.TrimSpace(raw) == "" {
return raw, false, nil
}
var cfg map[string]any
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
return raw, false, err
}
outbounds, _ := cfg["outbounds"].([]any)
changed := false
for _, ob := range outbounds {
obj, _ := ob.(map[string]any)
if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "dns") {
continue
}
settings, _ := obj["settings"].(map[string]any)
rules, _ := settings["rules"].([]any)
for _, r := range rules {
rule, _ := r.(map[string]any)
if qType, ok := rule["qType"].(float64); ok && qType == 0 {
rule["qType"] = "0"
changed = true
}
}
}
if !changed {
return raw, false, nil
}
out, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return raw, false, err
}
return string(out), true, nil
}
func normalizeSettingPaths() error {
pathKeys := []string{"webBasePath", "subPath", "subJsonPath", "subClashPath"}
var rows []model.Setting