Browse Source

Bug-label issue sweep: 16 fixes (#6083)

* fix(xray): block private-range egress in default freedom finalRules (#6037)

With domainStrategy AsIs the router never resolves domains, so a domain
with a private A record (e.g. 127-0-0-1.nip.io) sails past the
geoip:private routing block and freedom's allow-all finalRules let it
reach loopback services such as the xray gRPC API and metrics listener.

Prepend a block rule for geoip:private to the default template and add
the FreedomFinalRulesPrivateEgressBlock seeder so existing installs
still carrying the stock allow-only (or legacy private-only-allow)
finalRules are upgraded in place; customized rules are left untouched.

* fix(sub): version-gate unencrypted-outbound drops in outbound subscriptions (#6033)

Commit d38c912d taught CheckXrayConfig to keep unencrypted vless/trojan
outbounds when the running core predates the v26.7.11 rejection, but
filterOutboundsRejectedByCore still consulted the embedded validator
unconditionally, so outbound subscriptions kept silently dropping those
outbounds even on downgraded cores.

Apply the same shouldSkipLegacyUnencryptedOutboundRejection gate when
filtering fetched subscription outbounds.

* fix(xray): resolve geodata assets before building outbound configs (#5928)

Saving routing or template settings validates each outbound through the
embedded config loader, and a freedom outbound whose finalRules
reference geoip:private opens geoip.dat during that build. Unlike
ApplyRoutingConfig, ValidateOutboundConfig and AddOutbound never pointed
the in-process loader at the bin folder, so xray-core resolved the file
relative to the panel executable and saving failed with
'stat /usr/local/x-ui/geoip.dat: no such file or directory'.

Call ensureXrayAssetLocation before both build paths.

* fix(api): use a real i18n key in the client get handler (#5911)

The client fetch endpoint localized its error prefix with the bare key
'get', which exists in no translation file, so every lookup of a deleted
client's email logged 'message "get" not found in language ...' noise
alongside the expected record-not-found warning. Reuse the same
pages.inbounds.toasts.obtain key the sibling list handler uses.

* fix(sub): carry host record Host header and path into Clash/JSON output (#5944)

The raw-link path overrides the host/path share params from a Host
record via applyEndpointHostPath, but the Clash and JSON renderers read
the transport settings object, which applyHostStreamOverrides never
touched — so a Host record's WebSocket Host header (and path) silently
vanished from Clash/Mihomo and JSON subscriptions whenever the inbound's
own ws settings left them empty.

Inject hostHeader/path into the ws/httpupgrade/xhttp settings of the
per-host stream, mirroring the raw-link override.

* fix(metrics): accept Unicode outbound tags in the observatory (#5972)

The observatory validator whitelisted ASCII word characters, so any
outbound whose tag carries a flag emoji or other non-ASCII text was
silently dropped from the metrics snapshot, delay history, and health
notifications. The history store is an in-process map, so the strict
charset bought nothing.

Validate tags as non-empty, bounded, control-character-free UTF-8
instead, keeping spaces and emoji while still rejecting garbage input on
the query path.

* fix(database): default sqlite to WAL to stop background-job lock storms (#6057, #6068)

With journal_mode=DELETE every write serializes the whole database and
blocks readers, so under normal multi-job load (traffic sampling, node
sync, mtproto reconcile) transactions regularly outwaited the 10s busy
timeout and jobs failed with 'database is locked'.

Move to WAL by default: readers no longer block writers and vice versa,
which removes the observed contention while writer-writer access still
serializes safely. The single-file-at-rest property is preserved where
it matters — Checkpoint() now issues wal_checkpoint(TRUNCATE), so panel
and Telegram backups read a complete main file, and sqlite folds the WAL
back into the db on clean shutdown. XUI_DB_JOURNAL_MODE=DELETE restores
the previous behavior for setups that copy the live file directly.

* fix(database): strip finalmask.tcp from REALITY inbounds on upgrade (#6038)

validateFinalMaskRealityCombo blocks saving finalmask.tcp together with
REALITY because that combination crashes Xray-core 26.7.11 on the first
connection (XTLS/Xray-core#6453), but it only runs on add/update. An
inbound saved before the validator existed sailed through the upgrade
untouched and took the core down at boot.

Add the InboundRealityFinalmaskTcpStrip seeder: one-time scan that
removes finalmask.tcp from REALITY inbounds (other finalmask transports
survive), so upgraded panels start cleanly.

* fix(xray): stop deleting hand-written direct routing rules on save (#6056)

The DNS allow-rule sync recognized 'its' rules purely by shape
(type=field, ip, port, outboundTag=direct, nothing else), so any manual
rule of that shape — e.g. routing a LAN CIDR to a NAS port over direct —
was silently stripped on every settings save.

Mark managed rules with ruleTag=xui-dns-allow (round-tripped untouched
by both xray-core and the Routing tab editor) and only strip rules that
carry the tag. Untagged legacy managed rules are adopted when their
exact ip-set/port matches a currently configured private DNS endpoint;
anything else is left alone. A stale pre-tag managed rule whose DNS
server was removed now lingers until deleted manually — the safe side of
the trade against eating user rules.

* fix(clients): resolve email lookups through client_inbounds after a move (#6059)

GetClientInboundByEmail trusted the client_traffics.inbound_id pointer
whenever that inbound still existed, but a client moved between inbounds
leaves the row pointing at its old (still existing) inbound. The lookup
then searched the wrong inbound's clients and failed with 'Client Not
Found In Inbound For Email', which broke the Telegram bot's link and QR
generation for moved clients.

When the pointed-at inbound no longer carries the email, re-resolve
through the authoritative client_inbounds link to the inbound that
actually hosts the client.

* fix(nodes): replicate inbound fallbacks to nodes (#5963)

Fallbacks live in the inbound_fallbacks table and were only merged into
settings by the master's local config builder; the runtime inbound
pushed to nodes rebuilt settings without them, and the reconcile job
additionally fingerprinted the raw DB row, so fallback edits neither
reached nodes nor triggered a re-push.

Inject settings.fallbacks in buildRuntimeInboundForAPI (mirroring the
local builder, gated on inboundCanHostFallbacks) and make ReconcileNode
push and fingerprint that same runtime-built payload, aligning the
interactive and reconcile paths.

* fix(database): survive PostgreSQL outages without a runaway restart loop (#6023)

A PostgreSQL that was down or still starting made InitDB fail instantly;
the process exited with a generic startup error and systemd restarted it
every 5s forever, flooding the journal.

Retry the initial postgres connection with backoff (~70s total) and log
the real driver error on every attempt, and cap the systemd units with
StartLimitIntervalSec/StartLimitBurst so a persistently unreachable
database stops the unit instead of looping indefinitely.

* fix(xray): force a full restart when REALITY stream settings change (#6010)

A changed inbound is normally hot-swapped over gRPC as RemoveInbound +
AddInbound, but xray-core does not reliably rebuild a REALITY listener's
authenticator on a runtime re-add — key or shortId edits appeared
applied yet clients kept authenticating against the old parameters until
someone restarted the core manually, on nodes in particular.

Treat any non-client change to an inbound that uses (or starts using)
REALITY as not hot-appliable so the panel restarts the core instead.
Client-only edits on REALITY inbounds keep flowing through the per-user
AlterInbound path and still avoid restarts.

* feat(sub): allow insecure TLS for outbound subscription fetches (#6067)

An outbound subscription served over HTTPS with a self-signed or
private-CA certificate could never be fetched: the fetch client had no
TLS options, so refreshes died with 'x509: certificate signed by unknown
authority' and there was nothing the admin could toggle.

Add a per-subscription 'Allow insecure' switch (persisted as
allow_insecure, default off) that sets InsecureSkipVerify on the fetch
transport — including when the fetch is routed through the panel egress
proxy. The SSRF-guarded dialer and redirect re-validation stay in force
either way.

* fix(reality): send PROXY protocol header in the target scanner when xver is set (#6082)

The REALITY target scanner always probed with a plain TLS handshake, so
a target fronted by an Nginx listener that requires the PROXY protocol
(matching the inbound's xver>=1) reset the connection and the panel
reported a false 'TLS handshake failed'.

Thread the inbound's xver into the scan request and, when it is >=1,
lead with the matching PROXY protocol header (v1 for xver 1, binary v2
for xver 2) built from the dialed connection's own address pair. Batch
candidate scans against public sites are unaffected (xver 0).

* fix(frontend): default sockopt fields when editing a stored inbound (#5956)

Opening an existing inbound ran rawInboundToFormValues over the raw DB
row, and only xhttpSettings was re-parsed through its Zod schema to fill
defaults. A sockopt object saved before the TProxy control existed has
no tproxy key, so the Select rendered blank; picking Off didn't help
because the wire normalizer drops tproxy=off, recreating the missing
key on the next edit.

Re-parse streamSettings.sockopt through SockoptStreamSettingsSchema on
load, mirroring the xhttpSettings handling, so absent keys (tproxy,
tcpcongestion, …) get their schema defaults every time the form opens.
Sanaei 6 hours ago
parent
commit
892c06c8bc
34 changed files with 1172 additions and 85 deletions
  1. 8 0
      frontend/src/lib/xray/inbound-form-adapter.ts
  2. 2 1
      frontend/src/pages/inbounds/form/useSecurityActions.ts
  3. 12 3
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  4. 16 0
      frontend/src/test/inbound-form-adapter.test.ts
  5. 193 5
      internal/database/db.go
  6. 87 0
      internal/database/freedom_finalrules_migration_test.go
  7. 82 0
      internal/database/journal_mode_test.go
  8. 1 0
      internal/database/model/model.go
  9. 93 0
      internal/database/reality_finalmask_migration_test.go
  10. 14 0
      internal/sub/host_sub.go
  11. 33 0
      internal/sub/host_sub_test.go
  12. 4 4
      internal/web/controller/client.go
  13. 2 1
      internal/web/controller/server.go
  14. 6 3
      internal/web/controller/xray_setting.go
  15. 1 0
      internal/web/service/config.json
  16. 48 29
      internal/web/service/inbound.go
  17. 60 0
      internal/web/service/inbound_client_move_test.go
  18. 30 0
      internal/web/service/inbound_clients.go
  19. 78 0
      internal/web/service/inbound_fallback_runtime_test.go
  20. 8 1
      internal/web/service/inbound_node.go
  21. 32 10
      internal/web/service/outbound_subscription.go
  22. 1 1
      internal/web/service/outbound_subscription_ssrf_test.go
  23. 69 6
      internal/web/service/reality_scan.go
  24. 57 2
      internal/web/service/reality_scan_test.go
  25. 18 5
      internal/web/service/xray_metrics.go
  26. 48 0
      internal/web/service/xray_metrics_test.go
  27. 37 7
      internal/web/service/xray_setting_dns_routing.go
  28. 63 7
      internal/web/service/xray_setting_dns_routing_test.go
  29. 4 0
      internal/xray/api.go
  30. 17 0
      internal/xray/hot_diff.go
  31. 42 0
      internal/xray/hot_diff_test.go
  32. 2 0
      x-ui.service.arch
  33. 2 0
      x-ui.service.debian
  34. 2 0
      x-ui.service.rhel

+ 8 - 0
frontend/src/lib/xray/inbound-form-adapter.ts

@@ -14,6 +14,7 @@ import type { Sniffing } from '@/schemas/primitives';
 import type { z } from 'zod';
 import type { z } from 'zod';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
 import { canEnableSniffing } from '@/lib/xray/protocol-capabilities';
+import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt';
 import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp';
 
 
 const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
 const XMUX_DEFAULTS = XHttpXmuxSchema.parse({});
@@ -178,6 +179,13 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues {
         xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
         xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record<string, unknown>) };
       }
       }
     }
     }
+    const so = streamRecord.sockopt;
+    if (so && typeof so === 'object' && !Array.isArray(so)) {
+      const parsed = SockoptStreamSettingsSchema.safeParse(so);
+      if (parsed.success) {
+        streamRecord.sockopt = { ...(so as Record<string, unknown>), ...parsed.data };
+      }
+    }
   }
   }
   const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
   const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing;
 
 

+ 2 - 1
frontend/src/pages/inbounds/form/useSecurityActions.ts

@@ -86,11 +86,12 @@ export function useSecurityActions({ methods, setSaving, messageApi, nodeId, set
       messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
       messageApi.warning(t('pages.inbounds.form.realityTargetRequired'));
       return;
       return;
     }
     }
+    const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0;
     setScanning(true);
     setScanning(true);
     try {
     try {
       const msg = await HttpUtil.post<RealityScanResult>(
       const msg = await HttpUtil.post<RealityScanResult>(
         '/panel/api/server/scanRealityTarget',
         '/panel/api/server/scanRealityTarget',
-        { target },
+        { target, xver },
         { silent: true },
         { silent: true },
       );
       );
       if (!msg?.success || !msg.obj) {
       if (!msg?.success || !msg.obj) {

+ 12 - 3
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -61,6 +61,7 @@ interface OutboundSub {
   url?: string;
   url?: string;
   enabled?: boolean;
   enabled?: boolean;
   allowPrivate?: boolean;
   allowPrivate?: boolean;
+  allowInsecure?: boolean;
   prepend?: boolean;
   prepend?: boolean;
   priority?: number;
   priority?: number;
   tagPrefix?: string;
   tagPrefix?: string;
@@ -122,7 +123,7 @@ export default function OutboundsTab({
   const [subDrawerOpen, setSubDrawerOpen] = useState(false);
   const [subDrawerOpen, setSubDrawerOpen] = useState(false);
   const [subs, setSubs] = useState<OutboundSub[]>([]);
   const [subs, setSubs] = useState<OutboundSub[]>([]);
   const [subsLoading, setSubsLoading] = useState(false);
   const [subsLoading, setSubsLoading] = useState(false);
-  const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
+  const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false });
   const [editingSubId, setEditingSubId] = useState<number | null>(null);
   const [editingSubId, setEditingSubId] = useState<number | null>(null);
   const [savingSub, setSavingSub] = useState(false);
   const [savingSub, setSavingSub] = useState(false);
   const [refreshingId, setRefreshingId] = useState<number | null>(null);
   const [refreshingId, setRefreshingId] = useState<number | null>(null);
@@ -303,7 +304,7 @@ export default function OutboundsTab({
       setSubsLoading(false);
       setSubsLoading(false);
     }
     }
   }
   }
-  function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; prepend?: boolean }) {
+  function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; allowInsecure?: boolean; prepend?: boolean }) {
     return {
     return {
       remark: src.remark ?? '',
       remark: src.remark ?? '',
       url: src.url ?? '',
       url: src.url ?? '',
@@ -311,11 +312,12 @@ export default function OutboundsTab({
       updateInterval: src.updateInterval ?? 600,
       updateInterval: src.updateInterval ?? 600,
       enabled: src.enabled ?? true,
       enabled: src.enabled ?? true,
       allowPrivate: src.allowPrivate ?? false,
       allowPrivate: src.allowPrivate ?? false,
+      allowInsecure: src.allowInsecure ?? false,
       prepend: src.prepend ?? false,
       prepend: src.prepend ?? false,
     };
     };
   }
   }
   function resetSubForm() {
   function resetSubForm() {
-    setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, prepend: false });
+    setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false });
     setEditingSubId(null);
     setEditingSubId(null);
     setPreviewData(null);
     setPreviewData(null);
   }
   }
@@ -327,6 +329,7 @@ export default function OutboundsTab({
       updateInterval: sub.updateInterval ?? 600,
       updateInterval: sub.updateInterval ?? 600,
       enabled: sub.enabled ?? true,
       enabled: sub.enabled ?? true,
       allowPrivate: sub.allowPrivate ?? false,
       allowPrivate: sub.allowPrivate ?? false,
+      allowInsecure: sub.allowInsecure ?? false,
       prepend: sub.prepend ?? false,
       prepend: sub.prepend ?? false,
     });
     });
     setEditingSubId(sub.id);
     setEditingSubId(sub.id);
@@ -647,6 +650,12 @@ export default function OutboundsTab({
                   {t('pages.xray.outboundSub.allowPrivateHint')}
                   {t('pages.xray.outboundSub.allowPrivateHint')}
                 </div>
                 </div>
               </Form.Item>
               </Form.Item>
+              <Form.Item label={t('pages.hosts.fields.allowInsecure')}>
+                <Switch checked={newSub.allowInsecure} onChange={(v) => setNewSub({ ...newSub, allowInsecure: v })} />
+                <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
+                  {t('pages.hosts.hints.allowInsecure')}
+                </div>
+              </Form.Item>
               <Form.Item label={t('pages.xray.outboundSub.prepend')}>
               <Form.Item label={t('pages.xray.outboundSub.prepend')}>
                 <Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
                 <Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
                 <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
                 <div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>

+ 16 - 0
frontend/src/test/inbound-form-adapter.test.ts

@@ -155,6 +155,22 @@ describe('transportless streamSettings (wireguard / tunnel)', () => {
     }
     }
   });
   });
 
 
+  it('fills sockopt schema defaults for a stored inbound missing tproxy (#5956)', () => {
+    const values = rawInboundToFormValues({
+      port: 443,
+      protocol: 'vless',
+      settings: { clients: [] },
+      streamSettings: JSON.stringify({
+        network: 'tcp',
+        security: 'none',
+        sockopt: { tcpFastOpen: true },
+      }),
+    });
+    const stream = values.streamSettings as { sockopt?: { tproxy?: string; tcpFastOpen?: boolean } };
+    expect(stream.sockopt?.tproxy).toBe('off');
+    expect(stream.sockopt?.tcpFastOpen).toBe(true);
+  });
+
   it('still rejects a present-but-invalid network value', () => {
   it('still rejects a present-but-invalid network value', () => {
     const result = InboundFormSchema.safeParse({
     const result = InboundFormSchema.safeParse({
       port: 12345,
       port: 12345,

+ 193 - 5
internal/database/db.go

@@ -1057,7 +1057,7 @@ func runSeeders(isUsersEmpty bool) error {
 	}
 	}
 
 
 	if empty && isUsersEmpty {
 	if empty && isUsersEmpty {
-		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
+		seeders := []string{"UserPasswordHash", "ClientsTable", "InboundClientsArrayFix", "InboundClientTgIdFix2", "InboundClientSubIdFix", "FreedomFinalRulesReverseFix", "FreedomFinalRulesPrivateEgressBlock", "InboundRealityFinalmaskTcpStrip", "ApiTokensHash", "LegacyProxySettingsCleanup", "WireguardPeersToClients", "MtprotoSecretsToClients", "NodeInboundsAdopted", "ResetIpLimitNoFail2ban"}
 		for _, name := range seeders {
 		for _, name := range seeders {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 			if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil {
 				return err
 				return err
@@ -1144,6 +1144,18 @@ func runSeeders(isUsersEmpty bool) error {
 		}
 		}
 	}
 	}
 
 
+	if !slices.Contains(seedersHistory, "FreedomFinalRulesPrivateEgressBlock") {
+		if err := hardenFreedomFinalRules(); err != nil {
+			return err
+		}
+	}
+
+	if !slices.Contains(seedersHistory, "InboundRealityFinalmaskTcpStrip") {
+		if err := stripRealityFinalmaskTcp(); err != nil {
+			return err
+		}
+	}
+
 	if !slices.Contains(seedersHistory, "LegacyProxySettingsCleanup") {
 	if !slices.Contains(seedersHistory, "LegacyProxySettingsCleanup") {
 		if err := clearLegacyProxySettings(); err != nil {
 		if err := clearLegacyProxySettings(); err != nil {
 			return err
 			return err
@@ -1592,6 +1604,147 @@ func isLegacyPrivateOnlyFinalRules(v any) bool {
 	return true
 	return true
 }
 }
 
 
+func hardenFreedomFinalRules() 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: "FreedomFinalRulesPrivateEgressBlock"}).Error
+	}
+	if err != nil {
+		return err
+	}
+
+	updated, changed, rErr := rewriteFreedomFinalRulesPrivateEgress(setting.Value)
+	if rErr != nil {
+		log.Printf("FreedomFinalRulesPrivateEgressBlock: skip (invalid xrayTemplateConfig json): %v", rErr)
+		return db.Create(&model.HistoryOfSeeders{SeederName: "FreedomFinalRulesPrivateEgressBlock"}).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: "FreedomFinalRulesPrivateEgressBlock"}).Error
+	})
+}
+
+func rewriteFreedomFinalRulesPrivateEgress(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, ok := cfg["outbounds"].([]any)
+	if !ok {
+		return raw, false, nil
+	}
+	changed := false
+	for _, ob := range outbounds {
+		obj, ok := ob.(map[string]any)
+		if !ok {
+			continue
+		}
+		if proto, _ := obj["protocol"].(string); proto != "freedom" {
+			continue
+		}
+		settings, ok := obj["settings"].(map[string]any)
+		if !ok {
+			continue
+		}
+		if !isAllowOnlyFinalRules(settings["finalRules"]) && !isLegacyPrivateOnlyFinalRules(settings["finalRules"]) {
+			continue
+		}
+		settings["finalRules"] = []any{
+			map[string]any{"action": "block", "ip": []any{"geoip:private"}},
+			map[string]any{"action": "allow"},
+		}
+		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 stripRealityFinalmaskTcp() error {
+	var inbounds []model.Inbound
+	if err := db.Find(&inbounds).Error; err != nil {
+		return err
+	}
+	return db.Transaction(func(tx *gorm.DB) error {
+		for i := range inbounds {
+			updated, changed := stripRealityFinalmaskTcpFromStream(inbounds[i].StreamSettings)
+			if !changed {
+				continue
+			}
+			if err := tx.Model(&model.Inbound{}).Where("id = ?", inbounds[i].Id).
+				Update("stream_settings", updated).Error; err != nil {
+				return err
+			}
+			log.Printf("InboundRealityFinalmaskTcpStrip: removed finalmask.tcp from REALITY inbound %d (%s)", inbounds[i].Id, inbounds[i].Tag)
+		}
+		return tx.Create(&model.HistoryOfSeeders{SeederName: "InboundRealityFinalmaskTcpStrip"}).Error
+	})
+}
+
+func stripRealityFinalmaskTcpFromStream(raw string) (string, bool) {
+	if strings.TrimSpace(raw) == "" {
+		return raw, false
+	}
+	var stream map[string]any
+	if err := json.Unmarshal([]byte(raw), &stream); err != nil {
+		return raw, false
+	}
+	if sec, _ := stream["security"].(string); sec != "reality" {
+		return raw, false
+	}
+	finalmask, ok := stream["finalmask"].(map[string]any)
+	if !ok {
+		return raw, false
+	}
+	if tcp, _ := finalmask["tcp"].([]any); len(tcp) == 0 {
+		return raw, false
+	}
+	delete(finalmask, "tcp")
+	if len(finalmask) == 0 {
+		delete(stream, "finalmask")
+	}
+	out, err := json.Marshal(stream)
+	if err != nil {
+		return raw, false
+	}
+	return string(out), true
+}
+
+func isAllowOnlyFinalRules(v any) bool {
+	rules, ok := v.([]any)
+	if !ok || len(rules) != 1 {
+		return false
+	}
+	rule, ok := rules[0].(map[string]any)
+	if !ok {
+		return false
+	}
+	if action, _ := rule["action"].(string); action != "allow" {
+		return false
+	}
+	for k := range rule {
+		if k != "action" {
+			return false
+		}
+	}
+	return true
+}
+
 func normalizeClientJSONFields(obj map[string]any) {
 func normalizeClientJSONFields(obj map[string]any) {
 	normalizeInt := func(key string) {
 	normalizeInt := func(key string) {
 		raw, exists := obj[key]
 		raw, exists := obj[key]
@@ -1776,7 +1929,7 @@ func InitDB(dbPath string) error {
 		if dsn == "" {
 		if dsn == "" {
 			return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
 			return errors.New("XUI_DB_TYPE=postgres but XUI_DB_DSN is empty")
 		}
 		}
-		db, err = gorm.Open(postgres.Open(dsn), c)
+		db, err = openPostgresWithRetry(dsn, c)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
 		}
 		}
@@ -1787,7 +1940,8 @@ func InitDB(dbPath string) error {
 		}
 		}
 
 
 		sync := sqliteSynchronous()
 		sync := sqliteSynchronous()
-		dsn := dbPath + "?_journal_mode=DELETE&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate"
+		journal := sqliteJournalMode()
+		dsn := dbPath + "?_journal_mode=" + journal + "&_busy_timeout=10000&_synchronous=" + sync + "&_txlock=immediate"
 		db, err = gorm.Open(sqlite.Open(dsn), c)
 		db, err = gorm.Open(sqlite.Open(dsn), c)
 		if err != nil {
 		if err != nil {
 			return err
 			return err
@@ -1798,7 +1952,7 @@ func InitDB(dbPath string) error {
 		}
 		}
 
 
 		pragmas := []string{
 		pragmas := []string{
-			"PRAGMA journal_mode=DELETE",
+			"PRAGMA journal_mode=" + journal,
 			"PRAGMA busy_timeout=10000",
 			"PRAGMA busy_timeout=10000",
 			"PRAGMA synchronous=" + sync,
 			"PRAGMA synchronous=" + sync,
 			fmt.Sprintf("PRAGMA cache_size=-%d", envInt("XUI_DB_CACHE_MB", 32)*1024),
 			fmt.Sprintf("PRAGMA cache_size=-%d", envInt("XUI_DB_CACHE_MB", 32)*1024),
@@ -1851,6 +2005,40 @@ func normalizeApiTokenCreatedAtSeconds() error {
 		UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
 		UpdateColumn("created_at", gorm.Expr("created_at / ?", 1000)).Error
 }
 }
 
 
+// openPostgresWithRetry retries the initial PostgreSQL connection with
+// backoff so a database that starts slower than the panel (or drops out
+// briefly) does not immediately kill the process and trip systemd's
+// restart loop. Every failed attempt logs the real driver error, which
+// used to be buried behind a generic startup failure.
+func openPostgresWithRetry(dsn string, c *gorm.Config) (*gorm.DB, error) {
+	delays := []time.Duration{0, 2 * time.Second, 5 * time.Second, 10 * time.Second, 20 * time.Second, 30 * time.Second}
+	var lastErr error
+	for i, delay := range delays {
+		if delay > 0 {
+			time.Sleep(delay)
+		}
+		conn, err := gorm.Open(postgres.Open(dsn), c)
+		if err == nil {
+			if i > 0 {
+				log.Printf("postgres connection established on attempt %d/%d", i+1, len(delays))
+			}
+			return conn, nil
+		}
+		lastErr = err
+		log.Printf("postgres connection attempt %d/%d failed: %v", i+1, len(delays), err)
+	}
+	return nil, fmt.Errorf("postgres unreachable after %d attempts: %w", len(delays), lastErr)
+}
+
+func sqliteJournalMode() string {
+	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_JOURNAL_MODE"))) {
+	case "DELETE":
+		return "DELETE"
+	default:
+		return "WAL"
+	}
+}
+
 func sqliteSynchronous() string {
 func sqliteSynchronous() string {
 	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
 	switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) {
 	case "OFF":
 	case "OFF":
@@ -1909,7 +2097,7 @@ func Checkpoint() error {
 	if IsPostgres() {
 	if IsPostgres() {
 		return nil
 		return nil
 	}
 	}
-	return db.Exec("PRAGMA wal_checkpoint;").Error
+	return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error
 }
 }
 
 
 func ValidateSQLiteDB(dbPath string) error {
 func ValidateSQLiteDB(dbPath string) error {

+ 87 - 0
internal/database/freedom_finalrules_migration_test.go

@@ -0,0 +1,87 @@
+package database
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestRewriteFreedomFinalRulesPrivateEgress(t *testing.T) {
+	hardened := []any{
+		map[string]any{"action": "block", "ip": []any{"geoip:private"}},
+		map[string]any{"action": "allow"},
+	}
+
+	tests := []struct {
+		name        string
+		raw         string
+		wantChanged bool
+		wantRules   []any
+	}{
+		{
+			name:        "allow-only default is hardened",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"domainStrategy":"AsIs","finalRules":[{"action":"allow"}]},"tag":"direct"}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "legacy private-only allow is hardened",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"allow","ip":["geoip:private"]}]},"tag":"direct"}]}`,
+			wantChanged: true,
+			wantRules:   hardened,
+		},
+		{
+			name:        "customized rules are preserved",
+			raw:         `{"outbounds":[{"protocol":"freedom","settings":{"finalRules":[{"action":"block","ip":["1.2.3.4"]},{"action":"allow"}]},"tag":"direct"}]}`,
+			wantChanged: false,
+		},
+		{
+			name:        "non-freedom outbounds are ignored",
+			raw:         `{"outbounds":[{"protocol":"blackhole","settings":{},"tag":"blocked"}]}`,
+			wantChanged: false,
+		},
+		{
+			name:        "empty config is untouched",
+			raw:         "",
+			wantChanged: false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed, err := rewriteFreedomFinalRulesPrivateEgress(tc.raw)
+			if err != nil {
+				t.Fatalf("unexpected error: %v", err)
+			}
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v", changed, tc.wantChanged)
+			}
+			if !tc.wantChanged {
+				if updated != tc.raw {
+					t.Fatalf("raw config mutated without change flag:\n%s", updated)
+				}
+				return
+			}
+			var cfg map[string]any
+			if err := json.Unmarshal([]byte(updated), &cfg); err != nil {
+				t.Fatalf("updated config is not valid json: %v", err)
+			}
+			outbounds := cfg["outbounds"].([]any)
+			settings := outbounds[0].(map[string]any)["settings"].(map[string]any)
+			gotRules, _ := json.Marshal(settings["finalRules"])
+			wantRules, _ := json.Marshal(tc.wantRules)
+			if string(gotRules) != string(wantRules) {
+				t.Fatalf("finalRules = %s, want %s", gotRules, wantRules)
+			}
+		})
+	}
+}
+
+func TestRewriteFreedomFinalRulesPrivateEgressInvalidJSON(t *testing.T) {
+	_, changed, err := rewriteFreedomFinalRulesPrivateEgress("{not json")
+	if err == nil {
+		t.Fatal("expected a json error for malformed config")
+	}
+	if changed {
+		t.Fatal("malformed config must not report changed")
+	}
+}

+ 82 - 0
internal/database/journal_mode_test.go

@@ -0,0 +1,82 @@
+package database
+
+import (
+	"bytes"
+	"os"
+	"path/filepath"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func journalModeOf(t *testing.T) string {
+	t.Helper()
+	var mode string
+	if err := db.Raw("PRAGMA journal_mode;").Scan(&mode).Error; err != nil {
+		t.Fatalf("read journal_mode: %v", err)
+	}
+	return mode
+}
+
+func TestSqliteJournalModeDefaultsToWal(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	dbDir := t.TempDir()
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if got := journalModeOf(t); got != "wal" {
+		t.Fatalf("journal_mode = %q, want wal", got)
+	}
+}
+
+func TestSqliteJournalModeEnvOverrideDelete(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "delete")
+	dbDir := t.TempDir()
+	if err := InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if got := journalModeOf(t); got != "delete" {
+		t.Fatalf("journal_mode = %q, want delete", got)
+	}
+}
+
+func TestWalCheckpointMakesRawFileBackupComplete(t *testing.T) {
+	t.Setenv("XUI_DB_JOURNAL_MODE", "")
+	dbDir := t.TempDir()
+	dbPath := filepath.Join(dbDir, "x-ui.db")
+	if err := InitDB(dbPath); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = CloseDB() })
+
+	if err := db.Create(&model.Setting{Key: "walBackupProbe", Value: "42"}).Error; err != nil {
+		t.Fatalf("write setting: %v", err)
+	}
+	if err := Checkpoint(); err != nil {
+		t.Fatalf("Checkpoint: %v", err)
+	}
+
+	raw, err := os.ReadFile(dbPath)
+	if err != nil {
+		t.Fatalf("read db file: %v", err)
+	}
+	copyPath := filepath.Join(t.TempDir(), "copy.db")
+	if err := os.WriteFile(copyPath, raw, 0o600); err != nil {
+		t.Fatalf("write copy: %v", err)
+	}
+	if err := ValidateSQLiteDB(copyPath); err != nil {
+		t.Fatalf("checkpointed raw copy must be a valid sqlite db: %v", err)
+	}
+
+	dump, err := DumpSQLiteToBytes(copyPath)
+	if err != nil {
+		t.Fatalf("dump copy: %v", err)
+	}
+	if !bytes.Contains(dump, []byte("walBackupProbe")) {
+		t.Fatal("raw-file backup taken after Checkpoint must contain the latest write")
+	}
+}

+ 1 - 0
internal/database/model/model.go

@@ -1098,6 +1098,7 @@ type OutboundSubscription struct {
 	Url                  string `json:"url" form:"url"`
 	Url                  string `json:"url" form:"url"`
 	Enabled              bool   `json:"enabled" form:"enabled" gorm:"default:true"`
 	Enabled              bool   `json:"enabled" form:"enabled" gorm:"default:true"`
 	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
 	AllowPrivate         bool   `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"`
+	AllowInsecure        bool   `json:"allowInsecure" form:"allowInsecure" gorm:"default:false"`
 	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
 	TagPrefix            string `json:"tagPrefix" form:"tagPrefix"`
 	UpdateInterval       int    `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
 	UpdateInterval       int    `json:"updateInterval" form:"updateInterval" gorm:"default:600"` // seconds between refreshes
 	Priority             int    `json:"priority" form:"priority" gorm:"default:0"`               // order among subscriptions in the merged outbounds (lower = earlier)
 	Priority             int    `json:"priority" form:"priority" gorm:"default:0"`               // order among subscriptions in the merged outbounds (lower = earlier)

+ 93 - 0
internal/database/reality_finalmask_migration_test.go

@@ -0,0 +1,93 @@
+package database
+
+import (
+	"encoding/json"
+	"testing"
+)
+
+func TestStripRealityFinalmaskTcpFromStream(t *testing.T) {
+	tests := []struct {
+		name        string
+		raw         string
+		wantChanged bool
+		wantAbsent  []string
+		wantPresent []string
+	}{
+		{
+			name:        "reality with tcp masks is stripped",
+			raw:         `{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"},"finalmask":{"tcp":[{"type":"sudoku"}]}}`,
+			wantChanged: true,
+			wantAbsent:  []string{"finalmask"},
+			wantPresent: []string{"realitySettings"},
+		},
+		{
+			name:        "reality with tcp and udp masks keeps udp",
+			raw:         `{"security":"reality","finalmask":{"tcp":[{"type":"sudoku"}],"udp":[{"type":"salt"}]}}`,
+			wantChanged: true,
+			wantAbsent:  []string{"tcp"},
+			wantPresent: []string{"udp"},
+		},
+		{
+			name:        "tls with tcp masks is untouched",
+			raw:         `{"security":"tls","finalmask":{"tcp":[{"type":"sudoku"}]}}`,
+			wantChanged: false,
+		},
+		{
+			name:        "reality without finalmask is untouched",
+			raw:         `{"security":"reality","realitySettings":{}}`,
+			wantChanged: false,
+		},
+		{
+			name:        "empty stream is untouched",
+			raw:         "",
+			wantChanged: false,
+		},
+		{
+			name:        "invalid json is untouched",
+			raw:         "{not json",
+			wantChanged: false,
+		},
+	}
+
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			updated, changed := stripRealityFinalmaskTcpFromStream(tc.raw)
+			if changed != tc.wantChanged {
+				t.Fatalf("changed = %v, want %v (updated: %s)", changed, tc.wantChanged, updated)
+			}
+			if !tc.wantChanged {
+				if updated != tc.raw {
+					t.Fatalf("stream mutated without change flag: %s", updated)
+				}
+				return
+			}
+			var stream map[string]any
+			if err := json.Unmarshal([]byte(updated), &stream); err != nil {
+				t.Fatalf("updated stream is not valid json: %v", err)
+			}
+			flat, _ := json.Marshal(stream)
+			for _, key := range tc.wantAbsent {
+				if containsJSONKey(stream, key) {
+					t.Fatalf("key %q should be gone: %s", key, flat)
+				}
+			}
+			for _, key := range tc.wantPresent {
+				if !containsJSONKey(stream, key) {
+					t.Fatalf("key %q should survive: %s", key, flat)
+				}
+			}
+		})
+	}
+}
+
+func containsJSONKey(m map[string]any, key string) bool {
+	if _, ok := m[key]; ok {
+		return true
+	}
+	for _, v := range m {
+		if nested, ok := v.(map[string]any); ok && containsJSONKey(nested, key) {
+			return true
+		}
+	}
+	return false
+}

+ 14 - 0
internal/sub/host_sub.go

@@ -125,6 +125,20 @@ func hostMuxOverride(ep map[string]any) string {
 // the base stream strips sockopt) and finalMask. No-op for legacy externalProxy
 // the base stream strips sockopt) and finalMask. No-op for legacy externalProxy
 // entries (which never carry these keys), so existing output is unchanged.
 // entries (which never carry these keys), so existing output is unchanged.
 func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
 func applyHostStreamOverrides(ep map[string]any, stream map[string]any) {
+	if hh, ok := ep["hostHeader"].(string); ok && hh != "" {
+		for _, key := range []string{"wsSettings", "httpupgradeSettings", "xhttpSettings"} {
+			if ts, ok := stream[key].(map[string]any); ok && ts != nil {
+				ts["host"] = hh
+			}
+		}
+	}
+	if p, ok := ep["path"].(string); ok && p != "" {
+		for _, key := range []string{"wsSettings", "httpupgradeSettings", "xhttpSettings"} {
+			if ts, ok := stream[key].(map[string]any); ok && ts != nil {
+				ts["path"] = p
+			}
+		}
+	}
 	if sp, ok := ep["sockoptParams"].(string); ok && sp != "" {
 	if sp, ok := ep["sockoptParams"].(string); ok && sp != "" {
 		var sockopt map[string]any
 		var sockopt map[string]any
 		if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 {
 		if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 {

+ 33 - 0
internal/sub/host_sub_test.go

@@ -271,6 +271,39 @@ func TestSub_HostAllowInsecure(t *testing.T) {
 	}
 	}
 }
 }
 
 
+// A host's Host header and path reach the Clash and JSON renderers even when
+// the inbound's own ws settings leave them empty (#5944).
+func TestSub_HostHeaderReachesClashAndJson(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "hh", 4457, 1,
+		`{"network":"ws","security":"tls","wsSettings":{"path":"/"},"tlsSettings":{"serverName":"base.sni"}}`)
+	seedHost(t, &model.Host{
+		InboundId: ib.Id, SortOrder: 0, Remark: "HH", Address: "hh.cdn.com", Port: 8443, Security: "tls",
+		HostHeader: "cdn.example.com", Path: "/ws-path",
+	})
+
+	clash := NewSubClashService(false, "", NewSubService(""))
+	yaml, _, err := clash.GetClash("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if !strings.Contains(yaml, "Host: cdn.example.com") {
+		t.Fatalf("clash ws-opts should carry the host record's Host header:\n%s", yaml)
+	}
+	if !strings.Contains(yaml, "path: /ws-path") {
+		t.Fatalf("clash ws-opts should carry the host record's path:\n%s", yaml)
+	}
+
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com", false)
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if !strings.Contains(out, `"host": "cdn.example.com"`) && !strings.Contains(out, `"host":"cdn.example.com"`) {
+		t.Fatalf("json wsSettings should carry the host record's Host header:\n%s", out)
+	}
+}
+
 // A host's Final Mask reaches the raw share link as the fm param, merged with
 // A host's Final Mask reaches the raw share link as the fm param, merged with
 // any inbound-level mask (#5831).
 // any inbound-level mask (#5831).
 func TestSub_HostFinalMask_RawLink(t *testing.T) {
 func TestSub_HostFinalMask_RawLink(t *testing.T) {

+ 4 - 4
internal/web/controller/client.go

@@ -109,22 +109,22 @@ func (a *ClientController) get(c *gin.Context) {
 	email := c.Param("email")
 	email := c.Param("email")
 	rec, err := a.clientService.GetRecordByEmail(nil, email)
 	rec, err := a.clientService.GetRecordByEmail(nil, email)
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
 	inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id)
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
 	externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id)
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
 	flow, err := a.clientService.EffectiveFlow(nil, rec.Id)
 	if err != nil {
 	if err != nil {
-		jsonMsg(c, I18nWeb(c, "get"), err)
+		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err)
 		return
 		return
 	}
 	}
 	rec.Flow = flow
 	rec.Flow = flow

+ 2 - 1
internal/web/controller/server.go

@@ -463,7 +463,8 @@ func (a *ServerController) getRemoteCertHash(c *gin.Context) {
 // scanRealityTarget runs a live TLS 1.3 probe against the candidate REALITY
 // scanRealityTarget runs a live TLS 1.3 probe against the candidate REALITY
 // target and returns a structured feasibility verdict plus the cert SAN names.
 // target and returns a structured feasibility verdict plus the cert SAN names.
 func (a *ServerController) scanRealityTarget(c *gin.Context) {
 func (a *ServerController) scanRealityTarget(c *gin.Context) {
-	res, err := a.serverService.ScanRealityTarget(c.PostForm("target"))
+	xver, _ := strconv.Atoi(c.PostForm("xver"))
+	res, err := a.serverService.ScanRealityTarget(c.PostForm("target"), xver)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
 		jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err)
 		return
 		return

+ 6 - 3
internal/web/controller/xray_setting.go

@@ -408,6 +408,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) {
 	prefix := c.PostForm("tagPrefix")
 	prefix := c.PostForm("tagPrefix")
 	enabled := c.PostForm("enabled") != "false"
 	enabled := c.PostForm("enabled") != "false"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
+	allowInsecure := c.PostForm("allowInsecure") == "true"
 	prepend := c.PostForm("prepend") == "true"
 	prepend := c.PostForm("prepend") == "true"
 	intervalStr := c.PostForm("updateInterval")
 	intervalStr := c.PostForm("updateInterval")
 	interval := 600
 	interval := 600
@@ -416,7 +417,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) {
 			interval = v
 			interval = v
 		}
 		}
 	}
 	}
-	sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend)
+	sub, err := a.OutboundSubscriptionService.Create(remark, rawURL, prefix, enabled, interval, allowPrivate, prepend, allowInsecure)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, "Failed to create outbound subscription", err)
 		jsonMsg(c, "Failed to create outbound subscription", err)
 		return
 		return
@@ -436,6 +437,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
 	prefix := c.PostForm("tagPrefix")
 	prefix := c.PostForm("tagPrefix")
 	enabled := c.PostForm("enabled") != "false"
 	enabled := c.PostForm("enabled") != "false"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
+	allowInsecure := c.PostForm("allowInsecure") == "true"
 	prepend := c.PostForm("prepend") == "true"
 	prepend := c.PostForm("prepend") == "true"
 	intervalStr := c.PostForm("updateInterval")
 	intervalStr := c.PostForm("updateInterval")
 	interval := 600
 	interval := 600
@@ -444,7 +446,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) {
 			interval = v
 			interval = v
 		}
 		}
 	}
 	}
-	if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend); err != nil {
+	if err := a.OutboundSubscriptionService.Update(subID, remark, rawURL, prefix, enabled, interval, allowPrivate, prepend, allowInsecure); err != nil {
 		jsonMsg(c, "Failed to update outbound subscription", err)
 		jsonMsg(c, "Failed to update outbound subscription", err)
 		return
 		return
 	}
 	}
@@ -511,12 +513,13 @@ func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) {
 		return
 		return
 	}
 	}
 	allowPrivate := c.PostForm("allowPrivate") == "true"
 	allowPrivate := c.PostForm("allowPrivate") == "true"
+	allowInsecure := c.PostForm("allowInsecure") == "true"
 	// Use a throw-away service instance; it only needs the settingService for proxy.
 	// Use a throw-away service instance; it only needs the settingService for proxy.
 	svc := service.OutboundSubscriptionService{}
 	svc := service.OutboundSubscriptionService{}
 	// We don't have a direct "fetch once" that returns without storing, so we
 	// We don't have a direct "fetch once" that returns without storing, so we
 	// temporarily create a disabled row, refresh it, then delete. Cleaner would
 	// temporarily create a disabled row, refresh it, then delete. Cleaner would
 	// be to expose a pure ParseURL on the service, but this keeps the surface small.
 	// be to expose a pure ParseURL on the service, but this keeps the surface small.
-	tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false)
+	tmp, err := svc.Create("preview", rawURL, "", false, 600, allowPrivate, false, allowInsecure)
 	if err != nil {
 	if err != nil {
 		jsonMsg(c, "Failed to preview subscription", err)
 		jsonMsg(c, "Failed to preview subscription", err)
 		return
 		return

+ 1 - 0
internal/web/service/config.json

@@ -33,6 +33,7 @@
       "settings": {
       "settings": {
         "domainStrategy": "AsIs",
         "domainStrategy": "AsIs",
         "finalRules": [
         "finalRules": [
+          { "action": "block", "ip": ["geoip:private"] },
           { "action": "allow" }
           { "action": "allow" }
         ]
         ]
       },
       },

+ 48 - 29
internal/web/service/inbound.go

@@ -1418,45 +1418,64 @@ func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.I
 		return nil, err
 		return nil, err
 	}
 	}
 
 
-	clients, ok := settings["clients"].([]any)
-	if !ok {
-		return &runtimeInbound, nil
-	}
+	mutated := false
+	if clients, ok := settings["clients"].([]any); ok {
+		var clientStats []xray.ClientTraffic
+		err := tx.Model(xray.ClientTraffic{}).
+			Where("inbound_id = ?", inbound.Id).
+			Select("email", "enable").
+			Find(&clientStats).Error
+		if err != nil {
+			return nil, err
+		}
 
 
-	var clientStats []xray.ClientTraffic
-	err := tx.Model(xray.ClientTraffic{}).
-		Where("inbound_id = ?", inbound.Id).
-		Select("email", "enable").
-		Find(&clientStats).Error
-	if err != nil {
-		return nil, err
-	}
+		enableMap := make(map[string]bool, len(clientStats))
+		for _, clientTraffic := range clientStats {
+			enableMap[clientTraffic.Email] = clientTraffic.Enable
+		}
 
 
-	enableMap := make(map[string]bool, len(clientStats))
-	for _, clientTraffic := range clientStats {
-		enableMap[clientTraffic.Email] = clientTraffic.Enable
-	}
+		finalClients := make([]any, 0, len(clients))
+		for _, client := range clients {
+			c, ok := client.(map[string]any)
+			if !ok {
+				continue
+			}
 
 
-	finalClients := make([]any, 0, len(clients))
-	for _, client := range clients {
-		c, ok := client.(map[string]any)
-		if !ok {
-			continue
-		}
+			email, _ := c["email"].(string)
+			if enable, exists := enableMap[email]; exists && !enable {
+				continue
+			}
 
 
-		email, _ := c["email"].(string)
-		if enable, exists := enableMap[email]; exists && !enable {
-			continue
+			if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
+				continue
+			}
+
+			finalClients = append(finalClients, c)
 		}
 		}
 
 
-		if manualEnable, ok := c["enable"].(bool); ok && !manualEnable {
-			continue
+		settings["clients"] = finalClients
+		mutated = true
+	}
+
+	if inboundCanHostFallbacks(inbound) {
+		fallbacks, fbErr := s.fallbackService.BuildFallbacksJSON(tx, inbound.Id)
+		if fbErr != nil {
+			return nil, fbErr
 		}
 		}
+		if len(fallbacks) > 0 {
+			generic := make([]any, 0, len(fallbacks))
+			for _, f := range fallbacks {
+				generic = append(generic, f)
+			}
+			settings["fallbacks"] = generic
+			mutated = true
+		}
+	}
 
 
-		finalClients = append(finalClients, c)
+	if !mutated {
+		return &runtimeInbound, nil
 	}
 	}
 
 
-	settings["clients"] = finalClients
 	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
 	modifiedSettings, err := json.MarshalIndent(settings, "", "  ")
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err

+ 60 - 0
internal/web/service/inbound_client_move_test.go

@@ -0,0 +1,60 @@
+package service
+
+import (
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+)
+
+// TestGetClientByEmail_AfterMoveBetweenInbounds is the #6059 regression: a
+// client moved to another inbound keeps a client_traffics row pointing at the
+// old inbound (which still exists), so email lookups used to fail with
+// "Client Not Found In Inbound For Email" and the Telegram bot could not build
+// links or QR codes for the client anymore.
+func TestGetClientByEmail_AfterMoveBetweenInbounds(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	oldClients := []model.Client{
+		{Email: "stay@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
+	}
+	movedClients := []model.Client{
+		{Email: "moved@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
+	}
+
+	oldIb := mkInbound(t, 30101, model.VLESS, clientsSettings(t, oldClients))
+	newIb := mkInbound(t, 30102, model.VLESS, clientsSettings(t, movedClients))
+	if err := svc.clientService.SyncInbound(nil, oldIb.Id, oldClients); err != nil {
+		t.Fatalf("SyncInbound old: %v", err)
+	}
+	if err := svc.clientService.SyncInbound(nil, newIb.Id, movedClients); err != nil {
+		t.Fatalf("SyncInbound new: %v", err)
+	}
+
+	stale := xray.ClientTraffic{InboundId: oldIb.Id, Email: "moved@x", Enable: true, Up: 5, Down: 7}
+	if err := db.Create(&stale).Error; err != nil {
+		t.Fatalf("seed stale traffic row: %v", err)
+	}
+
+	traffic, inbound, err := svc.GetClientInboundByEmail("moved@x")
+	if err != nil {
+		t.Fatalf("GetClientInboundByEmail: %v", err)
+	}
+	if traffic == nil || traffic.Email != "moved@x" {
+		t.Fatalf("traffic = %+v, want the moved@x row", traffic)
+	}
+	if inbound == nil || inbound.Id != newIb.Id {
+		t.Fatalf("inbound = %+v, want the new inbound %d", inbound, newIb.Id)
+	}
+
+	_, client, err := svc.GetClientByEmail("moved@x")
+	if err != nil {
+		t.Fatalf("GetClientByEmail: %v", err)
+	}
+	if client == nil || client.Email != "moved@x" {
+		t.Fatalf("client = %+v, want moved@x", client)
+	}
+}

+ 30 - 0
internal/web/service/inbound_clients.go

@@ -413,9 +413,39 @@ func (s *InboundService) GetClientInboundByEmail(email string) (traffic *xray.Cl
 			inbound, err = s.GetInbound(ids[0])
 			inbound, err = s.GetInbound(ids[0])
 		}
 		}
 	}
 	}
+	if err == nil && inbound != nil && !s.inboundHasClientEmail(inbound, email) {
+		// The pointed-at inbound still exists but no longer carries the client —
+		// the client was moved to another inbound (#6059). Resolve through the
+		// client_inbounds link to the inbound that actually hosts it now.
+		ids, idErr := s.clientService.GetInboundIdsForEmail(db, email)
+		if idErr == nil {
+			for _, id := range ids {
+				if id == inbound.Id {
+					continue
+				}
+				if other, oErr := s.GetInbound(id); oErr == nil && s.inboundHasClientEmail(other, email) {
+					inbound = other
+					break
+				}
+			}
+		}
+	}
 	return traffic, inbound, err
 	return traffic, inbound, err
 }
 }
 
 
+func (s *InboundService) inboundHasClientEmail(inbound *model.Inbound, email string) bool {
+	clients, err := s.GetClients(inbound)
+	if err != nil {
+		return false
+	}
+	for _, client := range clients {
+		if client.Email == email {
+			return true
+		}
+	}
+	return false
+}
+
 func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraffic, *model.Client, error) {
 func (s *InboundService) GetClientByEmail(clientEmail string) (*xray.ClientTraffic, *model.Client, error) {
 	traffic, inbound, err := s.GetClientInboundByEmail(clientEmail)
 	traffic, inbound, err := s.GetClientInboundByEmail(clientEmail)
 	if err != nil {
 	if err != nil {

+ 78 - 0
internal/web/service/inbound_fallback_runtime_test.go

@@ -0,0 +1,78 @@
+package service
+
+import (
+	"encoding/json"
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+// TestBuildRuntimeInboundForAPI_InjectsFallbacks is the #5963 regression: the
+// runtime inbound sent to nodes never carried the inbound_fallbacks rows, so
+// fallbacks configured on the master silently vanished from every node.
+func TestBuildRuntimeInboundForAPI_InjectsFallbacks(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{Email: "fb@x", ID: "11111111-1111-1111-1111-111111111111", Enable: true},
+	}
+	master := mkInbound(t, 30201, model.VLESS, clientsSettings(t, clients))
+	master.StreamSettings = `{"network":"tcp","security":"tls","tlsSettings":{"serverName":"s"}}`
+	if err := db.Save(master).Error; err != nil {
+		t.Fatalf("save master stream: %v", err)
+	}
+
+	fb := model.InboundFallback{MasterId: master.Id, ChildId: 0, Dest: "8081", Alpn: "h2", Path: "/fb", Xver: 1}
+	if err := db.Create(&fb).Error; err != nil {
+		t.Fatalf("seed fallback: %v", err)
+	}
+
+	runtimeIb, err := svc.buildRuntimeInboundForAPI(db, master)
+	if err != nil {
+		t.Fatalf("buildRuntimeInboundForAPI: %v", err)
+	}
+
+	var settings map[string]any
+	if err := json.Unmarshal([]byte(runtimeIb.Settings), &settings); err != nil {
+		t.Fatalf("runtime settings not valid json: %v", err)
+	}
+	fallbacks, ok := settings["fallbacks"].([]any)
+	if !ok || len(fallbacks) != 1 {
+		t.Fatalf("runtime settings must carry the fallback, got: %s", runtimeIb.Settings)
+	}
+	if !strings.Contains(runtimeIb.Settings, `"dest"`) || !strings.Contains(runtimeIb.Settings, "8081") {
+		t.Fatalf("fallback dest missing from runtime settings: %s", runtimeIb.Settings)
+	}
+}
+
+// A non-fallback-capable inbound (ws transport) must stay untouched.
+func TestBuildRuntimeInboundForAPI_NoFallbacksOnWsInbound(t *testing.T) {
+	setupBulkDB(t)
+	svc := &InboundService{}
+	db := database.GetDB()
+
+	clients := []model.Client{
+		{Email: "ws@x", ID: "22222222-2222-2222-2222-222222222222", Enable: true},
+	}
+	ib := mkInbound(t, 30202, model.VLESS, clientsSettings(t, clients))
+	ib.StreamSettings = `{"network":"ws","security":"none"}`
+	if err := db.Save(ib).Error; err != nil {
+		t.Fatalf("save stream: %v", err)
+	}
+	fb := model.InboundFallback{MasterId: ib.Id, Dest: "8082"}
+	if err := db.Create(&fb).Error; err != nil {
+		t.Fatalf("seed fallback: %v", err)
+	}
+
+	runtimeIb, err := svc.buildRuntimeInboundForAPI(db, ib)
+	if err != nil {
+		t.Fatalf("buildRuntimeInboundForAPI: %v", err)
+	}
+	if strings.Contains(runtimeIb.Settings, "fallbacks") {
+		t.Fatalf("ws inbound must not receive fallbacks: %s", runtimeIb.Settings)
+	}
+}

+ 8 - 1
internal/web/service/inbound_node.go

@@ -125,7 +125,14 @@ func (s *InboundService) ReconcileNode(ctx context.Context, rt *runtime.Remote,
 				}
 				}
 			}
 			}
 		}
 		}
-		if _, err := rt.ReconcileInbound(ctx, ib, existsOnNode); err != nil {
+		// Reconcile with the same runtime-built payload interactive pushes
+		// send (disabled clients filtered, settings.fallbacks injected) so
+		// fingerprints line up and fallback edits actually reach the node.
+		runtimeIb := ib
+		if built, bErr := s.buildRuntimeInboundForAPI(db, ib); bErr == nil {
+			runtimeIb = built
+		}
+		if _, err := rt.ReconcileInbound(ctx, runtimeIb, existsOnNode); err != nil {
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 			errs = append(errs, fmt.Errorf("reconcile inbound %q: %w", ib.Tag, err))
 		}
 		}
 	}
 	}

+ 32 - 10
internal/web/service/outbound_subscription.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"context"
 	"context"
+	"crypto/tls"
 	"encoding/json"
 	"encoding/json"
 	"errors"
 	"errors"
 	"fmt"
 	"fmt"
@@ -24,14 +25,20 @@ import (
 // filterOutboundsRejectedByCore drops outbounds the vendored xray-core config
 // filterOutboundsRejectedByCore drops outbounds the vendored xray-core config
 // loader refuses to build — since v26.7.11 that includes unencrypted
 // loader refuses to build — since v26.7.11 that includes unencrypted
 // vless/trojan outbounds to public addresses — because one such outbound in
 // vless/trojan outbounds to public addresses — because one such outbound in
-// the merged config would keep the whole core from starting.
+// the merged config would keep the whole core from starting. When the running
+// core predates that rejection, unencrypted outbounds are kept, mirroring
+// CheckXrayConfig's version gate.
 func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
 func filterOutboundsRejectedByCore(label string, outbounds []any) ([]any, []string) {
+	coreVersion := "Unknown"
+	if p != nil {
+		coreVersion = p.GetXrayVersion()
+	}
 	kept := make([]any, 0, len(outbounds))
 	kept := make([]any, 0, len(outbounds))
 	var dropped []string
 	var dropped []string
 	for _, ob := range outbounds {
 	for _, ob := range outbounds {
 		raw, err := json.Marshal(ob)
 		raw, err := json.Marshal(ob)
 		if err == nil {
 		if err == nil {
-			if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil {
+			if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil && !shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, buildErr) {
 				tag := ""
 				tag := ""
 				if m, ok := ob.(map[string]any); ok {
 				if m, ok := ob.(map[string]any); ok {
 					tag, _ = m["tag"].(string)
 					tag, _ = m["tag"].(string)
@@ -155,7 +162,7 @@ func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string
 	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
 	return fmt.Sprintf("sub%d-", defaultPrefixNumber(subs, excludeId))
 }
 }
 
 
-func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) (*model.OutboundSubscription, error) {
+func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) (*model.OutboundSubscription, error) {
 	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
 	cleanURL, err := SanitizePublicHTTPURL(rawURL, allowPrivate)
 	if err != nil {
 	if err != nil {
 		return nil, common.NewError("invalid subscription URL:", err)
 		return nil, common.NewError("invalid subscription URL:", err)
@@ -178,6 +185,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 		Url:            cleanURL,
 		Url:            cleanURL,
 		Enabled:        enabled,
 		Enabled:        enabled,
 		AllowPrivate:   allowPrivate,
 		AllowPrivate:   allowPrivate,
+		AllowInsecure:  allowInsecure,
 		Prepend:        prepend,
 		Prepend:        prepend,
 		Priority:       int(count),
 		Priority:       int(count),
 		TagPrefix:      prefix,
 		TagPrefix:      prefix,
@@ -190,7 +198,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e
 }
 }
 
 
 // Update updates editable fields.
 // Update updates editable fields.
-func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend bool) error {
+func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix string, enabled bool, updateInterval int, allowPrivate, prepend, allowInsecure bool) error {
 	sub, err := s.Get(id)
 	sub, err := s.Get(id)
 	if err != nil {
 	if err != nil {
 		return err
 		return err
@@ -213,6 +221,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s
 	sub.Url = cleanURL
 	sub.Url = cleanURL
 	sub.Enabled = enabled
 	sub.Enabled = enabled
 	sub.AllowPrivate = allowPrivate
 	sub.AllowPrivate = allowPrivate
+	sub.AllowInsecure = allowInsecure
 	sub.Prepend = prepend
 	sub.Prepend = prepend
 	sub.TagPrefix = prefix
 	sub.TagPrefix = prefix
 	sub.UpdateInterval = updateInterval
 	sub.UpdateInterval = updateInterval
@@ -284,14 +293,27 @@ func (s *OutboundSubscriptionService) RefreshAllEnabled() (int, error) {
 // goes through the SSRF-guarded dialer, which resolves, checks and dials the same
 // goes through the SSRF-guarded dialer, which resolves, checks and dials the same
 // IP atomically — closing the DNS-rebinding gap left by validating the hostname
 // IP atomically — closing the DNS-rebinding gap left by validating the hostname
 // separately from the dial.
 // separately from the dial.
-func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration) *http.Client {
+func (s *OutboundSubscriptionService) subscriptionFetchClient(timeout time.Duration, allowInsecure bool) *http.Client {
+	var client *http.Client
 	if s.settingService.PanelEgressProxyURL() != "" {
 	if s.settingService.PanelEgressProxyURL() != "" {
-		return s.settingService.NewProxiedHTTPClient(timeout)
+		client = s.settingService.NewProxiedHTTPClient(timeout)
+	} else {
+		client = &http.Client{
+			Timeout:   timeout,
+			Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext},
+		}
 	}
 	}
-	return &http.Client{
-		Timeout:   timeout,
-		Transport: &http.Transport{DialContext: netsafe.SSRFGuardedDialContext},
+	if allowInsecure {
+		if tr, ok := client.Transport.(*http.Transport); ok && tr != nil {
+			cloned := tr.Clone()
+			if cloned.TLSClientConfig == nil {
+				cloned.TLSClientConfig = &tls.Config{}
+			}
+			cloned.TLSClientConfig.InsecureSkipVerify = true
+			client.Transport = cloned
+		}
 	}
 	}
+	return client
 }
 }
 
 
 // fetchAndStore does the actual network + parse + stability + persist work.
 // fetchAndStore does the actual network + parse + stability + persist work.
@@ -309,7 +331,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript
 	}
 	}
 	sub.Url = cleanURL // persist the cleaned version
 	sub.Url = cleanURL // persist the cleaned version
 
 
-	client := s.subscriptionFetchClient(30 * time.Second)
+	client := s.subscriptionFetchClient(30*time.Second, sub.AllowInsecure)
 	// Re-validate every redirect hop: the initial host is checked above, but a
 	// Re-validate every redirect hop: the initial host is checked above, but a
 	// redirect could still point at a private/internal address (SSRF). Cap the
 	// redirect could still point at a private/internal address (SSRF). Cap the
 	// redirect chain as well.
 	// redirect chain as well.

+ 1 - 1
internal/web/service/outbound_subscription_ssrf_test.go

@@ -12,7 +12,7 @@ import (
 
 
 func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) {
 func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) {
 	setupSettingTestDB(t)
 	setupSettingTestDB(t)
-	client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5 * time.Second)
+	client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5*time.Second, false)
 
 
 	ctx := netsafe.ContextWithAllowPrivate(context.Background(), false)
 	ctx := netsafe.ContextWithAllowPrivate(context.Background(), false)
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil)
 	req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil)

+ 69 - 6
internal/web/service/reality_scan.go

@@ -170,7 +170,7 @@ func enumerateCIDR(cidr string, max int) ([]string, error) {
 	return ips, nil
 	return ips, nil
 }
 }
 
 
-func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration) *RealityScanResult {
+func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, timeout time.Duration, xver int) *RealityScanResult {
 	addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
 	addr := net.JoinHostPort(dialHost, strconv.Itoa(port))
 	res := &RealityScanResult{Port: port}
 	res := &RealityScanResult{Port: port}
 	if net.ParseIP(dialHost) != nil {
 	if net.ParseIP(dialHost) != nil {
@@ -196,6 +196,17 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 	defer conn.Close()
 	defer conn.Close()
 	_ = conn.SetDeadline(time.Now().Add(timeout))
 	_ = conn.SetDeadline(time.Now().Add(timeout))
 
 
+	// A REALITY inbound with xver>=1 fronts a target that speaks the PROXY
+	// protocol (e.g. an Nginx listener with `proxy_protocol`), so the probe
+	// must lead with a PROXY header or the target resets the connection and
+	// the scan reports a spurious handshake failure (#6082).
+	if xver >= 1 {
+		if err := writeProxyProtocolHeader(conn, xver); err != nil {
+			res.Reason = "proxy protocol write failed: " + err.Error()
+			return res
+		}
+	}
+
 	cfg := &tls.Config{
 	cfg := &tls.Config{
 		ServerName:         sni,
 		ServerName:         sni,
 		InsecureSkipVerify: true,
 		InsecureSkipVerify: true,
@@ -272,16 +283,16 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string,
 	return res
 	return res
 }
 }
 
 
-func (s *ServerService) probeRealityTarget(host string, port int) *RealityScanResult {
-	return s.probeRealityAddr(host, port, host, realityScanTimeout)
+func (s *ServerService) probeRealityTarget(host string, port int, xver int) *RealityScanResult {
+	return s.probeRealityAddr(host, port, host, realityScanTimeout, xver)
 }
 }
 
 
-func (s *ServerService) ScanRealityTarget(target string) (*RealityScanResult, error) {
+func (s *ServerService) ScanRealityTarget(target string, xver int) (*RealityScanResult, error) {
 	host, port, err := splitRealityTarget(target)
 	host, port, err := splitRealityTarget(target)
 	if err != nil {
 	if err != nil {
 		return nil, err
 		return nil, err
 	}
 	}
-	return s.probeRealityTarget(host, port), nil
+	return s.probeRealityTarget(host, port, xver), nil
 }
 }
 
 
 func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
 func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanResult, error) {
@@ -336,7 +347,7 @@ func (s *ServerService) ScanRealityTargets(targetsCSV string) ([]*RealityScanRes
 		go func(idx int, tk realityProbeTask) {
 		go func(idx int, tk realityProbeTask) {
 			defer wg.Done()
 			defer wg.Done()
 			defer func() { <-sem }()
 			defer func() { <-sem }()
-			r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout)
+			r := s.probeRealityAddr(tk.dialHost, tk.port, tk.sni, tk.timeout, 0)
 			if tk.bulk && r.TLSVersion == "" {
 			if tk.bulk && r.TLSVersion == "" {
 				return
 				return
 			}
 			}
@@ -389,3 +400,55 @@ func sortRealityResults(results []*RealityScanResult) {
 		return a.LatencyMs - b.LatencyMs
 		return a.LatencyMs - b.LatencyMs
 	})
 	})
 }
 }
+
+// writeProxyProtocolHeader emits a PROXY protocol header describing the local
+// connection so a target that requires it (Nginx `proxy_protocol`, matching a
+// REALITY inbound's xver) accepts the probe instead of resetting it. xver 1
+// sends the human-readable v1 header; xver 2 sends the binary v2 header. The
+// addresses come from the already-dialed connection, so they are always a
+// consistent, real (src, dst) pair.
+func writeProxyProtocolHeader(conn net.Conn, xver int) error {
+	local, lok := conn.LocalAddr().(*net.TCPAddr)
+	remote, rok := conn.RemoteAddr().(*net.TCPAddr)
+	if !lok || !rok {
+		return fmt.Errorf("connection has no TCP addresses")
+	}
+	if xver >= 2 {
+		return writeProxyProtocolV2(conn, local, remote)
+	}
+	return writeProxyProtocolV1(conn, local, remote)
+}
+
+func writeProxyProtocolV1(conn net.Conn, local, remote *net.TCPAddr) error {
+	fam := "TCP4"
+	if local.IP.To4() == nil || remote.IP.To4() == nil {
+		fam = "TCP6"
+	}
+	header := fmt.Sprintf("PROXY %s %s %s %d %d\r\n", fam, local.IP.String(), remote.IP.String(), local.Port, remote.Port)
+	_, err := conn.Write([]byte(header))
+	return err
+}
+
+func writeProxyProtocolV2(conn net.Conn, local, remote *net.TCPAddr) error {
+	buf := []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A}
+	buf = append(buf, 0x21)
+
+	src4, dst4 := local.IP.To4(), remote.IP.To4()
+	if src4 != nil && dst4 != nil {
+		buf = append(buf, 0x11)
+		buf = append(buf, 0x00, 12)
+		buf = append(buf, src4...)
+		buf = append(buf, dst4...)
+		buf = append(buf, byte(local.Port>>8), byte(local.Port))
+		buf = append(buf, byte(remote.Port>>8), byte(remote.Port))
+	} else {
+		buf = append(buf, 0x21)
+		buf = append(buf, 0x00, 36)
+		buf = append(buf, local.IP.To16()...)
+		buf = append(buf, remote.IP.To16()...)
+		buf = append(buf, byte(local.Port>>8), byte(local.Port))
+		buf = append(buf, byte(remote.Port>>8), byte(remote.Port))
+	}
+	_, err := conn.Write(buf)
+	return err
+}

+ 57 - 2
internal/web/service/reality_scan_test.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"crypto/tls"
 	"crypto/tls"
+	"net"
 	"testing"
 	"testing"
 )
 )
 
 
@@ -77,13 +78,13 @@ func TestSplitRealityTarget(t *testing.T) {
 }
 }
 
 
 func TestScanRealityTargetInputValidation(t *testing.T) {
 func TestScanRealityTargetInputValidation(t *testing.T) {
-	if _, err := (&ServerService{}).ScanRealityTarget(""); err == nil {
+	if _, err := (&ServerService{}).ScanRealityTarget("", 0); err == nil {
 		t.Error("ScanRealityTarget(empty) expected error, got nil")
 		t.Error("ScanRealityTarget(empty) expected error, got nil")
 	}
 	}
 }
 }
 
 
 func TestScanRealityTargetBlocksPrivate(t *testing.T) {
 func TestScanRealityTargetBlocksPrivate(t *testing.T) {
-	res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443")
+	res, err := (&ServerService{}).ScanRealityTarget("127.0.0.1:443", 0)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err)
 		t.Fatalf("ScanRealityTarget(loopback) unexpected error: %v", err)
 	}
 	}
@@ -109,3 +110,57 @@ func TestScanRealityTargetsHandlesPrivateAndBadInput(t *testing.T) {
 		}
 		}
 	}
 	}
 }
 }
+
+func TestWriteProxyProtocolV1(t *testing.T) {
+	server, client := net.Pipe()
+	defer client.Close()
+
+	local := &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 51234}
+	remote := &net.TCPAddr{IP: net.ParseIP("203.0.113.5"), Port: 443}
+
+	got := make(chan string, 1)
+	go func() {
+		buf := make([]byte, 128)
+		n, _ := server.Read(buf)
+		got <- string(buf[:n])
+	}()
+
+	if err := writeProxyProtocolV1(client, local, remote); err != nil {
+		t.Fatalf("writeProxyProtocolV1: %v", err)
+	}
+	line := <-got
+	want := "PROXY TCP4 192.0.2.10 203.0.113.5 51234 443\r\n"
+	if line != want {
+		t.Fatalf("v1 header = %q, want %q", line, want)
+	}
+}
+
+func TestWriteProxyProtocolV2Signature(t *testing.T) {
+	server, client := net.Pipe()
+	defer client.Close()
+
+	local := &net.TCPAddr{IP: net.ParseIP("192.0.2.10"), Port: 51234}
+	remote := &net.TCPAddr{IP: net.ParseIP("203.0.113.5"), Port: 443}
+
+	got := make(chan []byte, 1)
+	go func() {
+		buf := make([]byte, 128)
+		n, _ := server.Read(buf)
+		got <- append([]byte(nil), buf[:n]...)
+	}()
+
+	if err := writeProxyProtocolV2(client, local, remote); err != nil {
+		t.Fatalf("writeProxyProtocolV2: %v", err)
+	}
+	hdr := <-got
+	sig := []byte{0x0D, 0x0A, 0x0D, 0x0A, 0x00, 0x0D, 0x0A, 0x51, 0x55, 0x49, 0x54, 0x0A}
+	if len(hdr) < 16 || string(hdr[:12]) != string(sig) {
+		t.Fatalf("v2 header missing the protocol signature: %v", hdr)
+	}
+	if hdr[12] != 0x21 {
+		t.Fatalf("v2 version/command byte = 0x%02x, want 0x21", hdr[12])
+	}
+	if hdr[13] != 0x11 {
+		t.Fatalf("v2 family/protocol byte = 0x%02x, want 0x11 (TCP over IPv4)", hdr[13])
+	}
+}

+ 18 - 5
internal/web/service/xray_metrics.go

@@ -5,11 +5,12 @@ import (
 	"encoding/json"
 	"encoding/json"
 	"fmt"
 	"fmt"
 	"net/http"
 	"net/http"
-	"regexp"
 	"sort"
 	"sort"
 	"strings"
 	"strings"
 	"sync"
 	"sync"
 	"time"
 	"time"
+	"unicode"
+	"unicode/utf8"
 
 
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/eventbus"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
 	"github.com/mhsanaei/3x-ui/v3/internal/logger"
@@ -61,7 +62,19 @@ type outboundHealth struct {
 	notified   bool
 	notified   bool
 }
 }
 
 
-var validObsTag = regexp.MustCompile(`^[a-zA-Z0-9._\-]+$`)
+const maxObsTagLength = 128
+
+func validObsTag(tag string) bool {
+	if tag == "" || len(tag) > maxObsTagLength || !utf8.ValidString(tag) {
+		return false
+	}
+	for _, r := range tag {
+		if unicode.IsControl(r) {
+			return false
+		}
+	}
+	return true
+}
 
 
 func obsHistoryKey(tag string) string {
 func obsHistoryKey(tag string) string {
 	return "xrObs." + tag + ".delay"
 	return "xrObs." + tag + ".delay"
@@ -102,7 +115,7 @@ func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot {
 }
 }
 
 
 func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
 func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
-	if !validObsTag.MatchString(tag) {
+	if !validObsTag(tag) {
 		return false
 		return false
 	}
 	}
 	s.mu.RLock()
 	s.mu.RLock()
@@ -112,7 +125,7 @@ func (s *XrayMetricsService) HasObservatoryTag(tag string) bool {
 }
 }
 
 
 func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
 func (s *XrayMetricsService) AggregateObservatory(tag string, bucketSeconds, maxPoints int) []map[string]any {
-	if !validObsTag.MatchString(tag) {
+	if !validObsTag(tag) {
 		return []map[string]any{}
 		return []map[string]any{}
 	}
 	}
 	return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
 	return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints)
@@ -212,7 +225,7 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra
 		if tag == "" {
 		if tag == "" {
 			tag = key
 			tag = key
 		}
 		}
-		if !validObsTag.MatchString(tag) {
+		if !validObsTag(tag) {
 			continue
 			continue
 		}
 		}
 		snap := ObsTagSnapshot{
 		snap := ObsTagSnapshot{

+ 48 - 0
internal/web/service/xray_metrics_test.go

@@ -2,6 +2,7 @@ package service
 
 
 import (
 import (
 	"path/filepath"
 	"path/filepath"
+	"strings"
 	"testing"
 	"testing"
 	"time"
 	"time"
 
 
@@ -120,3 +121,50 @@ func TestApplyObservatoryDebounce(t *testing.T) {
 		})
 		})
 	}
 	}
 }
 }
+
+func TestValidObsTag(t *testing.T) {
+	tests := []struct {
+		name string
+		tag  string
+		want bool
+	}{
+		{"plain ascii", "proxy-1", true},
+		{"dots and underscores", "warp_us.east", true},
+		{"flag emoji", "🇩🇪 Germany", true},
+		{"cyrillic", "Германия", true},
+		{"spaces allowed", "US proxy 2", true},
+		{"empty rejected", "", false},
+		{"control char rejected", "bad\x00tag", false},
+		{"newline rejected", "bad\ntag", false},
+		{"invalid utf8 rejected", string([]byte{0xff, 0xfe}), false},
+		{"overlong rejected", strings.Repeat("a", maxObsTagLength+1), false},
+	}
+	for _, tc := range tests {
+		t.Run(tc.name, func(t *testing.T) {
+			if got := validObsTag(tc.tag); got != tc.want {
+				t.Fatalf("validObsTag(%q) = %v, want %v", tc.tag, got, tc.want)
+			}
+		})
+	}
+}
+
+func TestApplyObservatoryKeepsUnicodeTags(t *testing.T) {
+	dbDir := t.TempDir()
+	if err := database.InitDB(filepath.Join(dbDir, "x-ui.db")); err != nil {
+		t.Fatalf("InitDB: %v", err)
+	}
+	t.Cleanup(func() { _ = database.CloseDB() })
+
+	s := &XrayMetricsService{settingService: SettingService{}}
+	s.applyObservatory(time.Unix(1000, 0), map[string]rawObsEntry{
+		"🇩🇪 Berlin": {Alive: true, Delay: 42, LastTryTime: 1},
+	})
+
+	if !s.HasObservatoryTag("🇩🇪 Berlin") {
+		t.Fatal("emoji-tagged outbound must appear in the observatory")
+	}
+	snaps := s.ObservatorySnapshot()
+	if len(snaps) != 1 || snaps[0].Tag != "🇩🇪 Berlin" {
+		t.Fatalf("snapshot = %+v, want the emoji tag", snaps)
+	}
+}

+ 37 - 7
internal/web/service/xray_setting_dns_routing.go

@@ -4,6 +4,7 @@ import (
 	"encoding/json"
 	"encoding/json"
 	"net"
 	"net"
 	"reflect"
 	"reflect"
+	"slices"
 	"sort"
 	"sort"
 	"strconv"
 	"strconv"
 	"strings"
 	"strings"
@@ -11,7 +12,39 @@ import (
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 	"github.com/mhsanaei/3x-ui/v3/internal/util/netsafe"
 )
 )
 
 
-// dnsAllowRuleShape identifies routing rules this file manages: a plain
+// dnsAllowRuleTag marks the routing rules this file manages. Both
+// xray-core and the Routing tab's rule editor round-trip ruleTag
+// untouched, so it is a stable provenance marker: only rules carrying it
+// (or legacy managed rules recognized by exact content, see
+// managedDnsAllowRule) are ever stripped and rebuilt.
+const dnsAllowRuleTag = "xui-dns-allow"
+
+// managedDnsAllowRule reports whether a routing rule was created by this
+// file. Current managed rules carry dnsAllowRuleTag. Rules written before
+// the tag existed are adopted only when their shape matches AND their
+// exact ip-set/port content equals a currently configured private DNS
+// endpoint group — a hand-written rule that merely resembles the managed
+// shape (e.g. a CIDR allow for a NAS on port 5000, #6056) never matches
+// and is left untouched.
+func managedDnsAllowRule(rule map[string]any, groups []dnsAllowPortGroup) bool {
+	if tag, _ := rule["ruleTag"].(string); tag == dnsAllowRuleTag {
+		return true
+	}
+	if !dnsAllowRuleShape(rule) {
+		return false
+	}
+	port, _ := rule["port"].(string)
+	ips := append([]string(nil), readRuleIPs(rule["ip"])...)
+	sort.Strings(ips)
+	for _, g := range groups {
+		if strconv.Itoa(g.port) == port && slices.Equal(ips, g.ips) {
+			return true
+		}
+	}
+	return false
+}
+
+// dnsAllowRuleShape identifies the legacy managed-rule shape: a plain
 // "type=field, ip=[...], port=..., outboundTag=direct" rule with no other
 // "type=field, ip=[...], port=..., outboundTag=direct" rule with no other
 // matchers. An "enabled" key is tolerated as long as it's true — the
 // matchers. An "enabled" key is tolerated as long as it's true — the
 // Routing tab's rule editor (RuleFormModal.tsx submit()) and its enabled
 // Routing tab's rule editor (RuleFormModal.tsx submit()) and its enabled
@@ -21,10 +54,6 @@ import (
 // toggled off (enabled=false) is treated as no longer ours: the admin
 // toggled off (enabled=false) is treated as no longer ours: the admin
 // explicitly turned it off, and re-enabling it on the next save would
 // explicitly turned it off, and re-enabling it on the next save would
 // silently override that choice.
 // silently override that choice.
-//
-// Rules shaped like this are kept in sync with the current dns.servers
-// config on every save; anything else (including rules an admin wrote by
-// hand that happen to also allow-list an IP) is left untouched.
 func dnsAllowRuleShape(rule map[string]any) bool {
 func dnsAllowRuleShape(rule map[string]any) bool {
 	if t, _ := rule["type"].(string); t != "field" {
 	if t, _ := rule["type"].(string); t != "field" {
 		return false
 		return false
@@ -270,7 +299,7 @@ func collectPrivateDnsAllowGroups(dnsRaw json.RawMessage) []dnsAllowPortGroup {
 // otherwise silently reintroduce the stall with nothing to notice or fix
 // otherwise silently reintroduce the stall with nothing to notice or fix
 // it). The rebuilt result is only written back if it actually differs
 // it). The rebuilt result is only written back if it actually differs
 // from the input, so well-formed configs aren't churned on every save.
 // from the input, so well-formed configs aren't churned on every save.
-// Manually-authored rules are never touched — see dnsAllowRuleShape.
+// Manually-authored rules are never touched — see managedDnsAllowRule.
 func EnsureDnsServerRouting(raw string) (string, error) {
 func EnsureDnsServerRouting(raw string) (string, error) {
 	var cfg map[string]json.RawMessage
 	var cfg map[string]json.RawMessage
 	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
 	if err := json.Unmarshal([]byte(raw), &cfg); err != nil {
@@ -339,7 +368,7 @@ func EnsureDnsServerRouting(raw string) (string, error) {
 func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) []map[string]any {
 func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) []map[string]any {
 	clean := make([]map[string]any, 0, len(rules))
 	clean := make([]map[string]any, 0, len(rules))
 	for _, rule := range rules {
 	for _, rule := range rules {
-		if !dnsAllowRuleShape(rule) {
+		if !managedDnsAllowRule(rule, groups) {
 			clean = append(clean, rule)
 			clean = append(clean, rule)
 		}
 		}
 	}
 	}
@@ -353,6 +382,7 @@ func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) []
 	for _, g := range groups {
 	for _, g := range groups {
 		managed = append(managed, map[string]any{
 		managed = append(managed, map[string]any{
 			"type":        "field",
 			"type":        "field",
+			"ruleTag":     dnsAllowRuleTag,
 			"ip":          g.ips,
 			"ip":          g.ips,
 			"port":        strconv.Itoa(g.port),
 			"port":        strconv.Itoa(g.port),
 			"outboundTag": "direct",
 			"outboundTag": "direct",

+ 63 - 7
internal/web/service/xray_setting_dns_routing_test.go

@@ -216,6 +216,8 @@ func TestEnsureDnsServerRouting_IdempotentOnSecondSave(t *testing.T) {
 }
 }
 
 
 func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) {
 func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) {
+	// A legacy (untagged) managed rule matching the current dns.servers is
+	// adopted: rebuilt once with the ruleTag marker, then stable.
 	in := `{
 	in := `{
 		"dns": {"servers": ["172.20.0.53"]},
 		"dns": {"servers": ["172.20.0.53"]},
 		"routing": {
 		"routing": {
@@ -229,8 +231,19 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("unexpected err: %v", err)
 		t.Fatalf("unexpected err: %v", err)
 	}
 	}
-	if out != in {
-		t.Fatalf("dns servers unchanged, expected no-op, got: %s", out)
+	rules := rulesFromRaw(t, out)
+	if len(rules) != 2 {
+		t.Fatalf("rules len = %d, want 2 (legacy rule adopted in place): %s", len(rules), out)
+	}
+	if tag, _ := rules[0]["ruleTag"].(string); tag != dnsAllowRuleTag {
+		t.Fatalf("adopted rule should carry %q, got %v", dnsAllowRuleTag, rules[0])
+	}
+	stable, err := EnsureDnsServerRouting(out)
+	if err != nil {
+		t.Fatalf("unexpected err: %v", err)
+	}
+	if stable != out {
+		t.Fatalf("expected no further change once tagged\nfirst:  %s\nsecond: %s", out, stable)
 	}
 	}
 
 
 	// Admin adds a second internal resolver on the same port.
 	// Admin adds a second internal resolver on the same port.
@@ -238,7 +251,7 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T)
 		"dns": {"servers": ["172.20.0.53", "10.0.0.53"]},
 		"dns": {"servers": ["172.20.0.53", "10.0.0.53"]},
 		"routing": {
 		"routing": {
 			"rules": [
 			"rules": [
-				{"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
+				{"type":"field","ruleTag":"xui-dns-allow","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
 				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
 				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
 			]
 			]
 		}
 		}
@@ -247,7 +260,7 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T)
 	if err != nil {
 	if err != nil {
 		t.Fatalf("unexpected err: %v", err)
 		t.Fatalf("unexpected err: %v", err)
 	}
 	}
-	rules := rulesFromRaw(t, out2)
+	rules = rulesFromRaw(t, out2)
 	if len(rules) != 2 {
 	if len(rules) != 2 {
 		t.Fatalf("rules len = %d, want 2 (existing rule updated in place): %s", len(rules), out2)
 		t.Fatalf("rules len = %d, want 2 (existing rule updated in place): %s", len(rules), out2)
 	}
 	}
@@ -258,13 +271,13 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T)
 }
 }
 
 
 func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T) {
 func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T) {
-	// Admin switches dns.servers to a public resolver — our previously
-	// inserted allow-rule is now dead weight and should be dropped.
+	// Admin switches dns.servers to a public resolver — the tagged managed
+	// rule is now dead weight and should be dropped.
 	in := `{
 	in := `{
 		"dns": {"servers": ["1.1.1.1"]},
 		"dns": {"servers": ["1.1.1.1"]},
 		"routing": {
 		"routing": {
 			"rules": [
 			"rules": [
-				{"type":"field","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
+				{"type":"field","ruleTag":"xui-dns-allow","ip":["172.20.0.53"],"port":"53","outboundTag":"direct"},
 				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
 				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
 			]
 			]
 		}
 		}
@@ -282,6 +295,49 @@ func TestEnsureDnsServerRouting_RemovesOwnedRuleWhenNoLongerNeeded(t *testing.T)
 	}
 	}
 }
 }
 
 
+func TestEnsureDnsServerRouting_KeepsManualDirectRuleOnSave(t *testing.T) {
+	// The #6056 regression: a hand-written "LAN service over direct" rule
+	// (CIDR ip + custom port, no ruleTag) matches nothing the panel
+	// manages and must survive a save untouched, even when dns.servers has
+	// no private entries at all.
+	in := `{
+		"dns": {"servers": ["1.1.1.1"]},
+		"routing": {
+			"rules": [
+				{"type":"field","ip":["192.168.178.0/24"],"port":"5000","outboundTag":"direct","enabled":true},
+				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
+			]
+		}
+	}`
+	out, err := EnsureDnsServerRouting(in)
+	if err != nil {
+		t.Fatalf("unexpected err: %v", err)
+	}
+	if out != in {
+		t.Fatalf("manual direct rule must survive the save untouched, got: %s", out)
+	}
+
+	// Same shape with a single literal private IP — indistinguishable from
+	// a legacy managed rule only if it matches a configured dns server,
+	// which it doesn't here, so it must survive too.
+	in2 := `{
+		"dns": {"servers": ["1.1.1.1"]},
+		"routing": {
+			"rules": [
+				{"type":"field","ip":["192.168.178.4"],"port":"5000","outboundTag":"direct"},
+				{"type":"field","outboundTag":"blocked","ip":["geoip:private"]}
+			]
+		}
+	}`
+	out2, err := EnsureDnsServerRouting(in2)
+	if err != nil {
+		t.Fatalf("unexpected err: %v", err)
+	}
+	if out2 != in2 {
+		t.Fatalf("manual single-IP direct rule must survive the save untouched, got: %s", out2)
+	}
+}
+
 func TestEnsureDnsServerRouting_DoesNotTouchManualRuleWithExtraMatchers(t *testing.T) {
 func TestEnsureDnsServerRouting_DoesNotTouchManualRuleWithExtraMatchers(t *testing.T) {
 	// A hand-written rule that also allows the DNS IP but carries an extra
 	// A hand-written rule that also allows the DNS IP but carries an extra
 	// matcher isn't recognized as "ours" and must be left alone; a fresh
 	// matcher isn't recognized as "ours" and must be left alone; a fresh

+ 4 - 0
internal/xray/api.go

@@ -176,6 +176,8 @@ func (x *XrayAPI) DelInbound(tag string) error {
 // startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds
 // startup — notably v26.7.11's refusal of unencrypted vless/trojan outbounds
 // whose server address is a public IP or domain.
 // whose server address is a public IP or domain.
 func ValidateOutboundConfig(outbound []byte) error {
 func ValidateOutboundConfig(outbound []byte) error {
+	ensureXrayAssetLocation()
+
 	detour := new(conf.OutboundDetourConfig)
 	detour := new(conf.OutboundDetourConfig)
 	if err := json.Unmarshal(outbound, detour); err != nil {
 	if err := json.Unmarshal(outbound, detour); err != nil {
 		return err
 		return err
@@ -191,6 +193,8 @@ func (x *XrayAPI) AddOutbound(outbound []byte) error {
 	}
 	}
 	client := *x.HandlerServiceClient
 	client := *x.HandlerServiceClient
 
 
+	ensureXrayAssetLocation()
+
 	conf := new(conf.OutboundDetourConfig)
 	conf := new(conf.OutboundDetourConfig)
 	if err := json.Unmarshal(outbound, conf); err != nil {
 	if err := json.Unmarshal(outbound, conf); err != nil {
 		logger.Debug("Failed to unmarshal outbound:", err)
 		logger.Debug("Failed to unmarshal outbound:", err)

+ 17 - 0
internal/xray/hot_diff.go

@@ -128,6 +128,10 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool {
 		if exists && diffInboundUsers(oldIb, newIb, diff) {
 		if exists && diffInboundUsers(oldIb, newIb, diff) {
 			continue
 			continue
 		}
 		}
+		if exists && (inboundUsesReality(oldIb) || inboundUsesReality(newIb)) {
+			logger.Debug("hot diff: inbound [", oldIb.Tag, "] REALITY configuration changed; a gRPC remove+add does not reliably rebuild the REALITY authenticator, forcing a full restart")
+			return false
+		}
 		diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
 		diff.RemovedInboundTags = append(diff.RemovedInboundTags, oldIb.Tag)
 		if exists {
 		if exists {
 			raw, err := json.Marshal(newIb)
 			raw, err := json.Marshal(newIb)
@@ -247,6 +251,19 @@ func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []b
 	return clients, rest, true
 	return clients, rest, true
 }
 }
 
 
+func inboundUsesReality(ib *InboundConfig) bool {
+	if ib == nil || len(ib.StreamSettings) == 0 {
+		return false
+	}
+	var stream struct {
+		Security string `json:"security"`
+	}
+	if err := json.Unmarshal(ib.StreamSettings, &stream); err != nil {
+		return false
+	}
+	return stream.Security == "reality"
+}
+
 func inboundHasReverseClient(ib *InboundConfig) bool {
 func inboundHasReverseClient(ib *InboundConfig) bool {
 	if ib == nil {
 	if ib == nil {
 		return false
 		return false

+ 42 - 0
internal/xray/hot_diff_test.go

@@ -343,3 +343,45 @@ func TestComputeHotDiff_RoutingStrategyChangeNeedsRestart(t *testing.T) {
 		t.Fatal("domainStrategy change must force a restart")
 		t.Fatal("domainStrategy change must force a restart")
 	}
 	}
 }
 }
+
+func TestComputeHotDiff_RealityStreamChangeNeedsRestart(t *testing.T) {
+	oldCfg := makeHotConfig()
+	oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"old-key","serverNames":["a.example"]}}`)
+	newCfg := makeHotConfig()
+	newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"new-key","serverNames":["a.example"]}}`)
+
+	if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+		t.Fatal("a REALITY stream-settings change must force a full restart, not a gRPC hot swap")
+	}
+}
+
+func TestComputeHotDiff_SecuritySwitchToRealityNeedsRestart(t *testing.T) {
+	oldCfg := makeHotConfig()
+	oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"none"}`)
+	newCfg := makeHotConfig()
+	newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
+
+	if _, ok := ComputeHotDiff(oldCfg, newCfg); ok {
+		t.Fatal("switching security to REALITY must force a full restart")
+	}
+}
+
+func TestComputeHotDiff_RealityClientOnlyChangeStaysHot(t *testing.T) {
+	oldCfg := makeHotConfig()
+	oldCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
+	oldCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"}],"decryption":"none"}`)
+	newCfg := makeHotConfig()
+	newCfg.InboundConfigs[1].StreamSettings = json_util.RawMessage(`{"network":"tcp","security":"reality","realitySettings":{"privateKey":"k"}}`)
+	newCfg.InboundConfigs[1].Settings = json_util.RawMessage(`{"clients":[{"email":"a","id":"uuid-a"},{"email":"b","id":"uuid-b"}],"decryption":"none"}`)
+
+	diff, ok := ComputeHotDiff(oldCfg, newCfg)
+	if !ok {
+		t.Fatal("client-only change on a REALITY inbound must stay hot-appliable")
+	}
+	if len(diff.RemovedInboundTags) != 0 || len(diff.AddedInbounds) != 0 {
+		t.Fatalf("client-only change must not replace the handler, got %+v", diff)
+	}
+	if len(diff.AddedUsers) != 1 || diff.AddedUsers[0].Email != "b" {
+		t.Fatalf("expected user b added via AlterInbound, got %+v", diff.AddedUsers)
+	}
+}

+ 2 - 0
x-ui.service.arch

@@ -2,6 +2,8 @@
 Description=x-ui Service
 Description=x-ui Service
 After=network.target
 After=network.target
 Wants=network.target
 Wants=network.target
+StartLimitIntervalSec=180
+StartLimitBurst=10
 
 
 [Service]
 [Service]
 EnvironmentFile=-/etc/conf.d/x-ui
 EnvironmentFile=-/etc/conf.d/x-ui

+ 2 - 0
x-ui.service.debian

@@ -2,6 +2,8 @@
 Description=x-ui Service
 Description=x-ui Service
 After=network.target
 After=network.target
 Wants=network.target
 Wants=network.target
+StartLimitIntervalSec=180
+StartLimitBurst=10
 
 
 [Service]
 [Service]
 EnvironmentFile=-/etc/default/x-ui
 EnvironmentFile=-/etc/default/x-ui

+ 2 - 0
x-ui.service.rhel

@@ -2,6 +2,8 @@
 Description=x-ui Service
 Description=x-ui Service
 After=network.target
 After=network.target
 Wants=network.target
 Wants=network.target
+StartLimitIntervalSec=180
+StartLimitBurst=10
 
 
 [Service]
 [Service]
 EnvironmentFile=-/etc/sysconfig/x-ui
 EnvironmentFile=-/etc/sysconfig/x-ui