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
-
\ 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")
+ }
+}