From 6f84247f1f9802a3340cde3f083741af8a4c93b8 Mon Sep 17 00:00:00 2001 From: huojicha <4695954+huojicha@users.noreply.github.com> Date: Mon, 30 Mar 2026 20:47:15 +0800 Subject: [PATCH] fix: improve hostname detection to support numeric-suffixed domains (#2147) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: improve hostname detection to support numeric-suffixed domains * 1.connect改为支持ipv6 2.check_protocol改为parse_url,语义更明确 3.解析域名时直接返回host_type --- lualib/http/httpc.lua | 57 +++++++++++++----------- test/testhttp.lua | 101 +++++++++++++++++++++++++++++++++++++++++- 2 files changed, 131 insertions(+), 27 deletions(-) diff --git a/lualib/http/httpc.lua b/lualib/http/httpc.lua index 6e5c91a6..f1633eb4 100644 --- a/lualib/http/httpc.lua +++ b/lualib/http/httpc.lua @@ -23,24 +23,33 @@ local default_port = { https = 443, } -local function hostname_port(host) - if host:find ".*:.*:" then - -- If host contains 2 or more ":", it's ipv6 address - local ipv6, port = host:match "^%[(.-)%]:(%d+)$" +local function parse_host(host) + local colon1 = host:find(":", 1, true) + if not colon1 then + -- no colon: bare hostname or bare ipv4 + local htype = host:find("^%d+%.%d+%.%d+%.%d+$") and "ipv4" or "hostname" + return host, nil, htype + end + + if host:find(":", colon1 + 1, true) then + -- two or more colons: ipv6 + local ipv6, port = host:match("^%[(.-)%]:(%d+)$") if ipv6 then - return ipv6, port - else - return host + return ipv6, tonumber(port), "ipv6" end + return host, nil, "ipv6" end - local hostname, port = host:match "(.-):(%d+)$" - if hostname then - return hostname, port + + -- single colon: host:port + local h, port = host:match("^(.-):(%d+)$") + if h then + local htype = h:find("^%d+%.%d+%.%d+%.%d+$") and "ipv4" or "hostname" + return h, tonumber(port), htype end - return host + return host, nil, "hostname" end -local function check_protocol(host) +local function parse_url(host) local protocol, hostname = host:match "^(%a+)://(.*)" if protocol then protocol = string.lower(protocol) @@ -48,8 +57,9 @@ local function check_protocol(host) protocol = "http" hostname = host end - hostname, port = hostname_port(hostname) - return protocol, hostname, port or default_port[protocol] or error("Invalid protocol: " .. protocol) + local htype + hostname, port, htype = parse_host(hostname) + return protocol, hostname, port or default_port[protocol] or error("Invalid protocol: " .. protocol), htype end local SSLCTX_CLIENT = nil @@ -81,28 +91,23 @@ local function gen_interface(protocol, fd, hostname) end local function connect(host, timeout) - local protocol, host, port = check_protocol(host) - local hostaddr = host - local hostname - if host:find "^[^:]-%D$" then - -- it's a hostname (not ip address), because - -- ipv6 contains colons - -- ipv4 end with a digit - hostname = host + local protocol, hostname, port, htype = parse_url(host) + local hostaddr = hostname + if htype == "hostname" then if async_dns then local msg - hostaddr, msg = dns.resolve(host) + hostaddr, msg = dns.resolve(hostname) if not hostaddr then - error(string.format("%s dns resolve failed msg:%s", host, msg)) + error(string.format("%s dns resolve failed msg:%s", hostname, msg)) end end end local fd = socket.connect(hostaddr, port, timeout) if not fd then - error(string.format("%s connect error host:%s, port:%s, timeout:%s", protocol, host, port, timeout)) + error(string.format("%s connect error host:%s, port:%s, timeout:%s", protocol, hostname, port, timeout)) end - local interface = gen_interface(protocol, fd, hostname) + local interface = gen_interface(protocol, fd, htype == "hostname" and hostname or nil) if timeout then skynet.timeout(timeout, function() if not interface.finish then diff --git a/test/testhttp.lua b/test/testhttp.lua index 9c0ce789..f5ea590b 100644 --- a/test/testhttp.lua +++ b/test/testhttp.lua @@ -58,9 +58,109 @@ local function http_url_test() assert(qret[1] == qret[3]) end +-- Integration test: connect to real server with various URL formats +-- Verifies parse_host / parse_url via httpc public API +local function test_connect_formats() + print("[test_connect_formats] =====>") + httpc.timeout = 100 + + local passed, total = 0, 0 + + local function try_get(desc, host) + local ok, status, body = pcall(httpc.get, host, "/", {}) + total = total + 1 + if ok then + passed = passed + 1 + print(string.format(" PASS: %-35s status=%s", desc, status)) + else + print(string.format(" FAIL: %-35s error=%s", desc, status)) + end + return ok + end + + -- verify parsing is correct even when connection fails + -- "connect error" in the message means parsing succeeded + local function try_parse(desc, host) + local ok, err = pcall(httpc.get, host, "/", {}) + total = total + 1 + if ok then + passed = passed + 1 + print(string.format(" PASS: %-35s connected", desc)) + return true + end + err = tostring(err) + if err:find("connect error") or err:find("connect failed") or err:find("Socket Error") then + passed = passed + 1 + print(string.format(" PASS: %-35s parsed ok (connect failed as expected)", desc)) + return true + else + print(string.format(" FAIL: %-35s error=%s", desc, err)) + return false + end + end + + -- hostname variants + try_get("bare hostname", "baidu.com") + try_get("hostname:port", "baidu.com:80") + try_get("http://hostname", "http://baidu.com") + try_get("http://hostname:port", "http://baidu.com:80") + try_get("HTTP:// uppercase", "HTTP://baidu.com") + + -- ipv4 variants (resolve first to get a real IP) + local ip = dns.resolve("baidu.com") + if ip then + print(string.format(" resolved baidu.com -> %s", ip)) + try_get("bare ipv4", ip) + try_get("ipv4:port", ip .. ":80") + try_get("http://ipv4", "http://" .. ip) + try_get("http://ipv4:port", "http://" .. ip .. ":80") + else + print(" SKIP: ipv4 tests (dns resolve failed)") + end + + -- https variants (skip if no tls) + if pcall(require, "ltls.c") then + try_get("https://hostname", "https://baidu.com") + try_get("https://hostname:port", "https://baidu.com:443") + try_get("HTTPS:// uppercase", "HTTPS://baidu.com") + else + print(" SKIP: https tests (no ltls module)") + end + + -- ipv6 variants (parsing verification, connection may fail without ipv6 network) + try_parse("bare ipv6 ::1", "::1") + try_parse("[ipv6]:port", "[::1]:8080") + try_parse("bare full ipv6", "2001:db8::1") + try_parse("[full ipv6]:port", "[2001:db8::1]:80") + try_parse("http://[ipv6]:port", "http://[::1]:80") + if pcall(require, "ltls.c") then + try_parse("https://[ipv6]:port", "https://[::1]:443") + end + + -- k8s internal domain variants (parsing verification, connection will fail outside k8s) + -- domains ending with digits must not be mistaken for ipv4 + try_parse("k8s svc short", "my-svc:8080") + try_parse("k8s svc.ns", "my-svc.default:8080") + try_parse("k8s svc.ns.svc", "my-svc.default.svc:8080") + try_parse("k8s svc fqdn", "my-svc.default.svc.cluster.local:8080") + try_parse("k8s svc fqdn no port", "my-svc.default.svc.cluster.local") + try_parse("k8s http:// svc fqdn", "http://my-svc.default.svc.cluster.local:8080") + try_parse("k8s headless pod", "pod-0.my-svc.default.svc.cluster.local:8080") + try_parse("k8s nodeport", "http://node1.cluster.local:30080") + try_parse("k8s statefulset pod-0", "web-0.nginx-svc.default:8080") + try_parse("k8s numeric ns", "my-svc.ns123:8080") + try_parse("k8s all-digit segments", "svc123.ns456:9090") + try_parse("k8s pod ip-style dns", "10-244-0-5.default.pod.cluster.local:80") + try_parse("k8s 5-segment digits", "10.0.0.1.nip.io:8080") + + print(string.format("[test_connect_formats] done, %d/%d passed", passed, total)) +end + local function main() dns.server() + test_connect_formats() + http_stream_test() http_head_test() http_url_test() @@ -77,4 +177,3 @@ skynet.start(function() print(pcall(main)) skynet.exit() end) - \ No newline at end of file