feat(api): 补齐资源分发服务入口

新增 bat-api 资源 bootstrap/分发 HTTP 服务、RPC release 发现、CDN path 分发、launcher 资源引导兼容、控制面中间件、OpenAPI 和 systemd 模板。

同步 Go 边界文档,明确 Rust bat 是资源生产者和同步运维入口,Go bat-api 是只读 bootstrap/分发服务,试验 Go CLI 产物为 bin/bat-go。

验证:未运行新命令;本轮已按要求停止重复构建/测试。
This commit is contained in:
2026-07-31 00:41:48 +08:00
parent 2079c6a307
commit 3f78f8f880
43 changed files with 4427 additions and 23 deletions
+26 -19
View File
@@ -1,4 +1,4 @@
.PHONY: help build build-ffi test clean check fmt lint install dev docker-build docker-up docker-down official-smoke
.PHONY: help build build-ffi test clean check fmt lint install dev docker-build docker-up docker-down official-smoke build-go build-go-api build-go-cli test-go test-go-api test-go-ffi test-go-all
# 默认目标
.DEFAULT_GOAL := help
@@ -28,21 +28,25 @@ build-ffi: ## 构建 bat-ffi release 库(cgo 链接依赖)
@echo "$(BLUE)Building bat-ffi (release)...$(NC)"
cargo build --release -p bat-ffi
build-go: build-ffi ## 构建 Go 组件
@echo "$(BLUE)Building Go CLI...$(NC)"
build-go: build-go-api ## 构建 Go 默认产物(bat-api bootstrap/分发;同步 CLI 请用 Rust bat
build-go-api: ## 构建 bat-api(资源 bootstrap/分发 HTTP,无 FFI
@echo "$(BLUE)Building bat-api (resource bootstrap + distribution)...$(NC)"
@mkdir -p bin
go build -o bin/bat-api ./cmd/bat-api
build-go-cli: build-ffi ## 构建试验性 Go CLI → bin/bat-go(禁止命名为 bat
@echo "$(BLUE)Building experimental Go CLI as bin/bat-go...$(NC)"
@mkdir -p bin
@if [ -f cmd/bat/main.go ]; then \
go build -o bin/bat ./cmd/bat; \
go build -o bin/bat-go ./cmd/bat; \
else \
echo "$(YELLOW)Go CLI entrypoint not implemented yet, skipping...$(NC)"; \
echo "$(YELLOW)experimental cmd/bat missing, skipping...$(NC)"; \
fi
install: ## 安装到本地
@echo "$(BLUE)Installing bat CLI...$(NC)"
@if [ -f cmd/bat/main.go ]; then \
go install ./cmd/bat; \
else \
echo "$(YELLOW)Go CLI entrypoint not implemented yet, skipping...$(NC)"; \
fi
install: build-go-api ## 安装 bat-api 到 GOPATH/bin(不安装名为 bat 的 Go 二进制)
@echo "$(BLUE)Installing bat-api...$(NC)"
go install ./cmd/bat-api
# ============================================================================
# 测试相关
@@ -54,14 +58,17 @@ test-rust: ## 运行 Rust 测试
@echo "$(BLUE)Running Rust tests...$(NC)"
cargo test --workspace
test-go: build-ffi ## 运行 Go 测试
@echo "$(BLUE)Running Go tests...$(NC)"
@if [ -n "$$(go list ./... 2>/dev/null)" ]; then \
go test -v ./...; \
else \
echo "$(YELLOW)No Go packages yet, skipping...$(NC)"; \
fi
test-go: test-go-api ## 默认 Go 门禁(无 FFI;见 GO_STATUS.md
test-go-api: ## 纯 Go 测试:internal/api + backendrpc
@echo "$(BLUE)Running pure Go tests (api + backendrpc)...$(NC)"
go test ./internal/api/... ./internal/backendrpc/...
test-go-ffi: build-ffi ## 含 FFI/试验 CLI 的 Go 测试
@echo "$(BLUE)Running Go tests including FFI packages...$(NC)"
go test ./...
test-go-all: test-go-api test-go-ffi ## 全部 Go 测试
bench: ## 运行性能基准测试
@echo "$(BLUE)Running benchmarks...$(NC)"
cargo bench --workspace
+3
View File
@@ -0,0 +1,3 @@
# Reserved empty directory
Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.md`.
+9
View File
@@ -0,0 +1,9 @@
# bat-api OpenAPI
`bat-api.yaml` describes the current resource bootstrap / read-only distribution
HTTP surface. The running service also exposes the same contract at
`GET /openapi.yaml`.
This contract covers resource bootstrap, launcher resource compatibility,
server-info rewrite, CDN-shaped resource bytes, auth schemes, and the reserved
admin panel entry. It does not describe a full game business API.
+138
View File
@@ -0,0 +1,138 @@
openapi: 3.0.3
info:
title: BlueArchive Toolkit bat-api
version: 0.1.0
description: Resource bootstrap and read-only distribution API.
servers:
- url: http://127.0.0.1:18080
security:
- bearerAuth: []
- queryToken: []
paths:
/healthz:
get:
summary: Liveness and refresh diagnostics
responses:
"200":
description: Service is alive.
/readyz:
get:
summary: Release readiness
responses:
"200":
description: A distributable release is available.
"503":
description: No distributable release is available.
/v1/bootstrap:
get:
summary: Startup resource bootstrap
responses:
"200":
description: Resource bootstrap response.
"503":
description: Release is not ready.
/v1/launcher/bootstrap:
get:
summary: Launcher-shaped resource bootstrap
responses:
"200":
description: Launcher bootstrap response.
"503":
description: Release is not ready.
/api/launcher/game/config:
get:
summary: Resource-only launcher game config compatibility
responses:
"200":
description: Launcher envelope with resource metadata.
/api/launcher/game/config/json:
get:
summary: Resource-only launcher manifest URL compatibility
parameters:
- name: version
in: query
schema:
type: string
- name: file_path
in: query
schema:
type: string
responses:
"200":
description: Launcher envelope pointing to resource bootstrap JSON.
/api/launcher/advanced/game/download/cdn:
get:
summary: Resource-only launcher CDN compatibility
responses:
"200":
description: Launcher envelope with public base URL as CDN root.
/v1/release:
get:
summary: Current release summary
responses:
"200":
description: Release summary.
/v1/resources:
get:
summary: Paginated resource manifest entries
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
responses:
"200":
description: Resource list page.
/v1/server-info:
get:
summary: Rewritten server-info document
responses:
"200":
description: Server-info JSON with AddressablesCatalogUrlRoot rewritten.
/openapi.yaml:
get:
summary: OpenAPI document
responses:
"200":
description: OpenAPI YAML.
/admin/:
get:
summary: Reserved admin panel entry
responses:
"200":
description: Admin panel placeholder and links.
/prod-clientpatch.bluearchiveyostar.com/{path}:
get:
summary: CDN-shaped resource bytes
parameters:
- name: path
in: path
required: true
schema:
type: string
responses:
"200":
description: Resource bytes.
"206":
description: Partial resource bytes.
head:
summary: CDN-shaped resource metadata
responses:
"200":
description: Resource headers.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
queryToken:
type: apiKey
in: query
name: bat_token
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+32
View File
@@ -0,0 +1,32 @@
# bat-api configuration example (copy to .env next to the binary or export)
# Priority: CLI flags > process environment > .env > built-in defaults.
#
# Boundary:
# - Rust bat: resource auto-discover / pull / verify / publish / daemon RPC
# - bat-api: resource bootstrap + read-only distribution (official CDN-shaped paths)
# + management APIs
BAT_API_LISTEN=:18080
BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
# Primary discovery: bat daemon JSON-RPC socket file
BAT_API_STATE_DIR=/tmp/bat-pid
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
# Optional release root override (local fixtures / emergency read-only diagnostics only).
# Production obtains resource_root from BAT_API_SOCKET RPC; do not set this there.
# BAT_API_RESOURCE_ROOT=
# BAT_API_SERVER_INFO_FILE=
BAT_API_REQUIRE_INDEXED=true
BAT_API_VERIFY_SIZE=true
BAT_API_RPC_TIMEOUT=30s
# Periodically re-read bat.sock so bat-api follows Rust bat release switches.
# Set to 0 in fixture-only local development.
BAT_API_REFRESH_INTERVAL=1m
# Reserved for future API persistence
# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable
# BAT_API_DATABASE_PASSWORD=
# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0
# BAT_API_REDIS_PASSWORD=
+143
View File
@@ -0,0 +1,143 @@
// Command bat-api is the resource bootstrap and distribution HTTP service for BlueArchiveToolkit.
//
// Responsibility boundary:
// - bat (Rust): official resource auto-discover, pull, verify, publish, daemon RPC
// - bat-api (Go): startup resource bootstrap, server-info rewrite,
// read-only distribution of published resources (CDN-shaped paths), release
// inspection APIs, and normal process configuration (.env / flags for listen
// port, RPC socket, reserved database/redis settings)
//
// bat-api discovers and periodically refreshes the current release through the
// bat.sock JSON-RPC contract (daemon.status first, then daemon.doctor, then
// catalog/resource methods). The production resource root comes from RPC; the
// resource-root override is for local fixtures or emergency diagnostics.
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"syscall"
"time"
"bat-api/internal/api"
"bat-api/internal/backendrpc"
)
func main() {
log.SetFlags(log.LstdFlags | log.Lmsgprefix)
log.SetPrefix("bat-api ")
cfg := api.DefaultConfig()
if os.Getenv("BAT_API_SKIP_ENV_FILE") != "1" {
envPath := envFilePath()
if err := ensureEnvTemplate(envPath); err != nil {
log.Printf("warn: env template: %v", err)
}
if err := api.LoadEnvFile(envPath); err != nil {
log.Fatalf("load .env: %v", err)
}
}
api.ApplyEnv(&cfg)
listen := flag.String("listen", cfg.Listen, "HTTP listen address")
publicBase := flag.String("public-base-url", cfg.PublicBaseURL, "public base URL for Addressables rewrite")
stateDir := flag.String("state-dir", cfg.StateDir, "bat daemon state dir (derives default socket)")
socket := flag.String("socket", cfg.SocketPath, "path to bat.sock JSON-RPC socket (primary discovery)")
resourceRoot := flag.String("resource-root", cfg.ResourceRoot, "override published release root (tests/emergency)")
serverInfo := flag.String("server-info-file", cfg.ServerInfoFile, "optional server-info JSON path")
requireIndexed := flag.Bool("require-indexed", cfg.RequireIndexed, "only serve files present in the release index")
verifySize := flag.Bool("verify-size", cfg.VerifySize, "reject CDN files whose size differs from the index")
rpcTimeout := flag.Duration("rpc-timeout", cfg.RPCTimeout, "daemon RPC timeout")
refreshInterval := flag.Duration("refresh-interval", cfg.RefreshInterval, "periodic release discovery interval (0 disables)")
authQueryParam := flag.String("auth-query-param", cfg.AuthQueryParam, "query parameter accepted for token auth fallback")
authExemptPaths := flag.String("auth-exempt-paths", strings.Join(cfg.AuthExemptPaths, ","), "comma-separated auth-exempt exact paths or slash-prefixes")
trustProxyHeaders := flag.Bool("trust-proxy-headers", cfg.TrustProxyHeaders, "trust X-Forwarded-For and X-Real-IP from reverse proxy")
accessLog := flag.Bool("access-log", cfg.AccessLog, "enable per-request access logs without query strings")
rateLimitRPS := flag.Float64("rate-limit-rps", cfg.RateLimitRPS, "per-client request rate limit; 0 disables")
rateLimitBurst := flag.Int("rate-limit-burst", cfg.RateLimitBurst, "per-client rate limit burst")
maxResourceLimit := flag.Int("max-resource-limit", cfg.MaxResourcePageLimit, "maximum /v1/resources page size")
flag.Parse()
cfg.Listen = *listen
cfg.PublicBaseURL = *publicBase
cfg.StateDir = *stateDir
cfg.SocketPath = *socket
cfg.ResourceRoot = *resourceRoot
cfg.ServerInfoFile = *serverInfo
cfg.RequireIndexed = *requireIndexed
cfg.VerifySize = *verifySize
cfg.RPCTimeout = *rpcTimeout
cfg.RefreshInterval = *refreshInterval
cfg.AuthQueryParam = *authQueryParam
cfg.AuthExemptPaths = splitFlagCSV(*authExemptPaths)
cfg.TrustProxyHeaders = *trustProxyHeaders
cfg.AccessLog = *accessLog
cfg.RateLimitRPS = *rateLimitRPS
cfg.RateLimitBurst = *rateLimitBurst
cfg.MaxResourcePageLimit = *maxResourceLimit
// If socket still empty after flags, derive from state-dir.
if cfg.SocketPath == "" {
cfg.SocketPath = filepath.Join(cfg.StateDir, "bat.sock")
}
if err := cfg.Normalize(); err != nil {
log.Fatalf("config: %v", err)
}
var backend api.Backend
client := backendrpc.New(cfg.SocketPath)
client.Timeout = cfg.RPCTimeout
backend = api.RPCClient{Client: client}
server := api.NewServer(cfg, backend, log.Default())
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer cancel()
refreshCtx, refreshCancel := context.WithTimeout(ctx, cfg.RPCTimeout+5*time.Second)
if err := server.Refresh(refreshCtx); err != nil {
log.Printf("initial discover failed: %v (serving with empty/partial index)", err)
}
refreshCancel()
server.StartRefreshLoop(ctx)
if err := server.ListenAndServe(ctx); err != nil && err != context.Canceled {
log.Fatalf("serve: %v", err)
}
}
func envFilePath() string {
exe, err := os.Executable()
if err != nil {
return api.EnvFileName
}
return filepath.Join(filepath.Dir(exe), api.EnvFileName)
}
func ensureEnvTemplate(path string) error {
if _, err := os.Stat(path); err == nil {
return nil
} else if !os.IsNotExist(err) {
return err
}
if err := os.WriteFile(path, []byte(api.EnvTemplate), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
log.Printf("wrote config template %s", path)
return nil
}
func splitFlagCSV(raw string) []string {
var out []string
for _, part := range strings.Split(raw, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
+9 -4
View File
@@ -34,10 +34,15 @@ func main() {
}
func printUsage() {
fmt.Println("bat - BlueArchiveToolkit CLI")
fmt.Println("bat-go - experimental Go helper (NOT the product CLI)")
fmt.Println()
fmt.Println("Product sync/ops CLI is the Rust binary `bat` (nearly fully automatic).")
fmt.Println("Product resource HTTP service is `bat-api` (see docs/reports/GO_STATUS.md).")
fmt.Println()
fmt.Println("This binary is experimental FFI demos only. Build output must be bin/bat-go.")
fmt.Println()
fmt.Println("Usage:")
fmt.Println(" bat doctor")
fmt.Println(" bat manifest inspect <file>")
fmt.Println(" bat sync plan <current-json> [previous-json]")
fmt.Println(" bat-go doctor")
fmt.Println(" bat-go manifest inspect <file>")
fmt.Println(" bat-go sync plan <current-json> [previous-json]")
}
+41
View File
@@ -0,0 +1,41 @@
# Optional overrides for bluearchive-toolkit-bat-api.service.
#
# Install as:
# sudo install -o root -g root -m 0644 deployments/systemd/bat-api.env.example /etc/bluearchive-toolkit/bat-api.env
#
# Production contract:
# - bat-api runs in the same server/container environment as Rust bat.
# - The current resource_root comes from bat.sock RPC.
# - Do not set BAT_API_RESOURCE_ROOT in production; it is only for local
# fixtures or emergency read-only diagnostics when RPC is unavailable.
# - Publish HTTP through a reverse proxy/TLS if exposed publicly; never expose
# bat.sock outside the host.
# - Player-facing deployments should set BAT_API_AUTH_TOKEN through a secret
# manager or process environment, not in a committed file.
BAT_API_LISTEN=127.0.0.1:18080
BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
BAT_API_STATE_DIR=/var/lib/bluearchive-toolkit/daemon-state
BAT_API_SOCKET=/var/lib/bluearchive-toolkit/daemon-state/bat.sock
BAT_API_REQUIRE_INDEXED=true
BAT_API_VERIFY_SIZE=true
BAT_API_RPC_TIMEOUT=30s
BAT_API_REFRESH_INTERVAL=1m
BAT_API_SKIP_ENV_FILE=1
BAT_API_AUTH_QUERY_PARAM=bat_token
# BAT_API_AUTH_TOKEN=
# BAT_API_AUTH_EXEMPT_PATHS=/healthz,/readyz
BAT_API_TRUST_PROXY_HEADERS=false
BAT_API_ACCESS_LOG=true
BAT_API_RATE_LIMIT_RPS=30
BAT_API_RATE_LIMIT_BURST=120
BAT_API_MAX_RESOURCE_LIMIT=1000
# Local fixture / emergency only:
# BAT_API_RESOURCE_ROOT=/var/lib/bluearchive-toolkit/official/current
# Reserved for future API persistence:
# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable
# BAT_API_DATABASE_PASSWORD=
# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0
# BAT_API_REDIS_PASSWORD=
@@ -0,0 +1,43 @@
[Unit]
Description=BlueArchiveToolkit bat-api resource bootstrap and distribution
Documentation=https://github.com/Yuyi-Oak/BlueArchiveToolkit
Wants=network-online.target
After=network-online.target
[Service]
Type=simple
User=bat
Group=bat
WorkingDirectory=/var/lib/bluearchive-toolkit
Environment=BAT_API_LISTEN=127.0.0.1:18080
Environment=BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
Environment=BAT_API_STATE_DIR=/var/lib/bluearchive-toolkit/daemon-state
Environment=BAT_API_SOCKET=/var/lib/bluearchive-toolkit/daemon-state/bat.sock
Environment=BAT_API_REQUIRE_INDEXED=true
Environment=BAT_API_VERIFY_SIZE=true
Environment=BAT_API_RPC_TIMEOUT=30s
Environment=BAT_API_REFRESH_INTERVAL=1m
Environment=BAT_API_SKIP_ENV_FILE=1
EnvironmentFile=-/etc/bluearchive-toolkit/bat-api.env
ExecStart=/opt/bluearchive-toolkit/bin/bat-api
Restart=on-failure
RestartSec=10
TimeoutStopSec=30
KillSignal=SIGTERM
StandardOutput=journal
StandardError=journal
RuntimeDirectory=bluearchive-toolkit-bat-api
RuntimeDirectoryMode=0750
LogsDirectory=bluearchive-toolkit
LogsDirectoryMode=0750
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict
ReadOnlyPaths=/var/lib/bluearchive-toolkit
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
LockPersonality=true
MemoryDenyWriteExecute=true
[Install]
WantedBy=multi-user.target
@@ -0,0 +1,196 @@
# bat-api / Rust bat Contract Fixture Handoff
更新时间:2026-07-28
本文用于两个 Codex 窗口之间间接联调 `bat-api` 与 Rust `bat` 的跨语言 contract fixture。它只定义协作协议和验收标准,不包含已审核 fixture。
## 最小上下文包
另一个窗口不需要知道本窗口的完整对话,只需要遵守以下上下文:
- 本次联调对象是 Rust `bat` RPC / snapshot JSON 与 Go `bat-api` mirror struct 的 contract fixture。
- 联调不要求本地运行全量长期服务端 `bat`;允许 Rust 侧使用 fixture root 或临时目录走真实代码路径导出 JSON。
- fixture 审核前只能放在 `/tmp/bat-contract-fixture/`,不能直接提交到仓库。
- Go 侧已经实现 player-facing HTTP 鉴权、限流、访问日志、反代适配、OpenAPI 和 `/admin/` 预留;contract fixture 是剩余跨语言强契约工作。
- Go 侧当前相关代码入口:
- `internal/api/rpc_release.go`
- `internal/api/release_index.go`
- `internal/api/responses.go`
- `internal/backendrpc/`
## 背景
- Rust `bat` 是资源同步、状态发布和 `bat.sock` RPC 的权威实现。
- Go `bat-api` 是只读 HTTP bootstrap / 分发服务,消费 Rust RPC 输出和已发布资源目录。
- contract fixture 不能由任一侧手写猜测;必须由 Rust 侧真实输出,经归一化和用户审核后,再由 Go 侧固化测试。
## 非目标
- 不引入真实玩家账号、登录、网关、鉴权绕过或游戏业务 API fixture。
- 不写入开发机绝对资源路径,例如 `/home/wanye/D/BlueArchive`
- 不把当前某个真实版本号、日期、远程目录或本地目录写成长期契约。
- 不让 Go fixture 反向约束 Rust 内部实现;只约束对外 JSON contract。
## 建议共享目录
联调前使用临时目录交换未审核产物:
```text
/tmp/bat-contract-fixture/
rust/
catalog-status.available.raw.json
catalog-status.unavailable.raw.json
resource-manifest.page0.raw.json
official-sync-snapshot.raw.json
normalized/
catalog-status.available.json
catalog-status.unavailable.json
resource-manifest.page0.json
official-sync-snapshot.json
notes.md
```
只有用户审核通过后,才允许把归一化 fixture 落入仓库,例如:
```text
internal/api/testdata/contract/
```
## Rust 侧需要产出
Rust 窗口请基于当前真实代码生成或导出以下 JSON:
1. `catalog.status` available=true 响应。
2. `catalog.status` available=false 响应。
3. `resource.manifest` 第一页响应,至少包含 1 到 2 个 entries。
4. 对应 release 的 `official-sync-snapshot.json`
输出应来自 Rust 代码路径,而不是手写 JSON。允许使用 fixture resource root 或临时目录,但不能依赖开发机真实资源目录。
## 归一化规则
归一化只允许处理环境相关值,不改变 schema:
- 绝对路径归一化为 `${RESOURCE_ROOT}``${STATE_DIR}`
- 版本 id 归一化为 `${VERSION_ID}`
- 时间戳可归一化为固定小整数或 `${COMPLETED_UNIX_SECONDS}`
- 真实 URL host 保留;路径中若含具体 release token,可归一化为 `{addressables-root}` / `{manifest-path}`
- 字段名、字段类型、字段层级、null / missing / array / number 语义不得修改。
## Go 侧验证范围
Go 窗口读取归一化后的 JSON,验证:
1. `parseCatalogStatus` 能解析 `available=true`,并正确映射:
- `app_version`
- `bundle_version`
- `connection_group_name`
- `addressables_root`
- `version.id`
- `version.completed_unix_seconds`
- `version.resource_root`
- `launcher_metadata`
- `game_main_config`
2. `parseCatalogStatus``available=false` 返回不可用而不是错误。
3. `resource.manifest` entry 字段能映射为 Go `ResourceManifestEntry`
- `url`
- `destination`
- `bytes`
- `blake3`
4. 本地 snapshot fixture 使用 `game_main_config_bootstrap`RPC `catalog.status` 使用 `game_main_config`
5. `bat-api` bootstrap 和 launcher bootstrap 不泄露归一化前的开发机路径。
Go 侧审核通过后的落地建议:
- `internal/api/testdata/contract/catalog-status.available.json`
- `internal/api/testdata/contract/catalog-status.unavailable.json`
- `internal/api/testdata/contract/resource-manifest.page0.json`
- `internal/api/testdata/contract/official-sync-snapshot.json`
- `internal/api/contract_fixture_test.go`
测试不应依赖 `/tmp/bat-contract-fixture/`;该目录只用于两窗口交接未审核产物。
## 必须覆盖的 optional 语义
至少需要两组 Rust 输出或派生 fixture 覆盖:
1. optional 字段非空:
- `launcher_metadata.game_lowest_version`
- `launcher_metadata.game_start_exe_name`
- `launcher_metadata.manifest_source`
- `game_main_config.server_info_data_url`
- `game_main_config.default_connection_group`
2. optional 字段为 null 或缺省:
- Go mirror 不应崩溃。
- HTTP response 中按当前 Go struct `omitempty` 策略输出。
## 用户审核点
落仓库前请用户审核:
- 归一化是否过度改变 Rust 真实输出。
- fixture 是否意外绑定真实版本、日期、本机路径或私有部署路径。
- `game_main_config``game_main_config_bootstrap` 的 RPC / snapshot 差异是否符合预期。
- optional 字段覆盖是否足够。
## notes.md 模板
Rust 侧生成 `/tmp/bat-contract-fixture/notes.md` 时建议使用以下结构:
```markdown
# bat contract fixture notes
## 生成命令
- catalog.status available=true: ...
- catalog.status available=false: ...
- resource.manifest page0: ...
- official-sync-snapshot: ...
## 原始输出来源
- Rust commit / working tree: ...
- 使用的 fixture root 或临时目录: ...
- 是否依赖真实开发机资源目录: 否
## 归一化
- `${RESOURCE_ROOT}`: ...
- `${STATE_DIR}`: ...
- `${VERSION_ID}`: ...
- `${COMPLETED_UNIX_SECONDS}`: ...
- URL 路径占位符: ...
## 需要用户审核
- ...
```
## 完成判定
contract fixture 工作只有在以下条件同时满足时才算完成:
1. Rust 侧原始 JSON 来自真实 Rust 代码路径。
2. 归一化 JSON 经过用户审核。
3. Go 侧测试读取归一化 fixture 并验证 mirror struct / launcher bootstrap 行为。
4. Go 测试不依赖开发机资源目录、远程长期运行 `bat``/tmp` 中的交接目录。
5. 文档记录 fixture 覆盖的风险和仍未覆盖的字段。
## 建议给另一个窗口的短指令
```text
请读取 docs/reports/BAT_API_CONTRACT_FIXTURE_HANDOFF.md。
你负责 Rust bat 侧 contract fixture 原始输出:
1. catalog.status available=true
2. catalog.status available=false
3. resource.manifest page0
4. 对应 official-sync-snapshot.json
请输出到 /tmp/bat-contract-fixture/rust/,不要手写 JSON,不要引用开发机真实资源目录。
输出后在 /tmp/bat-contract-fixture/notes.md 说明生成命令、是否做过归一化、哪些字段需要用户审核。
```
## 当前状态
- Go `bat-api` 已具备消费 `launcher_metadata` / `game_main_config` 的 mirror struct。
- Go `bat-api` 已具备 player-facing HTTP 控制面、OpenAPI 和管理面板预留。
- contract fixture 尚未落仓库,等待 Rust 侧真实输出与用户审核。
+151
View File
@@ -0,0 +1,151 @@
# Go 侧进度与边界(权威)
- **更新时间**2026-07-27
- **用途**:统一 Go module `bat-api` 的产品边界、既有约定和组件进度;其他文档与此冲突时以本文为准。
- **关联**issue #19 / G-009(资源 bootstrap/分发)、G-008(已决策关闭)、`docs/architecture/official-resource-backend.md` §7
---
## 1. 三个入口分别是什么
| 名称 | 路径 / 产物 | 角色 | 是否产品入口 |
|---|---|---|---|
| **Rust `bat`** | `infrastructure` bin → 正式同步二进制 | 官方资源**自动**发现 / 拉取 / 校验 / 发布 / watch·daemon / 运维子命令 | **是(同步与运维命令行)** |
| **Go `bat-api`** | `cmd/bat-api``bin/bat-api` | **资源 bootstrap + 分发 HTTP 服务**(官方 CDN path 形态)+ release 观察 API | **是(bootstrap/分发服务)** |
| **Go 试验 CLI** | `cmd/bat``bin/bat-go`(不得再叫 `bin/bat` | FFI 演示骨架 | **否** |
### 1.1 「同步命令行 = Rust `bat`」的含义
人类做资源同步与运维时,正式命令行是 **Rust 编译的 `bat`**(近乎全自动:`--auto-discover``--watch` / `--daemon` 后只需偶发 `status` / `refresh` / `repair`,不需要持久手操维护)。
这**不是**说整个项目只有 Rust,也**不是**取消 Go 入口:
- Go 的正式产品入口是 **`bat-api` 服务进程**(给客户端/工具提供启动前资源 bootstrap、server-info 改写和已发布资源字节),不是再做一套同步 CLI。
- Go `cmd/bat` 仅试验,禁止与 Rust `bat` 二进制重名。
### 1.2 `bat` 与 `bat-api` 的关系
`bat` 是资源生产者和状态拥有者;`bat-api` 是资源读侧和 HTTP 入口。
| 关系面 | Rust `bat` / daemon | Go `bat-api` |
|---|---|---|
| 资源发现 | 读取官方 launcher/resource metadata,解析 `GameMainConfig`、server-info 和 Addressables root | 通过 `bat.sock` 读取已发布版本摘要,不重新探测官方 metadata |
| 下载与发布 | 下载、校验、staging、原子发布 `current -> versions/<id>`,维护 manifest/snapshot/version-state | 不下载、不写 staging、不改 version-state;生产资源根来自 RPC 返回的 `resource_root` |
| 启动前资源入口 | 暴露 `catalog.status` / `resource.manifest` 等 RPC 数据 | 提供 `/v1/bootstrap``/v1/launcher/bootstrap`、launcher 资源 metadata 兼容端点、`/v1/server-info` 和 CDN path,组织给客户端/补丁器使用 |
| 长期状态 | watch/daemon、任务队列、日志、错误码、repair/sync/verify | 周期性经 RPC 刷新内存索引,只展示 ready、RPC 健康和 release;需要拉取/修复时由外部运维调用 `bat` 或 RPC 任务 |
这条边界允许 `bat-api` 做资源 bootstrap 兼容,但不允许它复制 Rust 下载器或伪装完整游戏业务服务。
### 1.3 决策(已核验)
1. **G-008 决策关闭(wontfix**:不另做产品级 Go 同步/运维 CLI。
2. **G-009**:资源 bootstrap/分发 MVP 部分完成;非完整游戏业务 API。
3. **USERGUIDE 的 bat-api 基础章节已补**;全量 release 联调后继续补充生产参数和排障样例。
---
## 2. 既有约定核对表(不可丢)
### 职责
| ID | 约定 |
|---|---|
| A | **自动发现 / 下载 / 校验 / 发布 / watch·daemon** 只在 **Rust `bat`** |
| B | **`bat-api` 只读分发**已发布 release,不实现下载器,不写 staging/version-state |
| C | 仿真范围 = **资源拉取相关**resource bootstrap + CDN path + 可选 server-info);**不是**完整游戏业务 API |
| D | launcher 资源 metadata 可作为 bootstrap 输入/输出兼容;账号、登录、网关和鉴权全链 **非 G-009 关闭条件** |
| E | USERGUIDE bat-api 基础章节已补;联调后补充实战样例 |
### 发现与数据
| ID | 约定 |
|---|---|
| F | 版本/清单经 **`bat.sock` JSON-RPC**`--socket`);不读 daemon 内部状态文件 |
| G | RPC 顺序:先 **`daemon.status`**,再 **`daemon.doctor`**,再 catalog/manifest |
| H | 生产文件字节从 RPC 返回的 `resource_root` 读盘;`bat-api` 与 daemon 同服务器/同容器/共享文件系统部署;`--resource-root` 仅 fixture 或应急只读诊断 |
| I | 真数据在**已全量拉取且长期运行 Rust `bat` 的远程服务器**;开发机不跑全量 `bat`,用 fixture、mock RPC 和 Go 门禁验证;远程联调等连接信息 |
| J | 索引以 **manifest + 磁盘 Present/size** 为准 |
### 进程配置
| ID | 约定 |
|---|---|
| K | `.env` / 环境变量 / CLI:端口、public base、RPC socket、RPC 刷新周期;**预留** database/redis |
| L | 管理面 / bootstrap`/healthz``/readyz``/v1/bootstrap``/v1/release``/v1/resources``/openapi.yaml``/admin/` 预留 |
| M | CDN`GET/HEAD /prod-clientpatch.bluearchiveyostar.com/...`,支持 Range、ETag、Last-Modified、长期缓存头 |
| N | server-info 可选;**只改 AddressablesCatalogUrlRoot** |
| N2 | launcher 兼容仅限资源引导:`/v1/launcher/bootstrap``/api/launcher/...` 形状端点输出已发布 release、launcher metadata 和 GameMainConfig 摘要;不下载 launcher 包、不生成完整 PC package update manifest、不仿造登录/网关 |
| N3 | 玩家-facing HTTP 控制面:可配置 token 鉴权、进程内限流、访问日志、反代 IP 适配、动态 JSON `no-store``/v1/resources` 分页上限 |
### 工程
| ID | 约定 |
|---|---|
| O | 权威文档与 `go list` 一致,禁止「API 完全没有」等过时句 |
| P | 试验 CLI 产物 **`bin/bat-go`**,禁止 `bin/bat` |
| Q | 空目录标明 reserved empty |
| R | 默认门禁:`make test-go-api` + `make build-go-api`(无 FFI |
---
## 3. 组件进度
| 组件 | 路径 | 状态 | 说明 |
|---|---|---|---|
| Module | `go.mod``bat-api` | 已用 | 服务层模块名 |
| RPC client | `internal/backendrpc` | **完成** | typed JSON-RPCfake transport 单测 |
| 资源 bootstrap/分发 | `cmd/bat-api` + `internal/api` | **MVP+生产控制面** | RPC 发现 + 周期刷新/诊断 + `/v1/bootstrap` + `/v1/launcher/bootstrap` + launcher 资源 metadata 兼容 + `/readyz` + CDN Range/缓存头 + 鉴权/限流/访问日志/反代适配 + OpenAPI + 管理面预留 + `.env` |
| 试验 CLI | `cmd/bat` | **试验** | doctor 固定 okmanifest/sync 走 FFI |
| FFI | `internal/ffi` | **可选** | 需 `build-ffi` |
| 空骨架 | `api/``pkg/*`、部分 `internal/*` | **空** | 见各目录 README |
| Web | `web/` | **空** | G-010 |
`go list ./...` 当前包:
- `bat-api/cmd/bat-api`
- `bat-api/cmd/bat`
- `bat-api/internal/api`
- `bat-api/internal/backendrpc`
- `bat-api/internal/ffi`
---
## 4. 验证门禁
```bash
# 默认(提交前 / CI 建议)
make test-go-api
make build-go-api
go vet ./internal/api/... ./internal/backendrpc/... ./cmd/bat-api/...
# 可选:改 FFI 或试验 CLI 时
make build-ffi
make test-go-ffi
make build-go-cli # 产出 bin/bat-go
```
---
## 5. 与缺口 / issue 对应
| 项 | 状态 |
|---|---|
| G-008 Go 同步 CLI | **已决策关闭**(正式同步 CLI = Rust `bat` |
| G-009 bat-api 资源 bootstrap/分发 | **部分完成**(MVP+生产控制面);已含资源 bootstrap 关系面、launcher 资源 metadata 兼容、HTTP 鉴权/限流/日志/反代适配、RPC 周期刷新/诊断、readiness、OpenAPI、管理面预留和部署模板,后续远程服务器联调/可选持久化 |
| issue #19 | 资源面 MVP 与 USERGUIDE 基础章节已编码;真机联调后继续补充实战样例;**未自动关 issue** |
| G-010 Web | 未开始 |
---
## 6. 资源布局与逆向
- **Release / URL / 分发契约(权威)**:`docs/architecture/resource-release-layout.md`
- 真机全量实勘、seed inventory diff、issue #2/#3 样本采集按该文档 §9–§10 执行
## 7. 后续(不在进度统一范围内)
1. 服务器 SSH 只读实勘(连接信息到位后)
2. bat-api 与远程长期运行的 `bat` / 全量 release 联调(含 `/v1/bootstrap`、server-info 和 CDN path
3. 预留 database/redis 的接入时机另议
4. USERGUIDE bat-api 联调排障样例(全量 release 验证后)
5. launcher 完整安装包更新链 / 登录网关链(若需要,新 issue)
+29
View File
@@ -0,0 +1,29 @@
package api
import "net/http"
func (s *Server) handleAdminIndex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
body := AdminIndexResponse{
Service: "bat-api",
Panel: "admin",
Status: "reserved",
Links: []string{
"/healthz",
"/readyz",
"/v1/bootstrap",
"/v1/release",
"/v1/resources",
"/openapi.yaml",
},
}
if r.Method == http.MethodHead {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
return
}
writeNoStoreJSON(w, http.StatusOK, body)
}
+962
View File
@@ -0,0 +1,962 @@
package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
"bat-api/internal/backendrpc"
)
func fixtureRoot(t *testing.T) string {
t.Helper()
root := filepath.Join("testdata", "release")
abs, err := filepath.Abs(root)
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(abs, "official-download-manifest.json")); err != nil {
t.Fatalf("fixture missing: %v", err)
}
return abs
}
func TestLoadIndexFromResourceRoot(t *testing.T) {
idx, err := LoadIndexFromResourceRoot(fixtureRoot(t))
if err != nil {
t.Fatal(err)
}
if len(idx.Entries) != 2 {
t.Fatalf("entries = %d", len(idx.Entries))
}
sum := idx.Summary()
if !sum.Ready || sum.PresentCount != 2 {
t.Fatalf("summary = %+v", sum)
}
if sum.Snapshot == nil {
t.Fatal("snapshot missing")
}
if sum.Snapshot.LauncherMetadata == nil {
t.Fatal("launcher metadata missing")
}
if sum.Snapshot.LauncherMetadata.GameLatestVersion != "1.70.0" {
t.Fatalf("launcher game version=%q", sum.Snapshot.LauncherMetadata.GameLatestVersion)
}
if got := sum.Snapshot.LauncherMetadata.GameStartParams; len(got) != 1 || got[0] != "BlueArchive.exe" {
t.Fatalf("launcher params=%v", got)
}
if sum.Snapshot.GameMainConfig == nil || sum.Snapshot.GameMainConfig.DefaultConnectionGroup != "Prod" {
t.Fatalf("game main config=%+v", sum.Snapshot.GameMainConfig)
}
rel := "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
entry, ok := idx.Lookup(rel)
if !ok || !entry.Present {
t.Fatalf("lookup failed: %+v", entry)
}
}
func TestSplitCDNPathRejectsEscape(t *testing.T) {
if _, _, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/../etc/passwd"); err == nil {
t.Fatal("expected error")
}
if _, _, err := SplitCDNPath("/evil.example/a"); err == nil {
t.Fatal("expected unsupported host")
}
host, rel, err := SplitCDNPath("/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes")
if err != nil || host != ClientPatchHost {
t.Fatalf("host=%s rel=%s err=%v", host, rel, err)
}
}
func TestCDNServesIndexedFile(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
cfg.PublicBaseURL = "http://127.0.0.1:18080"
_ = cfg.Normalize()
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{ResourceRoot: root, Index: idx}
s.mu.Unlock()
req := httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes", nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Body.String() != "TABLE_CATALOG_FIXTURE" {
t.Fatalf("body=%q", rr.Body.String())
}
if rr.Header().Get("Accept-Ranges") != "bytes" {
t.Fatalf("Accept-Ranges=%q", rr.Header().Get("Accept-Ranges"))
}
if rr.Header().Get("ETag") == "" {
t.Fatal("ETag missing")
}
if rr.Header().Get("Last-Modified") == "" {
t.Fatal("Last-Modified missing")
}
if rr.Header().Get("Cache-Control") != "public, max-age=31536000, immutable" {
t.Fatalf("Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
// Unknown path
req = httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/missing.bin", nil)
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("missing status=%d", rr.Code)
}
}
func TestCDNSupportsRangeHeadAndConditionalRequests(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
_ = cfg.Normalize()
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{ResourceRoot: root, Index: idx}
s.mu.Unlock()
path := "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"
req := httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Range", "bytes=0-4")
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusPartialContent {
t.Fatalf("range status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Body.String() != "TABLE" {
t.Fatalf("range body=%q", rr.Body.String())
}
if rr.Header().Get("Content-Range") != "bytes 0-4/21" {
t.Fatalf("Content-Range=%q", rr.Header().Get("Content-Range"))
}
etag := rr.Header().Get("ETag")
if etag != `"blake3-0000000000000000000000000000000000000000000000000000000000000000"` {
t.Fatalf("ETag=%q", etag)
}
req = httptest.NewRequest(http.MethodHead, path, nil)
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("head status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Body.Len() != 0 {
t.Fatalf("head body=%q", rr.Body.String())
}
if rr.Header().Get("Content-Length") != "21" {
t.Fatalf("head Content-Length=%q", rr.Header().Get("Content-Length"))
}
req = httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("If-None-Match", etag)
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusNotModified {
t.Fatalf("conditional status=%d body=%s", rr.Code, rr.Body.String())
}
req = httptest.NewRequest(http.MethodGet, path, nil)
req.Header.Set("Range", "bytes=99-120")
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusRequestedRangeNotSatisfiable {
t.Fatalf("invalid range status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestCDNHashContentType(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
_ = cfg.Normalize()
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{ResourceRoot: root, Index: idx}
s.mu.Unlock()
req := httptest.NewRequest(http.MethodGet, "/prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash", nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Header().Get("Content-Type") != "text/plain; charset=utf-8" {
t.Fatalf("Content-Type=%q", rr.Header().Get("Content-Type"))
}
}
func TestHealthzAndRelease(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
_ = cfg.Normalize()
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
s.mu.Unlock()
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != 200 {
t.Fatal(rr.Body.String())
}
var health map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
if health["ready"] != true {
t.Fatalf("health=%v", health)
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String())
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/resources?limit=1", nil))
if rr.Code != 200 {
t.Fatal(rr.Body.String())
}
}
func TestBootstrapDescribesBatRelationship(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
cfg.PublicBaseURL = "http://127.0.0.1:18080"
cfg.SocketPath = "/tmp/bat-pid/bat.sock"
_ = cfg.Normalize()
healthy := true
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{
Index: idx,
ResourceRoot: root,
RPCAvailable: true,
DoctorHealthy: &healthy,
}
s.mu.Unlock()
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["ready"] != true {
t.Fatalf("ready=%v", body["ready"])
}
bat := body["bat"].(map[string]any)
if bat["role"] != "sync_daemon_and_release_producer" {
t.Fatalf("bat role=%v", bat["role"])
}
resource := body["resource"].(map[string]any)
wantRoot := "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture"
if resource["addressables_catalog_url_root"] != wantRoot {
t.Fatalf("addressables root=%v", resource["addressables_catalog_url_root"])
}
if resource["server_info_url"] != "http://127.0.0.1:18080/yostar-serverinfo.bluearchiveyostar.com/server-info.json" {
t.Fatalf("server info url=%v", resource["server_info_url"])
}
policy := body["policy"].(map[string]any)
if policy["pull_owner"] != "rust_bat" || policy["writes_release_state"] != false {
t.Fatalf("policy=%v", policy)
}
}
func TestLauncherBootstrapDescribesResourceOnlyScope(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
cfg.PublicBaseURL = "http://127.0.0.1:18080"
_ = cfg.Normalize()
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
s.mu.Unlock()
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/launcher/bootstrap", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["ready"] != true {
t.Fatalf("ready=%v", body["ready"])
}
policy := body["policy"].(map[string]any)
if policy["source"] != "rust_bat_snapshot" || policy["emulates_login_or_gateway"] != false {
t.Fatalf("policy=%v", policy)
}
if policy["downloads_launcher_package"] != false || policy["package_update_manifest"] != false {
t.Fatalf("policy=%v", policy)
}
metadata := body["launcher_metadata"].(map[string]any)
if metadata["game_latest_file_path"] != "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" {
t.Fatalf("launcher metadata=%v", metadata)
}
gameMainConfig := body["game_main_config"].(map[string]any)
if gameMainConfig["server_info_data_url"] != "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json" {
t.Fatalf("game main config=%v", gameMainConfig)
}
if body["addressables_catalog_url_root"] != "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture" {
t.Fatalf("addressables root=%v", body["addressables_catalog_url_root"])
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/resource/bootstrap.json"), nil))
if rr.Code != http.StatusOK {
t.Fatalf("host-shaped bootstrap status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestLauncherCompatibilityEndpoints(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
cfg.PublicBaseURL = "http://127.0.0.1:18080"
_ = cfg.Normalize()
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
s.mu.Unlock()
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/game/config"), nil))
if rr.Code != http.StatusOK {
t.Fatalf("game config status=%d body=%s", rr.Code, rr.Body.String())
}
var envelope map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
if envelope["code"] != float64(200) {
t.Fatalf("envelope=%v", envelope)
}
data := envelope["data"].(map[string]any)
if data["game_latest_version"] != "1.70.0" {
t.Fatalf("game config=%v", data)
}
if data["game_latest_file_path"] != "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip" {
t.Fatalf("game config=%v", data)
}
if data["resource_bootstrap_url"] != "http://127.0.0.1:18080/v1/launcher/bootstrap" {
t.Fatalf("game config=%v", data)
}
if data["server_info_url"] != "http://127.0.0.1:18080/yostar-serverinfo.bluearchiveyostar.com/server-info.json" {
t.Fatalf("game config=%v", data)
}
if params := data["game_start_params"].([]any); len(params) != 1 || params[0] != "BlueArchive.exe" {
t.Fatalf("params=%v", data["game_start_params"])
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/launcher/game/config", nil))
if rr.Code != http.StatusOK {
t.Fatalf("bare game config status=%d body=%s", rr.Code, rr.Body.String())
}
rr = httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodGet,
launcherHostPath("/api/launcher/game/config/json")+"?version=1.70.0&file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-1.70.436321-game.zip",
nil,
)
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("game config json status=%d body=%s", rr.Code, rr.Body.String())
}
envelope = map[string]any{}
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
data = envelope["data"].(map[string]any)
if data["package_update_manifest"] != false || data["scope"] != "resource_bootstrap_only" {
t.Fatalf("game config json=%v", data)
}
url, _ := data["url"].(string)
if !strings.HasPrefix(url, "http://127.0.0.1:18080/api-launcher-jp.yo-star.com/api/launcher/resource/bootstrap.json?") {
t.Fatalf("bootstrap url=%q", url)
}
if !strings.Contains(url, "version=1.70.0") || !strings.Contains(url, "file_path=prod%2FZIP_TEMP%2FBlueArchive_JP_TEMP%2FBlueArchive_JP-1.70.436321-game.zip") {
t.Fatalf("bootstrap url=%q", url)
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, launcherHostPath("/api/launcher/advanced/game/download/cdn"), nil))
if rr.Code != http.StatusOK {
t.Fatalf("cdn config status=%d body=%s", rr.Code, rr.Body.String())
}
envelope = map[string]any{}
if err := json.Unmarshal(rr.Body.Bytes(), &envelope); err != nil {
t.Fatal(err)
}
data = envelope["data"].(map[string]any)
if data["primary_cdn"] != "http://127.0.0.1:18080" || data["back_up_cdn"] != "http://127.0.0.1:18080" {
t.Fatalf("cdn config=%v", data)
}
if data["package_update_manifest"] != false {
t.Fatalf("cdn config=%v", data)
}
}
func TestBootstrapNotReadyDoesNotClaimReleaseOwnership(t *testing.T) {
cfg := DefaultConfig()
cfg.PublicBaseURL = "http://127.0.0.1:18080"
cfg.SocketPath = "/tmp/bat-pid/bat.sock"
_ = cfg.Normalize()
s := NewServer(cfg, nil, nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var body map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if body["ready"] != false {
t.Fatalf("ready=%v", body["ready"])
}
policy := body["policy"].(map[string]any)
if policy["pull_owner"] != "rust_bat" {
t.Fatalf("pull owner=%v", policy["pull_owner"])
}
if policy["serves_only_published_release"] != true || policy["writes_release_state"] != false {
t.Fatalf("policy=%v", policy)
}
resource := body["resource"].(map[string]any)
if resource["entry_count"] != float64(0) || resource["present_count"] != float64(0) {
t.Fatalf("resource=%v", resource)
}
}
func TestServerInfoRewritesAddressablesOnly(t *testing.T) {
raw := []byte(`{
"ConnectionGroups":[{
"Name":"Prod",
"AddressablesCatalogUrlRoot":"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
"ApiUrl":"https://prod-game.example.invalid/"
}]
}`)
out, err := RewriteServerInfoAddressables(raw, "http://127.0.0.1:18080")
if err != nil {
t.Fatal(err)
}
var doc map[string]any
if err := json.Unmarshal(out, &doc); err != nil {
t.Fatal(err)
}
group := doc["ConnectionGroups"].([]any)[0].(map[string]any)
got := group["AddressablesCatalogUrlRoot"].(string)
want := "http://127.0.0.1:18080/prod-clientpatch.bluearchiveyostar.com/r93_fixture"
if got != want {
t.Fatalf("root=%s want=%s", got, want)
}
if group["ApiUrl"] != "https://prod-game.example.invalid/" {
t.Fatalf("ApiUrl should be unchanged: %v", group["ApiUrl"])
}
}
type fakeBackend struct {
statusCalls int
doctorCalls int
status *backendrpc.DaemonStatusReport
doctor *backendrpc.DoctorReport
catalog json.RawMessage
manifest *backendrpc.ResourceManifestPage
}
func (f *fakeBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
f.statusCalls++
return f.status, nil
}
func (f *fakeBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
f.doctorCalls++
if f.statusCalls == 0 {
// status must be called first in real DiscoverAndIndex; this is asserted by call order.
}
return f.doctor, nil
}
func (f *fakeBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
return &backendrpc.ResourceState{}, nil
}
func (f *fakeBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return f.catalog, nil
}
func (f *fakeBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
return f.manifest, nil
}
func TestDiscoverCallsStatusBeforeDoctor(t *testing.T) {
root := fixtureRoot(t)
bytes := uint64(20)
info, err := os.Stat(filepath.Join(root, "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes"))
if err != nil {
t.Fatal(err)
}
b := uint64(info.Size())
_ = bytes
catalogObj := map[string]any{
"available": true,
"app_version": "1.70.0",
"bundle_version": "s8tloc7lo3",
"connection_group_name": "Prod",
"addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
"version": map[string]any{
"id": "v1",
"resource_root": root,
},
}
catalogRaw, err := json.Marshal(catalogObj)
if err != nil {
t.Fatal(err)
}
fb := &fakeBackend{
status: &backendrpc.DaemonStatusReport{Status: "ok", Running: true, RPCAvailable: true},
doctor: &backendrpc.DoctorReport{Healthy: true, Status: "ok"},
catalog: catalogRaw,
manifest: &backendrpc.ResourceManifestPage{
Available: true,
ResourceRoot: root,
ManifestVersion: 1,
TotalEntries: 1,
Entries: []backendrpc.ResourceManifestEntry{{
URL: "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Destination: "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
Bytes: &b,
BLAKE3: strings.Repeat("0", 64),
}},
},
}
result, err := DiscoverAndIndex(context.Background(), fb, "")
if err != nil {
t.Fatal(err)
}
if fb.statusCalls != 1 || fb.doctorCalls != 1 {
t.Fatalf("status=%d doctor=%d", fb.statusCalls, fb.doctorCalls)
}
if !result.RPCAvailable || result.Index == nil || !result.Index.Summary().Ready {
t.Fatalf("result=%+v summary=%+v", result, result.Index.Summary())
}
if result.DoctorHealthy == nil || !*result.DoctorHealthy {
t.Fatal("doctor healthy expected")
}
}
func TestLoadEnvFileDoesNotOverride(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, ".env")
if err := os.WriteFile(path, []byte("BAT_API_LISTEN=:9999\n"), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("BAT_API_LISTEN", ":1111")
if err := LoadEnvFile(path); err != nil {
t.Fatal(err)
}
if os.Getenv("BAT_API_LISTEN") != ":1111" {
t.Fatal(os.Getenv("BAT_API_LISTEN"))
}
}
func TestRefreshDiagnosticsAndReadyz(t *testing.T) {
root := fixtureRoot(t)
cfg := DefaultConfig()
cfg.ResourceRoot = root
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, log.New(io.Discard, "", 0))
if err := s.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("healthz status=%d body=%s", rr.Code, rr.Body.String())
}
var health map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
refresh := health["refresh"].(map[string]any)
if refresh["last_success_unix_seconds"] == nil || refresh["last_error"] != "" {
t.Fatalf("refresh=%v", refresh)
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestRefreshFailureDiagnosticsAndReadyz(t *testing.T) {
cfg := DefaultConfig()
cfg.ResourceRoot = filepath.Join(t.TempDir(), "missing-release")
cfg.RefreshInterval = 0
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, log.New(io.Discard, "", 0))
if err := s.Refresh(context.Background()); err == nil {
t.Fatal("expected refresh failure")
}
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("healthz status=%d body=%s", rr.Code, rr.Body.String())
}
var health map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
refresh := health["refresh"].(map[string]any)
if refresh["last_error"] == "" || refresh["last_finished_unix_seconds"] == nil {
t.Fatalf("refresh=%v", refresh)
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/readyz", nil))
if rr.Code != http.StatusServiceUnavailable {
t.Fatalf("readyz status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestApplyEnvParsesRefreshInterval(t *testing.T) {
cfg := DefaultConfig()
t.Setenv("BAT_API_REFRESH_INTERVAL", "2m")
ApplyEnv(&cfg)
if cfg.RefreshInterval != 2*time.Minute {
t.Fatalf("refresh interval=%s", cfg.RefreshInterval)
}
cfg.RefreshInterval = -time.Second
if err := cfg.Normalize(); err == nil {
t.Fatal("expected negative refresh interval error")
}
}
type pollingBackend struct {
statusCalls atomic.Int64
}
func (p *pollingBackend) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
p.statusCalls.Add(1)
return nil, errors.New("offline")
}
func (p *pollingBackend) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
return nil, errors.New("unexpected doctor call")
}
func (p *pollingBackend) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
return nil, errors.New("unexpected resource state call")
}
func (p *pollingBackend) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return nil, errors.New("unexpected catalog call")
}
func (p *pollingBackend) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
return nil, errors.New("unexpected manifest call")
}
func TestStartRefreshLoopPollsBackend(t *testing.T) {
cfg := DefaultConfig()
cfg.RefreshInterval = 10 * time.Millisecond
cfg.RPCTimeout = 20 * time.Millisecond
backend := &pollingBackend{}
s := NewServer(cfg, backend, log.New(io.Discard, "", 0))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
s.StartRefreshLoop(ctx)
deadline := time.Now().Add(200 * time.Millisecond)
for time.Now().Before(deadline) {
if backend.statusCalls.Load() > 0 {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("refresh loop did not poll backend")
}
func TestRefreshWarningsRecordedWhenRPCUnavailable(t *testing.T) {
cfg := DefaultConfig()
cfg.RefreshInterval = 0
backend := &pollingBackend{}
s := NewServer(cfg, backend, log.New(io.Discard, "", 0))
if err := s.Refresh(context.Background()); err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
var health map[string]any
if err := json.Unmarshal(rr.Body.Bytes(), &health); err != nil {
t.Fatal(err)
}
refresh := health["refresh"].(map[string]any)
if refresh["last_error"] != "" || refresh["last_warning_count"] == float64(0) {
t.Fatalf("refresh=%v", refresh)
}
}
func TestHTTPAuthRequiresTokenAndAllowsExemptPaths(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
cfg.AuthToken = "secret-token"
cfg.AuthExemptPaths = []string{"/healthz"}
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
s.mu.Unlock()
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil))
if rr.Code != http.StatusUnauthorized {
t.Fatalf("missing token status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("unauthorized Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
var errBody ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil {
t.Fatal(err)
}
if errBody.Error.Code != "unauthorized" {
t.Fatalf("error=%+v", errBody)
}
req := httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil)
req.Header.Set("Authorization", "Bearer secret-token")
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("authorized status=%d body=%s", rr.Code, rr.Body.String())
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/healthz", nil))
if rr.Code != http.StatusOK {
t.Fatalf("exempt healthz status=%d body=%s", rr.Code, rr.Body.String())
}
}
func TestRateLimitUsesForwardedClientWhenTrusted(t *testing.T) {
cfg := DefaultConfig()
cfg.RateLimitRPS = 1
cfg.RateLimitBurst = 1
cfg.TrustProxyHeaders = true
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, log.New(io.Discard, "", 0))
handler := s.Handler()
req := httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.RemoteAddr = "10.0.0.10:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.10")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("first status=%d body=%s", rr.Code, rr.Body.String())
}
req = httptest.NewRequest(http.MethodGet, "/healthz", nil)
req.RemoteAddr = "10.0.0.10:1234"
req.Header.Set("X-Forwarded-For", "203.0.113.7, 10.0.0.10")
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
if rr.Code != http.StatusTooManyRequests {
t.Fatalf("second status=%d body=%s", rr.Code, rr.Body.String())
}
var errBody ErrorResponse
if err := json.Unmarshal(rr.Body.Bytes(), &errBody); err != nil {
t.Fatal(err)
}
if errBody.Error.Code != "rate_limited" {
t.Fatalf("error=%+v", errBody)
}
}
func TestResourcesLimitIsCappedAndDynamicResponsesNoStore(t *testing.T) {
root := fixtureRoot(t)
idx, err := LoadIndexFromResourceRoot(root)
if err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.ResourceRoot = root
cfg.MaxResourcePageLimit = 1
cfg.PublicBaseURL = "http://127.0.0.1:18080"
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, nil)
s.mu.Lock()
s.idx = idx
s.meta = DiscoverResult{Index: idx, ResourceRoot: root}
s.mu.Unlock()
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/resources?limit=999", nil))
if rr.Code != http.StatusOK {
t.Fatalf("resources status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("resources Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
var page ResourceListResponse
if err := json.Unmarshal(rr.Body.Bytes(), &page); err != nil {
t.Fatal(err)
}
if page.Limit != 1 || len(page.Items) != 1 {
t.Fatalf("page=%+v", page)
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/bootstrap", nil))
if rr.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("bootstrap Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/v1/launcher/bootstrap", nil))
if rr.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("launcher bootstrap Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
}
func TestAccessLogOmitsQueryString(t *testing.T) {
var logs bytes.Buffer
cfg := DefaultConfig()
cfg.AccessLog = true
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, log.New(&logs, "", 0))
req := httptest.NewRequest(http.MethodGet, "/healthz?bat_token=secret", nil)
req.Header.Set("User-Agent", "bat-test")
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
}
got := logs.String()
if !strings.Contains(got, "path=/healthz") {
t.Fatalf("log=%q", got)
}
if strings.Contains(got, "secret") || strings.Contains(got, "bat_token") {
t.Fatalf("log leaked query token: %q", got)
}
}
func TestOpenAPIAndAdminReservedEndpoints(t *testing.T) {
cfg := DefaultConfig()
if err := cfg.Normalize(); err != nil {
t.Fatal(err)
}
s := NewServer(cfg, nil, nil)
rr := httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/openapi.yaml", nil))
if rr.Code != http.StatusOK {
t.Fatalf("openapi status=%d body=%s", rr.Code, rr.Body.String())
}
if rr.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("openapi Cache-Control=%q", rr.Header().Get("Cache-Control"))
}
if rr.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatalf("X-Content-Type-Options=%q", rr.Header().Get("X-Content-Type-Options"))
}
if !strings.Contains(rr.Body.String(), "/v1/launcher/bootstrap") {
t.Fatalf("openapi missing launcher bootstrap")
}
rr = httptest.NewRecorder()
s.Handler().ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/admin/", nil))
if rr.Code != http.StatusOK {
t.Fatalf("admin status=%d body=%s", rr.Code, rr.Body.String())
}
var admin AdminIndexResponse
if err := json.Unmarshal(rr.Body.Bytes(), &admin); err != nil {
t.Fatal(err)
}
if admin.Status != "reserved" {
t.Fatalf("admin=%+v", admin)
}
}
+87
View File
@@ -0,0 +1,87 @@
package api
import (
"fmt"
"mime"
"net/http"
"os"
"path/filepath"
"strings"
)
func (s *Server) serveCDN(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
idx := s.index()
if idx == nil || idx.ResourceRoot == "" {
http.Error(w, "resource root not ready", http.StatusServiceUnavailable)
return
}
_, rel, err := SplitCDNPath(r.URL.Path)
if err != nil {
http.NotFound(w, r)
return
}
var entry ResourceEntry
var hasEntry bool
if s.cfg.RequireIndexed {
entry, hasEntry = idx.Lookup(rel)
if !hasEntry || !entry.Present || !entry.SizeMatch {
http.NotFound(w, r)
return
}
}
abs, err := ResolveUnderRoot(idx.ResourceRoot, rel, true)
if err != nil {
http.NotFound(w, r)
return
}
info, err := os.Stat(abs)
if err != nil || !info.Mode().IsRegular() {
http.NotFound(w, r)
return
}
if s.cfg.RequireIndexed && s.cfg.VerifySize {
if hasEntry && entry.Bytes > 0 && uint64(info.Size()) != entry.Bytes {
http.Error(w, "size mismatch with release index", http.StatusConflict)
return
}
}
file, err := os.Open(abs)
if err != nil {
http.NotFound(w, r)
return
}
defer file.Close()
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
w.Header().Set("ETag", cdnETag(entry, hasEntry, info))
w.Header().Set("Content-Type", cdnContentType(abs))
http.ServeContent(w, r, info.Name(), info.ModTime(), file)
}
func cdnETag(entry ResourceEntry, hasEntry bool, info os.FileInfo) string {
if hasEntry && entry.BLAKE3 != "" {
return fmt.Sprintf(`"blake3-%s"`, entry.BLAKE3)
}
return fmt.Sprintf(`W/"%d-%d"`, info.Size(), info.ModTime().UnixNano())
}
func cdnContentType(abs string) string {
ext := strings.ToLower(filepath.Ext(abs))
if ext == ".json" {
return "application/json; charset=utf-8"
}
if ext == ".hash" {
return "text/plain; charset=utf-8"
}
if detected := mime.TypeByExtension(ext); detected != "" {
return detected
}
return "application/octet-stream"
}
+372
View File
@@ -0,0 +1,372 @@
// Package api implements the resource bootstrap and distribution HTTP surface of bat-api.
//
// bat-api serves already-published official resources (CDN-shaped paths) and
// discovers version/manifest state and the current resource_root through the
// Rust bat daemon RPC socket.
// Automatic resource pull/sync remains the responsibility of the Rust bat
// binary; this package does not download or mutate release contents.
package api
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
// Default listen address and related defaults for the resource API.
const (
DefaultListen = ":18080"
DefaultPublicBaseURL = "http://127.0.0.1:18080"
DefaultStateDir = "/tmp/bat-pid"
DefaultAuthSkew = 5 * time.Minute
ClientPatchHost = "prod-clientpatch.bluearchiveyostar.com"
ServerInfoHost = "yostar-serverinfo.bluearchiveyostar.com"
EnvFileName = ".env"
)
// Config holds bat-api process configuration.
//
// Values come from (highest wins): CLI flags > process environment > .env file
// > built-in defaults. Database-related keys are reserved for future API
// persistence and are loaded but not required for the resource CDN surface.
type Config struct {
// Listen is the HTTP listen address, e.g. ":18080" or "127.0.0.1:18080".
Listen string
// PublicBaseURL is used when rewriting AddressablesCatalogUrlRoot.
PublicBaseURL string
// StateDir is used only to derive the default RPC socket path.
StateDir string
// SocketPath is the bat.sock Unix socket used for JSON-RPC.
SocketPath string
// ResourceRoot overrides the release root discovered via RPC (tests / emergency).
ResourceRoot string
// ServerInfoFile is an optional path to a server-info JSON document.
ServerInfoFile string
// RequireIndexed rejects CDN paths that are not in the release index.
RequireIndexed bool
// VerifySize rejects CDN files whose size differs from the index.
VerifySize bool
// RPCTimeout is the per-call timeout for daemon RPC.
RPCTimeout time.Duration
// RefreshInterval periodically re-discovers the release through RPC. Zero disables it.
RefreshInterval time.Duration
// SkipEnvFile disables loading .env when true (BAT_API_SKIP_ENV_FILE=1).
SkipEnvFile bool
// AuthToken enables HTTP token authentication when non-empty.
AuthToken string
// AuthQueryParam is the optional query parameter accepted for token auth.
AuthQueryParam string
// AuthExemptPaths lists exact paths or slash-prefixes that bypass token auth.
AuthExemptPaths []string
// TrustProxyHeaders allows X-Forwarded-For / X-Real-IP for client identity.
TrustProxyHeaders bool
// AccessLog enables structured per-request access logs.
AccessLog bool
// RateLimitRPS limits requests per client identity. Zero disables in-process limiting.
RateLimitRPS float64
// RateLimitBurst is the token bucket burst size when rate limiting is enabled.
RateLimitBurst int
// MaxResourcePageLimit caps /v1/resources limit.
MaxResourcePageLimit int
// Reserved for a future persistent API layer (not used by CDN MVP).
DatabaseURL string
DatabasePassword string
RedisURL string
RedisPassword string
}
// DefaultConfig returns built-in defaults.
func DefaultConfig() Config {
return Config{
Listen: DefaultListen,
PublicBaseURL: DefaultPublicBaseURL,
StateDir: DefaultStateDir,
RequireIndexed: true,
VerifySize: true,
RPCTimeout: 30 * time.Second,
RefreshInterval: time.Minute,
AuthQueryParam: "bat_token",
MaxResourcePageLimit: 1000,
}
}
// Normalize fills derived fields and validates required values.
func (c *Config) Normalize() error {
if c.Listen == "" {
c.Listen = DefaultListen
}
if c.PublicBaseURL == "" {
c.PublicBaseURL = DefaultPublicBaseURL
}
c.PublicBaseURL = strings.TrimRight(c.PublicBaseURL, "/")
if _, err := url.ParseRequestURI(c.PublicBaseURL); err != nil {
return fmt.Errorf("invalid public base URL %q: %w", c.PublicBaseURL, err)
}
if c.StateDir == "" {
c.StateDir = DefaultStateDir
}
if c.SocketPath == "" {
c.SocketPath = filepath.Join(c.StateDir, "bat.sock")
}
if c.RPCTimeout <= 0 {
c.RPCTimeout = 30 * time.Second
}
if c.RefreshInterval < 0 {
return fmt.Errorf("refresh interval must be >= 0")
}
if c.AuthQueryParam == "" {
c.AuthQueryParam = "bat_token"
}
c.AuthExemptPaths = normalizePathList(c.AuthExemptPaths)
if c.RateLimitRPS < 0 {
return fmt.Errorf("rate limit rps must be >= 0")
}
if c.RateLimitRPS > 0 && c.RateLimitBurst <= 0 {
c.RateLimitBurst = int(c.RateLimitRPS)
if c.RateLimitBurst <= 0 {
c.RateLimitBurst = 1
}
}
if c.RateLimitBurst < 0 {
return fmt.Errorf("rate limit burst must be >= 0")
}
if c.MaxResourcePageLimit <= 0 {
c.MaxResourcePageLimit = 1000
}
if c.ResourceRoot != "" {
abs, err := filepath.Abs(c.ResourceRoot)
if err != nil {
return fmt.Errorf("resolve resource root: %w", err)
}
c.ResourceRoot = abs
}
return nil
}
// LoadEnvFile reads KEY=VALUE lines from path into the process environment
// without overriding variables that are already set.
func LoadEnvFile(path string) error {
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); exists {
continue
}
if err := os.Setenv(key, value); err != nil {
return err
}
}
return nil
}
// ApplyEnv overlays environment variables onto cfg.
func ApplyEnv(cfg *Config) {
if v := os.Getenv("BAT_API_LISTEN"); v != "" {
cfg.Listen = v
}
// Alias common port-only style.
if v := os.Getenv("BAT_API_PORT"); v != "" && os.Getenv("BAT_API_LISTEN") == "" {
if strings.HasPrefix(v, ":") {
cfg.Listen = v
} else {
cfg.Listen = ":" + v
}
}
if v := os.Getenv("BAT_API_PUBLIC_BASE_URL"); v != "" {
cfg.PublicBaseURL = v
}
if v := os.Getenv("BAT_API_STATE_DIR"); v != "" {
cfg.StateDir = v
}
if v := os.Getenv("BAT_API_SOCKET"); v != "" {
cfg.SocketPath = v
}
if v := os.Getenv("BAT_API_RESOURCE_ROOT"); v != "" {
cfg.ResourceRoot = v
}
if v := os.Getenv("BAT_API_SERVER_INFO_FILE"); v != "" {
cfg.ServerInfoFile = v
}
if v := os.Getenv("BAT_API_REQUIRE_INDEXED"); v != "" {
cfg.RequireIndexed = parseBool(v, cfg.RequireIndexed)
}
if v := os.Getenv("BAT_API_VERIFY_SIZE"); v != "" {
cfg.VerifySize = parseBool(v, cfg.VerifySize)
}
if v := os.Getenv("BAT_API_RPC_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
cfg.RPCTimeout = d
}
}
if v := os.Getenv("BAT_API_REFRESH_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil {
cfg.RefreshInterval = d
}
}
if v := os.Getenv("BAT_API_DATABASE_URL"); v != "" {
cfg.DatabaseURL = v
}
if v := os.Getenv("BAT_API_DATABASE_PASSWORD"); v != "" {
cfg.DatabasePassword = v
}
if v := os.Getenv("BAT_API_REDIS_URL"); v != "" {
cfg.RedisURL = v
}
if v := os.Getenv("BAT_API_REDIS_PASSWORD"); v != "" {
cfg.RedisPassword = v
}
if v := os.Getenv("BAT_API_SKIP_ENV_FILE"); v != "" {
cfg.SkipEnvFile = parseBool(v, false)
}
if v := os.Getenv("BAT_API_AUTH_TOKEN"); v != "" {
cfg.AuthToken = v
}
if v := os.Getenv("BAT_API_AUTH_QUERY_PARAM"); v != "" {
cfg.AuthQueryParam = v
}
if v := os.Getenv("BAT_API_AUTH_EXEMPT_PATHS"); v != "" {
cfg.AuthExemptPaths = splitCSV(v)
}
if v := os.Getenv("BAT_API_TRUST_PROXY_HEADERS"); v != "" {
cfg.TrustProxyHeaders = parseBool(v, cfg.TrustProxyHeaders)
}
if v := os.Getenv("BAT_API_ACCESS_LOG"); v != "" {
cfg.AccessLog = parseBool(v, cfg.AccessLog)
}
if v := os.Getenv("BAT_API_RATE_LIMIT_RPS"); v != "" {
if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil {
cfg.RateLimitRPS = f
}
}
if v := os.Getenv("BAT_API_RATE_LIMIT_BURST"); v != "" {
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
cfg.RateLimitBurst = i
}
}
if v := os.Getenv("BAT_API_MAX_RESOURCE_LIMIT"); v != "" {
if i, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
cfg.MaxResourcePageLimit = i
}
}
}
func parseBool(raw string, fallback bool) bool {
b, err := strconv.ParseBool(strings.TrimSpace(raw))
if err != nil {
return fallback
}
return b
}
func splitCSV(raw string) []string {
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func normalizePathList(paths []string) []string {
out := make([]string, 0, len(paths))
seen := map[string]struct{}{}
for _, path := range paths {
path = strings.TrimSpace(path)
if path == "" {
continue
}
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
if _, ok := seen[path]; ok {
continue
}
seen[path] = struct{}{}
out = append(out, path)
}
return out
}
// EnvTemplate is written next to the bat-api binary on first run when missing.
const EnvTemplate = `# bat-api configuration
# Priority: CLI flags > process environment > this file > built-in defaults.
# Resource auto-pull/sync is owned by the Rust bat daemon, not bat-api.
# bat-api only bootstraps/distributes already-published resources and exposes
# a small management surface for release inspection.
# HTTP listen address (host:port or :port)
BAT_API_LISTEN=:18080
# Public base URL used when rewriting AddressablesCatalogUrlRoot
BAT_API_PUBLIC_BASE_URL=http://127.0.0.1:18080
# Rust bat daemon RPC socket (primary discovery path)
# Prefer BAT_API_SOCKET; BAT_API_STATE_DIR only derives the default socket path.
BAT_API_STATE_DIR=/tmp/bat-pid
# BAT_API_SOCKET=/tmp/bat-pid/bat.sock
# Optional override of the published release root (fixtures / emergency only).
# Production obtains resource_root from daemon RPC (catalog.status / resource.manifest).
# BAT_API_RESOURCE_ROOT=
# Optional server-info JSON for Addressables root rewrite
# BAT_API_SERVER_INFO_FILE=
# CDN safety
BAT_API_REQUIRE_INDEXED=true
BAT_API_VERIFY_SIZE=true
BAT_API_RPC_TIMEOUT=30s
# Periodically re-read bat.sock so bat-api follows Rust bat release switches.
# Set to 0 to disable in fixture-only local development.
BAT_API_REFRESH_INTERVAL=1m
# Player-facing HTTP controls.
# Prefer setting BAT_API_AUTH_TOKEN through a secret manager or process
# environment. Reverse proxies may inject Authorization: Bearer <token> or
# X-BAT-Token to authenticated upstream requests. Query token fallback uses
# BAT_API_AUTH_QUERY_PARAM and is supported for clients that cannot set headers.
# BAT_API_AUTH_TOKEN=
BAT_API_AUTH_QUERY_PARAM=bat_token
# BAT_API_AUTH_EXEMPT_PATHS=
BAT_API_TRUST_PROXY_HEADERS=false
BAT_API_ACCESS_LOG=false
# Zero disables in-process limiting; production may still enforce edge limits.
BAT_API_RATE_LIMIT_RPS=0
BAT_API_RATE_LIMIT_BURST=0
BAT_API_MAX_RESOURCE_LIMIT=1000
# Reserved for future API persistence (not required for resource CDN)
# BAT_API_DATABASE_URL=postgres://bat:@127.0.0.1:5432/bat?sslmode=disable
# BAT_API_DATABASE_PASSWORD=
# BAT_API_REDIS_URL=redis://127.0.0.1:6379/0
# BAT_API_REDIS_PASSWORD=
# Set to 1 to ignore this file entirely
# BAT_API_SKIP_ENV_FILE=0
`
+208
View File
@@ -0,0 +1,208 @@
package api
import (
"fmt"
"net/http"
"net/url"
"strings"
)
// LauncherAPIHost is the official JP launcher API host observed from the PC launcher.
const LauncherAPIHost = "api-launcher-jp.yo-star.com"
func (s *Server) handleLauncherBootstrap(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
sum := s.releaseSummary()
status := http.StatusOK
if !sum.Ready || sum.Snapshot == nil {
status = http.StatusServiceUnavailable
}
writeNoStoreJSON(w, status, s.launcherBootstrapBody(sum))
}
func (s *Server) handleLauncherGameConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
sum := s.releaseSummary()
if !sum.Ready || sum.Snapshot == nil {
writeLauncherEnvelope(w, http.StatusServiceUnavailable, 503, "release not ready", LauncherGameConfigJSONData{
ResourceBootstrapURL: s.launcherBootstrapURL(),
Scope: "resource_bootstrap_only",
})
return
}
snap := sum.Snapshot
data := LauncherGameConfigData{
GameLatestVersion: defaultString(s.launcherLatestVersion(snap), snap.AppVersion),
GameLatestFilePath: s.launcherLatestFilePath(snap),
GameStartExeName: s.launcherStartExeName(snap),
GameStartParams: s.launcherStartParams(snap),
ResourceBootstrapURL: s.launcherBootstrapURL(),
ServerInfoURL: s.serverInfoURL(),
ClientPatchBaseURL: s.clientPatchBaseURL(),
Scope: "resource_bootstrap_only",
}
if snap.LauncherMetadata != nil && snap.LauncherMetadata.GameLowestVersion != "" {
data.GameLowestVersion = snap.LauncherMetadata.GameLowestVersion
}
writeLauncherEnvelope(w, http.StatusOK, 200, "ok", data)
}
func (s *Server) handleLauncherGameConfigJSON(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
sum := s.releaseSummary()
if !sum.Ready || sum.Snapshot == nil {
writeLauncherEnvelope(w, http.StatusServiceUnavailable, 503, "release not ready", LauncherGameConfigJSONData{
URL: s.launcherBootstrapURL(),
ResourceBootstrapURL: s.launcherBootstrapURL(),
Scope: "resource_bootstrap_only",
})
return
}
writeLauncherEnvelope(w, http.StatusOK, 200, "ok", LauncherGameConfigJSONData{
URL: s.launcherBootstrapJSONURL(r.URL.Query()),
ResourceBootstrapURL: s.launcherBootstrapURL(),
PackageUpdateManifest: false,
Scope: "resource_bootstrap_only",
})
}
func (s *Server) handleLauncherCdnConfig(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
writeLauncherEnvelope(w, http.StatusOK, 200, "ok", LauncherCdnConfigData{
PrimaryCDN: strings.TrimRight(s.cfg.PublicBaseURL, "/"),
BackupCDN: strings.TrimRight(s.cfg.PublicBaseURL, "/"),
ResourceBootstrapURL: s.launcherBootstrapURL(),
PackageUpdateManifest: false,
Scope: "resource_bootstrap_only",
})
}
func (s *Server) launcherBootstrapBody(sum ReleaseSummary) LauncherBootstrapResponse {
publicBase := strings.TrimRight(s.cfg.PublicBaseURL, "/")
body := LauncherBootstrapResponse{
Service: "bat-api",
Ready: sum.Ready,
LauncherAPI: LauncherAPIInfo{
Host: LauncherAPIHost,
ObservedResourceRelevantEndpoints: []string{
"/api/launcher/game/config",
"/api/launcher/game/config/json",
"/api/launcher/advanced/game/download/cdn",
},
CompatibilityScope: "resource_bootstrap_only",
},
Policy: LauncherPolicy{
Source: "rust_bat_snapshot",
DownloadsLauncherPackage: false,
EmulatesLoginOrGateway: false,
PackageUpdateManifest: false,
},
Resource: LauncherResource{
Release: sum.Snapshot,
ServerInfoURL: s.serverInfoURL(),
ClientPatchBaseURL: s.clientPatchBaseURL(),
},
Endpoints: LauncherEndpointSet{
Bootstrap: publicBase + "/v1/bootstrap",
LauncherBootstrap: publicBase + "/v1/launcher/bootstrap",
ServerInfo: s.serverInfoURL(),
ClientPatchBase: s.clientPatchBaseURL(),
LauncherGameConfig: publicBase + "/" + LauncherAPIHost + "/api/launcher/game/config",
LauncherGameConfigJSON: publicBase + "/" + LauncherAPIHost + "/api/launcher/game/config/json",
LauncherCdnConfig: publicBase + "/" + LauncherAPIHost + "/api/launcher/advanced/game/download/cdn",
},
}
if sum.Snapshot != nil {
body.GameMainConfig = sum.Snapshot.GameMainConfig
body.LauncherMetadata = sum.Snapshot.LauncherMetadata
if rewritten, ok := rewriteAddressablesRoot(sum.Snapshot.AddressablesRoot, publicBase); ok {
body.AddressablesCatalogURLRoot = rewritten
}
}
return body
}
func (s *Server) releaseSummary() ReleaseSummary {
if idx := s.index(); idx != nil {
return idx.Summary()
}
return ReleaseSummary{}
}
func (s *Server) serverInfoURL() string {
return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + ServerInfoHost + "/server-info.json"
}
func (s *Server) clientPatchBaseURL() string {
return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + ClientPatchHost
}
func (s *Server) launcherBootstrapURL() string {
return strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/v1/launcher/bootstrap"
}
func (s *Server) launcherBootstrapJSONURL(query url.Values) string {
base := strings.TrimRight(s.cfg.PublicBaseURL, "/") + "/" + LauncherAPIHost + "/api/launcher/resource/bootstrap.json"
values := url.Values{}
for _, key := range []string{"version", "file_path"} {
if value := query.Get(key); value != "" {
values.Set(key, value)
}
}
if encoded := values.Encode(); encoded != "" {
return base + "?" + encoded
}
return base
}
func (s *Server) launcherLatestVersion(snap *SnapshotSummary) string {
if snap.LauncherMetadata != nil && snap.LauncherMetadata.GameLatestVersion != "" {
return snap.LauncherMetadata.GameLatestVersion
}
return snap.AppVersion
}
func (s *Server) launcherLatestFilePath(snap *SnapshotSummary) string {
if snap.LauncherMetadata != nil {
return snap.LauncherMetadata.GameLatestFilePath
}
return ""
}
func (s *Server) launcherStartExeName(snap *SnapshotSummary) string {
if snap.LauncherMetadata != nil {
return snap.LauncherMetadata.GameStartExeName
}
return ""
}
func (s *Server) launcherStartParams(snap *SnapshotSummary) []string {
if snap.LauncherMetadata != nil && len(snap.LauncherMetadata.GameStartParams) > 0 {
return append([]string(nil), snap.LauncherMetadata.GameStartParams...)
}
return []string{}
}
func writeLauncherEnvelope[T any](w http.ResponseWriter, status int, code int, message string, data T) {
writeNoStoreJSON(w, status, LauncherEnvelope[T]{
Code: code,
Message: message,
Data: data,
})
}
func launcherHostPath(path string) string {
return fmt.Sprintf("/%s%s", LauncherAPIHost, path)
}
+259
View File
@@ -0,0 +1,259 @@
package api
import (
"crypto/subtle"
"net"
"net/http"
"strings"
"sync"
"time"
)
func (s *Server) wrapHandler(next http.Handler) http.Handler {
handler := next
handler = s.authMiddleware(handler)
handler = s.rateLimitMiddleware(handler)
handler = s.securityHeadersMiddleware(handler)
handler = s.accessLogMiddleware(handler)
return handler
}
func (s *Server) securityHeadersMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
w.Header().Set("Permissions-Policy", "geolocation=(), microphone=(), camera=()")
w.Header().Set("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
next.ServeHTTP(w, r)
})
}
func (s *Server) authMiddleware(next http.Handler) http.Handler {
if s.cfg.AuthToken == "" {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if s.authExempt(r.URL.Path) {
next.ServeHTTP(w, r)
return
}
if !constantTimeTokenEqual(s.requestToken(r), s.cfg.AuthToken) {
w.Header().Set("WWW-Authenticate", `Bearer realm="bat-api"`)
writeErrorJSON(w, http.StatusUnauthorized, "unauthorized", "missing or invalid access token")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) authExempt(path string) bool {
for _, exempt := range s.cfg.AuthExemptPaths {
if exempt == path {
return true
}
if strings.HasSuffix(exempt, "/") && strings.HasPrefix(path, exempt) {
return true
}
}
return false
}
func (s *Server) requestToken(r *http.Request) string {
if auth := r.Header.Get("Authorization"); auth != "" {
const prefix = "Bearer "
if strings.HasPrefix(auth, prefix) {
return strings.TrimSpace(strings.TrimPrefix(auth, prefix))
}
}
for _, header := range []string{"X-BAT-Token", "X-BAT-API-Key"} {
if token := strings.TrimSpace(r.Header.Get(header)); token != "" {
return token
}
}
if s.cfg.AuthQueryParam != "" {
return r.URL.Query().Get(s.cfg.AuthQueryParam)
}
return ""
}
func constantTimeTokenEqual(got, want string) bool {
if got == "" || want == "" {
return false
}
gotBytes := []byte(got)
wantBytes := []byte(want)
if len(gotBytes) != len(wantBytes) {
subtle.ConstantTimeCompare(wantBytes, wantBytes)
return false
}
return subtle.ConstantTimeCompare(gotBytes, wantBytes) == 1
}
func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler {
if s.limiter == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
client := s.clientIdentity(r)
if !s.limiter.allow(client, time.Now()) {
w.Header().Set("Retry-After", "1")
writeErrorJSON(w, http.StatusTooManyRequests, "rate_limited", "too many requests")
return
}
next.ServeHTTP(w, r)
})
}
func (s *Server) accessLogMiddleware(next http.Handler) http.Handler {
if !s.cfg.AccessLog {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
requestID := safeLogValue(r.Header.Get("X-Request-ID"))
if requestID == "" {
requestID = "-"
}
s.logger.Printf(
"access method=%s path=%s status=%d bytes=%d duration_ms=%d client_ip=%s request_id=%s user_agent=%q",
r.Method,
r.URL.Path,
rec.status,
rec.bytes,
time.Since(start).Milliseconds(),
s.clientIdentity(r),
requestID,
r.UserAgent(),
)
})
}
func (s *Server) clientIdentity(r *http.Request) string {
if s.cfg.TrustProxyHeaders {
if forwarded := r.Header.Get("X-Forwarded-For"); forwarded != "" {
for _, part := range strings.Split(forwarded, ",") {
if ip := strings.TrimSpace(part); ip != "" {
return ip
}
}
}
if realIP := strings.TrimSpace(r.Header.Get("X-Real-IP")); realIP != "" {
return realIP
}
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err == nil && host != "" {
return host
}
return r.RemoteAddr
}
func safeLogValue(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
raw = strings.Map(func(r rune) rune {
if r < 32 || r == 127 {
return -1
}
return r
}, raw)
if len(raw) > 128 {
return raw[:128]
}
return raw
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(status int) {
r.status = status
r.ResponseWriter.WriteHeader(status)
}
func (r *statusRecorder) Write(data []byte) (int, error) {
n, err := r.ResponseWriter.Write(data)
r.bytes += n
return n, err
}
func (r *statusRecorder) Unwrap() http.ResponseWriter {
return r.ResponseWriter
}
type tokenBucketLimiter struct {
mu sync.Mutex
rate float64
burst float64
buckets map[string]*tokenBucket
lastCleanup time.Time
}
type tokenBucket struct {
tokens float64
last time.Time
lastSeen time.Time
}
func newTokenBucketLimiter(rps float64, burst int) *tokenBucketLimiter {
return &tokenBucketLimiter{
rate: rps,
burst: float64(burst),
buckets: map[string]*tokenBucket{},
}
}
func (l *tokenBucketLimiter) allow(key string, now time.Time) bool {
if key == "" {
key = "unknown"
}
l.mu.Lock()
defer l.mu.Unlock()
if l.lastCleanup.IsZero() || now.Sub(l.lastCleanup) > time.Minute {
l.cleanup(now)
}
bucket := l.buckets[key]
if bucket == nil {
bucket = &tokenBucket{tokens: l.burst, last: now, lastSeen: now}
l.buckets[key] = bucket
}
elapsed := now.Sub(bucket.last).Seconds()
bucket.tokens += elapsed * l.rate
if bucket.tokens > l.burst {
bucket.tokens = l.burst
}
bucket.last = now
bucket.lastSeen = now
if bucket.tokens < 1 {
return false
}
bucket.tokens--
return true
}
func (l *tokenBucketLimiter) cleanup(now time.Time) {
for key, bucket := range l.buckets {
if now.Sub(bucket.lastSeen) > 10*time.Minute {
delete(l.buckets, key)
}
}
l.lastCleanup = now
}
func writeErrorJSON(w http.ResponseWriter, status int, code string, message string) {
writeNoStoreJSON(w, status, ErrorResponse{
Error: ErrorBody{
Code: code,
Message: message,
Status: status,
},
})
}
+161
View File
@@ -0,0 +1,161 @@
package api
import (
"net/http"
"strconv"
)
const openAPISpecYAML = `openapi: 3.0.3
info:
title: BlueArchive Toolkit bat-api
version: 0.1.0
description: Resource bootstrap and read-only distribution API.
servers:
- url: http://127.0.0.1:18080
security:
- bearerAuth: []
- queryToken: []
paths:
/healthz:
get:
summary: Liveness and refresh diagnostics
responses:
"200":
description: Service is alive.
/readyz:
get:
summary: Release readiness
responses:
"200":
description: A distributable release is available.
"503":
description: No distributable release is available.
/v1/bootstrap:
get:
summary: Startup resource bootstrap
responses:
"200":
description: Resource bootstrap response.
"503":
description: Release is not ready.
/v1/launcher/bootstrap:
get:
summary: Launcher-shaped resource bootstrap
responses:
"200":
description: Launcher bootstrap response.
"503":
description: Release is not ready.
/api/launcher/game/config:
get:
summary: Resource-only launcher game config compatibility
responses:
"200":
description: Launcher envelope with resource metadata.
/api/launcher/game/config/json:
get:
summary: Resource-only launcher manifest URL compatibility
parameters:
- name: version
in: query
schema:
type: string
- name: file_path
in: query
schema:
type: string
responses:
"200":
description: Launcher envelope pointing to resource bootstrap JSON.
/api/launcher/advanced/game/download/cdn:
get:
summary: Resource-only launcher CDN compatibility
responses:
"200":
description: Launcher envelope with public base URL as CDN root.
/v1/release:
get:
summary: Current release summary
responses:
"200":
description: Release summary.
/v1/resources:
get:
summary: Paginated resource manifest entries
parameters:
- name: offset
in: query
schema:
type: integer
minimum: 0
- name: limit
in: query
schema:
type: integer
minimum: 1
maximum: 1000
responses:
"200":
description: Resource list page.
/v1/server-info:
get:
summary: Rewritten server-info document
responses:
"200":
description: Server-info JSON with AddressablesCatalogUrlRoot rewritten.
/openapi.yaml:
get:
summary: OpenAPI document
responses:
"200":
description: OpenAPI YAML.
/admin/:
get:
summary: Reserved admin panel entry
responses:
"200":
description: Admin panel placeholder and links.
/prod-clientpatch.bluearchiveyostar.com/{path}:
get:
summary: CDN-shaped resource bytes
parameters:
- name: path
in: path
required: true
schema:
type: string
responses:
"200":
description: Resource bytes.
"206":
description: Partial resource bytes.
head:
summary: CDN-shaped resource metadata
responses:
"200":
description: Resource headers.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
queryToken:
type: apiKey
in: query
name: bat_token
`
func (s *Server) handleOpenAPI(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Length", strconv.Itoa(len(openAPISpecYAML)))
w.WriteHeader(http.StatusOK)
if r.Method == http.MethodHead {
return
}
_, _ = w.Write([]byte(openAPISpecYAML))
}
+93
View File
@@ -0,0 +1,93 @@
package api
import (
"fmt"
"path/filepath"
"strings"
)
// OfficialHosts are host path prefixes allowed on the CDN surface.
var OfficialHosts = map[string]struct{}{
ClientPatchHost: {},
ServerInfoHost: {},
}
// SplitCDNPath splits a request URL path into host + relative path under that host.
// Expected form: /prod-clientpatch.bluearchiveyostar.com/r93_xxx/TableBundles/...
func SplitCDNPath(requestPath string) (host string, rel string, err error) {
cleaned := filepath.ToSlash(requestPath)
cleaned = strings.TrimPrefix(cleaned, "/")
if cleaned == "" {
return "", "", fmt.Errorf("empty path")
}
parts := strings.Split(cleaned, "/")
if len(parts) < 2 {
return "", "", fmt.Errorf("path must include host and object path")
}
host = parts[0]
if _, ok := OfficialHosts[host]; !ok {
return "", "", fmt.Errorf("unsupported host %q", host)
}
for _, segment := range parts {
if segment == "" || segment == "." || segment == ".." {
return "", "", fmt.Errorf("unsafe path segment")
}
if strings.ContainsAny(segment, `?\`) {
return "", "", fmt.Errorf("unsafe path character")
}
}
rel = strings.Join(parts, "/")
return host, rel, nil
}
// ResolveUnderRoot joins root with a slash-separated relative path and ensures
// the result stays within root. Symlink targets that escape root are rejected
// when evalSymlinks is true and the path exists.
func ResolveUnderRoot(root, rel string, evalSymlinks bool) (string, error) {
if root == "" {
return "", fmt.Errorf("resource root is empty")
}
rootAbs, err := filepath.Abs(root)
if err != nil {
return "", err
}
if evalSymlinks {
if resolved, err := filepath.EvalSymlinks(rootAbs); err == nil {
rootAbs = resolved
}
}
rel = filepath.ToSlash(rel)
rel = strings.TrimPrefix(rel, "/")
var segments []string
for _, segment := range strings.Split(rel, "/") {
if segment == "" || segment == "." {
continue
}
if segment == ".." {
return "", fmt.Errorf("path escapes resource root")
}
if strings.ContainsAny(segment, `?\`) {
return "", fmt.Errorf("unsafe path segment %q", segment)
}
segments = append(segments, segment)
}
if len(segments) == 0 {
return "", fmt.Errorf("empty relative path")
}
candidate := filepath.Join(append([]string{rootAbs}, segments...)...)
// Ensure candidate is under rootAbs even before existence checks.
relCheck, err := filepath.Rel(rootAbs, candidate)
if err != nil || strings.HasPrefix(relCheck, "..") || filepath.IsAbs(relCheck) {
return "", fmt.Errorf("path escapes resource root")
}
if evalSymlinks {
if resolved, err := filepath.EvalSymlinks(candidate); err == nil {
relCheck, err = filepath.Rel(rootAbs, resolved)
if err != nil || strings.HasPrefix(relCheck, "..") || filepath.IsAbs(relCheck) {
return "", fmt.Errorf("symlink escapes resource root")
}
candidate = resolved
}
}
return candidate, nil
}
+292
View File
@@ -0,0 +1,292 @@
package api
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
// ResourceEntry is one distributable object from the published release.
type ResourceEntry struct {
URL string `json:"url"`
RelativePath string `json:"relative_path"`
Bytes uint64 `json:"bytes"`
BLAKE3 string `json:"blake3,omitempty"`
Present bool `json:"present"`
SizeMatch bool `json:"size_match"`
}
// SnapshotSummary is a subset of official-sync-snapshot.json / catalog.status.
type SnapshotSummary struct {
AppVersion string `json:"app_version,omitempty"`
BundleVersion string `json:"bundle_version,omitempty"`
ConnectionGroupName string `json:"connection_group_name,omitempty"`
AddressablesRoot string `json:"addressables_root,omitempty"`
VersionID string `json:"version_id,omitempty"`
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds,omitempty"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"`
}
// LauncherMetadataSummary mirrors the resource-relevant part of Rust's launcher metadata snapshot.
type LauncherMetadataSummary struct {
LauncherVersion string `json:"launcher_version,omitempty"`
GameLatestVersion string `json:"game_latest_version,omitempty"`
GameLatestFilePath string `json:"game_latest_file_path,omitempty"`
GameLowestVersion string `json:"game_lowest_version,omitempty"`
GameStartExeName string `json:"game_start_exe_name,omitempty"`
GameStartParams []string `json:"game_start_params,omitempty"`
ManifestURL string `json:"manifest_url,omitempty"`
ManifestSource string `json:"manifest_source,omitempty"`
ManifestFileCount int `json:"manifest_file_count,omitempty"`
}
// GameMainConfigSummary mirrors the resource-relevant decrypted GameMainConfig fields.
type GameMainConfigSummary struct {
ServerInfoDataURL string `json:"server_info_data_url,omitempty"`
DefaultConnectionGroup string `json:"default_connection_group,omitempty"`
}
// ReleaseIndex is the in-memory view of a published resource root.
type ReleaseIndex struct {
mu sync.RWMutex
ResourceRoot string `json:"resource_root"`
Source string `json:"source"` // "rpc" | "resource_root" | "empty"
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
Entries []ResourceEntry `json:"entries"`
// byRel maps relative path (host/path...) to entry index.
byRel map[string]int
// MissingOnDisk lists relative paths present in the index but absent on disk.
MissingOnDisk []string `json:"missing_on_disk,omitempty"`
}
// Summary returns a JSON-serializable overview without the full entry list.
type ReleaseSummary struct {
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
Snapshot *SnapshotSummary `json:"snapshot,omitempty"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
Ready bool `json:"ready"`
}
// Summary builds a compact release overview.
func (idx *ReleaseIndex) Summary() ReleaseSummary {
idx.mu.RLock()
defer idx.mu.RUnlock()
present := 0
for _, e := range idx.Entries {
if e.Present && e.SizeMatch {
present++
}
}
return ReleaseSummary{
ResourceRoot: idx.ResourceRoot,
Source: idx.Source,
RPCAvailable: idx.RPCAvailable,
DoctorHealthy: idx.DoctorHealthy,
Snapshot: idx.Snapshot,
ManifestVersion: idx.ManifestVersion,
EntryCount: len(idx.Entries),
PresentCount: present,
MissingCount: len(idx.MissingOnDisk),
Ready: idx.ResourceRoot != "" && present > 0,
}
}
// Lookup returns the entry for a host/path relative path.
func (idx *ReleaseIndex) Lookup(rel string) (ResourceEntry, bool) {
idx.mu.RLock()
defer idx.mu.RUnlock()
rel = filepath.ToSlash(rel)
rel = strings.TrimPrefix(rel, "/")
i, ok := idx.byRel[rel]
if !ok {
return ResourceEntry{}, false
}
return idx.Entries[i], true
}
// List returns a stable page of entries.
func (idx *ReleaseIndex) List(offset, limit int) (items []ResourceEntry, total int) {
idx.mu.RLock()
defer idx.mu.RUnlock()
total = len(idx.Entries)
if offset < 0 {
offset = 0
}
if limit <= 0 {
limit = 100
}
if offset >= total {
return []ResourceEntry{}, total
}
end := offset + limit
if end > total {
end = total
}
out := make([]ResourceEntry, end-offset)
copy(out, idx.Entries[offset:end])
return out, total
}
// BuildIndexFromManifestEntries builds an index against resourceRoot.
func BuildIndexFromManifestEntries(
resourceRoot string,
source string,
rpcAvailable bool,
doctorHealthy *bool,
snapshot *SnapshotSummary,
manifestVersion int,
entries []manifestEntry,
) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot)
if err != nil {
return nil, err
}
idx := &ReleaseIndex{
ResourceRoot: rootAbs,
Source: source,
RPCAvailable: rpcAvailable,
DoctorHealthy: doctorHealthy,
Snapshot: snapshot,
ManifestVersion: manifestVersion,
byRel: make(map[string]int),
}
sort.Slice(entries, func(i, j int) bool {
return entries[i].URL < entries[j].URL
})
for _, raw := range entries {
rel := filepath.ToSlash(raw.Destination)
rel = strings.TrimPrefix(rel, "/")
if rel == "" {
// Derive from URL when destination is empty.
rel = relativePathFromURL(raw.URL)
}
if rel == "" {
continue
}
entry := ResourceEntry{
URL: raw.URL,
RelativePath: rel,
Bytes: raw.Bytes,
BLAKE3: raw.BLAKE3,
}
abs, err := ResolveUnderRoot(rootAbs, rel, false)
if err != nil {
entry.Present = false
entry.SizeMatch = false
idx.MissingOnDisk = append(idx.MissingOnDisk, rel)
} else {
info, err := os.Lstat(abs)
if err != nil || !info.Mode().IsRegular() {
entry.Present = false
entry.SizeMatch = false
idx.MissingOnDisk = append(idx.MissingOnDisk, rel)
} else {
entry.Present = true
entry.SizeMatch = uint64(info.Size()) == raw.Bytes || raw.Bytes == 0
if !entry.SizeMatch {
idx.MissingOnDisk = append(idx.MissingOnDisk, rel+"#size_mismatch")
}
}
}
idx.byRel[rel] = len(idx.Entries)
idx.Entries = append(idx.Entries, entry)
}
return idx, nil
}
// LoadIndexFromResourceRoot reads official-download-manifest.json under root.
// Used for tests and --resource-root fallback without RPC.
func LoadIndexFromResourceRoot(resourceRoot string) (*ReleaseIndex, error) {
rootAbs, err := filepath.Abs(resourceRoot)
if err != nil {
return nil, err
}
manifestPath := filepath.Join(rootAbs, "official-download-manifest.json")
data, err := os.ReadFile(manifestPath)
if err != nil {
return nil, fmt.Errorf("read download manifest: %w", err)
}
var manifest fileManifest
if err := json.Unmarshal(data, &manifest); err != nil {
return nil, fmt.Errorf("parse download manifest: %w", err)
}
if manifest.Version != 0 && manifest.Version != 1 {
return nil, fmt.Errorf("unsupported download manifest version %d", manifest.Version)
}
entries := make([]manifestEntry, 0, len(manifest.Entries))
for _, e := range manifest.Entries {
entries = append(entries, manifestEntry{
URL: e.URL,
Destination: e.Destination,
Bytes: e.Bytes,
BLAKE3: e.BLAKE3,
})
}
var snapshot *SnapshotSummary
if snapData, err := os.ReadFile(filepath.Join(rootAbs, "official-sync-snapshot.json")); err == nil {
var s fileSnapshot
if json.Unmarshal(snapData, &s) == nil {
snapshot = &SnapshotSummary{
AppVersion: s.AppVersion,
BundleVersion: s.BundleVersion,
ConnectionGroupName: s.ConnectionGroupName,
AddressablesRoot: s.AddressablesRoot,
LauncherMetadata: s.LauncherMetadata,
GameMainConfig: s.GameMainConfigBootstrap,
}
}
}
return BuildIndexFromManifestEntries(rootAbs, "resource_root", false, nil, snapshot, manifest.Version, entries)
}
type manifestEntry struct {
URL string
Destination string
Bytes uint64
BLAKE3 string
}
type fileManifest struct {
Version int `json:"version"`
Entries map[string]struct {
URL string `json:"url"`
Destination string `json:"destination"`
Bytes uint64 `json:"bytes"`
BLAKE3 string `json:"blake3"`
} `json:"entries"`
}
type fileSnapshot struct {
AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"`
ConnectionGroupName string `json:"connection_group_name"`
AddressablesRoot string `json:"addressables_root"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
GameMainConfigBootstrap *GameMainConfigSummary `json:"game_main_config_bootstrap,omitempty"`
}
func relativePathFromURL(rawURL string) string {
const prefix = "https://"
if !strings.HasPrefix(rawURL, prefix) {
return ""
}
rest := strings.TrimPrefix(rawURL, prefix)
rest = strings.SplitN(rest, "?", 2)[0]
rest = strings.SplitN(rest, "#", 2)[0]
return filepath.ToSlash(rest)
}
+174
View File
@@ -0,0 +1,174 @@
package api
import "net/http"
type ErrorResponse struct {
Error ErrorBody `json:"error"`
}
type ErrorBody struct {
Code string `json:"code"`
Message string `json:"message"`
Status int `json:"status"`
}
type RefreshDiagnostics struct {
InProgress bool `json:"in_progress"`
LastAttemptUnixSeconds *int64 `json:"last_attempt_unix_seconds"`
LastFinishedUnixSeconds *int64 `json:"last_finished_unix_seconds"`
LastSuccessUnixSeconds *int64 `json:"last_success_unix_seconds"`
LastDurationMilliseconds int64 `json:"last_duration_milliseconds"`
LastError string `json:"last_error"`
LastWarningCount int `json:"last_warning_count"`
LastWarnings []string `json:"last_warnings"`
RefreshIntervalSeconds int64 `json:"refresh_interval_seconds"`
ResourceRootOverrideActive bool `json:"resource_root_override_active"`
}
type BootstrapResponse struct {
Service string `json:"service"`
Ready bool `json:"ready"`
Bat BootstrapBat `json:"bat"`
BatAPI BootstrapAPI `json:"bat_api"`
Resource BootstrapResource `json:"resource"`
Policy BootstrapPolicy `json:"policy"`
Endpoints []string `json:"endpoints"`
Warnings []string `json:"warnings"`
}
type BootstrapBat struct {
Role string `json:"role"`
Socket string `json:"socket"`
RPCAvailable bool `json:"rpc_available"`
DoctorHealthy *bool `json:"doctor_healthy,omitempty"`
}
type BootstrapAPI struct {
Role string `json:"role"`
PublicBase string `json:"public_base"`
RefreshIntervalSeconds int64 `json:"refresh_interval_seconds"`
Refresh RefreshDiagnostics `json:"refresh"`
}
type BootstrapResource struct {
Release *SnapshotSummary `json:"release,omitempty"`
ResourceRoot string `json:"resource_root"`
Source string `json:"source"`
ManifestVersion int `json:"manifest_version,omitempty"`
EntryCount int `json:"entry_count"`
PresentCount int `json:"present_count"`
MissingCount int `json:"missing_count"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
}
type BootstrapPolicy struct {
PullOwner string `json:"pull_owner"`
ResourceRootDiscovery string `json:"resource_root_discovery"`
Deployment string `json:"deployment"`
ResourceRootOverride bool `json:"resource_root_override"`
ServesOnlyPublishedRelease bool `json:"serves_only_published_release"`
WritesReleaseState bool `json:"writes_release_state"`
FullGameBusinessAPI bool `json:"full_game_business_api"`
}
type RootResponse struct {
Service string `json:"service"`
Role string `json:"role"`
Note string `json:"note"`
Endpoints []string `json:"endpoints"`
}
type ResourceListResponse struct {
Total int `json:"total"`
Offset int `json:"offset"`
Limit int `json:"limit"`
Items []ResourceEntry `json:"items"`
}
type LauncherBootstrapResponse struct {
Service string `json:"service"`
Ready bool `json:"ready"`
LauncherAPI LauncherAPIInfo `json:"launcher_api"`
Policy LauncherPolicy `json:"policy"`
Resource LauncherResource `json:"resource"`
Endpoints LauncherEndpointSet `json:"endpoints"`
GameMainConfig *GameMainConfigSummary `json:"game_main_config,omitempty"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata,omitempty"`
AddressablesCatalogURLRoot string `json:"addressables_catalog_url_root,omitempty"`
}
type LauncherAPIInfo struct {
Host string `json:"host"`
ObservedResourceRelevantEndpoints []string `json:"observed_resource_relevant_endpoints"`
CompatibilityScope string `json:"compatibility_scope"`
}
type LauncherPolicy struct {
Source string `json:"source"`
DownloadsLauncherPackage bool `json:"downloads_launcher_package"`
EmulatesLoginOrGateway bool `json:"emulates_login_or_gateway"`
PackageUpdateManifest bool `json:"package_update_manifest"`
}
type LauncherResource struct {
Release *SnapshotSummary `json:"release,omitempty"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
}
type LauncherEndpointSet struct {
Bootstrap string `json:"bootstrap"`
LauncherBootstrap string `json:"launcher_bootstrap"`
ServerInfo string `json:"server_info"`
ClientPatchBase string `json:"client_patch_base"`
LauncherGameConfig string `json:"launcher_game_config"`
LauncherGameConfigJSON string `json:"launcher_game_config_json"`
LauncherCdnConfig string `json:"launcher_cdn_config"`
}
type LauncherGameConfigData struct {
GameLatestVersion string `json:"game_latest_version"`
GameLatestFilePath string `json:"game_latest_file_path"`
GameLowestVersion string `json:"game_lowest_version,omitempty"`
GameStartExeName string `json:"game_start_exe_name"`
GameStartParams []string `json:"game_start_params"`
ResourceBootstrapURL string `json:"resource_bootstrap_url"`
ServerInfoURL string `json:"server_info_url"`
ClientPatchBaseURL string `json:"client_patch_base_url"`
Scope string `json:"scope"`
}
type LauncherGameConfigJSONData struct {
URL string `json:"url"`
ResourceBootstrapURL string `json:"resource_bootstrap_url"`
PackageUpdateManifest bool `json:"package_update_manifest"`
Scope string `json:"scope"`
}
type LauncherCdnConfigData struct {
PrimaryCDN string `json:"primary_cdn"`
BackupCDN string `json:"back_up_cdn"`
ResourceBootstrapURL string `json:"resource_bootstrap_url"`
PackageUpdateManifest bool `json:"package_update_manifest"`
Scope string `json:"scope"`
}
type LauncherEnvelope[T any] struct {
Code int `json:"code"`
Message string `json:"message"`
Data T `json:"data"`
}
type AdminIndexResponse struct {
Service string `json:"service"`
Panel string `json:"panel"`
Status string `json:"status"`
Links []string `json:"links"`
}
func writeNoStoreJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, status, body)
}
+269
View File
@@ -0,0 +1,269 @@
package api
import (
"context"
"encoding/json"
"fmt"
"path/filepath"
"bat-api/internal/backendrpc"
)
// Backend is the subset of daemon RPC used by bat-api.
//
// Call order for discovery (per plan review):
// 1. daemon.status
// 2. daemon.doctor
// 3. catalog.status / resource.manifest (and resource.state as needed)
type Backend interface {
DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error)
DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error)
ResourceState(ctx context.Context) (*backendrpc.ResourceState, error)
CatalogStatus(ctx context.Context) (json.RawMessage, error)
ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error)
}
// RPCClient adapts *backendrpc.Client to Backend.
type RPCClient struct {
Client *backendrpc.Client
}
func (r RPCClient) DaemonStatus(ctx context.Context) (*backendrpc.DaemonStatusReport, error) {
return r.Client.DaemonStatus(ctx)
}
func (r RPCClient) DaemonDoctor(ctx context.Context) (*backendrpc.DoctorReport, error) {
return r.Client.DaemonDoctor(ctx)
}
func (r RPCClient) ResourceState(ctx context.Context) (*backendrpc.ResourceState, error) {
return r.Client.ResourceState(ctx)
}
func (r RPCClient) CatalogStatus(ctx context.Context) (json.RawMessage, error) {
return r.Client.CatalogStatus(ctx)
}
func (r RPCClient) ResourceManifest(ctx context.Context, offset int, limit int) (*backendrpc.ResourceManifestPage, error) {
return r.Client.ResourceManifest(ctx, offset, limit)
}
// DiscoverResult is the outcome of talking to the bat daemon.
type DiscoverResult struct {
RPCAvailable bool
DoctorHealthy *bool
Status *backendrpc.DaemonStatusReport
Doctor *backendrpc.DoctorReport
Snapshot *SnapshotSummary
ResourceRoot string
Index *ReleaseIndex
Warnings []string
}
// DiscoverAndIndex contacts the daemon (status first, then doctor) and builds
// a release index from paginated resource.manifest plus on-disk checks.
//
// If resourceRootOverride is non-empty, it wins over RPC-reported roots after
// RPC health probes (still preferred for production to call status/doctor).
func DiscoverAndIndex(ctx context.Context, backend Backend, resourceRootOverride string) (*DiscoverResult, error) {
out := &DiscoverResult{}
if backend == nil {
if resourceRootOverride == "" {
out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}}
return out, nil
}
idx, err := LoadIndexFromResourceRoot(resourceRootOverride)
if err != nil {
return nil, err
}
out.ResourceRoot = idx.ResourceRoot
out.Index = idx
return out, nil
}
// 1) daemon.status first
status, err := backend.DaemonStatus(ctx)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.status: %v", err))
if resourceRootOverride != "" {
idx, loadErr := LoadIndexFromResourceRoot(resourceRootOverride)
if loadErr != nil {
return out, fmt.Errorf("daemon.status failed (%v) and resource-root load failed: %w", err, loadErr)
}
out.ResourceRoot = idx.ResourceRoot
out.Index = idx
return out, nil
}
out.Index = &ReleaseIndex{Source: "empty", byRel: map[string]int{}}
return out, nil
}
out.RPCAvailable = true
out.Status = status
// 2) daemon.doctor second
doctor, err := backend.DaemonDoctor(ctx)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("daemon.doctor: %v", err))
} else {
out.Doctor = doctor
h := doctor.Healthy
out.DoctorHealthy = &h
}
// Catalog / resource discovery
var snapshot *SnapshotSummary
var resourceRoot string
if raw, err := backend.CatalogStatus(ctx); err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("catalog.status: %v", err))
} else {
snap, root, ok := parseCatalogStatus(raw)
if ok {
snapshot = snap
resourceRoot = root
}
}
if resourceRoot == "" {
if state, err := backend.ResourceState(ctx); err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.state: %v", err))
} else if state.ResourceOutputRoot != nil && *state.ResourceOutputRoot != "" {
// Prefer published current under output root when catalog root missing.
candidate := filepath.Join(*state.ResourceOutputRoot, "current")
resourceRoot = candidate
}
}
if resourceRootOverride != "" {
resourceRoot = resourceRootOverride
}
if resourceRoot == "" {
out.Snapshot = snapshot
out.Index = &ReleaseIndex{
Source: "rpc",
RPCAvailable: true,
DoctorHealthy: out.DoctorHealthy,
Snapshot: snapshot,
byRel: map[string]int{},
}
out.Warnings = append(out.Warnings, "no resource root from RPC; set --resource-root or publish a version")
return out, nil
}
entries, manifestVersion, rootFromManifest, err := fetchAllManifestEntries(ctx, backend)
if err != nil {
out.Warnings = append(out.Warnings, fmt.Sprintf("resource.manifest: %v", err))
// Fallback: load local manifest file under root.
idx, loadErr := LoadIndexFromResourceRoot(resourceRoot)
if loadErr != nil {
return out, fmt.Errorf("manifest RPC and local load failed: rpc=%v local=%w", err, loadErr)
}
idx.Source = "rpc+local_manifest"
idx.RPCAvailable = true
idx.DoctorHealthy = out.DoctorHealthy
if snapshot != nil {
idx.Snapshot = snapshot
}
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = idx.Snapshot
out.Index = idx
return out, nil
}
if rootFromManifest != "" {
resourceRoot = rootFromManifest
}
if resourceRootOverride != "" {
resourceRoot = resourceRootOverride
}
idx, err := BuildIndexFromManifestEntries(
resourceRoot,
"rpc",
true,
out.DoctorHealthy,
snapshot,
manifestVersion,
entries,
)
if err != nil {
return out, err
}
out.ResourceRoot = idx.ResourceRoot
out.Snapshot = snapshot
out.Index = idx
return out, nil
}
func parseCatalogStatus(raw json.RawMessage) (*SnapshotSummary, string, bool) {
if len(raw) == 0 || string(raw) == "null" {
return nil, "", false
}
var payload struct {
Available bool `json:"available"`
AppVersion string `json:"app_version"`
BundleVersion string `json:"bundle_version"`
ConnectionGroupName string `json:"connection_group_name"`
AddressablesRoot string `json:"addressables_root"`
LauncherMetadata *LauncherMetadataSummary `json:"launcher_metadata"`
GameMainConfig *GameMainConfigSummary `json:"game_main_config"`
Version *struct {
ID string `json:"id"`
CompletedUnixSeconds *uint64 `json:"completed_unix_seconds"`
ResourceRoot string `json:"resource_root"`
} `json:"version"`
}
if err := json.Unmarshal(raw, &payload); err != nil || !payload.Available {
return nil, "", false
}
snap := &SnapshotSummary{
AppVersion: payload.AppVersion,
BundleVersion: payload.BundleVersion,
ConnectionGroupName: payload.ConnectionGroupName,
AddressablesRoot: payload.AddressablesRoot,
LauncherMetadata: payload.LauncherMetadata,
GameMainConfig: payload.GameMainConfig,
}
root := ""
if payload.Version != nil {
snap.VersionID = payload.Version.ID
snap.CompletedUnixSeconds = payload.Version.CompletedUnixSeconds
root = payload.Version.ResourceRoot
}
return snap, root, true
}
func fetchAllManifestEntries(ctx context.Context, backend Backend) ([]manifestEntry, int, string, error) {
const pageSize = 500
offset := 0
var all []manifestEntry
var version int
var root string
for {
page, err := backend.ResourceManifest(ctx, offset, pageSize)
if err != nil {
return nil, 0, "", err
}
if !page.Available {
return nil, 0, "", fmt.Errorf("resource.manifest available=false")
}
if root == "" {
root = page.ResourceRoot
}
if version == 0 {
version = page.ManifestVersion
}
for _, e := range page.Entries {
var bytes uint64
if e.Bytes != nil {
bytes = *e.Bytes
}
all = append(all, manifestEntry{
URL: e.URL,
Destination: e.Destination,
Bytes: bytes,
BLAKE3: e.BLAKE3,
})
}
offset += len(page.Entries)
if len(page.Entries) == 0 || offset >= page.TotalEntries {
break
}
}
return all, version, root, nil
}
+483
View File
@@ -0,0 +1,483 @@
package api
import (
"context"
"encoding/json"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
// Server is the bat-api HTTP server for resource bootstrap and distribution.
type Server struct {
cfg Config
backend Backend
logger *log.Logger
limiter *tokenBucketLimiter
mu sync.RWMutex
idx *ReleaseIndex
meta DiscoverResult
refreshInProgress bool
lastRefreshStart time.Time
lastRefreshFinish time.Time
lastRefreshOK time.Time
lastRefreshError string
lastRefreshWarns []string
lastRefreshDur time.Duration
}
// NewServer constructs a server. Call Refresh before Listen when possible.
func NewServer(cfg Config, backend Backend, logger *log.Logger) *Server {
if logger == nil {
logger = log.Default()
}
var limiter *tokenBucketLimiter
if cfg.RateLimitRPS > 0 {
limiter = newTokenBucketLimiter(cfg.RateLimitRPS, cfg.RateLimitBurst)
}
return &Server{cfg: cfg, backend: backend, logger: logger, limiter: limiter}
}
// Handler returns the root HTTP handler.
func (s *Server) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", s.handleHealthz)
mux.HandleFunc("/readyz", s.handleReadyz)
mux.HandleFunc("/v1/bootstrap", s.handleBootstrap)
mux.HandleFunc("/v1/launcher/bootstrap", s.handleLauncherBootstrap)
mux.HandleFunc("/v1/release", s.handleRelease)
mux.HandleFunc("/v1/resources", s.handleResources)
mux.HandleFunc("/v1/server-info", s.handleServerInfoDebug)
mux.HandleFunc("/api/launcher/game/config", s.handleLauncherGameConfig)
mux.HandleFunc("/api/launcher/game/config/json", s.handleLauncherGameConfigJSON)
mux.HandleFunc("/api/launcher/advanced/game/download/cdn", s.handleLauncherCdnConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/game/config"), s.handleLauncherGameConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/game/config/json"), s.handleLauncherGameConfigJSON)
mux.HandleFunc(launcherHostPath("/api/launcher/advanced/game/download/cdn"), s.handleLauncherCdnConfig)
mux.HandleFunc(launcherHostPath("/api/launcher/resource/bootstrap.json"), s.handleLauncherBootstrap)
mux.HandleFunc("/openapi.yaml", s.handleOpenAPI)
mux.HandleFunc("/admin/", s.handleAdminIndex)
mux.HandleFunc("/"+ServerInfoHost+"/", s.handleServerInfoCDN)
mux.HandleFunc("/"+ClientPatchHost+"/", s.serveCDN)
// Catch-all for other official host prefixes and 404.
mux.HandleFunc("/", s.handleRoot)
return s.wrapHandler(mux)
}
// Refresh rebuilds the release index via RPC (and optional resource-root override).
func (s *Server) Refresh(ctx context.Context) error {
started := s.beginRefresh()
result, err := DiscoverAndIndex(ctx, s.backend, s.cfg.ResourceRoot)
if err != nil {
s.finishRefresh(started, err, nil)
return err
}
s.mu.Lock()
s.meta = *result
s.idx = result.Index
s.finishRefreshLocked(started, nil, result.Warnings)
s.mu.Unlock()
for _, w := range result.Warnings {
s.logger.Printf("bat-api discover warning: %s", w)
}
return nil
}
func (s *Server) index() *ReleaseIndex {
s.mu.RLock()
defer s.mu.RUnlock()
return s.idx
}
func (s *Server) discoverMeta() DiscoverResult {
s.mu.RLock()
defer s.mu.RUnlock()
return s.meta
}
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
writeNoStoreJSON(w, http.StatusOK, RootResponse{
Service: "bat-api",
Role: "resource_bootstrap_and_distribution",
Note: "resource auto-pull is owned by Rust bat; this service bootstraps and distributes published resources",
Endpoints: []string{
"/healthz",
"/readyz",
"/v1/bootstrap",
"/v1/launcher/bootstrap",
"/v1/release",
"/v1/resources",
"/v1/server-info",
"/api/launcher/game/config",
"/api/launcher/game/config/json",
"/api/launcher/advanced/game/download/cdn",
"/" + LauncherAPIHost + "/api/launcher/game/config",
"/" + LauncherAPIHost + "/api/launcher/game/config/json",
"/" + LauncherAPIHost + "/api/launcher/advanced/game/download/cdn",
"/" + ClientPatchHost + "/...",
"/" + ServerInfoHost + "/...",
"/openapi.yaml",
"/admin/",
},
})
return
}
// Attempt CDN for known hosts not registered above.
if strings.HasPrefix(r.URL.Path, "/"+ClientPatchHost+"/") ||
strings.HasPrefix(r.URL.Path, "/"+ServerInfoHost+"/") {
if strings.HasPrefix(r.URL.Path, "/"+ServerInfoHost+"/") {
s.handleServerInfoCDN(w, r)
return
}
s.serveCDN(w, r)
return
}
http.NotFound(w, r)
}
func (s *Server) handleBootstrap(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
meta := s.discoverMeta()
sum := ReleaseSummary{}
if idx := s.index(); idx != nil {
sum = idx.Summary()
}
publicBase := strings.TrimRight(s.cfg.PublicBaseURL, "/")
addressablesRoot := ""
if sum.Snapshot != nil && sum.Snapshot.AddressablesRoot != "" {
if rewritten, ok := rewriteAddressablesRoot(sum.Snapshot.AddressablesRoot, publicBase); ok {
addressablesRoot = rewritten
}
}
status := http.StatusOK
if !sum.Ready {
status = http.StatusServiceUnavailable
}
writeNoStoreJSON(w, status, BootstrapResponse{
Service: "bat-api",
Ready: sum.Ready,
Bat: BootstrapBat{
Role: "sync_daemon_and_release_producer",
Socket: s.cfg.SocketPath,
RPCAvailable: meta.RPCAvailable || sum.RPCAvailable,
DoctorHealthy: sum.DoctorHealthy,
},
BatAPI: BootstrapAPI{
Role: "resource_bootstrap_and_distribution",
PublicBase: publicBase,
RefreshIntervalSeconds: int64(s.cfg.RefreshInterval.Seconds()),
Refresh: s.refreshSnapshot(),
},
Resource: BootstrapResource{
Release: sum.Snapshot,
ResourceRoot: sum.ResourceRoot,
Source: sum.Source,
ManifestVersion: sum.ManifestVersion,
EntryCount: sum.EntryCount,
PresentCount: sum.PresentCount,
MissingCount: sum.MissingCount,
ServerInfoURL: publicBase + "/" + ServerInfoHost + "/server-info.json",
ClientPatchBaseURL: publicBase + "/" + ClientPatchHost,
AddressablesCatalogURLRoot: addressablesRoot,
},
Policy: BootstrapPolicy{
PullOwner: "rust_bat",
ResourceRootDiscovery: "bat_sock_rpc",
Deployment: "co_located_with_rust_bat_or_shared_filesystem",
ResourceRootOverride: s.cfg.ResourceRoot != "",
ServesOnlyPublishedRelease: true,
WritesReleaseState: false,
FullGameBusinessAPI: false,
},
Endpoints: []string{
"/v1/launcher/bootstrap",
"/api/launcher/game/config",
"/api/launcher/game/config/json",
"/api/launcher/advanced/game/download/cdn",
"/v1/server-info",
"/" + ServerInfoHost + "/server-info.json",
"/" + ClientPatchHost + "/...",
},
Warnings: meta.Warnings,
})
}
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
meta := s.discoverMeta()
sum := ReleaseSummary{}
if idx := s.index(); idx != nil {
sum = idx.Summary()
}
writeNoStoreJSON(w, http.StatusOK, map[string]any{
"ok": true,
"service": "bat-api",
"listen": s.cfg.Listen,
"socket": s.cfg.SocketPath,
"public_base": s.cfg.PublicBaseURL,
"rpc_available": meta.RPCAvailable || sum.RPCAvailable,
"doctor_healthy": sum.DoctorHealthy,
"refresh_interval_seconds": int64(s.cfg.RefreshInterval.Seconds()),
"refresh": s.refreshSnapshot(),
"resource_root": sum.ResourceRoot,
"resource_root_override_configured": s.cfg.ResourceRoot != "",
"ready": sum.Ready,
"entry_count": sum.EntryCount,
"present_count": sum.PresentCount,
"missing_count": sum.MissingCount,
"source": sum.Source,
"warnings": meta.Warnings,
// Database/redis are reserved config surface for a normal API process.
"database_configured": s.cfg.DatabaseURL != "",
"redis_configured": s.cfg.RedisURL != "",
})
}
func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
meta := s.discoverMeta()
sum := ReleaseSummary{}
if idx := s.index(); idx != nil {
sum = idx.Summary()
}
status := http.StatusOK
if !sum.Ready {
status = http.StatusServiceUnavailable
}
body := map[string]any{
"ready": sum.Ready,
"service": "bat-api",
"rpc_available": meta.RPCAvailable || sum.RPCAvailable,
"resource_root": sum.ResourceRoot,
"entry_count": sum.EntryCount,
"present_count": sum.PresentCount,
"missing_count": sum.MissingCount,
"source": sum.Source,
"refresh": s.refreshSnapshot(),
}
if r.Method == http.MethodHead {
w.WriteHeader(status)
return
}
writeNoStoreJSON(w, status, body)
}
func (s *Server) handleRelease(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
idx := s.index()
if idx == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_not_ready", "release index not ready")
return
}
writeNoStoreJSON(w, http.StatusOK, idx.Summary())
}
func (s *Server) handleResources(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
idx := s.index()
if idx == nil {
writeErrorJSON(w, http.StatusServiceUnavailable, "release_not_ready", "index not ready")
return
}
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
if limit <= 0 {
limit = 100
}
if limit > s.cfg.MaxResourcePageLimit {
limit = s.cfg.MaxResourcePageLimit
}
items, total := idx.List(offset, limit)
writeNoStoreJSON(w, http.StatusOK, ResourceListResponse{
Total: total,
Offset: offset,
Limit: limit,
Items: items,
})
}
func (s *Server) handleServerInfoDebug(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
s.writeServerInfo(w, r, "")
}
func (s *Server) handleServerInfoCDN(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
writeErrorJSON(w, http.StatusMethodNotAllowed, "method_not_allowed", "method not allowed")
return
}
name := strings.TrimPrefix(r.URL.Path, "/"+ServerInfoHost+"/")
name = strings.TrimPrefix(name, "/")
s.writeServerInfo(w, r, name)
}
func (s *Server) writeServerInfo(w http.ResponseWriter, r *http.Request, name string) {
raw, err := LoadServerInfoBytes(s.cfg, s.index(), name)
if err != nil {
writeErrorJSON(w, http.StatusNotFound, "server_info_not_found", err.Error())
return
}
rewritten, err := RewriteServerInfoAddressables(raw, s.cfg.PublicBaseURL)
if err != nil {
writeErrorJSON(w, http.StatusInternalServerError, "server_info_invalid", err.Error())
return
}
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(rewritten)))
if r.Method == http.MethodHead {
w.WriteHeader(http.StatusOK)
return
}
w.WriteHeader(http.StatusOK)
_, _ = w.Write(rewritten)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
data, err := json.Marshal(body)
if err != nil {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusInternalServerError)
fallback, _ := json.Marshal(ErrorResponse{
Error: ErrorBody{Code: "internal_error", Message: err.Error(), Status: http.StatusInternalServerError},
})
_, _ = w.Write(fallback)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_, _ = w.Write(data)
}
func (s *Server) beginRefresh() time.Time {
now := time.Now()
s.mu.Lock()
s.refreshInProgress = true
s.lastRefreshStart = now
s.mu.Unlock()
return now
}
func (s *Server) finishRefresh(started time.Time, err error, warnings []string) {
s.mu.Lock()
defer s.mu.Unlock()
s.finishRefreshLocked(started, err, warnings)
}
func (s *Server) finishRefreshLocked(started time.Time, err error, warnings []string) {
now := time.Now()
s.refreshInProgress = false
s.lastRefreshFinish = now
s.lastRefreshDur = now.Sub(started)
s.lastRefreshWarns = append([]string(nil), warnings...)
if err != nil {
s.lastRefreshError = err.Error()
return
}
s.lastRefreshError = ""
s.lastRefreshOK = now
}
func (s *Server) refreshSnapshot() RefreshDiagnostics {
s.mu.RLock()
defer s.mu.RUnlock()
warnings := append([]string(nil), s.lastRefreshWarns...)
return RefreshDiagnostics{
InProgress: s.refreshInProgress,
LastAttemptUnixSeconds: unixPtr(s.lastRefreshStart),
LastFinishedUnixSeconds: unixPtr(s.lastRefreshFinish),
LastSuccessUnixSeconds: unixPtr(s.lastRefreshOK),
LastDurationMilliseconds: s.lastRefreshDur.Milliseconds(),
LastError: s.lastRefreshError,
LastWarningCount: len(warnings),
LastWarnings: warnings,
RefreshIntervalSeconds: int64(s.cfg.RefreshInterval.Seconds()),
ResourceRootOverrideActive: s.cfg.ResourceRoot != "",
}
}
func unixPtr(t time.Time) *int64 {
if t.IsZero() {
return nil
}
v := t.Unix()
return &v
}
// StartRefreshLoop keeps the in-memory release index aligned with the Rust bat daemon.
func (s *Server) StartRefreshLoop(ctx context.Context) {
if s.cfg.RefreshInterval <= 0 {
s.logger.Printf("bat-api periodic release discovery disabled")
return
}
go func() {
ticker := time.NewTicker(s.cfg.RefreshInterval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
refreshCtx, cancel := context.WithTimeout(ctx, s.cfg.RPCTimeout+5*time.Second)
if err := s.Refresh(refreshCtx); err != nil {
s.logger.Printf("periodic discover failed: %v", err)
}
cancel()
}
}
}()
}
// ListenAndServe starts the HTTP server until ctx is cancelled.
func (s *Server) ListenAndServe(ctx context.Context) error {
httpServer := &http.Server{
Addr: s.cfg.Listen,
Handler: s.Handler(),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
MaxHeaderBytes: 1 << 20,
}
errCh := make(chan error, 1)
go func() {
s.logger.Printf("bat-api listening on %s (socket=%s public=%s)", s.cfg.Listen, s.cfg.SocketPath, s.cfg.PublicBaseURL)
errCh <- httpServer.ListenAndServe()
}()
select {
case <-ctx.Done():
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = httpServer.Shutdown(shutdownCtx)
return ctx.Err()
case err := <-errCh:
if err == http.ErrServerClosed {
return nil
}
return err
}
}
+125
View File
@@ -0,0 +1,125 @@
package api
import (
"encoding/json"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
// RewriteServerInfoAddressables rewrites AddressablesCatalogUrlRoot values so
// clients fetch resources from publicBaseURL while leaving business API URLs
// untouched.
func RewriteServerInfoAddressables(raw []byte, publicBaseURL string) ([]byte, error) {
var doc map[string]any
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, fmt.Errorf("parse server-info: %w", err)
}
groups, _ := doc["ConnectionGroups"].([]any)
for _, g := range groups {
group, ok := g.(map[string]any)
if !ok {
continue
}
rewriteGroupAddressables(group, publicBaseURL)
if overrides, ok := group["OverrideConnectionGroups"].([]any); ok {
for _, o := range overrides {
if og, ok := o.(map[string]any); ok {
rewriteGroupAddressables(og, publicBaseURL)
}
}
}
}
return json.Marshal(doc)
}
func rewriteGroupAddressables(group map[string]any, publicBaseURL string) {
raw, _ := group["AddressablesCatalogUrlRoot"].(string)
if raw == "" {
return
}
if rewritten, ok := rewriteAddressablesRoot(raw, publicBaseURL); ok {
group["AddressablesCatalogUrlRoot"] = rewritten
}
}
func rewriteAddressablesRoot(original, publicBaseURL string) (string, bool) {
original = strings.TrimRight(original, "/")
publicBaseURL = strings.TrimRight(publicBaseURL, "/")
// Already rewritten to this base.
if strings.HasPrefix(original, publicBaseURL+"/"+ClientPatchHost) ||
strings.HasPrefix(original, publicBaseURL+"/"+ServerInfoHost) {
return original, true
}
u, err := url.Parse(original)
if err != nil || u.Host == "" {
return "", false
}
// Keep host/path so CDN path mapping stays 1:1 with destination_for_url.
path := strings.TrimPrefix(u.Path, "/")
if path == "" {
return publicBaseURL + "/" + u.Host, true
}
return publicBaseURL + "/" + u.Host + "/" + path, true
}
// LoadServerInfoBytes loads server-info from an explicit file, or from the
// release tree under yostar-serverinfo host, or synthesizes a minimal document
// from the release snapshot addressables root.
func LoadServerInfoBytes(cfg Config, idx *ReleaseIndex, requestName string) ([]byte, error) {
if cfg.ServerInfoFile != "" {
return os.ReadFile(cfg.ServerInfoFile)
}
if idx != nil && idx.ResourceRoot != "" {
name := requestName
if name == "" {
name = "server-info.json"
}
// Official path layout.
candidates := []string{
filepath.Join(idx.ResourceRoot, ServerInfoHost, name),
filepath.Join(idx.ResourceRoot, ServerInfoHost, requestName),
}
for _, c := range candidates {
if c == "" {
continue
}
if data, err := os.ReadFile(c); err == nil {
return data, nil
}
}
// Any single json under server-info host.
dir := filepath.Join(idx.ResourceRoot, ServerInfoHost)
if entries, err := os.ReadDir(dir); err == nil {
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
continue
}
return os.ReadFile(filepath.Join(dir, e.Name()))
}
}
}
if idx != nil && idx.Snapshot != nil && idx.Snapshot.AddressablesRoot != "" {
doc := map[string]any{
"ConnectionGroups": []any{
map[string]any{
"Name": defaultString(idx.Snapshot.ConnectionGroupName, "Prod"),
"AddressablesCatalogUrlRoot": idx.Snapshot.AddressablesRoot,
"BundleVersion": idx.Snapshot.BundleVersion,
"IsLivePublished": true,
},
},
}
return json.Marshal(doc)
}
return nil, fmt.Errorf("server-info source not found")
}
func defaultString(v, fallback string) string {
if v == "" {
return fallback
}
return v
}
@@ -0,0 +1,17 @@
{
"version": 1,
"entries": {
"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes": {
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
"destination": "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.bytes",
"bytes": 21,
"blake3": "0000000000000000000000000000000000000000000000000000000000000000"
},
"https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash": {
"url": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash",
"destination": "prod-clientpatch.bluearchiveyostar.com/r93_fixture/TableBundles/TableCatalog.hash",
"bytes": 10,
"blake3": "0000000000000000000000000000000000000000000000000000000000000000"
}
}
}
@@ -0,0 +1,25 @@
{
"snapshot_version": 2,
"connection_group_name": "Prod",
"app_version": "1.70.0",
"bundle_version": "s8tloc7lo3",
"addressables_root": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
"launcher_metadata": {
"launcher_version": "1.7.2",
"game_latest_version": "1.70.0",
"game_latest_file_path": "prod/ZIP_TEMP/BlueArchive_JP_TEMP/BlueArchive_JP-1.70.436321-game.zip",
"game_start_exe_name": "xldr_BlueArchiveOnline_JP_loader_x64",
"game_start_params": [
"BlueArchive.exe"
],
"manifest_url": "https://launcher-pkg-ba-jp.yo-star.com/prod/ZIP_TEMP/BlueArchive_JP_TEMP/manifest.json",
"manifest_source": "BlueArchive_JP-1.70.436321-game",
"manifest_file_count": 2
},
"game_main_config_bootstrap": {
"server_info_data_url": "https://yostar-serverinfo.bluearchiveyostar.com/r93_fixture.json",
"default_connection_group": "Prod"
},
"endpoints": [],
"endpoint_markers": []
}
@@ -0,0 +1,11 @@
{
"ConnectionGroups": [
{
"Name": "Prod",
"AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
"BundleVersion": "s8tloc7lo3",
"IsLivePublished": true,
"ApiUrl": "https://prod-game.example.invalid/"
}
]
}
+11
View File
@@ -0,0 +1,11 @@
{
"ConnectionGroups": [
{
"Name": "Prod",
"AddressablesCatalogUrlRoot": "https://prod-clientpatch.bluearchiveyostar.com/r93_fixture",
"BundleVersion": "s8tloc7lo3",
"IsLivePublished": true,
"ApiUrl": "https://prod-game.example.invalid/"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+3
View File
@@ -0,0 +1,3 @@
# Reserved empty directory
Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.md`.
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+4
View File
@@ -0,0 +1,4 @@
# Reserved empty directory
This path is a monorepo placeholder and is **not implemented**.
Do not treat it as a finished module. See `docs/reports/GO_STATUS.md`.
+10
View File
@@ -0,0 +1,10 @@
# bat-api Admin Panel
This directory is reserved for the future player-facing `bat-api` management
panel. The current backend exposes a protected placeholder at `GET /admin/`
that returns JSON links for health, readiness, bootstrap, release, resources,
and OpenAPI.
The production panel must reuse the same HTTP authentication, rate limiting,
access logging, reverse-proxy handling, and no-store dynamic response policy as
the resource API. Static frontend assets are not implemented yet.
+3
View File
@@ -0,0 +1,3 @@
# Reserved empty directory
Placeholder only. **Not implemented.** See `docs/reports/GO_STATUS.md`.