From 46d83145d92f81bf9681c573e745b39c7e11009c Mon Sep 17 00:00:00 2001 From: Harmon Date: Fri, 16 Oct 2026 10:41:32 +0800 Subject: [PATCH] Keep the admin surface local while serving tiles on the LAN Binding to 0.0.0.0 used to demand credentials because the admin console and the tile routes shared one listener. That forced a choice between keeping the console off the network and letting other machines fetch tiles. Admin routes are now loopback-only by default: the service can bind 0.0.0.0 so /tiles, /vectors, the catalog and the probes reach the LAN, while /admin and /api/v1/admin answer 403 to anything arriving from another host. Credentials are only required when ADMIN_ALLOW_REMOTE=true actually opens the console up, which is the case worth guarding. Tests cover both sides: a remote admin request is refused, a remote tile request still reaches the token check. --- README.md | 6 +- config/service.env.example | 5 ++ docs/API.md | 63 +++++++++++++++---- services/api-go/cmd/api/main.go | 9 +-- .../internal/basemapapi/security_test.go | 52 +++++++++++++++ services/api-go/internal/basemapapi/server.go | 22 +++++++ services/api-go/internal/config/config.go | 6 ++ 7 files changed, 145 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b529c17..0e6dd7c 100644 --- a/README.md +++ b/README.md @@ -289,6 +289,10 @@ basemap, so they are guarded: - configure `ADMIN_TOKEN` for API clients (`Authorization: Bearer `) - with neither set, the service **refuses to start** unless `API_HOST` is a loopback address (`127.0.0.1`, `::1`, `localhost`) +- the admin surface is **loopback-only by default**, so the service can bind + `0.0.0.0` to serve tiles on the LAN while the console stays reachable only + from this machine; `ADMIN_ALLOW_REMOTE=true` opens it up and then requires + credentials Cross-origin requests are denied by default; add the exact origins you trust to `CORS_ALLOWED_ORIGINS`. Client tokens never appear in redirect URLs, and tokens @@ -370,4 +374,4 @@ not supervised and does not survive a reboot. - no result artifact management - no raster processing jobs -GIS software still produces the tiles and vector files. This project only scans, catalogs, authorizes, downloads, and pushes descriptors. +GIS software still produces the tiles and vector files. This project only scans, catalogs, authorizes, downloads, and pushes descriptors. \ No newline at end of file diff --git a/config/service.env.example b/config/service.env.example index 171e913..06d608c 100644 --- a/config/service.env.example +++ b/config/service.env.example @@ -36,6 +36,11 @@ GOSUMDB=off #ADMIN_PASSWORD=change-me #ADMIN_TOKEN=change-me-too +# Allow admin endpoints from other hosts. Off by default, so the service can +# serve tiles to the LAN while the console stays reachable only on this box. +# Turning it on requires ADMIN_USER/ADMIN_PASSWORD or ADMIN_TOKEN above. +#ADMIN_ALLOW_REMOTE=true + # Comma-separated list of browser origins allowed to call the API. Empty means # no cross-origin access at all; "*" allows every origin. #CORS_ALLOWED_ORIGINS=https://maps.example.com diff --git a/docs/API.md b/docs/API.md index ec38a8b..809d235 100644 --- a/docs/API.md +++ b/docs/API.md @@ -281,33 +281,70 @@ curl -o 0.webp "http://127.0.0.1:8910/tiles/gaode-image/current/0/0/0.webp?token ## 7. 局域网开放 -默认 `API_HOST=127.0.0.1`,**只有本机能访问**。要让同局域网的其他机器打开运维台, -必须在 `config/service.env` 里同时改三处: +默认 `API_HOST=127.0.0.1`,只有本机能访问。**管理台和切片可以分开暴露**: + +### 推荐:切片上局域网,管理台仍只在本机 + +服务绑到所有网卡,但管理员路由**只接受来自回环地址的请求**(默认行为): ```ini -# 1) 监听所有网卡 +# config/service.env + +# 1) 监听所有网卡,切片才能被其他机器取到 API_HOST=0.0.0.0 -# 2) 配管理员凭据 —— 浏览器打开运维台必须有 Basic 账号密码; -# 只配 ADMIN_TOKEN 的话浏览器没法带 Bearer,页面会 401 -ADMIN_USER=ops -ADMIN_PASSWORD=<改掉它> - -# 3) 对外地址改成局域网 IP 或主机名 +# 2) 对外地址改成局域网 IP(你这台是 192.168.1.62), +# 否则台账里的 url_template 发出去还是 127.0.0.1 API_BASE_URL=http://192.168.1.62:8910 TILE_PUBLIC_BASE_URL=http://192.168.1.62:8910 VECTOR_PUBLIC_BASE_URL=http://192.168.1.62:8910 + +# 3) 管理员路由保持仅回环(默认就是 false,写出来是为了明确) +ADMIN_ALLOW_REMOTE=false ``` -改完重启服务,**并跑一次扫描**(`POST /api/v1/admin/scan-sources/local-basemaps/run`)—— -台账里的 `url_template` 是扫描时用 base URL 拼出来的,不重扫的话对外仍然是旧的 +这样:`/tiles/*`、`/vectors/*`、`/api/v1/catalog/*`、`/health` 等从局域网可达 +(取图仍需令牌);`/admin/*` 与 `/api/v1/admin/*` 从其他机器请求会返回 **403**。 + +**改完必须重启服务并重扫一次**: + +``` +POST /api/v1/admin/scan-sources/local-basemaps/run +``` + +台账里的 `url_template` 是扫描时用 base URL 拼出来的,不重扫的话对外仍是旧的 `127.0.0.1` 地址。 -还要放行防火墙入站: +放行防火墙入站: ```powershell New-NetFirewallRule -DisplayName "Basemap Pusher 8910" -Direction Inbound ` -Protocol TCP -LocalPort 8910 -Action Allow ``` -之后局域网内访问 `http://192.168.1.62:8910/admin/`,浏览器会弹 Basic 认证框。 \ No newline at end of file +### 可选:管理台也上局域网 + +把 `ADMIN_ALLOW_REMOTE=true` 打开(或在局域网内直接用管理台)。此时**必须**配置 +管理员凭据,否则服务拒绝启动: + +```ini +ADMIN_ALLOW_REMOTE=true +ADMIN_USER=ops +ADMIN_PASSWORD=<改掉它> +``` + +浏览器会弹 Basic 认证框,此时局域网内访问 `http://192.168.1.62:8910/admin/`。 + +### 启动守卫规则 + +| API_HOST | ADMIN_ALLOW_REMOTE | 有管理员凭据 | 结果 | +|---|---|---|---| +| 回环 | 任意 | 任意 | 正常启动 | +| 非回环 | false(默认) | 不需要 | 正常启动,管理员路由仅回环 | +| 非回环 | true | 是 | 正常启动,管理台可远程访问 | +| 非回环 | true | 否 | **拒绝启动** | + +### 安全提醒 + +这是明文 HTTP,Basic 凭据与令牌在网线上只是 base64,没有加密。内网自用没问题, +但如果要跨网段或给外部系统调用,建议前面挂 Nginx 上 HTTPS。 \ No newline at end of file diff --git a/services/api-go/cmd/api/main.go b/services/api-go/cmd/api/main.go index 241cf23..0bffd44 100644 --- a/services/api-go/cmd/api/main.go +++ b/services/api-go/cmd/api/main.go @@ -87,7 +87,7 @@ func run(ctx context.Context) error { // The admin surface can create tokens that read every basemap, so it must // never be reachable off-host without credentials. - if cfg.BindsToNonLoopback() && !cfg.HasAdminCredentials() { + if cfg.BindsToNonLoopback() && cfg.AdminAllowRemote && !cfg.HasAdminCredentials() { return fmt.Errorf("refusing to listen on %s without admin credentials: set ADMIN_TOKEN or ADMIN_USER/ADMIN_PASSWORD", cfg.ListenAddr()) } @@ -149,9 +149,10 @@ func run(ctx context.Context) error { server, err := basemapapi.New(store, systemInfo, basemapapi.Options{ Admin: basemapapi.AdminAuth{ - Token: cfg.AdminToken, - User: cfg.AdminUser, - Password: cfg.AdminPassword, + Token: cfg.AdminToken, + User: cfg.AdminUser, + Password: cfg.AdminPassword, + AllowRemote: cfg.AdminAllowRemote, }, CORSAllowedOrigins: cfg.CORSAllowedOrigins, }) diff --git a/services/api-go/internal/basemapapi/security_test.go b/services/api-go/internal/basemapapi/security_test.go index b2904a6..fc054ad 100644 --- a/services/api-go/internal/basemapapi/security_test.go +++ b/services/api-go/internal/basemapapi/security_test.go @@ -277,3 +277,55 @@ func TestScanErrorStatusMapping(t *testing.T) { } } } + +func TestAdminIsLoopbackOnlyByDefault(t *testing.T) { + store := newTestStore(t, t.TempDir()) + server, err := New(store, basemap.SystemInfo{ServiceName: "basemap-pusher"}, Options{}) + if err != nil { + t.Fatalf("new server: %v", err) + } + handler := server.Handler() + + call := func(path, remote string) int { + request := httptest.NewRequest(http.MethodGet, path, nil) + request.RemoteAddr = remote + recorder := httptest.NewRecorder() + handler.ServeHTTP(recorder, request) + return recorder.Code + } + + if got := call("/api/v1/admin/basemaps", "127.0.0.1:5555"); got != http.StatusOK { + t.Fatalf("expected local admin access, got %d", got) + } + if got := call("/api/v1/admin/basemaps", "192.168.1.50:5555"); got != http.StatusForbidden { + t.Fatalf("expected a remote admin request to be refused, got %d", got) + } + + // The point of the split: tile traffic still reaches the service from the + // LAN, it just has to carry a valid token. + if got := call("/tiles/demo/current/0/0/0.png", "192.168.1.50:5555"); got != http.StatusUnauthorized { + t.Fatalf("expected the tile route to stay reachable from the LAN (401), got %d", got) + } + if got := call("/health", "192.168.1.50:5555"); got != http.StatusOK { + t.Fatalf("expected health to stay reachable, got %d", got) + } +} + +func TestAdminAllowRemoteOptIn(t *testing.T) { + store := newTestStore(t, t.TempDir()) + server, err := New(store, basemap.SystemInfo{ServiceName: "basemap-pusher"}, Options{ + Admin: AdminAuth{AllowRemote: true}, + }) + if err != nil { + t.Fatalf("new server: %v", err) + } + + request := httptest.NewRequest(http.MethodGet, "/api/v1/admin/basemaps", nil) + request.RemoteAddr = "192.168.1.50:5555" + recorder := httptest.NewRecorder() + server.Handler().ServeHTTP(recorder, request) + + if recorder.Code != http.StatusOK { + t.Fatalf("expected ADMIN_ALLOW_REMOTE to permit remote admin calls, got %d", recorder.Code) + } +} diff --git a/services/api-go/internal/basemapapi/server.go b/services/api-go/internal/basemapapi/server.go index b9a23fb..c58c507 100644 --- a/services/api-go/internal/basemapapi/server.go +++ b/services/api-go/internal/basemapapi/server.go @@ -7,6 +7,7 @@ import ( "fmt" "html/template" "log" + "net" "net/http" "net/url" "strconv" @@ -23,6 +24,9 @@ type AdminAuth struct { Token string User string Password string + // AllowRemote permits admin requests from other hosts. Off by default: the + // service can then serve tiles to the LAN while the console stays local. + AllowRemote bool } func (a AdminAuth) enabled() bool { @@ -348,6 +352,10 @@ func (s *Server) routes() { // service is only reachable on loopback, which main enforces at startup. func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { + if !s.admin.AllowRemote && !isLoopbackRequest(r) { + writeError(w, http.StatusForbidden, errors.New("admin endpoints are loopback-only; set ADMIN_ALLOW_REMOTE=true to accept remote callers")) + return + } if !s.admin.enabled() { next(w, r) return @@ -361,6 +369,20 @@ func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { } } +// isLoopbackRequest reports whether the request reached us over a loopback +// address. RemoteAddr is the only trustworthy source here; X-Forwarded-For is +// attacker-controlled unless a trusted proxy is in front. +func isLoopbackRequest(r *http.Request) bool { + host, _, err := net.SplitHostPort(strings.TrimSpace(r.RemoteAddr)) + if err != nil { + host = strings.TrimSpace(r.RemoteAddr) + } + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + return false + } + return ip.IsLoopback() +} func (s *Server) adminAllows(r *http.Request) bool { if token := strings.TrimSpace(s.admin.Token); token != "" { header := strings.TrimSpace(r.Header.Get("Authorization")) diff --git a/services/api-go/internal/config/config.go b/services/api-go/internal/config/config.go index a3110ef..a71e887 100644 --- a/services/api-go/internal/config/config.go +++ b/services/api-go/internal/config/config.go @@ -28,6 +28,7 @@ type Config struct { AdminToken string AdminUser string AdminPassword string + AdminAllowRemote bool CORSAllowedOrigins []string TokenDefaultTTL time.Duration @@ -50,6 +51,10 @@ func Load() (Config, error) { if err != nil { return Config{}, fmt.Errorf("parse AUTO_SCAN_ON_START: %w", err) } + adminAllowRemote, err := boolEnv("ADMIN_ALLOW_REMOTE", false) + if err != nil { + return Config{}, fmt.Errorf("parse ADMIN_ALLOW_REMOTE: %w", err) + } tokenDefaultTTL, err := durationEnv("TOKEN_DEFAULT_TTL", 720*time.Hour) if err != nil { return Config{}, fmt.Errorf("parse TOKEN_DEFAULT_TTL: %w", err) @@ -76,6 +81,7 @@ func Load() (Config, error) { AdminToken: strings.TrimSpace(stringEnv("ADMIN_TOKEN", "")), AdminUser: strings.TrimSpace(stringEnv("ADMIN_USER", "")), AdminPassword: stringEnv("ADMIN_PASSWORD", ""), + AdminAllowRemote: adminAllowRemote, CORSAllowedOrigins: listEnv("CORS_ALLOWED_ORIGINS"), TokenDefaultTTL: tokenDefaultTTL, HealthCheckInterval: healthCheckInterval,