From 4e339caa9a7c64c59caefc7e6dca244019b31b0d Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 30 May 2026 10:18:05 +0800 Subject: [PATCH] =?UTF-8?q?[=E4=BC=98=E5=8C=96]=20=E5=A2=9E=E5=8A=A0?= =?UTF-8?q?=E5=AF=B9=E8=8A=82=E7=82=B9=20IP=20=E7=9A=84=E8=87=AA=E5=8A=A8?= =?UTF-8?q?=E6=8E=A2=E6=B5=8B=EF=BC=8C=E4=BC=98=E5=85=88=E9=80=9A=E8=BF=87?= =?UTF-8?q?=E7=AC=AC=E4=B8=89=E6=96=B9=20API=20=E8=8E=B7=E5=8F=96=E5=85=AC?= =?UTF-8?q?=E7=BD=91=20IP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- docs/en/reference/configuration.md | 4 +- docs/guide/deployment.md | 2 +- docs/reference/configuration.md | 3 +- openflare_agent/go.mod | 8 + openflare_agent/go.sum | 20 +++ openflare_agent/internal/config/config.go | 24 +++ .../internal/config/config_test.go | 74 +++++++++ openflare_server/main.go | 2 +- openflare_server/model/option.go | 2 +- openflare_server/utils/geoip/geoip.go | 5 +- openflare_server/utils/geoip/outboundip.go | 151 ++++++++++++++++++ .../utils/geoip/outboundip_test.go | 82 ++++++++++ 13 files changed, 370 insertions(+), 9 deletions(-) create mode 100644 openflare_server/utils/geoip/outboundip.go create mode 100644 openflare_server/utils/geoip/outboundip_test.go diff --git a/README.md b/README.md index 870dcde7..1106c1ac 100644 --- a/README.md +++ b/README.md @@ -192,4 +192,4 @@ curl -fsSL https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/unin Star History Chart - \ No newline at end of file + diff --git a/docs/en/reference/configuration.md b/docs/en/reference/configuration.md index 52a42e42..1c1ebb8a 100644 --- a/docs/en/reference/configuration.md +++ b/docs/en/reference/configuration.md @@ -61,7 +61,7 @@ Agent supports the `-config` CLI flag, an `agent.json` file, and the `LOG_LEVEL` | `agent_token` | Node-specific auth token | one of `agent_token` / `discovery_token` | empty | | `discovery_token` | Global token for first registration | one of `agent_token` / `discovery_token` | empty | | `node_name` | Node name | no | host name | -| `node_ip` | Node IP | no | auto-detected | +| `node_ip` | Node IP | no | auto-detected; Agent first queries the public egress IP through a third-party API, then falls back to local interfaces | | `openresty_path` | OpenResty binary path | no | `openresty` | | `openresty_container_name` | Deprecated Docker-control field, read for compatibility only | no | empty | | `openresty_docker_image` | Deprecated Docker-control field, read for compatibility only | no | empty | @@ -74,3 +74,5 @@ Agent supports the `-config` CLI flag, an `agent.json` file, and the `LOG_LEVEL` | `request_timeout` | HTTP timeout | no | `10000` ms | `heartbeat_interval` and `request_timeout` accept milliseconds or Go duration strings. + +When `node_ip` is not configured, Agent first queries `https://realip.cc` for the real public egress IP, which avoids recording a Docker bridge address in container deployments. If that lookup fails, Agent falls back to local interface detection and prefers a public IPv4 address. diff --git a/docs/guide/deployment.md b/docs/guide/deployment.md index 3dd9c017..f4c86c10 100644 --- a/docs/guide/deployment.md +++ b/docs/guide/deployment.md @@ -167,7 +167,7 @@ journalctl -u openflare-agent -f ## Docker 运行 Agent -Docker 部署时直接运行 Agent 镜像。该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。 +Docker 部署时直接运行 Agent 镜像。该镜像基于 OpenResty 镜像制作,内置 Agent 控制器与 OpenResty 二进制。未显式配置 `node_ip` 时,Agent 会优先通过第三方 API 获取真实出口 IP,避免把 Docker 网桥地址登记为节点 IP。 挂载配置文件: diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 72ce248e..cec4cb04 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -163,7 +163,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 | `agent_token` | 节点专属认证 Token | 与 `discovery_token` 二选一 | 空 | | `discovery_token` | 首次自动注册使用的全局 Token | 与 `agent_token` 二选一 | 空 | | `node_name` | 节点名称 | 否 | 自动使用主机名 | -| `node_ip` | 节点 IP | 否 | 自动探测,优先选择公网 IPv4;仅无公网地址时退回可用内网地址 | +| `node_ip` | 节点 IP | 否 | 自动探测,优先通过第三方 API 获取真实出口公网 IP;失败时退回本机网卡探测 | | `openresty_path` | OpenResty 二进制路径 | 否 | `openresty` | | `openresty_container_name` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 | | `openresty_docker_image` | 旧 Docker 控制字段,仅兼容读取 | 否 | 空 | @@ -192,6 +192,7 @@ OpenResty 性能参数与缓存参数继续统一保存在 `Option` 表。当前 * 未配置 `openresty_path` 时默认调用 `openresty`。 * Agent 周期性健康检查会请求 `http://127.0.0.1:/openflare/stub_status`,不再通过高频 `openresty -t` 判断运行时健康;配置应用、启动恢复和 reload 前校验仍会执行 `openresty -t -c `。 * 如果 `agent.json` 不存在,但 `OPENFLARE_SERVER_URL` 与 Token 等环境变量足够,Agent 可以直接启动;两者同时存在时环境变量优先。 +* Agent 未配置 `node_ip` 时,会优先通过 `https://realip.cc` 获取真实出口公网 IP,适配 Docker/NAT 场景;该请求失败时,才退回本机网卡探测并优先选择公网 IPv4。 * Agent 自动探测到私网 `node_ip` 时,Server 会在注册/心跳阶段优先保留 Agent 直连来源的公网地址,避免 NAT/多网卡场景误登记内网网卡地址。 ## 常见配置组合 diff --git a/openflare_agent/go.mod b/openflare_agent/go.mod index d4b50a2c..064875bb 100644 --- a/openflare_agent/go.mod +++ b/openflare_agent/go.mod @@ -7,4 +7,12 @@ require ( openflare v0.0.0 ) +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/oschwald/maxminddb-golang v1.13.1 // indirect + golang.org/x/sys v0.43.0 // indirect +) + replace openflare => ../openflare_server diff --git a/openflare_agent/go.sum b/openflare_agent/go.sum index eea2ecc7..354949fc 100644 --- a/openflare_agent/go.sum +++ b/openflare_agent/go.sum @@ -1,2 +1,22 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38= +github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE= +github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/openflare_agent/internal/config/config.go b/openflare_agent/internal/config/config.go index ae42307b..e4d3a186 100644 --- a/openflare_agent/internal/config/config.go +++ b/openflare_agent/internal/config/config.go @@ -1,10 +1,12 @@ package config import ( + "context" "encoding/json" "errors" "fmt" "net" + "openflare/utils/geoip" "openflare/utils/geoip/iputil" "os" pathpkg "path" @@ -27,6 +29,11 @@ const ( defaultObservabilityReplayMinutes = 15 ) +var ( + lookupOutboundIP = geoip.GetOutboundIP + lookupLocalIP = detectLocalNodeIP +) + type Config struct { ServerURL string `json:"server_url"` AgentToken string `json:"agent_token"` @@ -403,6 +410,23 @@ func firstNonEmpty(values ...string) string { } func detectNodeIP() string { + if ip := detectOutboundNodeIP(); ip != "" { + return ip + } + return lookupLocalIP() +} + +func detectOutboundNodeIP() string { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + ip, err := lookupOutboundIP(ctx) + if err != nil || ip == nil { + return "" + } + return ip.String() +} + +func detectLocalNodeIP() string { interfaces, err := net.Interfaces() if err != nil { return "" diff --git a/openflare_agent/internal/config/config_test.go b/openflare_agent/internal/config/config_test.go index 2264746f..d80f99f4 100644 --- a/openflare_agent/internal/config/config_test.go +++ b/openflare_agent/internal/config/config_test.go @@ -1,8 +1,11 @@ package config import ( + "context" "encoding/json" + "errors" "net" + "openflare/utils/geoip" "os" "path/filepath" "testing" @@ -278,6 +281,77 @@ func TestLoadUsesEnvConfigWhenFileIsMissing(t *testing.T) { } } +func TestLoadDetectsOutboundIPWhenNodeIPMissing(t *testing.T) { + previousLookup := lookupOutboundIP + lookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) { + return net.ParseIP("8.8.8.8"), nil + } + defer func() { + lookupOutboundIP = previousLookup + }() + + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.NodeIP != "8.8.8.8" { + t.Fatalf("expected outbound IP, got %s", cfg.NodeIP) + } +} + +func TestLoadFallsBackToLocalIPWhenOutboundLookupFails(t *testing.T) { + previousOutboundLookup := lookupOutboundIP + previousLocalLookup := lookupLocalIP + lookupOutboundIP = func(ctx context.Context, strategies ...geoip.OutboundIPStrategy) (net.IP, error) { + return nil, errors.New("realip.cc unavailable") + } + lookupLocalIP = func() string { + return "9.9.9.9" + } + defer func() { + lookupOutboundIP = previousOutboundLookup + lookupLocalIP = previousLocalLookup + }() + + dir := t.TempDir() + configPath := filepath.Join(dir, "agent.json") + payload := map[string]any{ + "server_url": "http://127.0.0.1:3000", + "agent_token": "token", + "node_name": "edge-01", + } + data, err := json.Marshal(payload) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err = os.WriteFile(configPath, data, 0o644); err != nil { + t.Fatalf("failed to write config: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatalf("Load failed: %v", err) + } + if cfg.NodeIP != "9.9.9.9" { + t.Fatalf("expected local fallback IP, got %s", cfg.NodeIP) + } +} + func TestLoadEnvOverridesConfigFile(t *testing.T) { dir := t.TempDir() configPath := filepath.Join(dir, "agent.json") diff --git a/openflare_server/main.go b/openflare_server/main.go index 0aad5090..68413745 100644 --- a/openflare_server/main.go +++ b/openflare_server/main.go @@ -69,7 +69,7 @@ func main() { // Initialize options model.InitOptionMap() - geoip.InitGeoIP() + geoip.InitGeoIP(common.GeoIPProvider) backgroundCtx, cancelBackgroundTasks := context.WithCancel(context.Background()) defer cancelBackgroundTasks() service.StartDatabaseAutoCleanupScheduler(backgroundCtx) diff --git a/openflare_server/model/option.go b/openflare_server/model/option.go index 46bd9a42..25760f55 100644 --- a/openflare_server/model/option.go +++ b/openflare_server/model/option.go @@ -406,6 +406,6 @@ func updateOptionMap(key string, value string) { } common.OptionMapRWMutex.Unlock() if shouldRefreshGeoIP { - geoip.InitGeoIP() + geoip.InitGeoIP(common.GeoIPProvider) } } diff --git a/openflare_server/utils/geoip/geoip.go b/openflare_server/utils/geoip/geoip.go index 40d14ac7..606aa69e 100644 --- a/openflare_server/utils/geoip/geoip.go +++ b/openflare_server/utils/geoip/geoip.go @@ -4,7 +4,6 @@ import ( "fmt" "log/slog" "net" - "openflare/common" "strings" "sync" "time" @@ -110,8 +109,8 @@ func GetRegionUnicodeEmoji(isoCode string) string { return string(rune1) + string(rune2) } -func InitGeoIP() { - providerName := normalizeProvider(common.GeoIPProvider) +func InitGeoIP(provider string) { + providerName := normalizeProvider(provider) nextProvider, err := providerFactory(providerName) if err != nil { slog.Error("initialize GeoIP provider failed", "provider", providerName, "error", err) diff --git a/openflare_server/utils/geoip/outboundip.go b/openflare_server/utils/geoip/outboundip.go new file mode 100644 index 00000000..cee30de8 --- /dev/null +++ b/openflare_server/utils/geoip/outboundip.go @@ -0,0 +1,151 @@ +package geoip + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "openflare/utils/geoip/iputil" + "strings" + "time" +) + +const defaultOutboundIPLookupTimeout = 5 * time.Second + +// OutboundIPStrategy defines a lookup strategy for the current public egress IP. +type OutboundIPStrategy interface { + Name() string + GetOutboundIP(ctx context.Context) (net.IP, error) +} + +// OutboundIPAPIAdapter adapts a third-party HTTP API response into an IP value. +type OutboundIPAPIAdapter interface { + Name() string + Endpoint() string + DecodeIP(io.Reader) (net.IP, error) +} + +type HTTPOutboundIPStrategy struct { + Client *http.Client + Adapter OutboundIPAPIAdapter +} + +func NewHTTPOutboundIPStrategy(adapter OutboundIPAPIAdapter, client *http.Client) *HTTPOutboundIPStrategy { + if client == nil { + client = &http.Client{Timeout: defaultOutboundIPLookupTimeout} + } + return &HTTPOutboundIPStrategy{ + Client: client, + Adapter: adapter, + } +} + +func (s *HTTPOutboundIPStrategy) Name() string { + if s == nil || s.Adapter == nil { + return "http-outbound-ip" + } + return s.Adapter.Name() +} + +func (s *HTTPOutboundIPStrategy) GetOutboundIP(ctx context.Context) (net.IP, error) { + if s == nil || s.Adapter == nil { + return nil, errors.New("outbound IP adapter is nil") + } + if ctx == nil { + ctx = context.Background() + } + client := s.Client + if client == nil { + client = &http.Client{Timeout: defaultOutboundIPLookupTimeout} + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, s.Adapter.Endpoint(), nil) + if err != nil { + return nil, fmt.Errorf("%s create request failed: %w", s.Name(), err) + } + response, err := client.Do(request) + if err != nil { + return nil, fmt.Errorf("%s request failed: %w", s.Name(), err) + } + defer response.Body.Close() + if response.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%s returned non-200 status: %d %s", s.Name(), response.StatusCode, response.Status) + } + ip, err := s.Adapter.DecodeIP(response.Body) + if err != nil { + return nil, fmt.Errorf("%s decode response failed: %w", s.Name(), err) + } + if !iputil.IsPublic(ip) { + return nil, fmt.Errorf("%s returned non-public IP: %s", s.Name(), ip.String()) + } + return ip, nil +} + +type RealIPCCAdapter struct { + URL string +} + +type realIPCCResponse struct { + IP string `json:"ip"` +} + +func NewRealIPCCOutboundIPStrategy() *HTTPOutboundIPStrategy { + return NewHTTPOutboundIPStrategy(RealIPCCAdapter{}, nil) +} + +func (a RealIPCCAdapter) Name() string { + return "realip.cc" +} + +func (a RealIPCCAdapter) Endpoint() string { + if strings.TrimSpace(a.URL) != "" { + return strings.TrimSpace(a.URL) + } + return "https://realip.cc" +} + +func (a RealIPCCAdapter) DecodeIP(reader io.Reader) (net.IP, error) { + var payload realIPCCResponse + if err := json.NewDecoder(reader).Decode(&payload); err != nil { + return nil, err + } + ip := net.ParseIP(strings.TrimSpace(payload.IP)) + if ip == nil { + return nil, fmt.Errorf("invalid IP %q", payload.IP) + } + if ipv4 := ip.To4(); ipv4 != nil { + return ipv4, nil + } + return ip, nil +} + +func DefaultOutboundIPStrategies() []OutboundIPStrategy { + return []OutboundIPStrategy{ + NewRealIPCCOutboundIPStrategy(), + } +} + +func GetOutboundIP(ctx context.Context, strategies ...OutboundIPStrategy) (net.IP, error) { + if len(strategies) == 0 { + strategies = DefaultOutboundIPStrategies() + } + var errs []error + for _, strategy := range strategies { + if strategy == nil { + continue + } + ip, err := strategy.GetOutboundIP(ctx) + if err == nil && ip != nil { + return ip, nil + } + if err != nil { + errs = append(errs, fmt.Errorf("%s: %w", strategy.Name(), err)) + } + } + if len(errs) == 0 { + return nil, errors.New("no outbound IP lookup strategy configured") + } + return nil, errors.Join(errs...) +} diff --git a/openflare_server/utils/geoip/outboundip_test.go b/openflare_server/utils/geoip/outboundip_test.go new file mode 100644 index 00000000..76010697 --- /dev/null +++ b/openflare_server/utils/geoip/outboundip_test.go @@ -0,0 +1,82 @@ +package geoip + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +type fakeOutboundIPStrategy struct { + name string + ip net.IP + err error +} + +func (f fakeOutboundIPStrategy) Name() string { + return f.name +} + +func (f fakeOutboundIPStrategy) GetOutboundIP(ctx context.Context) (net.IP, error) { + return f.ip, f.err +} + +func TestRealIPCCAdapterDecodeIP(t *testing.T) { + ip, err := RealIPCCAdapter{}.DecodeIP(strings.NewReader(`{"ip":"8.8.8.8","country":"United States"}`)) + if err != nil { + t.Fatalf("DecodeIP failed: %v", err) + } + if ip.String() != "8.8.8.8" { + t.Fatalf("unexpected IP: %s", ip.String()) + } +} + +func TestHTTPOutboundIPStrategyUsesAdapter(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + t.Fatalf("unexpected method: %s", r.Method) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ip":"8.8.4.4"}`)) + })) + defer server.Close() + + strategy := NewHTTPOutboundIPStrategy(RealIPCCAdapter{URL: server.URL}, server.Client()) + ip, err := strategy.GetOutboundIP(context.Background()) + if err != nil { + t.Fatalf("GetOutboundIP failed: %v", err) + } + if ip.String() != "8.8.4.4" { + t.Fatalf("unexpected outbound IP: %s", ip.String()) + } +} + +func TestGetOutboundIPFallsBackToNextStrategy(t *testing.T) { + ip, err := GetOutboundIP( + context.Background(), + fakeOutboundIPStrategy{name: "first", err: errors.New("temporary failure")}, + fakeOutboundIPStrategy{name: "second", ip: net.ParseIP("1.1.1.1")}, + ) + if err != nil { + t.Fatalf("GetOutboundIP failed: %v", err) + } + if ip.String() != "1.1.1.1" { + t.Fatalf("unexpected outbound IP: %s", ip.String()) + } +} + +func TestHTTPOutboundIPStrategyRejectsPrivateIP(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"ip":"172.17.0.2"}`)) + })) + defer server.Close() + + strategy := NewHTTPOutboundIPStrategy(RealIPCCAdapter{URL: server.URL}, server.Client()) + if _, err := strategy.GetOutboundIP(context.Background()); err == nil { + t.Fatal("expected private IP to be rejected") + } +}