diff --git a/frontend/src/lib/xray/inbound-form-adapter.ts b/frontend/src/lib/xray/inbound-form-adapter.ts index c34499c70..4362655c3 100644 --- a/frontend/src/lib/xray/inbound-form-adapter.ts +++ b/frontend/src/lib/xray/inbound-form-adapter.ts @@ -14,6 +14,7 @@ import type { Sniffing } from '@/schemas/primitives'; import type { z } from 'zod'; import { normalizeStreamSettingsForWire } from '@/lib/xray/stream-wire-normalize'; import { canEnableSniffing } from '@/lib/xray/protocol-capabilities'; +import { SockoptStreamSettingsSchema } from '@/schemas/protocols/stream/sockopt'; import { XHttpStreamSettingsSchema, XHttpXmuxSchema } from '@/schemas/protocols/stream/xhttp'; const XMUX_DEFAULTS = XHttpXmuxSchema.parse({}); @@ -178,6 +179,13 @@ export function rawInboundToFormValues(row: RawInboundRow): InboundFormValues { xhttp.xmux = { ...XMUX_DEFAULTS, ...(xmux as Record) }; } } + 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), ...parsed.data }; + } + } } const sniffing = coerceJsonObject(row.sniffing) as unknown as Sniffing; diff --git a/frontend/src/pages/inbounds/form/useSecurityActions.ts b/frontend/src/pages/inbounds/form/useSecurityActions.ts index a5dc5e387..a869bb943 100644 --- a/frontend/src/pages/inbounds/form/useSecurityActions.ts +++ b/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')); return; } + const xver = Number(getValues('streamSettings.realitySettings.xver')) || 0; setScanning(true); try { const msg = await HttpUtil.post( '/panel/api/server/scanRealityTarget', - { target }, + { target, xver }, { silent: true }, ); if (!msg?.success || !msg.obj) { diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index 662b9187b..f4e15bcb0 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -61,6 +61,7 @@ interface OutboundSub { url?: string; enabled?: boolean; allowPrivate?: boolean; + allowInsecure?: boolean; prepend?: boolean; priority?: number; tagPrefix?: string; @@ -122,7 +123,7 @@ export default function OutboundsTab({ const [subDrawerOpen, setSubDrawerOpen] = useState(false); const [subs, setSubs] = useState([]); 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(null); const [savingSub, setSavingSub] = useState(false); const [refreshingId, setRefreshingId] = useState(null); @@ -303,7 +304,7 @@ export default function OutboundsTab({ 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 { remark: src.remark ?? '', url: src.url ?? '', @@ -311,11 +312,12 @@ export default function OutboundsTab({ updateInterval: src.updateInterval ?? 600, enabled: src.enabled ?? true, allowPrivate: src.allowPrivate ?? false, + allowInsecure: src.allowInsecure ?? false, prepend: src.prepend ?? false, }; } 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); setPreviewData(null); } @@ -327,6 +329,7 @@ export default function OutboundsTab({ updateInterval: sub.updateInterval ?? 600, enabled: sub.enabled ?? true, allowPrivate: sub.allowPrivate ?? false, + allowInsecure: sub.allowInsecure ?? false, prepend: sub.prepend ?? false, }); setEditingSubId(sub.id); @@ -647,6 +650,12 @@ export default function OutboundsTab({ {t('pages.xray.outboundSub.allowPrivateHint')} + + setNewSub({ ...newSub, allowInsecure: v })} /> +
+ {t('pages.hosts.hints.allowInsecure')} +
+
setNewSub({ ...newSub, prepend: v })} />
diff --git a/frontend/src/test/inbound-form-adapter.test.ts b/frontend/src/test/inbound-form-adapter.test.ts index 360e82431..234f13e2c 100644 --- a/frontend/src/test/inbound-form-adapter.test.ts +++ b/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', () => { const result = InboundFormSchema.safeParse({ port: 12345, diff --git a/internal/database/db.go b/internal/database/db.go index faab8f95b..d080cfcc4 100644 --- a/internal/database/db.go +++ b/internal/database/db.go @@ -1057,7 +1057,7 @@ func runSeeders(isUsersEmpty bool) error { } 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 { if err := db.Create(&model.HistoryOfSeeders{SeederName: name}).Error; err != nil { 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 err := clearLegacyProxySettings(); err != nil { return err @@ -1592,6 +1604,147 @@ func isLegacyPrivateOnlyFinalRules(v any) bool { 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) { normalizeInt := func(key string) { raw, exists := obj[key] @@ -1776,7 +1929,7 @@ func InitDB(dbPath string) error { if dsn == "" { 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 { return err } @@ -1787,7 +1940,8 @@ func InitDB(dbPath string) error { } 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) if err != nil { return err @@ -1798,7 +1952,7 @@ func InitDB(dbPath string) error { } pragmas := []string{ - "PRAGMA journal_mode=DELETE", + "PRAGMA journal_mode=" + journal, "PRAGMA busy_timeout=10000", "PRAGMA synchronous=" + sync, 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 } +// 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 { switch strings.ToUpper(strings.TrimSpace(os.Getenv("XUI_DB_SYNCHRONOUS"))) { case "OFF": @@ -1909,7 +2097,7 @@ func Checkpoint() error { if IsPostgres() { return nil } - return db.Exec("PRAGMA wal_checkpoint;").Error + return db.Exec("PRAGMA wal_checkpoint(TRUNCATE);").Error } func ValidateSQLiteDB(dbPath string) error { diff --git a/internal/database/freedom_finalrules_migration_test.go b/internal/database/freedom_finalrules_migration_test.go new file mode 100644 index 000000000..201ef9f94 --- /dev/null +++ b/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") + } +} diff --git a/internal/database/journal_mode_test.go b/internal/database/journal_mode_test.go new file mode 100644 index 000000000..f08c56350 --- /dev/null +++ b/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") + } +} diff --git a/internal/database/model/model.go b/internal/database/model/model.go index 773d0c214..4ad891c32 100644 --- a/internal/database/model/model.go +++ b/internal/database/model/model.go @@ -1098,6 +1098,7 @@ type OutboundSubscription struct { Url string `json:"url" form:"url"` Enabled bool `json:"enabled" form:"enabled" gorm:"default:true"` AllowPrivate bool `json:"allowPrivate" form:"allowPrivate" gorm:"default:false"` + AllowInsecure bool `json:"allowInsecure" form:"allowInsecure" gorm:"default:false"` TagPrefix string `json:"tagPrefix" form:"tagPrefix"` 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) diff --git a/internal/database/reality_finalmask_migration_test.go b/internal/database/reality_finalmask_migration_test.go new file mode 100644 index 000000000..64a73ad68 --- /dev/null +++ b/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 +} diff --git a/internal/sub/host_sub.go b/internal/sub/host_sub.go index 16ca6647d..d5a4fc5e1 100644 --- a/internal/sub/host_sub.go +++ b/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 // entries (which never carry these keys), so existing output is unchanged. 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 != "" { var sockopt map[string]any if json.Unmarshal([]byte(sp), &sockopt) == nil && len(sockopt) > 0 { diff --git a/internal/sub/host_sub_test.go b/internal/sub/host_sub_test.go index 7a0befc83..a57b85ef5 100644 --- a/internal/sub/host_sub_test.go +++ b/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 // any inbound-level mask (#5831). func TestSub_HostFinalMask_RawLink(t *testing.T) { diff --git a/internal/web/controller/client.go b/internal/web/controller/client.go index 093217ea8..8c88a0922 100644 --- a/internal/web/controller/client.go +++ b/internal/web/controller/client.go @@ -109,22 +109,22 @@ func (a *ClientController) get(c *gin.Context) { email := c.Param("email") rec, err := a.clientService.GetRecordByEmail(nil, email) if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } inboundIds, err := a.clientService.GetInboundIdsForRecord(rec.Id) if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } externalLinks, err := a.clientService.GetExternalLinksForRecord(rec.Id) if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } flow, err := a.clientService.EffectiveFlow(nil, rec.Id) if err != nil { - jsonMsg(c, I18nWeb(c, "get"), err) + jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.obtain"), err) return } rec.Flow = flow diff --git a/internal/web/controller/server.go b/internal/web/controller/server.go index 11c512a1b..bd1078d60 100644 --- a/internal/web/controller/server.go +++ b/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 // target and returns a structured feasibility verdict plus the cert SAN names. 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 { jsonMsg(c, I18nWeb(c, "pages.inbounds.toasts.scanRealityTargetError"), err) return diff --git a/internal/web/controller/xray_setting.go b/internal/web/controller/xray_setting.go index 446259820..9f1d006d5 100644 --- a/internal/web/controller/xray_setting.go +++ b/internal/web/controller/xray_setting.go @@ -408,6 +408,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) { prefix := c.PostForm("tagPrefix") enabled := c.PostForm("enabled") != "false" allowPrivate := c.PostForm("allowPrivate") == "true" + allowInsecure := c.PostForm("allowInsecure") == "true" prepend := c.PostForm("prepend") == "true" intervalStr := c.PostForm("updateInterval") interval := 600 @@ -416,7 +417,7 @@ func (a *XraySettingController) createOutboundSub(c *gin.Context) { 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 { jsonMsg(c, "Failed to create outbound subscription", err) return @@ -436,6 +437,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) { prefix := c.PostForm("tagPrefix") enabled := c.PostForm("enabled") != "false" allowPrivate := c.PostForm("allowPrivate") == "true" + allowInsecure := c.PostForm("allowInsecure") == "true" prepend := c.PostForm("prepend") == "true" intervalStr := c.PostForm("updateInterval") interval := 600 @@ -444,7 +446,7 @@ func (a *XraySettingController) updateOutboundSub(c *gin.Context) { 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) return } @@ -511,12 +513,13 @@ func (a *XraySettingController) parseOutboundSubURL(c *gin.Context) { return } allowPrivate := c.PostForm("allowPrivate") == "true" + allowInsecure := c.PostForm("allowInsecure") == "true" // Use a throw-away service instance; it only needs the settingService for proxy. svc := service.OutboundSubscriptionService{} // 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 // 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 { jsonMsg(c, "Failed to preview subscription", err) return diff --git a/internal/web/service/config.json b/internal/web/service/config.json index d67412bab..29077fa1a 100644 --- a/internal/web/service/config.json +++ b/internal/web/service/config.json @@ -33,6 +33,7 @@ "settings": { "domainStrategy": "AsIs", "finalRules": [ + { "action": "block", "ip": ["geoip:private"] }, { "action": "allow" } ] }, diff --git a/internal/web/service/inbound.go b/internal/web/service/inbound.go index 2c17665bc..902490b8b 100644 --- a/internal/web/service/inbound.go +++ b/internal/web/service/inbound.go @@ -1418,45 +1418,64 @@ func (s *InboundService) buildRuntimeInboundForAPI(tx *gorm.DB, inbound *model.I return nil, err } - clients, ok := settings["clients"].([]any) - if !ok { + 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 + } + + 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 + } + + 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) + } + + 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 + } + } + + if !mutated { return &runtimeInbound, nil } - 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 - } - - 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 - } - - if manualEnable, ok := c["enable"].(bool); ok && !manualEnable { - continue - } - - finalClients = append(finalClients, c) - } - - settings["clients"] = finalClients modifiedSettings, err := json.MarshalIndent(settings, "", " ") if err != nil { return nil, err diff --git a/internal/web/service/inbound_client_move_test.go b/internal/web/service/inbound_client_move_test.go new file mode 100644 index 000000000..b0c6e9d02 --- /dev/null +++ b/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) + } +} diff --git a/internal/web/service/inbound_clients.go b/internal/web/service/inbound_clients.go index d9cd33707..7e6a1e511 100644 --- a/internal/web/service/inbound_clients.go +++ b/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]) } } + 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 } +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) { traffic, inbound, err := s.GetClientInboundByEmail(clientEmail) if err != nil { diff --git a/internal/web/service/inbound_fallback_runtime_test.go b/internal/web/service/inbound_fallback_runtime_test.go new file mode 100644 index 000000000..68798e890 --- /dev/null +++ b/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) + } +} diff --git a/internal/web/service/inbound_node.go b/internal/web/service/inbound_node.go index d80cfccc7..eb9b646bb 100644 --- a/internal/web/service/inbound_node.go +++ b/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)) } } diff --git a/internal/web/service/outbound_subscription.go b/internal/web/service/outbound_subscription.go index 489bff8ac..6f5e92d5b 100644 --- a/internal/web/service/outbound_subscription.go +++ b/internal/web/service/outbound_subscription.go @@ -2,6 +2,7 @@ package service import ( "context" + "crypto/tls" "encoding/json" "errors" "fmt" @@ -24,14 +25,20 @@ import ( // filterOutboundsRejectedByCore drops outbounds the vendored xray-core config // loader refuses to build β€” since v26.7.11 that includes unencrypted // 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) { + coreVersion := "Unknown" + if p != nil { + coreVersion = p.GetXrayVersion() + } kept := make([]any, 0, len(outbounds)) var dropped []string for _, ob := range outbounds { raw, err := json.Marshal(ob) if err == nil { - if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil { + if buildErr := xray.ValidateOutboundConfig(raw); buildErr != nil && !shouldSkipLegacyUnencryptedOutboundRejection(coreVersion, buildErr) { tag := "" if m, ok := ob.(map[string]any); ok { tag, _ = m["tag"].(string) @@ -155,7 +162,7 @@ func (s *OutboundSubscriptionService) nextDefaultSubPrefix(excludeId int) string 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) if err != nil { return nil, common.NewError("invalid subscription URL:", err) @@ -178,6 +185,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e Url: cleanURL, Enabled: enabled, AllowPrivate: allowPrivate, + AllowInsecure: allowInsecure, Prepend: prepend, Priority: int(count), TagPrefix: prefix, @@ -190,7 +198,7 @@ func (s *OutboundSubscriptionService) Create(remark, rawURL, tagPrefix string, e } // 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) if err != nil { return err @@ -213,6 +221,7 @@ func (s *OutboundSubscriptionService) Update(id int, remark, rawURL, tagPrefix s sub.Url = cleanURL sub.Enabled = enabled sub.AllowPrivate = allowPrivate + sub.AllowInsecure = allowInsecure sub.Prepend = prepend sub.TagPrefix = prefix 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 // IP atomically β€” closing the DNS-rebinding gap left by validating the hostname // 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() != "" { - 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. @@ -309,7 +331,7 @@ func (s *OutboundSubscriptionService) fetchAndStore(sub *model.OutboundSubscript } 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 // redirect could still point at a private/internal address (SSRF). Cap the // redirect chain as well. diff --git a/internal/web/service/outbound_subscription_ssrf_test.go b/internal/web/service/outbound_subscription_ssrf_test.go index ddf646489..979efe0fe 100644 --- a/internal/web/service/outbound_subscription_ssrf_test.go +++ b/internal/web/service/outbound_subscription_ssrf_test.go @@ -12,7 +12,7 @@ import ( func TestSubscriptionFetchClientBlocksPrivateDial(t *testing.T) { setupSettingTestDB(t) - client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5 * time.Second) + client := (&OutboundSubscriptionService{}).subscriptionFetchClient(5*time.Second, false) ctx := netsafe.ContextWithAllowPrivate(context.Background(), false) req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://127.0.0.1:1/", nil) diff --git a/internal/web/service/reality_scan.go b/internal/web/service/reality_scan.go index 2b4b99854..b17c9fd37 100644 --- a/internal/web/service/reality_scan.go +++ b/internal/web/service/reality_scan.go @@ -170,7 +170,7 @@ func enumerateCIDR(cidr string, max int) ([]string, error) { 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)) res := &RealityScanResult{Port: port} if net.ParseIP(dialHost) != nil { @@ -196,6 +196,17 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, defer conn.Close() _ = 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{ ServerName: sni, InsecureSkipVerify: true, @@ -272,16 +283,16 @@ func (s *ServerService) probeRealityAddr(dialHost string, port int, sni string, 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) if err != nil { return nil, err } - return s.probeRealityTarget(host, port), nil + return s.probeRealityTarget(host, port, xver), nil } 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) { defer wg.Done() 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 == "" { return } @@ -389,3 +400,55 @@ func sortRealityResults(results []*RealityScanResult) { 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 +} diff --git a/internal/web/service/reality_scan_test.go b/internal/web/service/reality_scan_test.go index e93c99fea..c365bf9a7 100644 --- a/internal/web/service/reality_scan_test.go +++ b/internal/web/service/reality_scan_test.go @@ -2,6 +2,7 @@ package service import ( "crypto/tls" + "net" "testing" ) @@ -77,13 +78,13 @@ func TestSplitRealityTarget(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") } } 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 { 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]) + } +} diff --git a/internal/web/service/xray_metrics.go b/internal/web/service/xray_metrics.go index 28ef95593..3633e065d 100644 --- a/internal/web/service/xray_metrics.go +++ b/internal/web/service/xray_metrics.go @@ -5,11 +5,12 @@ import ( "encoding/json" "fmt" "net/http" - "regexp" "sort" "strings" "sync" "time" + "unicode" + "unicode/utf8" "github.com/mhsanaei/3x-ui/v3/internal/eventbus" "github.com/mhsanaei/3x-ui/v3/internal/logger" @@ -61,7 +62,19 @@ type outboundHealth struct { 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 { return "xrObs." + tag + ".delay" @@ -102,7 +115,7 @@ func (s *XrayMetricsService) ObservatorySnapshot() []ObsTagSnapshot { } func (s *XrayMetricsService) HasObservatoryTag(tag string) bool { - if !validObsTag.MatchString(tag) { + if !validObsTag(tag) { return false } 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 { - if !validObsTag.MatchString(tag) { + if !validObsTag(tag) { return []map[string]any{} } return xrayMetrics.aggregate(obsHistoryKey(tag), bucketSeconds, maxPoints) @@ -212,7 +225,7 @@ func (s *XrayMetricsService) applyObservatory(t time.Time, entries map[string]ra if tag == "" { tag = key } - if !validObsTag.MatchString(tag) { + if !validObsTag(tag) { continue } snap := ObsTagSnapshot{ diff --git a/internal/web/service/xray_metrics_test.go b/internal/web/service/xray_metrics_test.go index c95749b96..afecf1d5f 100644 --- a/internal/web/service/xray_metrics_test.go +++ b/internal/web/service/xray_metrics_test.go @@ -2,6 +2,7 @@ package service import ( "path/filepath" + "strings" "testing" "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) + } +} diff --git a/internal/web/service/xray_setting_dns_routing.go b/internal/web/service/xray_setting_dns_routing.go index 16eff08a4..1ded733d1 100644 --- a/internal/web/service/xray_setting_dns_routing.go +++ b/internal/web/service/xray_setting_dns_routing.go @@ -4,6 +4,7 @@ import ( "encoding/json" "net" "reflect" + "slices" "sort" "strconv" "strings" @@ -11,7 +12,39 @@ import ( "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 // matchers. An "enabled" key is tolerated as long as it's true β€” the // 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 // explicitly turned it off, and re-enabling it on the next save would // 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 { if t, _ := rule["type"].(string); t != "field" { return false @@ -270,7 +299,7 @@ func collectPrivateDnsAllowGroups(dnsRaw json.RawMessage) []dnsAllowPortGroup { // otherwise silently reintroduce the stall with nothing to notice or fix // 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. -// Manually-authored rules are never touched β€” see dnsAllowRuleShape. +// Manually-authored rules are never touched β€” see managedDnsAllowRule. func EnsureDnsServerRouting(raw string) (string, error) { var cfg map[string]json.RawMessage 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 { clean := make([]map[string]any, 0, len(rules)) for _, rule := range rules { - if !dnsAllowRuleShape(rule) { + if !managedDnsAllowRule(rule, groups) { clean = append(clean, rule) } } @@ -353,6 +382,7 @@ func rebuildDnsAllowRules(rules []map[string]any, groups []dnsAllowPortGroup) [] for _, g := range groups { managed = append(managed, map[string]any{ "type": "field", + "ruleTag": dnsAllowRuleTag, "ip": g.ips, "port": strconv.Itoa(g.port), "outboundTag": "direct", diff --git a/internal/web/service/xray_setting_dns_routing_test.go b/internal/web/service/xray_setting_dns_routing_test.go index 2e7c5c860..466028a14 100644 --- a/internal/web/service/xray_setting_dns_routing_test.go +++ b/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) { + // A legacy (untagged) managed rule matching the current dns.servers is + // adopted: rebuilt once with the ruleTag marker, then stable. in := `{ "dns": {"servers": ["172.20.0.53"]}, "routing": { @@ -229,8 +231,19 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) if err != nil { 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. @@ -238,7 +251,7 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) "dns": {"servers": ["172.20.0.53", "10.0.0.53"]}, "routing": { "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"]} ] } @@ -247,7 +260,7 @@ func TestEnsureDnsServerRouting_UpdatesOwnedRuleWhenServersChange(t *testing.T) if err != nil { t.Fatalf("unexpected err: %v", err) } - rules := rulesFromRaw(t, out2) + rules = rulesFromRaw(t, out2) if len(rules) != 2 { 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) { - // 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 := `{ "dns": {"servers": ["1.1.1.1"]}, "routing": { "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"]} ] } @@ -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) { // 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 diff --git a/internal/xray/api.go b/internal/xray/api.go index 29332212c..98ae9c07e 100644 --- a/internal/xray/api.go +++ b/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 // whose server address is a public IP or domain. func ValidateOutboundConfig(outbound []byte) error { + ensureXrayAssetLocation() + detour := new(conf.OutboundDetourConfig) if err := json.Unmarshal(outbound, detour); err != nil { return err @@ -191,6 +193,8 @@ func (x *XrayAPI) AddOutbound(outbound []byte) error { } client := *x.HandlerServiceClient + ensureXrayAssetLocation() + conf := new(conf.OutboundDetourConfig) if err := json.Unmarshal(outbound, conf); err != nil { logger.Debug("Failed to unmarshal outbound:", err) diff --git a/internal/xray/hot_diff.go b/internal/xray/hot_diff.go index 0bb0b0ef1..d43898aee 100644 --- a/internal/xray/hot_diff.go +++ b/internal/xray/hot_diff.go @@ -128,6 +128,10 @@ func diffInbounds(oldCfg, newCfg *Config, diff *HotDiff) bool { if exists && diffInboundUsers(oldIb, newIb, diff) { 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) if exists { raw, err := json.Marshal(newIb) @@ -247,6 +251,19 @@ func splitSettingsClients(raw json_util.RawMessage) (map[string]clientEntry, []b 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 { if ib == nil { return false diff --git a/internal/xray/hot_diff_test.go b/internal/xray/hot_diff_test.go index 03c32fd4a..2d464492b 100644 --- a/internal/xray/hot_diff_test.go +++ b/internal/xray/hot_diff_test.go @@ -343,3 +343,45 @@ func TestComputeHotDiff_RoutingStrategyChangeNeedsRestart(t *testing.T) { 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) + } +} diff --git a/x-ui.service.arch b/x-ui.service.arch index b001db92d..c8134b01a 100644 --- a/x-ui.service.arch +++ b/x-ui.service.arch @@ -2,6 +2,8 @@ Description=x-ui Service After=network.target Wants=network.target +StartLimitIntervalSec=180 +StartLimitBurst=10 [Service] EnvironmentFile=-/etc/conf.d/x-ui diff --git a/x-ui.service.debian b/x-ui.service.debian index d101ce663..dead90993 100644 --- a/x-ui.service.debian +++ b/x-ui.service.debian @@ -2,6 +2,8 @@ Description=x-ui Service After=network.target Wants=network.target +StartLimitIntervalSec=180 +StartLimitBurst=10 [Service] EnvironmentFile=-/etc/default/x-ui diff --git a/x-ui.service.rhel b/x-ui.service.rhel index 70912bc9a..b7ae77764 100644 --- a/x-ui.service.rhel +++ b/x-ui.service.rhel @@ -2,6 +2,8 @@ Description=x-ui Service After=network.target Wants=network.target +StartLimitIntervalSec=180 +StartLimitBurst=10 [Service] EnvironmentFile=-/etc/sysconfig/x-ui