diff --git a/frontend/src/lib/xray/outbound-form-adapter.ts b/frontend/src/lib/xray/outbound-form-adapter.ts
index 41fdd1374..340fdb476 100644
--- a/frontend/src/lib/xray/outbound-form-adapter.ts
+++ b/frontend/src/lib/xray/outbound-form-adapter.ts
@@ -354,14 +354,23 @@ function freedomFromWire(
network,
port: asString(rr.port),
ip: asArray(rr.ip).map((x) => asString(x)),
- blockDelay: asString(rr.blockDelay),
+ // The deprecated blockDelay wins over delay, as it does in xray-core 26.11.9.
+ delay: asString(rr.blockDelay) || asString(rr.delay),
+ logLevel: asString(rr.logLevel),
};
});
// Legacy ipsBlocked → finalRule(block) backfill
if (finalRules.length === 0) {
const ipsBlocked = asArray(raw.ipsBlocked).map((x) => asString(x));
if (ipsBlocked.length > 0) {
- finalRules.push({ action: 'block', network: '', port: '', ip: ipsBlocked, blockDelay: '' });
+ finalRules.push({
+ action: 'block',
+ network: '',
+ port: '',
+ ip: ipsBlocked,
+ delay: '',
+ logLevel: '',
+ });
}
}
// Wire fragment is either missing or a populated object. Mirror the
@@ -828,7 +837,8 @@ function freedomToWire(s: FreedomOutboundFormSettings) {
network: r.network || undefined,
port: r.port || undefined,
ip: r.ip.length > 0 ? r.ip : undefined,
- blockDelay: r.action === 'block' && r.blockDelay ? r.blockDelay : undefined,
+ delay: r.action === 'block' && r.delay ? r.delay : undefined,
+ logLevel: r.action === 'block' && r.logLevel ? r.logLevel : undefined,
}))
: undefined,
};
diff --git a/frontend/src/pages/xray/outbounds/protocols/freedom.tsx b/frontend/src/pages/xray/outbounds/protocols/freedom.tsx
index 5e4a7b253..d2d43da6b 100644
--- a/frontend/src/pages/xray/outbounds/protocols/freedom.tsx
+++ b/frontend/src/pages/xray/outbounds/protocols/freedom.tsx
@@ -6,6 +6,7 @@ import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { selectTagRender } from '@/components/form';
import { FormField } from '@/components/form/rhf';
+import { LOG_LEVELS } from '@/pages/xray/basics/constants';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives';
@@ -220,7 +221,14 @@ export default function FreedomFields() {
icon={}
aria-label={t('add')}
onClick={() =>
- appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })
+ appendFinalRule({
+ action: 'allow',
+ network: '',
+ port: '',
+ ip: [],
+ delay: '',
+ logLevel: '',
+ })
}
/>
@@ -281,12 +289,24 @@ export default function FreedomFields() {
/>
{finalRulesValues[index]?.action === 'block' && (
-
-
-
+ <>
+
+
+
+
+
+ >
)}
))}
diff --git a/frontend/src/schemas/forms/outbound-form.ts b/frontend/src/schemas/forms/outbound-form.ts
index 535d0d74c..596dee752 100644
--- a/frontend/src/schemas/forms/outbound-form.ts
+++ b/frontend/src/schemas/forms/outbound-form.ts
@@ -132,15 +132,15 @@ export const MasqueOutboundFormSettingsSchema = z.object({
});
export type MasqueOutboundFormSettings = z.infer;
-// FinalRule (freedom): network/port are strings; ip is string[]; blockDelay
-// is only meaningful when action === 'block'. The adapter omits empty
-// fields from the wire payload.
+// FinalRule (freedom): the core refuses delay and logLevel on a rule whose
+// action is not 'block' (xray-core 26.11.9), so the adapter drops them there.
export const FreedomFinalRuleFormSchema = z.object({
action: FreedomFinalRuleActionSchema.default('block'),
network: z.string().default(''),
port: z.string().default(''),
ip: z.array(z.string()).default([]),
- blockDelay: z.string().default(''),
+ delay: z.string().default(''),
+ logLevel: z.string().default(''),
});
export type FreedomFinalRuleForm = z.infer;
diff --git a/frontend/src/schemas/protocols/outbound/freedom.ts b/frontend/src/schemas/protocols/outbound/freedom.ts
index 026b0ea60..b47a2fcf8 100644
--- a/frontend/src/schemas/protocols/outbound/freedom.ts
+++ b/frontend/src/schemas/protocols/outbound/freedom.ts
@@ -45,6 +45,9 @@ export const FreedomFinalRuleSchema = z.object({
network: z.string().optional(),
port: z.string().optional(),
ip: z.array(z.string()).default([]),
+ delay: z.string().optional(),
+ logLevel: z.string().optional(),
+ // xray-core 26.11.9 (#7329) deprecated blockDelay in favour of delay.
blockDelay: z.string().optional(),
});
export type FreedomFinalRule = z.infer;
diff --git a/frontend/src/test/outbound-form-adapter.test.ts b/frontend/src/test/outbound-form-adapter.test.ts
index 87239929a..4ffa8b26a 100644
--- a/frontend/src/test/outbound-form-adapter.test.ts
+++ b/frontend/src/test/outbound-form-adapter.test.ts
@@ -537,7 +537,14 @@ describe('outbound-form-adapter: round-trip', () => {
proxyProtocol: 0,
noises: [],
finalRules: [
- { action: 'block', network: '', port: '', ip: ['geoip:private'], blockDelay: '' },
+ {
+ action: 'block',
+ network: '',
+ port: '',
+ ip: ['geoip:private'],
+ delay: '',
+ logLevel: '',
+ },
],
},
} as unknown as Parameters[0];
@@ -548,6 +555,26 @@ describe('outbound-form-adapter: round-trip', () => {
expect((back.settings as { finalRules?: unknown[] }).finalRules).toHaveLength(1);
});
+ it('freedom writes finalRules delay and logLevel on block rules only (xray-core 26.11.9)', () => {
+ const round = formValuesToWirePayload(
+ rawOutboundToFormValues({
+ protocol: 'freedom',
+ settings: {
+ finalRules: [
+ { action: 'block', blockDelay: '5000-10000', delay: '1-2', logLevel: 'none' },
+ { action: 'block', delay: '100-200' },
+ { action: 'allow', delay: '1-2', logLevel: 'debug' },
+ ],
+ },
+ }),
+ );
+ expect((round.settings as { finalRules?: unknown[] }).finalRules).toEqual([
+ { action: 'block', delay: '5000-10000', logLevel: 'none' },
+ { action: 'block', delay: '100-200' },
+ { action: 'allow' },
+ ]);
+ });
+
it('freedom omits proxyProtocol when disabled (0)', () => {
const round = formValuesToWirePayload(
rawOutboundToFormValues({
diff --git a/internal/database/db.go b/internal/database/db.go
index f3cbb7c47..1e133e43c 100644
--- a/internal/database/db.go
+++ b/internal/database/db.go
@@ -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
diff --git a/internal/database/freedom_final_rule_delay_migration_test.go b/internal/database/freedom_final_rule_delay_migration_test.go
new file mode 100644
index 000000000..9ff23b2e7
--- /dev/null
+++ b/internal/database/freedom_final_rule_delay_migration_test.go
@@ -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)
+ }
+}