fix(xray): adopt freedom finalRules delay and logLevel

xray-core 26.11.9 (#7329) renamed a final rule's blockDelay to delay, added
logLevel, and now refuses either on a rule whose action is not block. A
stored template with a blockDelay on an allow rule therefore stops the core
at startup after the upgrade.

A one-time seeder strips delay, blockDelay and logLevel from non-block rules
of the template's freedom outbounds and moves blockDelay to delay on block
rules. The outbound form reads both spellings (blockDelay wins, as in the
core), writes delay, gains a log level control on block rules and emits
neither key on an allow rule.
This commit is contained in:
Sanaei
2026-11-12 19:10:18 +01:00
parent 192dc93c16
commit a748f799c7
7 changed files with 263 additions and 16 deletions
+88 -1
View File
@@ -1530,7 +1530,7 @@ func runSeeders(isUsersEmpty bool) error {
}
if empty && isUsersEmpty {
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban", "ClientGroupNameTrim", "OwnerlessClientHwidPurge", "ClientForwardedPortsBackfill"}
seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "UppercaseFreedomFinalRulesFix", "FreedomFinalRuleDelayFix", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "SessionSecretRotation", "OutboundRemovedKeysFix", "FreedomDomainStrategyFix", "DNSOutboundLegacyKeysFix", "DNSOutboundQTypeZeroFix", "WireguardDomainStrategyFix", "XdnsFinalmaskNamesAddrsFix", "UDPHopClientMaskFix", "HostVerifyPeerCertByNameBoolFix", "HostMihomoX25519Reset", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "NodeDownNotified", "ResetIpLimitNoFail2ban", "ClientGroupNameTrim", "OwnerlessClientHwidPurge", "ClientForwardedPortsBackfill"}
for _, name := range seeders {
if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
return err
@@ -1635,6 +1635,12 @@ func runSeeders(isUsersEmpty bool) error {
}
}
if !slices.Contains(seedersHistory, "FreedomFinalRuleDelayFix") {
if err := fixFreedomFinalRuleDelay(); err != nil {
return err
}
}
if !slices.Contains(seedersHistory, "LegacyProxySettingsCleanup") {
if err := clearLegacyProxySettings(); err != nil {
return err
@@ -3106,6 +3112,87 @@ func rewriteUppercaseFreedomFinalRules(raw string) (string, bool, error) {
return hardened, true, nil
}
func fixFreedomFinalRuleDelay() 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: "FreedomFinalRuleDelayFix"}).Error
}
if err != nil {
return err
}
updated, changed, rErr := rewriteFreedomFinalRuleDelay(setting.Value)
if rErr != nil {
log.Printf("FreedomFinalRuleDelayFix: skip (invalid xrayTemplateConfig json): %v", rErr)
return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRuleDelayFix"}).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: "FreedomFinalRuleDelayFix"}).Error
})
}
// xray-core 26.11.9 (#7329) renamed finalRules blockDelay to delay and refuses
// delay or logLevel on a rule that is not a block, which stops the core.
func rewriteFreedomFinalRuleDelay(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, ok := ob.(map[string]any)
if !ok {
continue
}
if proto, _ := obj["protocol"].(string); !strings.EqualFold(proto, "freedom") {
continue
}
settings, _ := obj["settings"].(map[string]any)
rules, _ := settings["finalRules"].([]any)
for _, r := range rules {
rule, ok := r.(map[string]any)
if !ok {
continue
}
legacyDelay, hasLegacyDelay := rule["blockDelay"]
if action, _ := rule["action"].(string); !strings.EqualFold(action, "block") {
for _, key := range []string{"blockDelay", "delay", "logLevel"} {
if _, present := rule[key]; present {
delete(rule, key)
changed = true
}
}
continue
}
if hasLegacyDelay {
delete(rule, "blockDelay")
rule["delay"] = legacyDelay
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 hasNonLowercaseFreedomOutbound(raw string) bool {
if strings.TrimSpace(raw) == "" {
return false
@@ -0,0 +1,100 @@
package database
import (
"encoding/json"
"reflect"
"strings"
"testing"
"github.com/mhsanaei/3x-ui/v3/internal/config"
"github.com/mhsanaei/3x-ui/v3/internal/database/model"
"github.com/mhsanaei/3x-ui/v3/internal/xray"
)
const legacyFinalRuleDelayTemplate = `{"outbounds":[{"protocol":"freedom","tag":"direct","settings":{"finalRules":[` +
`{"action":"allow","ip":["10.0.0.0/8"],"blockDelay":"1000-2000"},` +
`{"action":"Block","blockDelay":"5000-10000","delay":"1-2"}]}}]}`
func finalRulesOfFirstOutbound(t *testing.T, raw string) (json.RawMessage, []any) {
t.Helper()
var cfg struct {
Outbounds []json.RawMessage `json:"outbounds"`
}
if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
t.Fatalf("template is not JSON: %v", err)
}
var outbound struct {
Settings struct {
FinalRules []any `json:"finalRules"`
} `json:"settings"`
}
if err := json.Unmarshal(cfg.Outbounds[0], &outbound); err != nil {
t.Fatalf("outbound is not JSON: %v", err)
}
return cfg.Outbounds[0], outbound.Settings.FinalRules
}
func TestRewriteFreedomFinalRuleDelay(t *testing.T) {
before, _ := finalRulesOfFirstOutbound(t, legacyFinalRuleDelayTemplate)
err := xray.ValidateOutboundConfig(before)
if err == nil || !strings.Contains(err.Error(), "only supported for block finalRules") {
t.Fatalf("expected the vendored core to refuse a delay on an allow rule, got %v", err)
}
updated, changed, err := rewriteFreedomFinalRuleDelay(legacyFinalRuleDelayTemplate)
if err != nil {
t.Fatalf("rewrite: %v", err)
}
if !changed {
t.Fatal("the allow rule kept the blockDelay the core refuses")
}
after, rules := finalRulesOfFirstOutbound(t, updated)
if err := xray.ValidateOutboundConfig(after); err != nil {
t.Fatalf("rewritten outbound still refused by xray-core: %v", err)
}
want := []any{
map[string]any{"action": "allow", "ip": []any{"10.0.0.0/8"}},
map[string]any{"action": "Block", "delay": "5000-10000"},
}
if !reflect.DeepEqual(rules, want) {
t.Fatalf("finalRules = %v, want %v", rules, want)
}
if _, again, err := rewriteFreedomFinalRuleDelay(updated); err != nil || again {
t.Fatalf("second pass changed=%v err=%v, want a no-op", again, err)
}
}
func TestFreedomFinalRuleDelayFixRunsOnUpgradedPanels(t *testing.T) {
t.Setenv("XUI_DB_FOLDER", t.TempDir())
if err := InitDB(config.GetDBPath()); err != nil {
if strings.Contains(err.Error(), "CGO_ENABLED=0") {
t.Skipf("sqlite needs cgo: %v", err)
}
t.Fatalf("init db: %v", err)
}
t.Cleanup(func() { _ = CloseDB() })
seedTemplate(t, legacyFinalRuleDelayTemplate)
if err := db.Where("seeder_name = ?", "FreedomFinalRuleDelayFix").
Delete(&model.HistoryOfSeeders{}).Error; err != nil {
t.Fatalf("clear seeder history: %v", err)
}
if err := runSeeders(false); err != nil {
t.Fatalf("runSeeders: %v", err)
}
outbound, _ := finalRulesOfFirstOutbound(t, storedTemplate(t))
if err := xray.ValidateOutboundConfig(outbound); err != nil {
t.Fatalf("stored template still refused by xray-core: %v", err)
}
var count int64
if err := db.Model(&model.HistoryOfSeeders{}).
Where("seeder_name = ?", "FreedomFinalRuleDelayFix").Count(&count).Error; err != nil {
t.Fatalf("count seeder rows: %v", err)
}
if count != 1 {
t.Fatalf("FreedomFinalRuleDelayFix recorded %d times, want 1", count)
}
}