mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-03 08:57:15 +00:00
fix(links): bracket ipv6 hosts in share links and qr codes (#5310)
* fix(sub): bracket ipv6 hosts in share links * fix(frontend): bracket ipv6 hosts in share links
This commit is contained in:
@@ -23,6 +23,14 @@ import { getHeaderValue } from './headers';
|
|||||||
type ForceTls = 'same' | 'tls' | 'none';
|
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])?)*$/;
|
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<string, string> on the wire (Zod schema)
|
// xHTTP headers ship as Record<string, string> on the wire (Zod schema)
|
||||||
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
|
// rather than the legacy class's HeaderEntry[]. Lookup by case-folded key.
|
||||||
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
function xhttpHostFallback(xhttp: XHttpStreamSettings | undefined): string {
|
||||||
@@ -400,7 +408,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
|
|||||||
params.set('security', 'none');
|
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);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -524,7 +532,7 @@ export function genTrojanLink(input: GenTrojanLinkInput): string {
|
|||||||
params.set('security', 'none');
|
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);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -583,7 +591,7 @@ export function genShadowsocksLink(input: GenShadowsocksLinkInput): string {
|
|||||||
if (isSSMultiUser) passwords.push(clientPassword);
|
if (isSSMultiUser) passwords.push(clientPassword);
|
||||||
|
|
||||||
const userinfo = Base64.encode(`${settings.method}:${passwords.join(':')}`, true);
|
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);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -681,7 +689,7 @@ export function genHysteriaLink(input: GenHysteriaLinkInput): string {
|
|||||||
params.set('mport', hopPorts);
|
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);
|
for (const [key, value] of params) url.searchParams.set(key, value);
|
||||||
url.hash = encodeURIComponent(remark);
|
url.hash = encodeURIComponent(remark);
|
||||||
return url.toString();
|
return url.toString();
|
||||||
@@ -724,7 +732,7 @@ export function genWireguardLink(input: GenWireguardLinkInput): string {
|
|||||||
const peer = settings.peers[peerIndex];
|
const peer = settings.peers[peerIndex];
|
||||||
if (!peer) return '';
|
if (!peer) return '';
|
||||||
|
|
||||||
const url = new URL(`wireguard://${address}:${port}`);
|
const url = new URL(`wireguard://${formatUrlHost(address)}:${port}`);
|
||||||
url.username = peer.privateKey ?? '';
|
url.username = peer.privateKey ?? '';
|
||||||
|
|
||||||
const pubKey = settings.secretKey.length > 0
|
const pubKey = settings.secretKey.length > 0
|
||||||
|
|||||||
@@ -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)', () => {
|
describe('external proxy pinned cert (pcs)', () => {
|
||||||
const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
|
const [, raw] = fixturesForProtocol('vless').find(([name]) => name === 'vless-ws-tls')!;
|
||||||
const typed = InboundSchema.parse(raw);
|
const typed = InboundSchema.parse(raw);
|
||||||
|
|||||||
+18
-8
@@ -9,6 +9,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/url"
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -563,7 +564,7 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
|
|||||||
params,
|
params,
|
||||||
security,
|
security,
|
||||||
func(dest string, port int) string {
|
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 {
|
func(ep map[string]any) string {
|
||||||
return s.genRemark(inbound, email, ep["remark"].(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, ""))
|
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,7 +615,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
|
|||||||
params,
|
params,
|
||||||
security,
|
security,
|
||||||
func(dest string, port int) string {
|
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 {
|
func(ep map[string]any) string {
|
||||||
return s.genRemark(inbound, email, ep["remark"].(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, ""))
|
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,6 +638,15 @@ func encodeUserinfo(s string) string {
|
|||||||
return strings.ReplaceAll(url.QueryEscape(s), "+", "%20")
|
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 {
|
func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) string {
|
||||||
if inbound.Protocol != model.Shadowsocks {
|
if inbound.Protocol != model.Shadowsocks {
|
||||||
return ""
|
return ""
|
||||||
@@ -679,7 +689,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
|
|||||||
proxyParams,
|
proxyParams,
|
||||||
security,
|
security,
|
||||||
func(dest string, port int) string {
|
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 {
|
func(ep map[string]any) string {
|
||||||
return s.genRemark(inbound, email, ep["remark"].(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, ""))
|
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)
|
epParams := cloneStringMap(params)
|
||||||
applyExternalProxyHysteriaParams(ep, epParams)
|
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)))
|
links = append(links, buildLinkWithParams(link, epParams, s.genRemark(inbound, email, epRemark)))
|
||||||
}
|
}
|
||||||
return strings.Join(links, "\n")
|
return strings.Join(links, "\n")
|
||||||
@@ -803,7 +813,7 @@ func (s *SubService) genHysteriaLink(inbound *model.Inbound, email string) strin
|
|||||||
if hopPorts := hysteriaHopPorts(stream); hopPorts != "" {
|
if hopPorts := hysteriaHopPorts(stream); hopPorts != "" {
|
||||||
params["mport"] = 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, ""))
|
return buildLinkWithParams(link, params, s.genRemark(inbound, email, ""))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
func TestGetHostFromXFH_HostOnly(t *testing.T) {
|
||||||
got, err := getHostFromXFH("example.com")
|
got, err := getHostFromXFH("example.com")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
Reference in New Issue
Block a user