diff --git a/frontend/src/lib/xray/inbound-link.ts b/frontend/src/lib/xray/inbound-link.ts index c3da5fb4a..d815ad679 100644 --- a/frontend/src/lib/xray/inbound-link.ts +++ b/frontend/src/lib/xray/inbound-link.ts @@ -23,6 +23,14 @@ import { getHeaderValue } from './headers'; type ForceTls = 'same' | 'tls' | 'none'; const SHARE_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?(\.[A-Za-z0-9]([A-Za-z0-9-]*[A-Za-z0-9])?)*$/; +// Format a host for interpolation into a URL authority. IPv6 literals are +// wrapped in square brackets per RFC 3986; IPv4 and hostnames are left as-is. +// Any brackets already present are first stripped so the helper is idempotent. +function formatUrlHost(address: string): string { + const bare = address.replace(/^\[|\]$/g, ''); + return bare.includes(':') ? `[${bare}]` : bare; +} + // xHTTP headers ship as Record on the wire (Zod schema) // rather than the legacy class's HeaderEntry[]. Lookup by case-folded key. function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string { @@ -400,7 +408,7 @@ export function genVlessLink(input: GenVlessLinkInput): string { params.set('security', 'none'); } - const url = new URL(`vless://${clientId}@${address}:${port}`); + const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`); for (const [key, value] of params) url.searchParams.set(key, value); url.hash = encodeURIComponent(remark); return url.toString(); @@ -524,7 +532,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string { params.set('security', 'none'); } - const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${address}:${port}`); + const url = new URL(`trojan://${encodeURIComponent(clientPassword)}@${formatUrlHost(address)}:${port}`); for (const [key, value] of params) url.searchParams.set(key, value); url.hash = encodeURIComponent(remark); return url.toString(); @@ -583,7 +591,7 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string { if (isSSMultiUser) passwords.push(clientPassword); const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true); - const url = new URL(`ss://${userinfo}@${address}:${port}`); + const url = new URL(`ss://${userinfo}@${formatUrlHost(address)}:${port}`); for (const [key, value] of params) url.searchParams.set(key, value); url.hash = encodeURIComponent(remark); return url.toString(); @@ -681,7 +689,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string { params.set('mport', hopPorts); } - const url = new URL(`${scheme}://${clientAuth}@${address}:${port}`); + const url = new URL(`${scheme}://${clientAuth}@${formatUrlHost(address)}:${port}`); for (const [key, value] of params) url.searchParams.set(key, value); url.hash = encodeURIComponent(remark); return url.toString(); @@ -724,7 +732,7 @@ export function genWireguardLink(input: GenWireguardLinkInput): string { const peer = settings.peers[peerIndex]; if (!peer) return ''; - const url = new URL(`wireguard://${address}:${port}`); + const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`); url.username = peer.privateKey ?? ''; const pubKey = settings.secretKey.length > 0 diff --git a/frontend/src/test/inbound-link.test.ts b/frontend/src/test/inbound-link.test.ts index accee1a63..28ead0757 100644 --- a/frontend/src/test/inbound-link.test.ts +++ b/frontend/src/test/inbound-link.test.ts @@ -437,6 +437,91 @@ describe('genShadowsocksLink', () => { } }); +describe('IPv6 bracket wrapping in share-link authority', () => { + it('genVlessLink brackets a bare IPv6 address', () => { + const [, raw] = fixturesForProtocol('vless')[0]; + const typed = InboundSchema.parse(raw); + const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id; + + const link = genVlessLink({ + inbound: typed, + address: '2001:db8::1', + port: 443, + clientId, + }); + expect(new URL(link).host).toBe('[2001:db8::1]:443'); + }); + + it('genTrojanLink brackets a bare IPv6 address', () => { + const [, raw] = fixturesForProtocol('trojan')[0]; + const typed = InboundSchema.parse(raw); + const clientPassword = (raw as { settings: { clients: Array<{ password: string }> } }).settings.clients[0].password; + + const link = genTrojanLink({ + inbound: typed, + address: '2001:db8::1', + port: 443, + clientPassword, + }); + expect(new URL(link).host).toBe('[2001:db8::1]:443'); + }); + + it('genShadowsocksLink brackets a bare IPv6 address', () => { + const [, raw] = fixturesForProtocol('shadowsocks')[0]; + const typed = InboundSchema.parse(raw); + const clientPassword = (raw as { settings: { clients?: Array<{ password: string }> } }).settings.clients?.[0]?.password ?? ''; + + const link = genShadowsocksLink({ + inbound: typed, + address: '2001:db8::1', + port: 443, + clientPassword, + }); + expect(new URL(link).host).toBe('[2001:db8::1]:443'); + }); + + it('genHysteriaLink brackets a bare IPv6 address', () => { + const [, raw] = fixturesForProtocol('hysteria')[0]; + const typed = InboundSchema.parse(raw); + const clientAuth = (raw as { settings: { clients: Array<{ auth: string }> } }).settings.clients[0].auth; + + const link = genHysteriaLink({ + inbound: typed, + address: '2001:db8::1', + port: 443, + clientAuth, + }); + expect(new URL(link).host).toBe('[2001:db8::1]:443'); + }); + + it('genWireguardLink brackets a bare IPv6 address', () => { + const [, raw] = fixturesForProtocol('wireguard')[0]; + const typed = InboundSchema.parse(raw); + if (typed.protocol !== 'wireguard') throw new Error('not a wireguard fixture'); + const settings = typed.settings as WireguardInboundSettings; + + const link = genWireguardLink({ + settings, + address: '2001:db8::1', + port: 443, + peerIndex: 0, + }); + expect(new URL(link).host).toBe('[2001:db8::1]:443'); + }); + + it('does not bracket IPv4 addresses or hostnames', () => { + const [, raw] = fixturesForProtocol('vless')[0]; + const typed = InboundSchema.parse(raw); + const clientId = (raw as { settings: { clients: Array<{ id: string }> } }).settings.clients[0].id; + + const v4 = genVlessLink({ inbound: typed, address: '203.0.113.7', port: 443, clientId }); + expect(new URL(v4).host).toBe('203.0.113.7:443'); + + const host = genVlessLink({ inbound: typed, address: 'example.test', port: 443, clientId }); + expect(new URL(host).host).toBe('example.test:443'); + }); +}); + describe('external proxy pinned cert (pcs)', () => { const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!; const typed = InboundSchema.parse(raw); diff --git a/internal/sub/service.go b/internal/sub/service.go index 7b5a441cc..2afacfb02 100644 --- a/internal/sub/service.go +++ b/internal/sub/service.go @@ -9,6 +9,7 @@ import ( "net" "net/url" "slices" + "strconv" "strings" "time" @@ -563,7 +564,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { params, security, func(dest string, port int) string { - return fmt.Sprintf("vless://%s@%s:%d", uuid, dest, port) + return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.genRemark(inbound, email, ep["remark"].(string)) @@ -571,7 +572,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string { ) } - link := fmt.Sprintf("vless://%s@%s:%d", uuid, address, port) + link := fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(address, port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } @@ -614,7 +615,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string params, security, func(dest string, port int) string { - return fmt.Sprintf("trojan://%s@%s:%d", password, dest, port) + return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.genRemark(inbound, email, ep["remark"].(string)) @@ -622,7 +623,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string ) } - link := fmt.Sprintf("trojan://%s@%s:%d", password, address, port) + link := fmt.Sprintf("trojan://%s@%s", password, joinHostPort(address, port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } @@ -637,6 +638,15 @@ func encodeUserinfo(s string) string { return strings.ReplaceAll(url.QueryEscape(s), "+", "%20") } +// joinHostPort wraps an IPv6 host in square brackets the way RFC 3986 +// requires for URI authorities, while leaving IPv4 addresses and hostnames +// untouched. It also strips any brackets already present on the input so +// callers don't have to normalize upstream. +func joinHostPort(host string, port int) string { + host = strings.Trim(host, "[]") + return net.JoinHostPort(host, strconv.Itoa(port)) +} + func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string { if inbound.Protocol != model.Shadowsocks { return "" @@ -679,7 +689,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st proxyParams, security, func(dest string, port int) string { - return fmt.Sprintf("ss://%s@%s:%d", base64.RawURLEncoding.EncodeToString([]byte(encPart)), dest, port) + return fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(dest, port)) }, func(ep map[string]any) string { return s.genRemark(inbound, email, ep["remark"].(string)) @@ -687,7 +697,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st ) } - link := fmt.Sprintf("ss://%s@%s:%d", base64.RawURLEncoding.EncodeToString([]byte(encPart)), address, inbound.Port) + link := fmt.Sprintf("ss://%s@%s", base64.RawURLEncoding.EncodeToString([]byte(encPart)), joinHostPort(address, inbound.Port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } @@ -792,7 +802,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin epParams := cloneStringMap(params) applyExternalProxyHysteriaParams(ep, epParams) - link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, dest, int(portF)) + link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(dest, int(portF))) links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark))) } return strings.Join(links, "\n") @@ -803,7 +813,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin if hopPorts := hysteriaHopPorts(stream); hopPorts != "" { params["mport"] = hopPorts } - link := fmt.Sprintf("%s://%s@%s:%d", protocol, auth, s.resolveInboundAddress(inbound), inbound.Port) + link := fmt.Sprintf("%s://%s@%s", protocol, auth, joinHostPort(s.resolveInboundAddress(inbound), inbound.Port)) return buildLinkWithParams(link, params, s.genRemark(inbound, email, "")) } diff --git a/internal/sub/service_test.go b/internal/sub/service_test.go index f6f3a0835..23df61cac 100644 --- a/internal/sub/service_test.go +++ b/internal/sub/service_test.go @@ -426,6 +426,25 @@ func TestCloneStringMap_Empty(t *testing.T) { } } +func TestJoinHostPort(t *testing.T) { + cases := []struct { + host string + port int + want string + }{ + {"example.com", 443, "example.com:443"}, + {"1.2.3.4", 443, "1.2.3.4:443"}, + {"2001:db8::1", 443, "[2001:db8::1]:443"}, + {"[2001:db8::1]", 443, "[2001:db8::1]:443"}, + {"2001:db8::1", 8080, "[2001:db8::1]:8080"}, + } + for _, c := range cases { + if got := joinHostPort(c.host, c.port); got != c.want { + t.Fatalf("joinHostPort(%q, %d) = %q, want %q", c.host, c.port, got, c.want) + } + } +} + func TestGetHostFromXFH_HostOnly(t *testing.T) { got, err := getHostFromXFH("example.com") if err != nil {