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,