mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-13 00:25:04 +03:00
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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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={<PlusOutlined aria-hidden />}
|
||||
aria-label={t('add')}
|
||||
onClick={() =>
|
||||
appendFinalRule({ action: 'allow', network: '', port: '', ip: [], blockDelay: '' })
|
||||
appendFinalRule({
|
||||
action: 'allow',
|
||||
network: '',
|
||||
port: '',
|
||||
ip: [],
|
||||
delay: '',
|
||||
logLevel: '',
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span className="ml-8" style={{ opacity: 0.6 }}>
|
||||
@@ -281,12 +289,24 @@ export default function FreedomFields() {
|
||||
/>
|
||||
</FormField>
|
||||
{finalRulesValues[index]?.action === 'block' && (
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.blockDelay')}
|
||||
name={['settings', 'finalRules', index, 'blockDelay']}
|
||||
>
|
||||
<Input placeholder="optional: 5000-10000" />
|
||||
</FormField>
|
||||
<>
|
||||
<FormField
|
||||
label={t('pages.xray.outboundForm.blockDelay')}
|
||||
name={['settings', 'finalRules', index, 'delay']}
|
||||
>
|
||||
<Input placeholder="optional: 5000-10000" />
|
||||
</FormField>
|
||||
<FormField
|
||||
label={t('pages.xray.logLevel')}
|
||||
name={['settings', 'finalRules', index, 'logLevel']}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="info"
|
||||
options={LOG_LEVELS.map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -132,15 +132,15 @@ export const MasqueOutboundFormSettingsSchema = z.object({
|
||||
});
|
||||
export type MasqueOutboundFormSettings = z.infer<typeof MasqueOutboundFormSettingsSchema>;
|
||||
|
||||
// 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<typeof FreedomFinalRuleFormSchema>;
|
||||
|
||||
|
||||
@@ -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<typeof FreedomFinalRuleSchema>;
|
||||
|
||||
@@ -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<typeof formValuesToWirePayload>[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({
|
||||
|
||||
+88
-1
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user