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.
This commit is contained in:
2026-10-16 10:41:32 +08:00
parent 93bfd406ba
commit 46d83145d9
7 changed files with 145 additions and 18 deletions
+4
View File
@@ -289,6 +289,10 @@ basemap, so they are guarded:
- configure `ADMIN_TOKEN` for API clients (`Authorization: Bearer <token>`) - configure `ADMIN_TOKEN` for API clients (`Authorization: Bearer <token>`)
- with neither set, the service **refuses to start** unless `API_HOST` is a - with neither set, the service **refuses to start** unless `API_HOST` is a
loopback address (`127.0.0.1`, `::1`, `localhost`) 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 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 `CORS_ALLOWED_ORIGINS`. Client tokens never appear in redirect URLs, and tokens
+5
View File
@@ -36,6 +36,11 @@ GOSUMDB=off
#ADMIN_PASSWORD=change-me #ADMIN_PASSWORD=change-me
#ADMIN_TOKEN=change-me-too #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 # Comma-separated list of browser origins allowed to call the API. Empty means
# no cross-origin access at all; "*" allows every origin. # no cross-origin access at all; "*" allows every origin.
#CORS_ALLOWED_ORIGINS=https://maps.example.com #CORS_ALLOWED_ORIGINS=https://maps.example.com
+50 -13
View File
@@ -281,33 +281,70 @@ curl -o 0.webp "http://127.0.0.1:8910/tiles/gaode-image/current/0/0/0.webp?token
## 7. 局域网开放 ## 7. 局域网开放
默认 `API_HOST=127.0.0.1`,**只有本机能访问**。要让同局域网的其他机器打开运维台, 默认 `API_HOST=127.0.0.1`,只有本机能访问。**管理台和切片可以分开暴露**:
必须在 `config/service.env` 里同时改三处:
### 推荐:切片上局域网,管理台仍只在本机
服务绑到所有网卡,但管理员路由**只接受来自回环地址的请求**(默认行为):
```ini ```ini
# 1) 监听所有网卡 # config/service.env
# 1) 监听所有网卡,切片才能被其他机器取到
API_HOST=0.0.0.0 API_HOST=0.0.0.0
# 2) 配管理员凭据 —— 浏览器打开运维台必须有 Basic 账号密码; # 2) 对外地址改成局域网 IP(你这台是 192.168.1.62),
# 只配 ADMIN_TOKEN 的话浏览器没法带 Bearer,页面会 401 # 否则台账里的 url_template 发出去还是 127.0.0.1
ADMIN_USER=ops
ADMIN_PASSWORD=<改掉它>
# 3) 对外地址改成局域网 IP 或主机名
API_BASE_URL=http://192.168.1.62:8910 API_BASE_URL=http://192.168.1.62:8910
TILE_PUBLIC_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 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`)—— 这样:`/tiles/*`、`/vectors/*`、`/api/v1/catalog/*`、`/health` 等从局域网可达
台账里的 `url_template` 是扫描时用 base URL 拼出来的,不重扫的话对外仍然是旧的 (取图仍需令牌);`/admin/*` 与 `/api/v1/admin/*` 从其他机器请求会返回 **403**。
**改完必须重启服务并重扫一次**:
```
POST /api/v1/admin/scan-sources/local-basemaps/run
```
台账里的 `url_template` 是扫描时用 base URL 拼出来的,不重扫的话对外仍是旧的
`127.0.0.1` 地址。 `127.0.0.1` 地址。
还要放行防火墙入站: 放行防火墙入站:
```powershell ```powershell
New-NetFirewallRule -DisplayName "Basemap Pusher 8910" -Direction Inbound ` New-NetFirewallRule -DisplayName "Basemap Pusher 8910" -Direction Inbound `
-Protocol TCP -LocalPort 8910 -Action Allow -Protocol TCP -LocalPort 8910 -Action Allow
``` ```
之后局域网内访问 `http://192.168.1.62:8910/admin/`,浏览器会弹 Basic 认证框。 ### 可选:管理台也上局域网
把 `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。
+5 -4
View File
@@ -87,7 +87,7 @@ func run(ctx context.Context) error {
// The admin surface can create tokens that read every basemap, so it must // The admin surface can create tokens that read every basemap, so it must
// never be reachable off-host without credentials. // 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()) 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{ server, err := basemapapi.New(store, systemInfo, basemapapi.Options{
Admin: basemapapi.AdminAuth{ Admin: basemapapi.AdminAuth{
Token: cfg.AdminToken, Token: cfg.AdminToken,
User: cfg.AdminUser, User: cfg.AdminUser,
Password: cfg.AdminPassword, Password: cfg.AdminPassword,
AllowRemote: cfg.AdminAllowRemote,
}, },
CORSAllowedOrigins: cfg.CORSAllowedOrigins, CORSAllowedOrigins: cfg.CORSAllowedOrigins,
}) })
@@ -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)
}
}
@@ -7,6 +7,7 @@ import (
"fmt" "fmt"
"html/template" "html/template"
"log" "log"
"net"
"net/http" "net/http"
"net/url" "net/url"
"strconv" "strconv"
@@ -23,6 +24,9 @@ type AdminAuth struct {
Token string Token string
User string User string
Password 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 { func (a AdminAuth) enabled() bool {
@@ -348,6 +352,10 @@ func (s *Server) routes() {
// service is only reachable on loopback, which main enforces at startup. // service is only reachable on loopback, which main enforces at startup.
func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc { func (s *Server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) { 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() { if !s.admin.enabled() {
next(w, r) next(w, r)
return 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 { func (s *Server) adminAllows(r *http.Request) bool {
if token := strings.TrimSpace(s.admin.Token); token != "" { if token := strings.TrimSpace(s.admin.Token); token != "" {
header := strings.TrimSpace(r.Header.Get("Authorization")) header := strings.TrimSpace(r.Header.Get("Authorization"))
@@ -28,6 +28,7 @@ type Config struct {
AdminToken string AdminToken string
AdminUser string AdminUser string
AdminPassword string AdminPassword string
AdminAllowRemote bool
CORSAllowedOrigins []string CORSAllowedOrigins []string
TokenDefaultTTL time.Duration TokenDefaultTTL time.Duration
@@ -50,6 +51,10 @@ func Load() (Config, error) {
if err != nil { if err != nil {
return Config{}, fmt.Errorf("parse AUTO_SCAN_ON_START: %w", err) 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) tokenDefaultTTL, err := durationEnv("TOKEN_DEFAULT_TTL", 720*time.Hour)
if err != nil { if err != nil {
return Config{}, fmt.Errorf("parse TOKEN_DEFAULT_TTL: %w", err) return Config{}, fmt.Errorf("parse TOKEN_DEFAULT_TTL: %w", err)
@@ -76,6 +81,7 @@ func Load() (Config, error) {
AdminToken: strings.TrimSpace(stringEnv("ADMIN_TOKEN", "")), AdminToken: strings.TrimSpace(stringEnv("ADMIN_TOKEN", "")),
AdminUser: strings.TrimSpace(stringEnv("ADMIN_USER", "")), AdminUser: strings.TrimSpace(stringEnv("ADMIN_USER", "")),
AdminPassword: stringEnv("ADMIN_PASSWORD", ""), AdminPassword: stringEnv("ADMIN_PASSWORD", ""),
AdminAllowRemote: adminAllowRemote,
CORSAllowedOrigins: listEnv("CORS_ALLOWED_ORIGINS"), CORSAllowedOrigins: listEnv("CORS_ALLOWED_ORIGINS"),
TokenDefaultTTL: tokenDefaultTTL, TokenDefaultTTL: tokenDefaultTTL,
HealthCheckInterval: healthCheckInterval, HealthCheckInterval: healthCheckInterval,