diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 00000000..7cfc7f8d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +**/node_modules +web/dist +build +db +cert +pgdata +*.db diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..4ad6b8bf --- /dev/null +++ b/.gitattributes @@ -0,0 +1,5 @@ +# Shell scripts must stay LF so the Docker build works when the repo is +# checked out on Windows (CRLF breaks the script shebang -> exit 127). +*.sh text eol=lf +DockerInit.sh text eol=lf +DockerEntrypoint.sh text eol=lf diff --git a/frontend/src/lib/xray/inbound-tag.ts b/frontend/src/lib/xray/inbound-tag.ts new file mode 100644 index 00000000..6daef0cf --- /dev/null +++ b/frontend/src/lib/xray/inbound-tag.ts @@ -0,0 +1,91 @@ +// Client-side mirror of the backend inbound-tag derivation +// (web/service/port_conflict.go). Keep in sync; inbound-tag.test.ts guards parity. + +type TransportBits = number; +const TCP: TransportBits = 1; +const UDP: TransportBits = 2; + +function asString(v: unknown): string { + return typeof v === 'string' ? v : ''; +} + +function inboundTransports( + protocol: string, + streamSettings: Record | undefined, + settings: Record | undefined, +): TransportBits { + if (protocol === 'hysteria' || protocol === 'wireguard') return UDP; + + let bits: TransportBits = 0; + const network = asString(streamSettings?.network); + if (network === 'kcp' || network === 'quic') bits |= UDP; + else bits |= TCP; + + if (settings) { + if (protocol === 'shadowsocks' || protocol === 'tunnel') { + const key = protocol === 'tunnel' ? 'allowedNetwork' : 'network'; + const n = asString(settings[key]); + if (n !== '') { + bits = 0; + for (const part of n.split(',')) { + const p = part.trim(); + if (p === 'tcp') bits |= TCP; + else if (p === 'udp') bits |= UDP; + } + } + } else if (protocol === 'mixed') { + if (settings.udp === true) bits |= UDP; + } + } + + if (bits === 0) bits = TCP; + return bits; +} + +function transportTagSuffix(bits: TransportBits): string { + if (bits === TCP) return 'tcp'; + if (bits === UDP) return 'udp'; + if (bits === (TCP | UDP)) return 'tcpudp'; + return 'any'; +} + +function isAnyListen(listen: string): boolean { + return listen === '' || listen === '0.0.0.0' || listen === '::' || listen === '::0'; +} + +function baseInboundTag(listen: string, port: number): string { + return isAnyListen(listen) ? `in-${port}` : `in-${listen}:${port}`; +} + +function nodeTagPrefix(nodeId: number | null | undefined): string { + return nodeId == null ? '' : `n${nodeId}-`; +} + +export interface InboundTagInput { + listen: string; + port: number; + nodeId: number | null | undefined; + protocol: string; + streamSettings?: Record; + settings?: Record; +} + +export function composeInboundTag(input: InboundTagInput): string { + const bits = inboundTransports(input.protocol, input.streamSettings, input.settings); + return ( + nodeTagPrefix(input.nodeId) + + baseInboundTag(input.listen ?? '', input.port ?? 0) + + '-' + + transportTagSuffix(bits) + ); +} + +export function isAutoInboundTag(tag: string, input: InboundTagInput): boolean { + if (tag === '') return true; + const base = composeInboundTag(input); + if (tag === base) return true; + const prefix = `${base}-`; + if (!tag.startsWith(prefix)) return false; + const suffix = tag.slice(prefix.length); + return suffix !== '' && /^[0-9]+$/.test(suffix); +} diff --git a/frontend/src/pages/inbounds/form/InboundFormModal.tsx b/frontend/src/pages/inbounds/form/InboundFormModal.tsx index eb1551c7..28cf2460 100644 --- a/frontend/src/pages/inbounds/form/InboundFormModal.tsx +++ b/frontend/src/pages/inbounds/form/InboundFormModal.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; import { @@ -20,6 +20,7 @@ import { formValuesToWirePayload, } from '@/lib/xray/inbound-form-adapter'; import { createDefaultInboundSettings } from '@/lib/xray/inbound-defaults'; +import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag'; import { canEnableReality, canEnableStream, @@ -158,6 +159,23 @@ export default function InboundFormModal({ const network = Form.useWatch(['streamSettings', 'network'], form) ?? ''; const security = Form.useWatch(['streamSettings', 'security'], form) ?? 'none'; const streamEnabled = canEnableStream({ protocol }); + + const wListen = Form.useWatch('listen', form) ?? ''; + const wPort = Form.useWatch('port', form); + const wNodeId = Form.useWatch('nodeId', form) ?? null; + const wTag = Form.useWatch('tag', form) ?? ''; + const wSsNetwork = Form.useWatch(['settings', 'network'], form); + const wTunnelNetwork = Form.useWatch(['settings', 'allowedNetwork'], form); + const autoTagRef = useRef(true); + const lastWrittenTagRef = useRef(''); + const currentTagInput = (): InboundTagInput => ({ + listen: typeof wListen === 'string' ? wListen : '', + port: typeof wPort === 'number' ? wPort : 0, + nodeId: typeof wNodeId === 'number' ? wNodeId : null, + protocol, + streamSettings: { network }, + settings: { network: wSsNetwork, allowedNetwork: wTunnelNetwork, udp: mixedUdpOn }, + }); const isFallbackHost = (protocol === Protocols.VLESS || protocol === Protocols.TROJAN) && network === 'tcp' @@ -273,6 +291,16 @@ export default function InboundFormModal({ : buildAddModeValues(); form.resetFields(); form.setFieldsValue(initial); + const initialTag = (initial.tag ?? '') as string; + autoTagRef.current = isAutoInboundTag(initialTag, { + listen: initial.listen ?? '', + port: initial.port ?? 0, + nodeId: initial.nodeId ?? null, + protocol: initial.protocol, + streamSettings: (initial.streamSettings ?? {}) as Record, + settings: (initial.settings ?? {}) as Record, + }); + lastWrittenTagRef.current = initialTag; if ( mode === 'edit' && dbInbound @@ -286,6 +314,23 @@ export default function InboundFormModal({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open, mode, dbInbound, form]); + useEffect(() => { + if (!open) return; + if (wTag === lastWrittenTagRef.current) return; + autoTagRef.current = isAutoInboundTag(wTag, currentTagInput()); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, wTag]); + + useEffect(() => { + if (!open || !autoTagRef.current) return; + const next = composeInboundTag(currentTagInput()); + if (next !== (form.getFieldValue('tag') ?? '')) { + lastWrittenTagRef.current = next; + form.setFieldValue('tag', next); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [open, wListen, wPort, wNodeId, protocol, network, mixedUdpOn, wSsNetwork, wTunnelNetwork]); + // Why: protocol picker reset cascades through the form — clearing the // settings DU branch and dropping a nodeId that no longer applies. The // legacy modal did this imperatively in onProtocolChange; here we hook diff --git a/frontend/src/test/inbound-tag.test.ts b/frontend/src/test/inbound-tag.test.ts new file mode 100644 index 00000000..7f87079a --- /dev/null +++ b/frontend/src/test/inbound-tag.test.ts @@ -0,0 +1,65 @@ +import { describe, it, expect } from 'vitest'; + +import { composeInboundTag, isAutoInboundTag, type InboundTagInput } from '@/lib/xray/inbound-tag'; + +// Parity with web/service/port_conflict.go TestInboundTransports: the L4 suffix +// the tag encodes must match the Go service so the form preview agrees with the +// tag the backend re-derives on save. +describe('composeInboundTag transport suffix parity', () => { + const base = (over: Partial): InboundTagInput => ({ + listen: '0.0.0.0', + port: 443, + nodeId: null, + protocol: 'vless', + ...over, + }); + + const cases: Array<[string, InboundTagInput, string]> = [ + ['vless tcp', base({ streamSettings: { network: 'tcp' } }), 'in-443-tcp'], + ['vless ws (still tcp)', base({ streamSettings: { network: 'ws' } }), 'in-443-tcp'], + ['vless kcp is udp', base({ streamSettings: { network: 'kcp' } }), 'in-443-udp'], + ['vless quic is udp', base({ streamSettings: { network: 'quic' } }), 'in-443-udp'], + ['vless empty stream defaults tcp', base({}), 'in-443-tcp'], + ['vmess tcp', base({ protocol: 'vmess', streamSettings: { network: 'tcp' } }), 'in-443-tcp'], + ['trojan grpc is tcp', base({ protocol: 'trojan', streamSettings: { network: 'grpc' } }), 'in-443-tcp'], + ['hysteria forced udp', base({ protocol: 'hysteria', streamSettings: { network: 'tcp' } }), 'in-443-udp'], + ['wireguard forced udp', base({ protocol: 'wireguard' }), 'in-443-udp'], + ['shadowsocks tcp,udp', base({ protocol: 'shadowsocks', settings: { network: 'tcp,udp' } }), 'in-443-tcpudp'], + ['shadowsocks udp only', base({ protocol: 'shadowsocks', settings: { network: 'udp' } }), 'in-443-udp'], + ['shadowsocks tcp only', base({ protocol: 'shadowsocks', settings: { network: 'tcp' } }), 'in-443-tcp'], + ['mixed udp on', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: true } }), 'in-443-tcpudp'], + ['mixed udp off', base({ protocol: 'mixed', streamSettings: { network: 'tcp' }, settings: { udp: false } }), 'in-443-tcp'], + ['tunnel allowedNetwork udp', base({ protocol: 'tunnel', settings: { allowedNetwork: 'udp' } }), 'in-443-udp'], + ]; + + it.each(cases)('%s', (_name, input, want) => { + expect(composeInboundTag(input)).toBe(want); + }); + + it('scopes a non-any listen and node prefix', () => { + expect(composeInboundTag(base({ listen: '127.0.0.1', port: 8443, streamSettings: { network: 'tcp' } }))) + .toBe('in-127.0.0.1:8443-tcp'); + expect(composeInboundTag(base({ nodeId: 1, port: 443, streamSettings: { network: 'tcp' } }))) + .toBe('n1-in-443-tcp'); + }); +}); + +// Parity with TestIsAutoGeneratedTag. +describe('isAutoInboundTag', () => { + const input: InboundTagInput = { + listen: '0.0.0.0', port: 443, nodeId: null, protocol: 'vless', streamSettings: { network: 'tcp' }, + }; + + it('recognises canonical, dedup-suffixed and empty as auto', () => { + expect(isAutoInboundTag('in-443-tcp', input)).toBe(true); + expect(isAutoInboundTag('in-443-tcp-2', input)).toBe(true); + expect(isAutoInboundTag('', input)).toBe(true); + }); + + it('treats custom / stale / malformed-suffix tags as not auto', () => { + expect(isAutoInboundTag('my-custom', input)).toBe(false); + expect(isAutoInboundTag('in-8443-tcp', input)).toBe(false); + expect(isAutoInboundTag('in-443-tcp-x', input)).toBe(false); + expect(isAutoInboundTag('in-443-tcp-', input)).toBe(false); + }); +}); diff --git a/web/runtime/remote.go b/web/runtime/remote.go index 44f82a1a..56caaa0e 100644 --- a/web/runtime/remote.go +++ b/web/runtime/remote.go @@ -146,18 +146,32 @@ func (r *Remote) do(ctx context.Context, method, path string, body any) (*envelo } func (r *Remote) resolveRemoteID(ctx context.Context, tag string) (int, error) { - if id, ok := r.cacheGet(tag); ok { + if id, ok := r.cacheGetTag(tag); ok { return id, nil } if err := r.refreshRemoteIDs(ctx); err != nil { return 0, err } - if id, ok := r.cacheGet(tag); ok { + if id, ok := r.cacheGetTag(tag); ok { return id, nil } return 0, fmt.Errorf("remote inbound with tag %q not found on node %s", tag, r.node.Name) } +// cacheGetTag looks up a remote inbound id by tag, tolerating an n- prefix +// that lives on only one of the two panels: the node may carry the bare tag +// while the central panel stores the prefixed form, or vice versa. +func (r *Remote) cacheGetTag(tag string) (int, bool) { + if id, ok := r.cacheGet(tag); ok { + return id, true + } + prefix := fmt.Sprintf("n%d-", r.node.Id) + if stripped, found := strings.CutPrefix(tag, prefix); found { + return r.cacheGet(stripped) + } + return r.cacheGet(prefix + tag) +} + func (r *Remote) cacheGet(tag string) (int, bool) { r.mu.RLock() defer r.mu.RUnlock() diff --git a/web/runtime/remote_test.go b/web/runtime/remote_test.go index d00cffb4..a86d5c5b 100644 --- a/web/runtime/remote_test.go +++ b/web/runtime/remote_test.go @@ -3,8 +3,39 @@ package runtime import ( "encoding/json" "testing" + + "github.com/mhsanaei/3x-ui/v3/database/model" ) +// cacheGetTag must resolve a remote inbound id even when the n- prefix +// sits on only one side: the node may store the bare tag while the central +// panel pushes the prefixed form, or vice versa. Without this a mismatch makes +// the push create a duplicate inbound on the node. +func TestCacheGetTag_PrefixAgnostic(t *testing.T) { + cases := []struct { + name string + cacheTag string + lookup string + wantID int + wantFound bool + }{ + {"exact", "n1-in-443-tcp", "n1-in-443-tcp", 7, true}, + {"node bare, lookup prefixed", "in-443-tcp", "n1-in-443-tcp", 7, true}, + {"node prefixed, lookup bare", "n1-in-443-tcp", "in-443-tcp", 7, true}, + {"unrelated tag", "in-443-tcp", "in-999-tcp", 0, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + r := NewRemote(&model.Node{Id: 1, Name: "n1"}) + r.cacheSet(c.cacheTag, 7) + id, ok := r.cacheGetTag(c.lookup) + if ok != c.wantFound || id != c.wantID { + t.Fatalf("cacheGetTag(%q) = (%d, %v), want (%d, %v)", c.lookup, id, ok, c.wantID, c.wantFound) + } + }) + } +} + func TestSanitizeStreamSettingsForRemote(t *testing.T) { tests := []struct { name string diff --git a/web/service/inbound.go b/web/service/inbound.go index 162f764e..5210b140 100644 --- a/web/service/inbound.go +++ b/web/service/inbound.go @@ -758,8 +758,11 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, if err != nil { return inbound, false, err } + inbound.NodeID = oldInbound.NodeID tag := oldInbound.Tag + oldBits := inboundTransports(oldInbound.Protocol, oldInbound.StreamSettings, oldInbound.Settings) + oldTagWasAuto := isAutoGeneratedTag(tag, oldInbound.Listen, oldInbound.Port, oldInbound.NodeID, oldBits) db := database.GetDB() tx := db.Begin() @@ -847,10 +850,14 @@ func (s *InboundService) UpdateInbound(inbound *model.Inbound) (*model.Inbound, oldInbound.Settings = inbound.Settings oldInbound.StreamSettings = inbound.StreamSettings oldInbound.Sniffing = inbound.Sniffing + if oldTagWasAuto && inbound.Tag == tag { + inbound.Tag = "" + } oldInbound.Tag, err = s.resolveInboundTag(inbound, inbound.Id) if err != nil { return inbound, false, err } + inbound.Tag = oldInbound.Tag needRestart := false rt, rterr := s.runtimeFor(oldInbound) @@ -1267,14 +1274,19 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi Find(¢ral).Error; err != nil { return false, err } - // Index under both stored tag and the prefix-stripped form so a snap's - // bare tag resolves whether or not we rewrote it with n- at create. + // Index under the stored tag and its prefix-flipped form so a snap matches + // whether the n- prefix lives on the node side, the central side, or + // neither — a mismatch must never spawn a duplicate central inbound. tagToCentral := make(map[string]*model.Inbound, len(central)*2) prefix := nodeTagPrefix(&nodeID) for i := range central { tagToCentral[central[i].Tag] = ¢ral[i] - if prefix != "" && strings.HasPrefix(central[i].Tag, prefix) { - tagToCentral[strings.TrimPrefix(central[i].Tag, prefix)] = ¢ral[i] + if prefix != "" { + if stripped, found := strings.CutPrefix(central[i].Tag, prefix); found { + tagToCentral[stripped] = ¢ral[i] + } else { + tagToCentral[prefix+central[i].Tag] = ¢ral[i] + } } } @@ -1329,6 +1341,15 @@ func (s *InboundService) setRemoteTrafficLocked(nodeID int, snap *runtime.Traffi continue } snapTags[snapIb.Tag] = struct{}{} + // Record the prefix-flipped form too so the orphan sweep below keeps a + // central inbound whether its tag carries the n- prefix or not. + if prefix != "" { + if stripped, found := strings.CutPrefix(snapIb.Tag, prefix); found { + snapTags[stripped] = struct{}{} + } else { + snapTags[prefix+snapIb.Tag] = struct{}{} + } + } c, ok := tagToCentral[snapIb.Tag] if !ok { diff --git a/web/service/inbound_update_tag_test.go b/web/service/inbound_update_tag_test.go new file mode 100644 index 00000000..9942a76a --- /dev/null +++ b/web/service/inbound_update_tag_test.go @@ -0,0 +1,100 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/database" + "github.com/mhsanaei/3x-ui/v3/database/model" +) + +// changing an inbound's port must re-derive an auto-generated tag, both in +// the persisted row and in the value returned to the caller (the API +// response the UI renders). The UI round-trips the old tag in a hidden +// field, so the update arrives carrying the stale tag. +func TestUpdateInbound_RegeneratesAutoTagOnPortChange(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "in-22435-tcp", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`) + + var existing model.Inbound + if err := database.GetDB().Where("tag = ?", "in-22435-tcp").First(&existing).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + + svc := &InboundService{} + update := existing + update.Port = 33000 + update.Tag = "in-22435-tcp" + got, _, err := svc.UpdateInbound(&update) + if err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.Tag != "in-33000-tcp" { + t.Fatalf("persisted tag = %q, want in-33000-tcp", reloaded.Tag) + } + if got.Tag != "in-33000-tcp" { + t.Fatalf("returned tag = %q, want in-33000-tcp", got.Tag) + } +} + +// a node-scoped inbound (tag carries the "n1-" prefix) must keep that prefix +// when its port changes, even if the caller omits nodeId in the update body — +// the node can't be migrated, so the stored NodeID drives the tag. The runtime +// manager isn't wired in unit tests, so UpdateInbound returns a runtime error +// for node inbounds before persisting; we assert on the tag it computed (set on +// the returned object) which is what the save would use. +func TestUpdateInbound_NodeTagKeepsPrefixWhenNodeIdOmitted(t *testing.T) { + setupConflictDB(t) + seedInboundConflictNode(t, "n1-in-443-tcp", "0.0.0.0", 443, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`, intPtr(1)) + + var existing model.Inbound + if err := database.GetDB().Where("tag = ?", "n1-in-443-tcp").First(&existing).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + + svc := &InboundService{} + update := existing + update.Port = 8443 + update.Tag = "n1-in-443-tcp" + update.NodeID = nil + got, _, _ := svc.UpdateInbound(&update) + if got.Tag != "n1-in-8443-tcp" { + t.Fatalf("node prefix must survive a port change, got %q", got.Tag) + } +} + +// a tag the user set by hand (doesn't match the canonical shape) survives a +// port change untouched. +func TestUpdateInbound_KeepsCustomTagOnPortChange(t *testing.T) { + setupConflictDB(t) + seedInboundConflict(t, "my-custom-tag", "0.0.0.0", 22435, model.VLESS, `{"network":"tcp"}`, `{"clients":[]}`) + + var existing model.Inbound + if err := database.GetDB().Where("tag = ?", "my-custom-tag").First(&existing).Error; err != nil { + t.Fatalf("read seeded row: %v", err) + } + + svc := &InboundService{} + update := existing + update.Port = 33000 + update.Tag = "my-custom-tag" + got, _, err := svc.UpdateInbound(&update) + if err != nil { + t.Fatalf("UpdateInbound: %v", err) + } + + var reloaded model.Inbound + if err := database.GetDB().First(&reloaded, existing.Id).Error; err != nil { + t.Fatalf("reload: %v", err) + } + if reloaded.Tag != "my-custom-tag" { + t.Fatalf("persisted tag = %q, want my-custom-tag", reloaded.Tag) + } + if got.Tag != "my-custom-tag" { + t.Fatalf("returned tag = %q, want my-custom-tag", got.Tag) + } +} diff --git a/web/service/node_tag_sync_test.go b/web/service/node_tag_sync_test.go new file mode 100644 index 00000000..89481387 --- /dev/null +++ b/web/service/node_tag_sync_test.go @@ -0,0 +1,66 @@ +package service + +import ( + "testing" + + "github.com/mhsanaei/3x-ui/v3/database" + "github.com/mhsanaei/3x-ui/v3/database/model" + "github.com/mhsanaei/3x-ui/v3/web/runtime" +) + +// A node-backed inbound whose central tag carries the n- prefix must +// survive a snapshot in which the node reports the bare tag (prefix lives on +// the central side only). Before the fix the orphan sweep matched snapTags +// exactly, so it deleted and recreated the inbound on every sync — churning +// its id and dropping traffic for that cycle. +func TestSetRemoteTraffic_KeepsInboundOnPrefixMismatch(t *testing.T) { + setupConflictDB(t) + db := database.GetDB() + + const nodeID = 1 + id := nodeID + central := &model.Inbound{ + UserId: 1, + NodeID: &id, + Tag: "n1-in-443-tcp", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + } + if err := db.Create(central).Error; err != nil { + t.Fatalf("create node inbound: %v", err) + } + centralID := central.Id + + snap := &runtime.TrafficSnapshot{ + Inbounds: []*model.Inbound{{ + Tag: "in-443-tcp", + Enable: true, + Port: 443, + Protocol: model.VLESS, + Settings: `{"clients":[]}`, + Up: 1000, + Down: 2000, + }}, + } + + svc := InboundService{} + if _, err := svc.setRemoteTrafficLocked(nodeID, snap); err != nil { + t.Fatalf("setRemoteTrafficLocked: %v", err) + } + + var rows []model.Inbound + if err := db.Where("node_id = ?", nodeID).Find(&rows).Error; err != nil { + t.Fatalf("list node inbounds: %v", err) + } + if len(rows) != 1 { + t.Fatalf("expected exactly 1 node inbound (no churn), got %d", len(rows)) + } + if rows[0].Id != centralID { + t.Fatalf("inbound was deleted+recreated: id %d -> %d", centralID, rows[0].Id) + } + if rows[0].Up != 1000 || rows[0].Down != 2000 { + t.Fatalf("traffic not attributed across prefix mismatch: up=%d down=%d", rows[0].Up, rows[0].Down) + } +} diff --git a/web/service/port_conflict.go b/web/service/port_conflict.go index 2c36d54f..b7db89b3 100644 --- a/web/service/port_conflict.go +++ b/web/service/port_conflict.go @@ -204,6 +204,23 @@ func composeInboundTag(listen string, port int, nodeID *int, bits transportBits) return nodeTagPrefix(nodeID) + baseInboundTag(listen, port) + "-" + transportTagSuffix(bits) } +func isAutoGeneratedTag(tag, listen string, port int, nodeID *int, bits transportBits) bool { + base := composeInboundTag(listen, port, nodeID, bits) + if tag == base { + return true + } + suffix, ok := strings.CutPrefix(tag, base+"-") + if !ok || suffix == "" { + return false + } + for _, r := range suffix { + if r < '0' || r > '9' { + return false + } + } + return true +} + func (s *InboundService) generateInboundTag(inbound *model.Inbound, ignoreId int) (string, error) { bits := inboundTransports(inbound.Protocol, inbound.StreamSettings, inbound.Settings) candidate := composeInboundTag(inbound.Listen, inbound.Port, inbound.NodeID, bits) diff --git a/web/service/port_conflict_test.go b/web/service/port_conflict_test.go index f28dfc13..13f81ad5 100644 --- a/web/service/port_conflict_test.go +++ b/web/service/port_conflict_test.go @@ -635,3 +635,37 @@ func TestCheckPortConflict_DetailMessage(t *testing.T) { t.Fatalf("message should mention the port; got %q", msg) } } + +// isAutoGeneratedTag must recognise the tags generateInboundTag emits (so an +// edit that changes port/transport re-derives them) while leaving user-typed +// or cross-panel tags untouched. +func TestIsAutoGeneratedTag(t *testing.T) { + tcp := transportTCP + cases := []struct { + name string + tag string + listen string + port int + nodeID *int + bits transportBits + want bool + }{ + {"canonical", "in-443-tcp", "0.0.0.0", 443, nil, tcp, true}, + {"canonical udp", "in-443-udp", "0.0.0.0", 443, nil, transportUDP, true}, + {"dedup suffix", "in-443-tcp-2", "0.0.0.0", 443, nil, tcp, true}, + {"listen scoped", "in-127.0.0.1:443-tcp", "127.0.0.1", 443, nil, tcp, true}, + {"node prefixed", "n1-in-443-tcp", "0.0.0.0", 443, intPtr(1), tcp, true}, + {"custom tag", "my-cool-tag", "0.0.0.0", 443, nil, tcp, false}, + {"stale port", "in-443-tcp", "0.0.0.0", 8443, nil, tcp, false}, + {"stale transport", "in-443-tcp", "0.0.0.0", 443, nil, transportUDP, false}, + {"non-numeric suffix", "in-443-tcp-x", "0.0.0.0", 443, nil, tcp, false}, + {"empty suffix", "in-443-tcp-", "0.0.0.0", 443, nil, tcp, false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := isAutoGeneratedTag(c.tag, c.listen, c.port, c.nodeID, c.bits); got != c.want { + t.Fatalf("isAutoGeneratedTag(%q) = %v, want %v", c.tag, got, c.want) + } + }) + } +}