Keep the hostname, change only where the connection goes
1. The Core Problem: Why Naive URL Replacement Fails
When querying a web server, API, or RDAP endpoint directly by its IP address (for instance, testing an endpoint discovered via Shodan or testing a server before DNS propagation), the intuitive instinct is often to replace the hostname in the URL:
# Intuitive thought:
https://rdap.nic.fr/domain/nic.fr ---> https://[2001:67c:2218:651::51:200]/domain/nic.fr
If you do this, your request will almost always fail immediately due to two independent networking layers:
A. The TLS / SNI Layer (Transport Security)
During the TLS handshake, the client sends a Server Name Indication (SNI) extension telling the server which certificate it expects.
- If you pass an IP literal in the URL, the TLS client sends either an IP address or no SNI at all.
- The server’s certificate is issued for CN=rdap.nic.fr (or *.nic.fr).
- Your TLS client aborts with:
x509: certificate is valid for rdap.nic.fr, not 2001:67c:2218:651::51:200
(or in PHP / cURL: SSL: certificate subject name does not match target host name).
B. The HTTP Virtual Host Layer (Layer 7)
Modern web servers (Nginx, Caddy, Apache, Cloudflare) host hundreds of domains on a single IP. They route incoming requests by inspecting the HTTP Host: header:
- A request sent to https://[2001:…]/ carries Host: [2001:…].
- The server has no default virtual host configured for raw IP literals and responds with 400 Bad Request, 404 Not Found, or 403 Forbidden.
2. The Solution: Decouple Transport Routing from Application Identity
To make the request succeed, you must separate where the TCP socket connects from what the HTTP/TLS request asks for:
- TCP Connection: Dials the target IP directly (tcp4 or tcp6), completely bypassing OS and recursive DNS resolvers.
- TLS Handshake: Uses the original domain name in the SNI field.
- HTTP Request: Sends the original domain name in the Host: header.
3. What Was the Syntax? (IPv6:domain vs. ([ip]):url)
If you write 2001:db8::1:example.com, standard parsers cannot tell where the IPv6 address ends and the domain or port begins, because IPv6 addresses use colons as internal hextet separators.
The Universal Enclosure Syntax: ([ip]):url
In our test harnesses and discovery tools, we use an unambiguous prefix format:
# IPv6 format:
(2001:67c:2218:651::51:200):https://rdap.nic.fr/domain/nic.fr
# IPv4 format:
(128.0.173.52):https://rdap.eunic.net.ua/domain/eunic.net.ua
A simple regular expression parses this with zero ambiguity:
^\(([0-9a-fA-F:.]+)\):(.*)$
- Group 1: The forced IP address (IPv4 or IPv6).
- Group 2: The full, unaltered target URL.
The Standard cURL Syntax: --resolve
The command-line curl utility uses the syntax:
curl --resolve <host>:<port>:<ip> <url>
For IPv6:
curl -v --resolve "rdap.nic.fr:443:[2001:67c:2218:651::51:200]" "https://rdap.nic.fr/domain/nic.fr"
For IPv4:
curl -v --resolve "rdap.eunic.net.ua:443:128.0.173.52" "https://rdap.eunic.net.ua/domain/eunic.net.ua"
4. “I Can’t Do That in PHP, Right?” — Actually, You CAN!
A very common myth is that PHP cannot route an HTTPS request to an arbitrary IP without disabling SSL certificate verification. This is completely false. PHP can do this natively in two different ways.
Method 1: PHP cURL with CURLOPT_RESOLVE (PHP 5.5+)
Since PHP 5.5, PHP’s cURL extension exposes libcurl’s CURLOPT_RESOLVE. It populates libcurl’s internal DNS cache with custom IP mappings before making the request:
<?php
// Function to fetch a URL while forcing resolution to a specific IP
function fetchForcedIP(string $url, string $forcedIP, int $port = 443, int $timeout = 10): ?string {
$parsed = parse_url($url);
$host = $parsed['host'];
// Format IPv6 inside brackets if not already bracketed
$ipFormatted = filter_var($forcedIP, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
? "[" . trim($forcedIP, "[]") . "]"
: $forcedIP;
$resolveRule = "{$host}:{$port}:{$ipFormatted}";
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => $timeout,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_RESOLVE => [$resolveRule], // <-- The magic happens here!
CURLOPT_SSL_VERIFYPEER => true, // Full TLS validation enabled!
CURLOPT_SSL_VERIFYHOST => 2, // Strict hostname verification!
CURLOPT_HTTPHEADER => [
'Accept: application/rdap+json, application/json',
'User-Agent: RDAP-Auditor/1.0'
],
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$error = curl_error($ch);
curl_close($ch);
if ($response === false) {
throw new RuntimeException("cURL Error: {$error}");
}
echo "Status: {$httpCode}\n";
return $response;
}
// Example usage:
$url = "https://rdap.nic.fr/domain/nic.fr";
$ipv6 = "2001:67c:2218:651::51:200";
$body = fetchForcedIP($url, $ipv6);
echo substr($body, 0, 200) . "...\n";
Method 2: PHP Native Stream Contexts (peer_name)
If you do not have the cURL extension enabled or want raw socket control, PHP’s native stream wrapper supports peer_name:
<?php
$host = "rdap.nic.fr";
$ip = "2001:67c:2218:651::51:200";
$path = "/domain/nic.fr";
// Format socket destination
$socketTarget = "ssl://[" . trim($ip, "[]") . "]:443";
$context = stream_context_create([
'ssl' => [
'peer_name' => $host, // Forces TLS SNI and cert check to match $host
'verify_peer' => true,
'verify_peer_name' => true,
'allow_self_signed' => false,
]
]);
$fp = stream_socket_client($socketTarget, $errno, $errstr, 10, STREAM_CLIENT_CONNECT, $context);
if (!$fp) {
die("Connection failed: $errstr ($errno)\n");
}
// Send HTTP/1.1 request with explicit Host header
$req = "GET {$path} HTTP/1.1\r\n";
$req .= "Host: {$host}\r\n";
$req .= "User-Agent: PHP-Stream-Auditor/1.0\r\n";
$req .= "Accept: application/rdap+json\r\n";
$req .= "Connection: close\r\n\r\n";
fwrite($fp, $req);
$response = stream_get_contents($fp);
fclose($fp);
echo substr($response, 0, 300) . "...\n";
5. How to Do It in Go: Custom http.Transport.DialContext
In Golang, http.Transport allows you to customize the low-level connection function DialContext. Go’s TLS stack automatically uses the hostname from req.URL for SNI, and sets the HTTP Host: header automatically.
package main
import (
"context"
"crypto/tls"
"fmt"
"io"
"net"
"net/http"
"regexp"
"strings"
"time"
)
var forcedIPRegex = regexp.MustCompile(`^\(([0-9a-fA-F:.]+)\):(.*)$`)
// NewForcedIPClient returns an http.Client where all TCP dials are redirected
// to forcedIP while preserving TLS SNI and HTTP Host headers.
func NewForcedIPClient(forcedIP string, timeout time.Duration) *http.Client {
// Detect if IPv6
isIPv6 := strings.Contains(forcedIP, ":")
network := "tcp4"
if isIPv6 {
network = "tcp6"
}
dialer := &net.Dialer{
Timeout: timeout,
KeepAlive: 30 * time.Second,
}
transport := &http.Transport{
DialContext: func(ctx context.Context, netw, addr string) (net.Conn, error) {
_, port, err := net.SplitHostPort(addr)
if err != nil {
port = "443"
}
// Format destination host:port
cleanIP := strings.Trim(forcedIP, "[]")
destAddr := net.JoinHostPort(cleanIP, port)
// Dial the forced IP directly over the network layer
// DNS is 100% bypassed!
return dialer.DialContext(ctx, network, destAddr)
},
TLSClientConfig: &tls.Config{
MinVersion: tls.VersionTLS12,
},
ForceAttemptHTTP2: true,
MaxIdleConns: 100,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
}
return &http.Client{
Transport: transport,
Timeout: timeout + 5*time.Second,
}
}
// ExecuteProbe handles "(forced_ip):url" strings or standard URLs
func ExecuteProbe(input string) ([]byte, int, error) {
targetURL := input
var client *http.Client
if matches := forcedIPRegex.FindStringSubmatch(input); len(matches) == 3 {
forcedIP := matches[1]
targetURL = matches[2]
client = NewForcedIPClient(forcedIP, 10*time.Second)
} else {
client = &http.Client{Timeout: 10 * time.Second}
}
req, err := http.NewRequest("GET", targetURL, nil)
if err != nil {
return nil, 0, err
}
req.Header.Set("User-Agent", "GoFinder-RDAP-Prober/2.0")
req.Header.Set("Accept", "application/rdap+json, application/json")
resp, err := client.Do(req)
if err != nil {
return nil, 0, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return body, resp.StatusCode, err
}
func main() {
// Live test against French ccTLD registry
testInput := "(2001:67c:2218:651::51:200):https://rdap.nic.fr/domain/nic.fr"
body, code, err := ExecuteProbe(testInput)
if err != nil {
fmt.Printf("Error: %v\n", err)
return
}
fmt.Printf("Status: %d\nLength: %d bytes\nPreview: %s\n", code, len(body), string(body[:120]))
}
6. Summary Comparison Matrix
| Feature / Capability | Raw IP in URL (https://[ip]/) | cURL CLI (–resolve) | PHP (CURLOPT_RESOLVE) | PHP Streams (peer_name) | Golang (DialContext) |
|---|---|---|---|---|---|
| Bypasses DNS | Yes | Yes | Yes | Yes | Yes |
| Preserves TLS SNI | ❌ Fails (Cert Mismatch) | ✅ Valid | ✅ Valid | ✅ Valid | ✅ Valid |
| Valid HTTP Host Header | ❌ Fails (IP Host) | ✅ Valid | ✅ Valid | ✅ Valid | ✅ Valid |
| IPv4 Supported | ✅ | ✅ | ✅ | ✅ | ✅ |
| IPv6 Supported | ✅ (Needs […]) | ✅ (Needs […]) | ✅ (Needs […]) | ✅ (Needs […]) | ✅ (via net.JoinHostPort) |
| Syntax | https://[2001:…]/ | –resolve host:port:ip | [“host:port:ip”] | ssl://[ip]:port | (ip):https://url |
7. Practical Takeaway for Shodan RDAP Auditing
When you have a Shodan record like:
- IP: 81.169.145.71 (Strato AG / IONOS in Berlin)
- SSL Cert Subject: CN=rdap.ionos.com
- Target Query: /domain/test.de
You do not need to wait for public DNS or risk hitting stale CDN caches. You can test that specific Shodan finding immediately:
- In GoFinder:
(81.169.145.71):https://rdap.ionos.com/domain/test.de - In PHP:
CURLOPT_RESOLVE => ["rdap.ionos.com:443:81.169.145.71"] - In Bash:
curl --resolve "rdap.ionos.com:443:81.169.145.71" https://rdap.ionos.com/domain/test.de
Where this fits
Everything above is how I poke at registry servers for DNSLister.com, the domain name search tool that would very much like you to find a good name before somebody else does. If you enjoy this kind of plumbing, there is more on the way: a growing library of small, fast Go apps is coming soon. I will try to keep the DNS puns to a manageable level. No promises.