panel v0.9.1 — open-source game server manager

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 23:18:05 -07:00
commit 4cf3471398
2161 changed files with 300831 additions and 0 deletions
+34
View File
@@ -0,0 +1,34 @@
# Runtime data — gitignored and churns constantly
# (CA certs, agent certs, per-instance sidecar metadata, container logs, backups)
data/
# Compiled binaries (dev artifacts, not source)
agent.exe
controller.exe
panelctl.exe
*.exe
# Generated protobuf stubs — regenerated by `buf generate`
# Keep them searchable since they're committed, but deprioritize vs. source proto
# (no ignore — just noting that proto/panel/v1/*.pb.go is generated)
# Go build + test caches
*.test
*.prof
vendor/
# Git internals
.git/
# Editor / OS
.vscode/
.idea/
.DS_Store
Thumbs.db
# Archive / backup files
*.rar
*.zip
*.7z
*.tar
*.tar.gz
+70
View File
@@ -0,0 +1,70 @@
# Binaries
*.exe
*.dll
*.so
*.dylib
/bin/
/controller/controller
/agent/agent
# Test / coverage
*.test
*.out
coverage.*
# UI regression gate (the *.test Go-binary rule above would swallow this dir)
!/controller/.test/
/controller/.test/node_modules/
/controller/.test/test-results/
/controller/.test/playwright-report/
# Go
vendor/
# Generated proto stubs (regenerated via buf)
# Comment these lines if you want to check generated code in
# /proto/panel/v1/*.pb.go
# /proto/panel/v1/*_grpc.pb.go
# IDE
.idea/
.vscode/
*.swp
# Local AI-tooling artifacts (auto-synced, machine-specific)
.claude/
AGENTS.md
# C# mod build artifacts
modules/7dtd/mod-source/**/bin/
modules/7dtd/mod-source/**/obj/
# OS
.DS_Store
Thumbs.db
# Local dev
/data/
*.local.yaml
*.local.env
.env
.env.local
# Internal CA private key material — NEVER commit
/ca/
/certs/
*.pem
*.key
!**/testdata/*.pem
!**/testdata/*.key
# dev stub data (deckstub)
/Temp/
# Private ops notes (maintainer-local, not part of the public release)
memory/
HANDOFF-new-ui.md
scripts/sync-agent-to-panel.sh
# Proprietary embedded mod bundles (maintainer-local; built in via -tags refugebot)
controller/cmd/controller/embedded_mods/
+48
View File
@@ -0,0 +1,48 @@
# panel
Open-source Go-based game-server management panel (AMP-class). Distributed Controller/Target over gRPC, Docker runtime, CEL-driven automation, 26 game module directories, live dashboard + `panelctl` CLI. Single static binary per component.
## First thing every session
Read the latest `memory/changelog_*.md` (the file with the most recent date in its name) before doing anything else. Each changelog covers what shipped in a previous autonomous session — bugs, root causes, files touched, and live one-off cleanups that didn't survive a recreate. Skipping this means re-discovering bugs you already fixed last time.
If the user opens with a generic ask ("what should we work on?", "any bugs left over?"), the changelog's "What still needs to happen" tail section is the answer.
## Start here
- **[README.md](README.md)** — public overview: quickstart, modules, panelctl commands, architecture diagram.
- **[memory/](memory/)** — project-specific deep refs migrated from the user's global auto-memory. Covers repo layout, build commands, module quirks, UX preferences, gotchas. **Always start with `memory/README.md`** — it's the index pointing at every other deep ref + every changelog.
## Component entrypoints
- **Controller:** `./controller/cmd/controller` (port 8080 web / 8443 gRPC). Embeds DB migrations, admin bootstrap, static dashboard. **Default UI** is the Refuge Command Center split assets: `static/new.html` (shell, 1,723 lines) + `static/rcc.js` (18,928 lines) + `static/rcc.css` (5,727 lines), served hash-stamped/immutable. `static/index.html` (23,982 lines) is the **frozen classic UI** — opt-in via `panel_ui=classic` cookie, no new features land there.
- **Agent:** `./agent/cmd/agent` (any host with Docker; dials controller). Loads modules from `./modules/`, talks to local Docker daemon.
- **CLI:** `./controller/cmd/panelctl``agents | instances | create | start | stop | update | backup | rcon | watch …` plus `admin <subcommand>` (HTTP, see [memory/admin_cli.md](memory/admin_cli.md)).
## Build + run (dev)
```bash
export PATH="/c/Program Files/Go/bin:$PATH"
cd /c/Users/dbled/sources/panel && \
go build -o /c/Users/dbled/AppData/Local/Temp/controller.exe ./controller/cmd/controller && \
go build -o /c/Users/dbled/AppData/Local/Temp/agent.exe ./agent/cmd/agent
powershell -Command "Get-Process -Name controller,agent -ErrorAction SilentlyContinue | Stop-Process -Force"
# Then relaunch each (see memory/build.md for full cycle)
```
Static/HTML/CSS/JS changes for the default UI live in `controller/cmd/controller/static/new.html` + `rcc.js` + `rcc.css` — all embedded via `//go:embed`, so controller rebuild+restart is required on any change. Do NOT add features to `static/index.html`; the classic UI is frozen.
**Gate before any controller deploy:** `node controller/.test/gate-parse.mjs` — extracts and parses every inline `<script>` block in `static/{new,index,login}.html` (a syntax slip blanks the whole dashboard; `go build` can't catch it because the HTML embeds as opaque bytes). For `rcc.js` itself, `node --check controller/cmd/controller/static/rcc.js`. (`controller/.test/ui.spec.mjs` is the Playwright suite; it has not had a first real run yet.)
**Running Go tests:** the repo is a `go.work` multi-module workspace — `go test ./...` from the root does NOT cover everything. Invoke per module dir explicitly: `go test ./controller/... ./agent/... ./pkg/...` (or cd into each). Known baseline: 1 pre-existing failure `TestDialerForUnknown` + 1 `go vet` copylock nit in `agent/internal/dispatch/dispatch.go` (PortMap).
## Golden rules
- **Never switch `buf.gen.yaml` to local plugins.** Smart App Control blocks locally-built `protoc-gen-*.exe`. Stay on remote buf plugins.
- **When adding a new Agent→Controller reply type**, wire it into BOTH the first `switch` (type-specific) AND the second `case` in `handleAgentMessage` that delivers by correlation ID. Missing the second = silent timeout.
- **Docker image rebuild ≠ container rebuild.** Containers pin to image ID at create time. Use Settings → Maintenance → Rebuild container, or the API delete-preserve-volumes + create dance.
- **The module manifest loader uses strict `KnownFields(true)`** (`pkg/module/loader.go:22`). An old agent binary that doesn't know a new module.yaml key (e.g. `ready_pattern`, `appearance`) will REJECT the whole manifest. Ship agent binaries BEFORE or WITH any module.yaml sync that adds new keys.
- **SteamCMD exit 2/5/8 is transient.** Already auto-retried in `steamcmd.go`. Don't surface as a user error.
- **Volumes survive container delete unless `purge=true`.** The Delete button's Danger Zone purges. Keep default `purge=false` for world safety.
- **User prefers local art over CDN hotlinks, gradients + animations over flat UI, sub-tabs for any config >10 fields, no wrapper headings for single items, transparent retry on flaky externals.** Full list in [memory/ux_rules.md](memory/ux_rules.md).
+61
View File
@@ -0,0 +1,61 @@
# Credits
## Third-party Docker images
Most modules run `panel-*` images that the agent auto-builds from the
in-tree `modules/<id>/Dockerfile` (`FROM debian:12-slim`;
`empyrion-bridge` builds from the Microsoft .NET 8 images). A few
modules and sidecars use community-maintained images directly — thank
you to their maintainers:
| Image | Used by | Upstream |
|---|---|---|
| `itzg/minecraft-server` | `modules/minecraft-java` | https://github.com/itzg/docker-minecraft-server |
| `itzg/minecraft-bedrock-server` | `modules/minecraft-bedrock` | https://github.com/itzg/docker-minecraft-bedrock-server |
| `acekorneya/asa_server` | `modules/ark-sa` | https://github.com/acekorneya/Ark-Survival-Ascended-Server |
| `steamcmd/steamcmd` | SteamCMD update sidecar (`agent/internal/updater/steamcmd.go`) | https://github.com/steamcmd/docker |
| `alpine` | `modules/demo`, `modules/steamcmd-test`, tar backup sidecar | https://hub.docker.com/_/alpine |
| `debian:12-slim` | base of every auto-built `panel-*` image | https://hub.docker.com/_/debian |
| `postgres:17-alpine` | managed database container (installers / compose) | https://hub.docker.com/_/postgres |
| `mcr.microsoft.com/dotnet/{sdk,aspnet}:8.0-alpine` | `modules/empyrion-bridge` build | https://github.com/dotnet/dotnet-docker |
Several `panel-*` module entrypoints also learned from earlier
community images they replaced (e.g. `vinanrra/7dtd-server`,
LGSM-based images) — see the comments at the top of the relevant
`module.yaml` / `Dockerfile` files.
## CubeCoders AMPTemplates (MIT)
The game catalog (`controller/cmd/controller/static/catalog.json`, generated
by `tools/amp-distill`) is distilled from the CubeCoders AMPTemplates
repository: https://github.com/CubeCoders/AMPTemplates
Several panel modules also used AMPTemplates as a reference when hand-porting
(port lists, console regexes, Steam app IDs) — see the `Reference:` line in
each `modules/<id>/module.yaml`.
AMPTemplates is licensed under the MIT License:
```
MIT License
Copyright (c) 2024 CubeCoders Limited
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
```
+190
View File
@@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for describing the origin of the Work and
reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Support. While redistributing the Work or
Derivative Works thereof, You may choose to offer, and charge a
fee for, acceptance of support, warranty, indemnity, or other
liability obligations and/or rights consistent with this License.
However, in accepting such obligations, You may act only on Your
own behalf and on Your sole responsibility, not on behalf of any
other Contributor, and only if You agree to indemnify, defend,
and hold each Contributor harmless for any liability incurred by,
or claims asserted against, such Contributor by reason of your
accepting any such warranty or support.
END OF TERMS AND CONDITIONS
Copyright 2026 panel contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+55
View File
@@ -0,0 +1,55 @@
# Panel — build/test/install entrypoints.
#
# The single source of truth for the build version is
# github.com/dbledeez/panel/pkg/version.Version (defaults to "dev"),
# injected here via -ldflags -X. Override with: make build VERSION=v1.2.3
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
LDFLAGS = -s -w -X github.com/dbledeez/panel/pkg/version.Version=$(VERSION)
GOFLAGS = -trimpath
BIN_DIR ?= bin
PREFIX ?= /opt/panel
# Windows-friendly: `make build` on Windows produces .exe automatically
# because go respects GOOS; output names stay extension-less here and Go
# appends .exe itself only with -o omitted, so be explicit:
EXE :=
ifeq ($(OS),Windows_NT)
EXE := .exe
endif
.PHONY: all build test vet docker version install-local clean
all: build
build:
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/controller$(EXE) ./controller/cmd/controller
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/agent$(EXE) ./agent/cmd/agent
go build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BIN_DIR)/panelctl$(EXE) ./controller/cmd/panelctl
test:
go test ./controller/... ./agent/... ./pkg/...
vet:
go vet ./controller/... ./agent/... ./pkg/...
# Production container images (see deploy/docker-compose.yml).
docker:
docker build -f deploy/Dockerfile.controller --build-arg VERSION=$(VERSION) -t panel-controller:$(VERSION) .
docker build -f deploy/Dockerfile.agent --build-arg VERSION=$(VERSION) -t panel-agent:$(VERSION) .
version:
@echo $(VERSION)
# Install binaries + modules + systemd units on the local Linux box.
# Root-only step of install.sh; useful for upgrades from a git checkout.
install-local: build
install -d $(PREFIX)/bin $(PREFIX)/data
install -m 0755 $(BIN_DIR)/controller $(BIN_DIR)/agent $(BIN_DIR)/panelctl $(PREFIX)/bin/
cp -r modules $(PREFIX)/
install -m 0644 deploy/systemd/panel-controller.service deploy/systemd/panel-agent.service /etc/systemd/system/
systemctl daemon-reload
@echo "installed to $(PREFIX); enable with: systemctl enable --now panel-controller panel-agent"
clean:
rm -rf $(BIN_DIR)
+823
View File
@@ -0,0 +1,823 @@
# panel
An open-source, modular game-server management panel — AMP-class features,
distributed Controller/Agent architecture, CEL-driven automation, full
instance lifecycle (install → run → update → backup → restore), live
dashboard, and CLI.
Written in Go. Single static binary per component. Docker-backed runtime.
26 modules ship in-tree, including the awkward Windows-only games
(Conan Exiles, Empyrion, V Rising, Sons of the Forest, DayZ, Soulmask,
Project Zomboid, Windrose) under Wine + Xvfb. The agent **auto-builds**
every module's Docker image from its in-tree `Dockerfile` the first time
an instance needs it — there is no manual per-module `docker build` step.
## 60-second quickstart
One box, one line. Docker is installed for you if missing; Postgres runs
as a managed container; controller + agent become systemd units (Linux)
or Scheduled Tasks (Windows), and the local agent is auto-paired over mTLS.
```bash
# Linux (Debian/Ubuntu/Fedora/RHEL/Raspbian) — run as root:
curl -fsSL https://raw.githubusercontent.com/dbledeez/panel/main/install.sh | sudo bash
```
```powershell
# Windows (Docker Desktop must already be running):
powershell -ExecutionPolicy Bypass -File install.ps1
```
Then open `http://<host>:8080/` and log in with the `ADMIN BOOTSTRAP`
credentials printed in the controller log (or pre-seed via
`PANEL_ADMIN_PASSWORD`). Full options, docker-compose, and from-source
paths: [docs/INSTALL.md](docs/INSTALL.md).
```
┌─────────────────────────┐
│ Operator │
│ (browser / panelctl) │
└────────────┬────────────┘
│ HTTPS / gRPC
┌────────────▼────────────┐
│ Controller │
│ • Web dashboard :8080 │
│ • Agent gRPC :8443 │
│ • Postgres │
│ • Scheduler (cron+CEL) │
│ • Event bus + SSE │
│ • Session auth │
└────────────┬────────────┘
│ gRPC bidi (mTLS)
┌────────────────────┼────────────────────┐
│ │ │
┌────────▼────────┐ ┌────────▼────────┐ ┌────────▼────────┐
│ Agent A │ │ Agent B │ │ Agent C │
│ • Docker │ │ • Docker │ │ • Docker │
│ • Modules │ │ • Modules │ │ • Modules │
│ • RCON clients │ │ • RCON clients │ │ • RCON clients │
│ • State tracker│ │ • State tracker│ │ • State tracker│
└─────────────────┘ └─────────────────┘ └─────────────────┘
```
---
## Documentation
| Doc | Covers |
|---|---|
| [docs/INSTALL.md](docs/INSTALL.md) | Installers, docker-compose, from-source, flags reference, systemd, reverse proxy + TLS, firewall |
| [docs/NETWORKING.md](docs/NETWORKING.md) | Per-game port table, router port-forwarding, what the panel auto-allocates |
| [docs/PAIRING.md](docs/PAIRING.md) | mTLS agent pairing, multi-host setup |
| [docs/ADMIN.md](docs/ADMIN.md) | First-admin bootstrap, password reset, Steam sign-in, Steam credentials for paid games |
| [docs/BACKUP.md](docs/BACKUP.md) | Backups, pg_dump, disaster-recovery runbook |
| [docs/architecture.md](docs/architecture.md) | The real stack, component by component |
## Table of contents
- [Status](#status)
- [Why panel](#why-panel)
- [Architecture](#architecture)
- [Quick start (dev)](#quick-start-dev)
- [Production-ish layout](#production-ish-layout)
- [Modules](#modules)
- [Module manifest](#module-manifest)
- [Wine/Xvfb modules](#winexvfb-modules)
- [`panelctl` CLI](#panelctl-cli)
- [HTTP API](#http-api)
- [Admin HTTP (out-of-band repair)](#admin-http-out-of-band-repair)
- [Scheduler](#scheduler)
- [Mods & Workshop](#mods--workshop)
- [Backups](#backups)
- [File manager](#file-manager)
- [Repository layout](#repository-layout)
- [Data directory layout](#data-directory-layout)
- [Build](#build)
- [Working with the embedded UI](#working-with-the-embedded-ui)
- [Troubleshooting](#troubleshooting)
- [Contributing / writing a new module](#contributing--writing-a-new-module)
- [License](#license)
---
## Status
panel is a working private deployment driving real game servers — not a
toy. Feature completeness varies by game; the headline state is:
- ✅ Manifest-driven module plugins (26 shipped — see [Modules](#modules))
- ✅ mTLS agent pairing: one-time token minted in the UI → `agent
--pair-token` fetches the CA + a signed cert and exits; the agent then
connects with mTLS and rogue agents are rejected at the TLS handshake
(see [docs/PAIRING.md](docs/PAIRING.md))
- ✅ Agent auto-builds `panel-*` module images from the in-tree Dockerfiles
on first use — pull-through for third-party images, no manual builds
- ✅ Distributed Controller + Agent over gRPC (bidi streams, reconnect +
rehydrate, sidecar metadata survives restarts)
- ✅ Docker runtime with bind-mount and named-volume support, host
networking, per-instance port allocation
- ✅ Wine + Xvfb pattern for Windows-only dedicated servers (Empyrion,
Conan Exiles, V Rising, Sons of the Forest, DayZ, Soulmask, Project
Zomboid, Windrose, Dragonwilds)
- ✅ Full state tracking: RCON polling + log-line event matching → typed
PlayerEvent + AppState bus
- ✅ Telnet **and** Source RCON adapters (Minecraft, CS-derived games,
Rust, Valheim w/ plugins, 7DTD, Conan, etc.) with redial-on-EOF
- ✅ Scheduler: 6-field cron triggers **and** CEL event triggers with
sustained-for windows
- ✅ Config-file templating with auto-generated secrets; per-game custom
Config tabs over `ServerSettings.ini` / `GameUserSettings.ini` /
`serverconfig.xml` / properties / JSON
- ✅ File manager: web UI browses container volumes via `docker exec` +
`docker cp`, bind-mount fallback, path-traversal defended, multi-root
per module
- ✅ Update providers: **Direct HTTP**, **GitHub releases**, **SteamCMD
sidecar** (with platform overrides for Windows-on-Linux), **Steam beta
branches** (e.g. Conan Legacy UE4, Conan Enhanced UE5)
- ✅ Backup / restore via tar-sidecar containers
- ✅ Postgres persistence (agents, instances, schedules, backups, users)
- ✅ Local auth: argon2id + session cookies, admin bootstrap, change
password, optional Sign-in-with-Steam linking
- ✅ Live event bus with SSE stream (`Last-Event-ID` resume + snapshot) +
`panelctl watch`
- ✅ Template Library: 236-entry game catalog (`static/catalog.json`,
distilled from CubeCoders' MIT templates by `tools/amp-distill`) with an
admin one-click module scaffolder (`POST /api/scaffold-module`)
- ✅ Update-check (check-only): agent compares installed Steam build id vs
`app_info` (15-min cache) → Updates-tab panel + card badge
- ✅ Prometheus `/metrics` (admin-token / loopback gated)
- ✅ Mods tab: ARK CurseForge / DayZ Workshop / Conan Workshop / V Rising
PlasmaCore / Empyrion scenarios — search, install, version-pin
- ✅ ARK SA cluster registry with cross-server transfer + admin rebind
- 🟡 Cross-platform: Linux works for everything except modules that
require nested KVM. Primary dev host is Windows + Docker Desktop.
- ❌ OIDC / WebAuthn / Casbin ACL (local auth only; role = admin / user)
- ❌ SFTP (web FM covers the common case)
- ❌ Host-mode runtime (cgroups v2 / Windows Job Objects)
## Why panel
Pterodactyl/Pelican is good but Docker-only and PHP, AMP is closed-source
and per-machine, GameServerApp and friends are SaaS. panel is what falls
out if you want:
- One static Go binary per component, no Composer / npm / Node runtime
on the host.
- Modules as plain YAML — drop a `module.yaml` in `modules/<id>/`,
restart the agent, the game appears in the dashboard.
- Real distributed multi-host: Controller is one box, Agents run
wherever Docker runs.
- A scheduler that can react to *the world* — "stop the server if
it's been empty for 30 minutes", "restart at 3am only on Tuesdays" —
rather than just cron.
- First-class Wine/Xvfb support: Windows-only dedicated servers run on
Linux Docker hosts without per-game artisanal scripting.
- Out-of-band repair via a token-authed `/admin/v1/*` HTTP surface —
the dashboard breaks, you SSH in, and `panelctl admin recreate` /
`set-config` / `ark-rebind` still work.
Designed for someone running ~10 game servers across 2-4 boxes for
themselves and a couple of communities, not for a hosting company. No
multi-tenancy, no billing.
## Architecture
Two long-running components plus optional sidecars.
**Controller** — `./controller/cmd/controller`
- HTTP :8080 (configurable) — dashboard, REST API, SSE event stream
- gRPC :8443 — bidi stream from each Agent
- Postgres for durable state (no SQLite default — explicitly not sharded)
- Embedded migrations, embedded static dashboard (default UI =
`static/new.html` shell + `static/rcc.js` + `static/rcc.css`, served
hash-stamped with immutable caching via `//go:embed`; the legacy
single-file `static/index.html` is the frozen "classic" UI, opt-in via
the `panel_ui=classic` cookie)
- Scheduler in-process (cron + CEL)
- Prometheus `GET /metrics` (admin-token or loopback gated)
**Agent** — `./agent/cmd/agent`
- Dials Controller, stays connected with reconnect + heartbeat
- Loads modules from `--modules-dir` at boot (default `./modules`)
- Drives the local Docker daemon (`/var/run/docker.sock` or
`npipe:////./pipe/docker_engine` on Windows)
- Spawns short-lived sidecar containers for SteamCMD updates and tar
backup/restore
- Per-instance state tracker: RCON polling, log-line tail, event
pattern matching → typed events streamed back to Controller
**Sidecars (per-game)**
- `steamcmd-sidecar` — SteamCMD with `+force_install_dir` mounted at
the instance's game-content volume; supports `+@sSteamCmdForcePlatformType
windows` for cross-platform Windows depots, beta branch pinning, and
Workshop downloads.
- `tar-sidecar` — exec'd against the volumes for backup/restore so the
game container doesn't have to expose its filesystem.
**RCON adapters** (`agent/internal/rcon/`)
- `source_rcon` — Source-RCON wire protocol (Minecraft Java, Conan,
Factorio, Palworld, Project Zomboid)
- `telnet` — Telnet-style password gate then line protocol (7DTD, Empyrion)
- `websocket_rcon` — Rust's WebRCON
- `stdio` — attach to the container's stdin (Terraria, Valheim, Vein,
Enshrouded)
- `be_rcon` — BattlEye RCON over UDP (DayZ)
- `docker_exec_rcon` — exec a CLI inside the container (ARK SA)
All adapters auto-redial on EOF (Conan and Palworld lessons baked in).
## Quick start (dev)
Prereqs: Go 1.26+, Docker Desktop (Windows) or Docker Engine (Linux),
[`buf`](https://buf.build/docs/installation/) on PATH for proto stubs.
```bash
# 1) Postgres (single dev container)
docker compose -f docker-compose.dev.yml up -d
# 2) Generate protobuf stubs (uses remote plugins — no local protoc needed)
buf generate
# 3) Start the controller. First run prints an ADMIN BOOTSTRAP banner
# with the generated admin password — copy it out of stderr.
go run ./controller/cmd/controller
# 4) Start an agent (any host with Docker + reachable to controller)
go run ./agent/cmd/agent --insecure \
--modules-dir=./modules \
--data-root=./data/instances \
--meta-dir=./data/instance-meta \
--backup-dir=./data/backups
# 5) Open http://localhost:8080/, log in as admin@panel.local with
# the bootstrap password.
```
If you'd rather pre-seed the password instead of reading it from logs:
```bash
PANEL_ADMIN_PASSWORD=changeme go run ./controller/cmd/controller
```
## Production-ish layout
This is the layout the maintainer runs. Not prescriptive — adapt freely.
- One **Controller** box with Postgres co-located. Reverse-proxied
behind Nginx Proxy Manager / Caddy / Cloudflare. Public dashboard
URL has a real cert; gRPC :8443 is on the LAN only.
- N **Agent** boxes, each with Docker. Agents don't need to be reachable
from the internet — they dial out to the Controller's gRPC port.
- A `data/` directory per machine (Controller + each Agent) under
whatever path you want. **Don't put it in the repo working tree** —
`data/` is gitignored for a reason: it holds per-instance state,
RCON passwords, mTLS material, and game saves.
- One systemd unit per binary on Linux, one Service on Windows
(NSSM works fine).
The agent is intentionally stateless beyond `data/instance-meta/*.json`
(rehydrated from the Controller on connect). Wipe the meta directory
and the agent rebuilds it from the Controller's authoritative DB.
## Modules
Game modules live under `modules/<id>/` and are picked up by every
agent at boot. `panel-*` images are **auto-built by the agent** from the
module's in-tree `Dockerfile` the first time an instance needs them
(they are never published to a registry); third-party images are pulled. Each ships with a `module.yaml` and (where needed) an
`entrypoint.sh` and `Dockerfile`.
| Module | Image / build | RCON | Notes |
|---|---|---|---|
| `7dtd` | `panel-7dtd` (auto-built) | telnet | Canonical-nim freeze, cluster playbook |
| `ark-sa` | `acekorneya/asa_server:latest` | `docker_exec_rcon` | Cluster registry, CurseForge mods, transfer flow |
| `barotrauma` | `panel-barotrauma` (auto-built) | log only | Workshop mods |
| `conan-exiles` | `panel-conan-exiles` (auto-built, Wine) | `source_rcon` | UE4 Legacy / UE5 Enhanced edition picker, Workshop |
| `core-keeper` | `panel-core-keeper` (auto-built) | log only | |
| `dayz` | `panel-dayz` (auto-built, Wine) | `be_rcon` | Steam Workshop manager; `requires_steam_login` |
| `demo` | `alpine:3.21` | none | Lifecycle / event sandbox |
| `dragonwilds` | `panel-dragonwilds` (auto-built, Wine) | log only | |
| `empyrion` | `panel-empyrion` (auto-built, Wine) | telnet | Custom scenario manager, EAH bridge, discovery streamer |
| `empyrion-bridge` | multi-stage dotnet (auto-built) | bridge | EWA gateway sidecar |
| `enshrouded` | `panel-enshrouded` (auto-built) | stdio | |
| `factorio` | `panel-factorio` (auto-built) | `source_rcon` | |
| `minecraft-bedrock` | `itzg/minecraft-bedrock-server` | log only | |
| `minecraft-java` | `itzg/minecraft-server:latest` | `source_rcon` | |
| `palworld` | `panel-palworld` (auto-built) | `source_rcon` | RCON redial-on-EOF |
| `project-zomboid` | `panel-project-zomboid` (auto-built, Wine) | `source_rcon` | |
| `rust` | `panel-rust` (auto-built) | `websocket_rcon` | Rust+ port |
| `satisfactory` | `panel-satisfactory` (auto-built) | none | |
| `sons-of-the-forest` | `panel-sons-of-the-forest` (auto-built, Wine) | log only | |
| `soulmask` | `panel-soulmask` (auto-built, Wine) | log only | |
| `steamcmd-test` | `alpine:3.21` + SteamCMD | none | HLDS smoke test (~930 MB) |
| `terraria` | `panel-terraria` (auto-built) | stdio | |
| `v-rising` | `panel-v-rising` (auto-built, Wine) | log only | PlasmaCore mods |
| `valheim` | `panel-valheim` (auto-built) | stdio | |
| `vein` | `panel-vein` (auto-built) | stdio | |
| `windrose` | `panel-windrose` (auto-built, Wine) | log only | |
Per-game gotchas live in [`memory/gotchas.md`](memory/gotchas.md) — read
that before debugging any specific game.
## Module manifest
Every module is one declarative YAML file. The schema is in
[`pkg/module/manifest.go`](pkg/module/manifest.go). A condensed example:
```yaml
id: my-game
name: "My Game"
version: 0.1.0
supported_modes: [docker]
runtime:
docker:
image: itzg/my-game-server:latest # or local-built image tag
network_mode: host # optional
browseable_root: /data # File-manager root
browseable_roots: # Optional multi-root
- { name: "Saves", path: /data, hint: "world data" }
- { name: "Game", path: /game, hint: "engine binaries" }
env: # Pre-declare every env key
MAX_PLAYERS: "20" # you want forwarded;
ADMIN_PASSWORD: "" # resolve.go filters by
volumes: # this map.
- { type: volume, name: "panel-$INSTANCE_ID-data", container: /data }
- { target: "$DATA_PATH/logs", container: /log } # bind mount
ports:
- { name: game, proto: tcp, default: 25565, required: true, env: GAME_PORT }
- { name: query, proto: udp, default: 25566, required: false, env: QUERY_PORT }
- { name: rcon, proto: tcp, default: 25575, internal: true, env: RCON_PORT }
resources:
min_ram_mb: 2048
recommended_ram_mb: 4096
rcon:
adapter: source_rcon # or telnet, stdio, websocket_rcon, be_rcon, docker_exec_rcon
host_port: rcon
password_secret: RCON_PASSWORD
password_literal: "fallback"
commands:
list: "list"
broadcast: "say {msg}"
kick: "kick {player}"
save: "save-all"
shutdown: "stop"
state_sources:
- type: rcon
command: list
every: 60s
parse:
kind: regex
pattern: '(?P<n>\d+)\s+of\s+(?P<m>\d+) players'
fields: { players_online: 1, players_max: 2 }
events:
join: { pattern: "(?P<name>\\S+) joined", kind: join }
leave: { pattern: "(?P<name>\\S+) left", kind: leave }
chat: { pattern: "<(?P<name>[^>]+)> (?P<msg>.+)", kind: chat }
config_files:
- path: server.properties
format: properties
template: templates/server.properties.tmpl
config_values: # Drive the Config tab in the dashboard
- key: MAX_PLAYERS
label: "Max players"
description: "Player slot limit."
default: "20"
- key: ADMIN_PASSWORD
label: "Admin password"
description: "In-game admin auth."
default: ""
secrets:
- { name: admin_token, generated: true }
update_providers:
- id: stable
kind: steamcmd
app_id: "294420"
install_path: /data
skip_validate: false # Set true for app_ids whose validate hangs
```
Key rules the loader enforces:
- Only env keys pre-declared under `runtime.docker.env` get forwarded
to the container. Adding a `config_values` entry without the matching
env declaration silently drops the value — this trips people up.
- `volumes:` entries use `$INSTANCE_ID` and `$DATA_PATH` substitutions
resolved by the agent at create time.
- `internal: true` ports are not exposed by the allocator but still get
their `env:` substituted.
- `update_providers` are picked from a dropdown in the UI; multiple
providers per module is fine (Conan ships `enhanced` and `legacy`).
## Wine/Xvfb modules
The Windows-only dedicated servers (Empyrion, Conan, V Rising, Sons of
the Forest, DayZ, Soulmask, Project Zomboid, Windrose, Dragonwilds) run
inside a Linux container under `xvfb-run` + `wine`. The pattern:
```
panel-<game>:latest # custom image: debian + wineHQ + xvfb + setpriv
↓ docker run
container starts as root
↓ stage 1: chown volumes, symlink /Logs → save volume, drop caps
exec setpriv → user `panel`
↓ stage 2 (panel user): stamp env into game INI, then
xvfb-run -a wine /game/.../Win64-Shipping.exe ARGS
```
Per-game entrypoints live under `modules/<id>/entrypoint.sh`. They all:
- Refuse to run wine as root (wine policy).
- Allocate a fresh X display via `xvfb-run -a` to avoid colliding with
host Xvfb (a bug we hit when host networking is on — see
`memory/gotchas.md` "network_mode: host Xvfb collision").
- Stamp non-empty env values into the game's INI/JSON config files
*idempotently* — empty env means "don't touch this key", so operators
who edit the INI directly via the Files tab won't get clobbered.
- Trap SIGTERM and call `wineserver -k` for clean shutdown.
The shared SteamCMD sidecar runs `+@sSteamCmdForcePlatformType windows`
to fetch the Windows depot onto a Linux volume.
## `panelctl` CLI
Two surfaces in one binary at `./controller/cmd/panelctl`.
**Operator gRPC** — talks to the Controller's gRPC Panel service. Day-to-day
server lifecycle.
```
panelctl agents
panelctl instances [agent-id]
panelctl create <agent-id> <instance-id> <module-id>
panelctl start <agent-id> <instance-id>
panelctl stop <agent-id> <instance-id> [grace-seconds]
panelctl delete <agent-id> <instance-id> [--purge]
panelctl update <agent-id> <instance-id> [provider-id]
panelctl rcon <agent-id> <instance-id> <command...>
panelctl watch [instance-id]
panelctl backup create <agent-id> <instance-id> [description]
panelctl backup restore <instance-id> <backup-id>
panelctl modules
```
**Admin HTTP** — out-of-band repair. Drives the recreate dance
(env-config edits, ARK cluster rebinds, generic recreate with mount
overrides) without needing a dashboard session. Auth is loopback-bypass
when run on the Controller host, OR a long-lived token at
`<data-dir>/admin-token` (auto-generated on first start, mode 0600).
```
panelctl admin token-show
panelctl admin status
panelctl admin instances
panelctl admin ark-rebind <instance-id>
panelctl admin set-config <instance-id> KEY=VALUE [KEY=VALUE ...]
panelctl admin del-config <instance-id> KEY [KEY ...]
panelctl admin recreate <instance-id> [--mount CONTAINER_PATH=HOST_PATH ...]
```
Common flags:
```
--http URL Default http://localhost:8080
--admin-token TOK Or $PANEL_ADMIN_TOKEN, or read from <data-dir>/admin-token
--data-dir DIR Default ./data
```
## HTTP API
All `/api/*` require an authenticated session cookie. `/api/login` is
the only unauthenticated endpoint.
| Method | Path | What it does |
|---|---|---|
| `POST` | `/api/login` | Issues session cookie |
| `POST` | `/api/logout` | Revokes session |
| `GET` | `/api/me` | Current user + role |
| `POST` | `/api/change-password` | Rotate own password |
| `GET` | `/api/agents` | Live-connected agents |
| `GET` | `/api/modules` | Union of modules across agents |
| `GET` | `/api/instances[?agent_id=]` | Persisted instance rows |
| `POST` | `/api/instances` | Create |
| `POST` | `/api/instances/{id}/start` | Start |
| `POST` | `/api/instances/{id}/stop[?grace=N]` | Stop (container retained) |
| `DELETE` | `/api/instances/{id}[?purge=true]` | Delete instance + optionally volumes |
| `POST` | `/api/instances/{id}/rcon` | Ad-hoc RCON exec |
| `POST` | `/api/instances/{id}/update` | Trigger update provider |
| `GET` | `/api/instances/{id}/files[?path=]` | List |
| `GET` | `/api/instances/{id}/files/read?path=` | Read file |
| `POST` | `/api/instances/{id}/files` | Write file |
| `DELETE` | `/api/instances/{id}/files?path=` | Delete |
| `GET` | `/api/instances/{id}/backups` | List backups |
| `POST` | `/api/instances/{id}/backups` | Create backup |
| `POST` | `/api/instances/{id}/backups/{bkpId}/restore` | Restore |
| `DELETE` | `/api/instances/{id}/backups/{bkpId}` | Remove DB row |
| `GET` | `/api/schedules[?instance_id=]` | List schedules |
| `POST` | `/api/schedules` | Create (cron **or** event trigger) |
| `DELETE` | `/api/schedules/{id}` | Remove |
| `POST` | `/api/schedules/{id}/{enable\|disable}` | Toggle |
| `GET` | `/api/events[?instance=&agent=]` | SSE stream (supports `Last-Event-ID` / `?last_event_id=` resume) |
| `GET` | `/api/template-catalog` | 236-entry game template catalog |
| `POST` | `/api/scaffold-module` | Admin: scaffold a new `modules/<id>/` from a catalog entry |
| `GET` | `/api/instances/{id}/update-check` | Last cached update-check result |
| `POST` | `/api/instances/{id}/update-check` | Run a check (installed build vs Steam `app_info`) |
| `GET` | `/api/update-checks` | All instances' update-check state |
| `GET` | `/metrics` | Prometheus metrics (outside `/api`; admin token or loopback only) |
## Admin HTTP (out-of-band repair)
Sits **outside** the session middleware. Auth: loopback-bypass OR
`X-Panel-Admin-Token: <hex>` header (constant-time compared against
`<data-dir>/admin-token`, auto-generated mode 0600).
| Method | Path | What it does |
|---|---|---|
| `GET` | `/admin/v1/health` | `{ok, version, hostname, loopback, time}` |
| `GET` | `/admin/v1/instances` | All instances + `config_values` + `assigned_ports` + `ark_cluster_id` |
| `POST` | `/admin/v1/instances/{id}/ark-cluster-rebind` | Re-runs the cluster mount recreate against the registry's current cluster id |
| `POST` | `/admin/v1/instances/{id}/config` | Body `{"set":{...},"delete":[...]}` — merges config_values, runs the env-config recreate dance |
| `POST` | `/admin/v1/instances/{id}/recreate` | Body `{"mount_overrides":{...}}` — generic stop → delete-preserve → create → start |
The point of this surface: when the dashboard is broken, you can SSH
in, hit `/admin/v1/*` with curl, and rescue an instance.
## Scheduler
Two trigger kinds.
**cron** — robfig/cron v3, 6-field (seconds resolution):
```json
{
"instance_id": "mc-1",
"trigger_kind": "cron",
"cron_spec": "0 0 3 * * *",
"action": {"type": "instance", "op": "restart", "grace_seconds": 30}
}
```
**event** — CEL expression over the live event bus, with optional
sustain-for window. The classic example: stop the server if it's been
empty for 30 minutes.
```json
{
"instance_id": "mc-1",
"trigger_kind": "event",
"event_spec": "event_type == \"app_state\" && players_online == 0",
"event_sustained_seconds": 1800,
"action": {"type": "instance", "op": "stop", "grace_seconds": 60}
}
```
CEL input fields available at evaluation time:
```
event_type "join" | "leave" | "chat" | "app_state" | "log_match" | …
instance_id string
agent_id string
players_online int
players_max int
uptime_seconds int
instance_status "running" | "stopped" | "starting" | "exited"
exit_code int (only on exit events)
player_kind "join" | "leave" | "chat"
player_name string
player_id string
player_detail string
log_line string (raw matched line)
log_stream "stdout" | "stderr"
```
Action types:
```json
{"type": "rcon", "command": "saveworld"}
{"type": "instance", "op": "start" | "stop" | "restart", "grace_seconds": 30}
```
## Mods & Workshop
Per-game module integrations live in
`controller/cmd/controller/<game>mods.go`. The current set:
- **ARK SA** — CurseForge API, search + install + version pin. Cluster
registry tracks shared `ClusterDirOverride` mounts; rebind via Admin
HTTP rewrites all member instances atomically.
- **DayZ** — Steam Workshop item search, mod-priority load order, deploy
via SteamCMD sidecar.
- **Conan Exiles** — Steam Workshop with `requiredtags[]=Enhanced|Legacy`
filtering driven by the instance's `EDITION` config_value, so UE5
servers don't see UE4-only mods. `ServerModList=` stamped into
ServerSettings.ini and `ConanSandbox/Mods/modlist.txt`.
- **V Rising** — PlasmaCore + community .pak drops, install path
pinned per-instance.
- **Empyrion** — custom scenario manager (zip uploads + scenario
selector), discovery streamer for in-game POI markers.
- **Barotrauma** — Steam Workshop subscriptions written into the
client/server `WorkshopMods/` tree.
Volumes are reused across instances where it's safe: e.g. all Conan
instances on a given agent share `panel-conan-workshop` for downloaded
Workshop content, mounted at the game's expected `/game/steamapps/workshop`
path.
## Backups
`POST /api/instances/{id}/backups` spawns a `tar-sidecar` container
that mounts the same volumes as the game container (read-only on the
data side) and writes a tarball to the **agent's** `--backup-dir`
(default `./data/backups/<instance-id>/`). Restore reverses the flow,
with the game container stopped first.
Backups are timestamped and labeled with an optional description, and
can be scheduled (scheduler action `{"type":"backup"}`). Retention is
manual right now — list, pick, delete. See [docs/BACKUP.md](docs/BACKUP.md).
## File manager
Three operations against an instance's `browseable_root` (or any
`browseable_roots[i].path`):
- **List** (`GET /files?path=`) — `docker exec ls -la --full-time`
parsed into typed entries.
- **Read** (`GET /files/read?path=`) — `docker cp` to a temp file,
stream back. Caps at a sane size; bigger files prompt for download.
- **Write** (`POST /files`) — temp file, `docker cp`, `chown`.
- **Delete** (`DELETE /files`) — `docker exec rm`. Symlinks are
resolved with `realpath` first; path traversal blocked at the
manifest's declared roots.
Bind-mounted volumes get a fast-path that skips `docker cp` entirely.
## Repository layout
```
panel/
├── agent/ # Per-host agent
│ ├── cmd/agent/ # Binary entrypoint
│ └── internal/
│ ├── dispatch/ # gRPC reply handlers — one file per concern
│ ├── module/ # Module loader + resolver
│ ├── rcon/ # Source/Telnet/MC RCON adapters
│ ├── runtime/ # Docker driver
│ ├── state/ # Per-instance state tracker
│ └── updater/ # SteamCMD/HTTP/GitHub sidecars
├── controller/
│ ├── cmd/
│ │ ├── controller/ # Web + gRPC server
│ │ │ └── static/ # Embedded dashboard (new.html + rcc.js/rcc.css;
│ │ │ # index.html = frozen classic; catalog.json)
│ │ └── panelctl/ # CLI
│ └── internal/
│ ├── db/ # SQL migrations + queries
│ ├── scheduler/ # cron + CEL
│ └── eventbus/ # SSE + agent fanout
├── pkg/module/ # Shared module-manifest parsing
├── proto/panel/v1/ # gRPC contract (buf-managed)
├── modules/ # 26 modules — drop-in YAML + scripts
│ └── <game>/
│ ├── module.yaml
│ ├── Dockerfile # if a custom image is needed
│ └── entrypoint.sh
├── docs/ # INSTALL, NETWORKING, PAIRING, ADMIN, BACKUP, architecture
├── docker-compose.dev.yml # Postgres only
├── go.work # Multi-module workspace
└── memory/ # Maintainer's working notes (not user docs)
```
## Data directory layout
`data/` is gitignored. On a fresh install you'll grow:
```
data/
├── ark-clusters.json # Operator-curated cluster config (TRACKED)
├── arkcluster/ # Per-cluster shared state
├── backups/ # Tarballs from tar-sidecar
├── ca/ # Internal CA private key + server cert (mTLS)
├── certs/ # Issued agent certs
├── instance-meta/<id>.json # Per-instance metadata (rcon password, paths)
└── instances/<id>/ # Bind-mount volumes per instance
├── logs/
└── ...
```
`admin-token` lives at `<data-dir>/admin-token` (mode 0600,
auto-generated on first controller start).
## Build
Multi-module Go workspace. The root `go.work` ties together
`agent/`, `controller/`, `pkg/`, `proto/`.
```bash
# Generate proto stubs (once after a clone, and after any .proto change)
buf generate
# Linux/Mac:
go build -o bin/controller ./controller/cmd/controller
go build -o bin/agent ./agent/cmd/agent
go build -o bin/panelctl ./controller/cmd/panelctl
# Windows (PowerShell):
go build -o controller.exe .\controller\cmd\controller
go build -o agent.exe .\agent\cmd\agent
go build -o panelctl.exe .\controller\cmd\panelctl
```
Cross-compile a Linux agent from a Windows host:
```powershell
$env:GOOS="linux"; $env:GOARCH="amd64"
go build -o bin/agent-linux ./agent/cmd/agent
Remove-Item Env:GOOS, Env:GOARCH
```
## Working with the embedded UI
The default dashboard is split across three embedded files in
`controller/cmd/controller/static/`: `new.html` (shell), `rcc.js`
(all feature JS), `rcc.css` (design system) — plain JS + CSS, no build
step. `/rcc.js` and `/rcc.css` are served hash-stamped with immutable
cache headers, so browsers pick up new builds without cache pain. The
legacy single-file `index.html` (~24k lines) is the **frozen classic UI**
— it carries a freeze notice, is only served on `panel_ui=classic`, and
no new features land there.
- Everything is embedded via `//go:embed`, so editing any static file
requires a controller rebuild + restart.
- **Before any controller build that touched the UI**, run the parse gate:
`node controller/.test/gate-parse.mjs` — a single JS syntax error blanks
the whole dashboard and `go build` cannot catch it.
- Per-game Config tabs are keyed by module id with a small schema
(`groups[].fields[]` with `{prop, label, type, options, hint}`) that the
form renderer turns into INI/JSON read+write against the game's config
file via the file manager API.
- Module icon/art/accent + readiness regex are manifest-driven:
`appearance:` and `ready_pattern:` in `module.yaml`, served via
`GET /api/modules` (inline maps remain as fallback).
- The shared form for `config_values` (env-driven settings) is rendered
on the dashboard's *Config Values* sub-tab; this is separate from the
game-specific config-file editors above.
## Troubleshooting
- **"agent connected but no modules"** — agent's `--modules-dir` is
wrong, or the manifest failed to parse. Check the agent's stderr.
- **"create succeeded but instance never starts"** — for SteamCMD-driven
modules, the install volume is empty. Click *Update* once first; the
entrypoints exit `EX_CONFIG (78)` with a hint when they can't find
the dedicated server binary.
- **"environment variable I added to module.yaml never reaches the
container"** — you forgot to pre-declare the key under
`runtime.docker.env`. resolve.go silently filters unknown keys.
- **"dashboard broken, can't recover"** — `panelctl admin recreate
<instance-id>`. If the controller is also down, the data directory
+ Postgres dump is enough to spin up a replacement.
- **Wine modules: container stays "running" but the game never binds
ports** — usually a top-level launcher stub problem. Invoke the deep
`Binaries/Win64/<Game>Server-Win64-Shipping.exe` directly (see Conan's
entrypoint for the canonical pattern).
- **`network_mode: host` + Xvfb collision** — when two host-net
containers both run xvfb on the same display number you'll see "Server
is already active for display" in stderr. Always use `xvfb-run -a` to
auto-allocate.
The maintainer's running list of caught-on-fire-once notes lives in
[`memory/gotchas.md`](memory/gotchas.md).
## Contributing / writing a new module
1. Create `modules/<id>/module.yaml`. Easiest is to copy the closest
existing module — Source-RCON game? Start from Conan's. Wine? V
Rising's. Plain Linux dedicated server? Factorio's.
2. If you need a custom image, add a `Dockerfile` next to it. Include
`setpriv`, `xvfb-run`, and `wine` for Windows-on-Linux modules.
3. Pre-declare every env key that any `config_values` entry maps to
inside `runtime.docker.env`. Empty defaults are fine.
4. Restart the agent — modules load on boot. The new game appears in
the dashboard's create-instance dropdown.
5. Test the lifecycle end-to-end: create → update → start → connect a
client → stop → delete (no purge) → recreate → verify world saves
survived.
6. If the game has a Workshop / mods scene, add a `<game>mods.go` to
`controller/cmd/controller/` and surface it in the dashboard's per-game
Mods tab.
PRs welcome. The maintainer's editorial preferences are recorded in
[`memory/ux_rules.md`](memory/ux_rules.md) — read it before redesigning
any tab.
## License
Apache-2.0 — see [LICENSE](LICENSE).
+421
View File
@@ -0,0 +1,421 @@
package main
import (
"context"
"crypto/tls"
"crypto/x509"
"errors"
"flag"
"fmt"
"io"
"log/slog"
"math/rand/v2"
"os"
"os/signal"
"path/filepath"
"runtime"
"strings"
"syscall"
"time"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/agent/internal/dispatch"
agentruntime "github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
"github.com/dbledeez/panel/pkg/version"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// loadClientTLS returns gRPC credentials configured for mTLS against
// the controller's CA, or nil if cert files aren't present (caller then
// decides whether to refuse or fall back to plaintext). The returned CN
// is the agent identity the controller issued during pairing — callers
// should use it as the agent_id so identity stays cryptographically bound
// to the cert instead of drifting to whatever hostname the process sees.
func loadClientTLS(certDir, controllerAddr string) (credentials.TransportCredentials, string, error) {
caPath := filepath.Join(certDir, "ca.crt")
certPath := filepath.Join(certDir, "agent.crt")
keyPath := filepath.Join(certDir, "agent.key")
for _, p := range []string{caPath, certPath, keyPath} {
if _, err := os.Stat(p); err != nil {
return nil, "", fmt.Errorf("missing %s: %w", p, err)
}
}
caPEM, err := os.ReadFile(caPath)
if err != nil {
return nil, "", err
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, "", fmt.Errorf("bad CA PEM")
}
cert, err := tls.LoadX509KeyPair(certPath, keyPath)
if err != nil {
return nil, "", fmt.Errorf("load cert+key: %w", err)
}
var cn string
if len(cert.Certificate) > 0 {
if leaf, perr := x509.ParseCertificate(cert.Certificate[0]); perr == nil {
cn = leaf.Subject.CommonName
}
}
// ServerName defaults to the host part of controllerAddr. If it's
// an IP we still set ServerName so Go's TLS stack uses SNI; the
// controller cert includes 127.0.0.1 as an IP SAN so this works.
serverName := controllerAddr
if i := strings.LastIndex(serverName, ":"); i > 0 {
serverName = serverName[:i]
}
tlsCfg := &tls.Config{
Certificates: []tls.Certificate{cert},
RootCAs: pool,
ServerName: serverName,
MinVersion: tls.VersionTLS12,
}
return credentials.NewTLS(tlsCfg), cn, nil
}
// agentVersion mirrors the shared build version (ldflags-injected via
// github.com/dbledeez/panel/pkg/version.Version; "dev" in ad-hoc builds).
var agentVersion = version.Version
func hostname() string {
h, err := os.Hostname()
if err != nil || h == "" {
return "unknown"
}
return h
}
func main() {
controllerAddr := flag.String("controller", "localhost:8443", "controller gRPC address host:port")
agentID := flag.String("agent-id", "", "agent identifier (default: mTLS cert CN, or hostname when --insecure)")
modulesDir := flag.String("modules-dir", "./modules", "path to modules directory")
dataRoot := flag.String("data-root", "./data/instances", "root directory for instance data")
metaDir := flag.String("meta-dir", "./data/instance-meta", "directory for per-instance sidecar metadata (survives agent restart)")
backupDir := flag.String("backup-dir", "./data/backups", "directory where instance backup archives are written")
certDir := flag.String("cert-dir", "./data/certs", "directory holding agent.crt + agent.key + ca.crt after pairing")
heartbeatInterval := flag.Duration("heartbeat", 10*time.Second, "heartbeat interval")
insecureDial := flag.Bool("insecure", false, "force plaintext (no TLS). Use --insecure=true only for dev/local setups.")
pairToken := flag.String("pair-token", "", "if set, agent runs in pair mode: fetches CA + signed cert from --pair-url and exits")
pairURL := flag.String("pair-url", "http://localhost:8080", "controller HTTP URL for pair/CA/issue endpoints (used with --pair-token)")
logLevel := flag.String("log-level", "info", "log level: debug, info, warn, error")
flag.Parse()
var lvl slog.Level
if err := lvl.UnmarshalText([]byte(*logLevel)); err != nil {
fmt.Fprintf(os.Stderr, "invalid log level %q: %v\n", *logLevel, err)
os.Exit(2)
}
// Pair mode: fetch CA + sign a client cert, then exit. Pair runs before
// we touch the local cert so the agent-id has to come from either --agent-id
// or hostname — there's no cert CN to read yet.
if *pairToken != "" {
if *agentID == "" {
*agentID = hostname()
}
pairLogger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})).
With("agent_id", *agentID)
if err := runPair(pairConfig{
AgentID: *agentID,
PairURL: *pairURL,
Token: *pairToken,
CertDir: *certDir,
Logger: pairLogger,
}); err != nil {
pairLogger.Error("pair failed", "err", err)
os.Exit(1)
}
return
}
// TLS creds: try loading cert files; fall back if --insecure.
// The cert's CN is the canonical agent identity. If --agent-id wasn't
// set explicitly, adopt the CN; if it was set to something different,
// refuse — a mismatched identity just silently orphans instances in the
// DB (the exact bug that motivated this change).
var tlsCreds credentials.TransportCredentials
var certCN string
creds, cn, tlsErr := loadClientTLS(*certDir, *controllerAddr)
switch {
case tlsErr == nil:
tlsCreds = creds
certCN = cn
case *insecureDial:
// Plaintext path — cert CN unavailable, fall through to hostname.
default:
fmt.Fprintf(os.Stderr, "no TLS certs found and --insecure not set. Run pairing first:\n"+
" agent --pair-token <token> --pair-url http://controller:8080 --cert-dir %s\n"+
"detail: %v\n", *certDir, tlsErr)
os.Exit(2)
}
switch {
case *agentID == "" && certCN != "":
*agentID = certCN
case *agentID == "":
*agentID = hostname()
case certCN != "" && *agentID != certCN:
fmt.Fprintf(os.Stderr, "--agent-id=%q does not match mTLS cert CN=%q; refusing to start.\n"+
"Re-pair the agent with the intended id or drop --agent-id to adopt the cert CN.\n",
*agentID, certCN)
os.Exit(2)
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: lvl})).
With("agent_id", *agentID)
if tlsCreds != nil {
logger.Info("mTLS enabled", "cert_dir", *certDir, "cert_cn", certCN)
} else {
logger.Warn("running WITHOUT TLS (--insecure) — agent traffic is plaintext")
}
absDataRoot, err := filepath.Abs(*dataRoot)
if err != nil {
logger.Error("resolve data-root", "err", err)
os.Exit(1)
}
if err := os.MkdirAll(absDataRoot, 0o755); err != nil {
logger.Error("mkdir data-root", "err", err)
os.Exit(1)
}
absMetaDir, err := filepath.Abs(*metaDir)
if err != nil {
logger.Error("resolve meta-dir", "err", err)
os.Exit(1)
}
if err := os.MkdirAll(absMetaDir, 0o755); err != nil {
logger.Error("mkdir meta-dir", "err", err)
os.Exit(1)
}
absBackupDir, err := filepath.Abs(*backupDir)
if err != nil {
logger.Error("resolve backup-dir", "err", err)
os.Exit(1)
}
if err := os.MkdirAll(absBackupDir, 0o755); err != nil {
logger.Error("mkdir backup-dir", "err", err)
os.Exit(1)
}
registry := modulepkg.NewRegistry()
if err := registry.LoadDir(*modulesDir); err != nil {
logger.Warn("module registry load failed", "dir", *modulesDir, "err", err)
}
logger.Info("modules loaded", "count", registry.Len(), "dir", *modulesDir)
for _, m := range registry.List() {
logger.Info("module registered", "id", m.ID, "name", m.Name, "version", m.Version)
}
dockerRT, err := agentruntime.NewDocker()
if err != nil {
logger.Error("docker runtime init", "err", err)
os.Exit(1)
}
defer dockerRT.Close()
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
if err := dockerRT.Ping(ctx); err != nil {
logger.Warn("docker daemon not reachable; instance commands will fail", "err", err)
} else {
logger.Info("docker runtime ready")
}
disp := dispatch.New(logger, registry, dockerRT, absDataRoot, absMetaDir, absBackupDir)
if err := disp.Rehydrate(ctx); err != nil {
logger.Warn("rehydrate failed (agent will start fresh)", "err", err)
}
// Reconnect loop: keep dialing the controller until ctx is cancelled.
// A session ends either because ctx was cancelled (operator signal)
// or because the stream died (controller blip, network, etc.).
backoff := 2 * time.Second
const maxBackoff = 30 * time.Second
for {
if ctx.Err() != nil {
break
}
err := runSession(ctx, logger, *controllerAddr, *agentID, *heartbeatInterval, disp, tlsCreds)
if ctx.Err() != nil {
break
}
logger.Warn("session ended, will reconnect", "err", err, "in", backoff)
select {
case <-ctx.Done():
case <-time.After(backoff + jitter(backoff)):
}
if backoff < maxBackoff {
backoff *= 2
if backoff > maxBackoff {
backoff = maxBackoff
}
}
}
logger.Info("operator signal — gracefully stopping running instances")
disp.Shutdown(context.Background())
}
// runSession opens one gRPC connection, handshakes, (re)announces current
// instance state, then runs the recv/send loops until the stream ends.
// Returns nil if the operator signalled shutdown, or an error if the
// connection broke.
func runSession(ctx context.Context, logger *slog.Logger, addr, agentID string, hb time.Duration, disp *dispatch.Dispatcher, tlsCreds credentials.TransportCredentials) error {
var dialOpt grpc.DialOption
if tlsCreds != nil {
dialOpt = grpc.WithTransportCredentials(tlsCreds)
} else {
dialOpt = grpc.WithTransportCredentials(insecure.NewCredentials())
}
// Lift the default 4 MiB per-message cap so big file uploads
// (FsWrite carrying mod zips, scenario archives, save backups)
// flow through end-to-end. Matches the controller-side cap.
const grpcMaxMsgSize = 5 * 1024 * 1024 * 1024
conn, err := grpc.NewClient(addr, dialOpt,
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(grpcMaxMsgSize),
grpc.MaxCallSendMsgSize(grpcMaxMsgSize),
),
)
if err != nil {
return fmt.Errorf("grpc client: %w", err)
}
defer conn.Close()
client := panelv1.NewAgentClient(conn)
logger.Info("connecting to controller", "addr", addr)
stream, err := client.Connect(ctx)
if err != nil {
return fmt.Errorf("open stream: %w", err)
}
hello := &panelv1.AgentEnvelope{
CorrelationId: "hello",
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Hello{
Hello: &panelv1.AgentHello{
AgentId: agentID,
AgentVersion: agentVersion,
HostOs: runtime.GOOS,
HostArch: runtime.GOARCH,
Hostname: hostname(),
SupportedRuntimes: []string{"docker"},
Modules: summarizeModules(disp),
},
},
}
if err := stream.Send(hello); err != nil {
return fmt.Errorf("send hello: %w", err)
}
resp, err := stream.Recv()
if err != nil {
return fmt.Errorf("recv hello: %w", err)
}
ch := resp.GetHello()
if ch == nil {
return fmt.Errorf("unexpected first message: %T", resp.Payload)
}
logger.Info("handshake complete", "controller_version", ch.ControllerVersion)
// Install the current stream as the upward sender and re-announce.
disp.SetSender(stream)
disp.Announce(ctx)
// Drop the sender before we return so no one sends into a dead stream.
defer disp.SetSender(nil)
recvDone := make(chan struct{})
recvErr := make(chan error, 1)
go func() {
defer close(recvDone)
for {
env, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
logger.Info("controller closed stream")
recvErr <- nil
} else if ctx.Err() == nil {
recvErr <- err
} else {
recvErr <- nil
}
return
}
// Dispatch each envelope in its OWN goroutine so a slow/blocked
// handler can't freeze the recv loop (and thus the whole agent).
// This was the Valheim+stdio hang: an RCON write to a wedged stdin
// pipe blocked here forever, so the subsequent stop never got read.
// Safe to parallelize: handlers guard shared state with d.mu, and the
// controller serializes dependent ops (create→start) via its own
// correlation-id awaits, so it never sends a dependent op before the
// prior reply arrives.
go disp.Handle(ctx, env)
}
}()
ticker := time.NewTicker(hb)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
_ = stream.CloseSend()
<-recvDone
return nil
case <-recvDone:
return <-recvErr
case t := <-ticker.C:
// Heartbeats go through the dispatcher's send queue (droppable)
// so sendLoop stays the sole owner of stream.Send — sending here
// directly raced the drainer goroutine on the same gRPC stream.
// A dead stream is detected by the recv goroutine (stream.Recv
// errors → recvDone), not by a failed heartbeat send.
disp.SendHeartbeat(t)
}
}
}
// jitter returns up to ±25% of d as a random offset, so N agents don't
// synchronize their reconnects against a restarting controller.
func jitter(d time.Duration) time.Duration {
if d <= 0 {
return 0
}
half := int64(d / 4)
return time.Duration(rand.Int64N(2*half+1) - half)
}
// summarizeModules reduces the agent's loaded manifests to the small
// ModuleSummary record the controller caches for UI picking.
func summarizeModules(disp *dispatch.Dispatcher) []*panelv1.ModuleSummary {
mods := disp.Modules().List()
out := make([]*panelv1.ModuleSummary, 0, len(mods))
for _, m := range mods {
providers := make([]*panelv1.ModuleUpdateProviderSummary, 0, len(m.UpdateProviders))
for _, p := range m.UpdateProviders {
providers = append(providers, &panelv1.ModuleUpdateProviderSummary{
Id: p.ID,
Kind: p.Kind,
RequiresSteamLogin: p.RequiresSteamLogin,
})
}
out = append(out, &panelv1.ModuleSummary{
Id: m.ID,
Name: m.Name,
Version: m.Version,
SupportedModes: m.SupportedModes,
UpdateProviders: providers,
HasRcon: m.RCON != nil,
Authors: m.Authors,
})
}
return out
}
+172
View File
@@ -0,0 +1,172 @@
package main
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// Pairing flow (agent side):
//
// 1. Fetch the controller's CA cert over HTTPS (with InsecureSkipVerify —
// this is the only moment we're exposed; the one-time token gates
// the actual cert issuance below).
// 2. Generate an RSA-2048 keypair locally. The private key never
// leaves this host.
// 3. Build a CSR with CN = agent-id.
// 4. POST {token, agent_id, csr_pem} to /api/pair/issue, now verifying
// the server cert against the CA we just fetched.
// 5. Persist ca.crt + agent.crt + agent.key to --cert-dir, mode 0600 on the key.
//
// After pairing, run the agent normally; it auto-detects the cert files
// and connects to the controller with mTLS.
type pairConfig struct {
AgentID string
PairURL string // e.g. https://controller:8080 — the HTTP dashboard port
Token string
CertDir string
Logger *slog.Logger
}
func runPair(cfg pairConfig) error {
cfg.Logger.Info("pair: starting", "pair_url", cfg.PairURL, "agent_id", cfg.AgentID, "cert_dir", cfg.CertDir)
if err := os.MkdirAll(cfg.CertDir, 0o755); err != nil {
return fmt.Errorf("mkdir cert-dir: %w", err)
}
// Step 1 — fetch CA with InsecureSkipVerify, then verify with it.
caPEM, err := fetchCA(cfg.PairURL, cfg.Logger)
if err != nil {
return fmt.Errorf("fetch CA: %w", err)
}
caPath := filepath.Join(cfg.CertDir, "ca.crt")
if err := os.WriteFile(caPath, caPEM, 0o644); err != nil {
return fmt.Errorf("write ca.crt: %w", err)
}
cfg.Logger.Info("pair: CA saved", "path", caPath, "bytes", len(caPEM))
// Step 2 — keypair + CSR
priv, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("gen key: %w", err)
}
tpl := &x509.CertificateRequest{
Subject: pkix.Name{CommonName: cfg.AgentID, Organization: []string{"panel-agent"}},
DNSNames: []string{cfg.AgentID},
}
csrDER, err := x509.CreateCertificateRequest(rand.Reader, tpl, priv)
if err != nil {
return fmt.Errorf("create CSR: %w", err)
}
csrPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE REQUEST", Bytes: csrDER})
// Step 3 — POST to /api/pair/issue, verifying CA.
certPEM, err := issueCert(cfg.PairURL, caPEM, cfg.Token, cfg.AgentID, csrPEM, cfg.Logger)
if err != nil {
return fmt.Errorf("issue cert: %w", err)
}
// Step 4 — persist.
if err := os.WriteFile(filepath.Join(cfg.CertDir, "agent.crt"), certPEM, 0o644); err != nil {
return err
}
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(priv)})
if err := os.WriteFile(filepath.Join(cfg.CertDir, "agent.key"), keyPEM, 0o600); err != nil {
return err
}
cfg.Logger.Info("pair: cert issued", "cert_dir", cfg.CertDir, "agent_id", cfg.AgentID)
return nil
}
func fetchCA(baseURL string, log *slog.Logger) ([]byte, error) {
trimmed := strings.TrimSuffix(baseURL, "/")
url := trimmed + "/api/pair/ca"
log.Info("pair: fetching CA", "url", url)
// Until we have the CA we can't verify the server. InsecureSkipVerify
// is narrowly scoped to this one request — the cert-issuance POST
// below verifies against the CA we just got.
cli := &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
resp, err := cli.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(body))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return nil, err
}
block, _ := pem.Decode(data)
if block == nil || block.Type != "CERTIFICATE" {
return nil, fmt.Errorf("response isn't a CERTIFICATE PEM")
}
if _, err := x509.ParseCertificate(block.Bytes); err != nil {
return nil, fmt.Errorf("parse CA cert: %w", err)
}
return data, nil
}
func issueCert(baseURL string, caPEM []byte, token, agentID string, csrPEM []byte, log *slog.Logger) ([]byte, error) {
trimmed := strings.TrimSuffix(baseURL, "/")
url := trimmed + "/api/pair/issue"
log.Info("pair: requesting cert", "url", url, "agent_id", agentID)
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(caPEM) {
return nil, fmt.Errorf("append CA to pool failed")
}
cli := &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}},
}
body, _ := json.Marshal(map[string]string{
"token": token,
"agent_id": agentID,
"csr_pem": string(csrPEM),
})
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := cli.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return nil, fmt.Errorf("http %d: %s", resp.StatusCode, string(errBody))
}
var out struct {
CertPEM string `json:"cert_pem"`
CAPEM string `json:"ca_pem"`
Subject string `json:"subject"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return nil, err
}
if out.CertPEM == "" {
return nil, fmt.Errorf("response missing cert_pem")
}
return []byte(out.CertPEM), nil
}
+367
View File
@@ -0,0 +1,367 @@
package main
import (
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/dbledeez/panel/pkg/regionmedic"
)
// region-medic autoheal — the auto-on-reboot flow, designed to run (in a sidecar)
// while the 7DTD server is STOPPED, just before the agent starts it:
//
// 1. Validate EVERY .7rg in the active world's Region dir (catches whole-file
// corruption like a bad magic header, which has no error_backups and so is
// invisible to the error_backup-cluster scan).
// 2. Heal each corrupt region from the newest source that has a validated-clean
// copy: the rolling folder snapshots first (fast, local), then the panel's
// tar backups as a fallback. Corrupt originals are quarantined first.
// 3. Snapshot the now-clean Region dir to a fresh rolling slot (real folder
// copy — instant restore, no unzip) and rotate to keep the newest -keep.
//
// Emits a JSON report the agent relays to the controller for the Discord ping.
//
// region-medic autoheal -region-dir D -rolling-dir R [-backups-dir B] [-keep 2] [-apply]
//
// Without -apply it only validates + reports (dry run: no heal, no snapshot).
type ahHeal struct {
Region string `json:"region"` // r.X.Z
Source string `json:"source"` // "rolling:<slot>" | "backup:<id>"
SourceTime string `json:"source_time"` // RFC3339 of the snapshot/backup
}
type ahReport struct {
RegionDir string `json:"region_dir"`
TotalRegions int `json:"total_regions"`
CorruptFound int `json:"corrupt_found"`
Healed []ahHeal `json:"healed"`
Unhealable []string `json:"unhealable"`
SnapshotSlot string `json:"snapshot_slot"`
RollingKept int `json:"rolling_kept"`
Applied bool `json:"applied"`
DurationMs int64 `json:"duration_ms"`
}
func cmdAutoheal(args []string) error {
var regionDir, rollingDir, backupsDir, savesRoot, discordChannel, discordTokenFile, label string
keep := 2
apply := false
for i := 0; i < len(args); i++ {
switch args[i] {
case "-region-dir":
i++
if i < len(args) {
regionDir = args[i]
}
case "-saves-root":
i++
if i < len(args) {
savesRoot = args[i]
}
case "-rolling-dir":
i++
if i < len(args) {
rollingDir = args[i]
}
case "-backups-dir":
i++
if i < len(args) {
backupsDir = args[i]
}
case "-keep":
i++
if i < len(args) {
keep, _ = strconv.Atoi(args[i])
}
case "-discord-channel":
i++
if i < len(args) {
discordChannel = args[i]
}
case "-discord-token-file":
i++
if i < len(args) {
discordTokenFile = args[i]
}
case "-label":
i++
if i < len(args) {
label = args[i]
}
case "-apply", "--apply":
apply = true
default:
return fmt.Errorf("unknown flag %q", args[i])
}
}
// -saves-root auto-resolves the active world's Region dir (so the agent
// doesn't have to know GameWorld/GameName, and RWG seed-named worlds work).
// Per-server config lives in the saves volume (panel writes it via FsWrite,
// no recreate): world pin + keep + Discord channel; enabled:false skips.
var cfgWorld string
if savesRoot != "" {
if c, ok := readSavesMedicConfig(savesRoot); ok {
if c.Enabled != nil && !*c.Enabled {
fmt.Fprintln(os.Stderr, "autoheal: disabled via region-medic.json, skipping")
return nil
}
if c.Keep > 0 {
keep = c.Keep
}
if c.DiscordChannel != "" {
discordChannel = c.DiscordChannel
}
cfgWorld = strings.TrimSpace(c.World)
}
}
// Resolve the Region dir. An explicit world pin wins over auto-detect — some
// servers carry multiple saves, so the operator picks which one to heal. If
// the pinned world has no Region dir we SKIP rather than silently auto-pick a
// different world the operator didn't choose.
if regionDir == "" && savesRoot != "" {
if cfgWorld != "" {
wd := filepath.Join(savesRoot, ".local/share/7DaysToDie/Saves", cfgWorld, "Region")
if st, err := os.Stat(wd); err != nil || !st.IsDir() {
fmt.Fprintf(os.Stderr, "autoheal: pinned world %q has no Region dir at %s — skipping\n", cfgWorld, wd)
return nil
}
regionDir = wd
} else {
rd, rerr := resolveRegionDir(savesRoot)
if rerr != nil {
return rerr
}
regionDir = rd
}
}
if regionDir == "" || rollingDir == "" {
return fmt.Errorf("required: -region-dir (or -saves-root) and -rolling-dir (and -backups-dir for tar fallback)")
}
if keep < 1 {
keep = 1
}
start := time.Now()
rep := ahReport{RegionDir: regionDir, RollingKept: keep, Applied: apply, Healed: []ahHeal{}, Unhealable: []string{}}
// 1. Validate every .7rg.
ents, err := os.ReadDir(regionDir)
if err != nil {
return fmt.Errorf("read region dir: %w", err)
}
var corrupt []regionmedic.RegionCoord
for _, e := range ents {
if e.IsDir() {
continue
}
rc, ok := regionmedic.ParseRegionFileName(e.Name())
if !ok {
continue
}
rep.TotalRegions++
if !regionmedic.ValidateRegionFile(filepath.Join(regionDir, e.Name()), false).Healthy() {
corrupt = append(corrupt, rc)
}
}
rep.CorruptFound = len(corrupt)
// 2. Heal (only with -apply).
quarantineDir := filepath.Join(rollingDir, "_quarantine")
if apply && len(corrupt) > 0 {
slots := listRollingSlots(rollingDir) // newest first
var panelBackups []regionmedic.BackupRef
if backupsDir != "" {
panelBackups, _ = regionmedic.ListPanelBackups(backupsDir)
}
stamp := start.UTC().Format("20060102-150405")
for _, rc := range corrupt {
fn := rc.FileName()
done := false
// 2a. newest rolling snapshot with a clean copy.
for _, s := range slots {
cand := filepath.Join(s.path, fn)
if fi, e := os.Stat(cand); e == nil && !fi.IsDir() &&
regionmedic.ValidateRegionFile(cand, false).Healthy() {
if e := healFromFile(regionDir, fn, cand, quarantineDir, stamp); e == nil {
rep.Healed = append(rep.Healed, ahHeal{Region: rc.String(), Source: "rolling:" + filepath.Base(s.path), SourceTime: s.t.UTC().Format(time.RFC3339)})
done = true
break
}
}
}
// 2b. panel tar backups fallback.
if !done && len(panelBackups) > 0 {
entry := regionmedic.SaveRelEntry(regionDir, fn)
best, _, _ := regionmedic.FindLastGoodRegion(panelBackups, entry, false, time.Time{})
if best.Found && best.Clean() {
if e := healFromBytes(regionDir, fn, best.Bytes, quarantineDir, stamp); e == nil {
rep.Healed = append(rep.Healed, ahHeal{Region: rc.String(), Source: "backup:" + best.Backup.ID, SourceTime: best.Backup.CreatedAt.UTC().Format(time.RFC3339)})
done = true
}
}
}
if !done {
rep.Unhealable = append(rep.Unhealable, rc.String())
}
}
}
// 3. Snapshot the now-clean Region dir to a fresh rolling slot + rotate.
if apply {
slotName := fmt.Sprintf("slot-%d", start.Unix())
if err := snapshotRegions(regionDir, filepath.Join(rollingDir, slotName)); err != nil {
rep.SnapshotSlot = "ERROR: " + err.Error()
} else {
rep.SnapshotSlot = slotName
rotateRollingSlots(rollingDir, keep)
}
}
rep.DurationMs = time.Since(start).Milliseconds()
b, _ := json.MarshalIndent(rep, "", " ")
fmt.Println(string(b))
fmt.Fprintf(os.Stderr, "autoheal: %d regions, %d corrupt, %d healed, %d unhealable, snapshot=%s (%dms)\n",
rep.TotalRegions, rep.CorruptFound, len(rep.Healed), len(rep.Unhealable), rep.SnapshotSlot, rep.DurationMs)
// Discord alert — only when we actually healed something on this reboot.
if apply {
if derr := postDiscordHealReport(discordTokenFile, discordChannel, label, rep); derr != nil {
fmt.Fprintf(os.Stderr, "discord post failed: %v\n", derr)
} else if discordChannel != "" && len(rep.Healed) > 0 {
fmt.Fprintf(os.Stderr, "discord: posted heal embed to channel %s\n", discordChannel)
}
}
return nil
}
type rollingSlot struct {
path string
t time.Time
}
// listRollingSlots returns slot dirs named "slot-<unix>" newest first.
func listRollingSlots(dir string) []rollingSlot {
ents, err := os.ReadDir(dir)
if err != nil {
return nil
}
var out []rollingSlot
for _, e := range ents {
if !e.IsDir() || !strings.HasPrefix(e.Name(), "slot-") {
continue
}
secs, err := strconv.ParseInt(strings.TrimPrefix(e.Name(), "slot-"), 10, 64)
if err != nil {
continue
}
out = append(out, rollingSlot{path: filepath.Join(dir, e.Name()), t: time.Unix(secs, 0)})
}
sort.Slice(out, func(i, j int) bool { return out[i].t.After(out[j].t) })
return out
}
// rotateRollingSlots keeps the newest `keep` slot dirs, removing older ones.
func rotateRollingSlots(dir string, keep int) {
for i, s := range listRollingSlots(dir) {
if i >= keep {
_ = os.RemoveAll(s.path)
}
}
}
// snapshotRegions copies every *.7rg from regionDir into slotPath.
func snapshotRegions(regionDir, slotPath string) error {
if err := os.MkdirAll(slotPath, 0o755); err != nil {
return err
}
ents, err := os.ReadDir(regionDir)
if err != nil {
return err
}
for _, e := range ents {
if e.IsDir() || !strings.HasSuffix(e.Name(), ".7rg") {
continue
}
if err := copyFile(filepath.Join(regionDir, e.Name()), filepath.Join(slotPath, e.Name())); err != nil {
return fmt.Errorf("copy %s: %w", e.Name(), err)
}
}
return nil
}
// healFromFile quarantines the corrupt original then overwrites the live region
// file in place with a validated-clean source file.
func healFromFile(regionDir, fn, srcPath, quarantineDir, stamp string) error {
_ = os.MkdirAll(quarantineDir, 0o755)
live := filepath.Join(regionDir, fn)
_ = copyFile(live, filepath.Join(quarantineDir, fn+".corrupt-"+stamp))
return copyFileInPlace(srcPath, live)
}
// healFromBytes is the same but the clean source is in-memory (a tar backup).
func healFromBytes(regionDir, fn string, data []byte, quarantineDir, stamp string) error {
_ = os.MkdirAll(quarantineDir, 0o755)
live := filepath.Join(regionDir, fn)
_ = copyFile(live, filepath.Join(quarantineDir, fn+".corrupt-"+stamp))
tmp := live + ".medic-new"
if err := os.WriteFile(tmp, data, 0o644); err != nil {
return err
}
return os.Rename(tmp, live)
}
// copyFile copies src→dst atomically (temp+rename), same filesystem assumed for
// the rename target dir. Used for snapshots + quarantine (rolling dir).
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".tmp-copy"
out, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}
// copyFileInPlace overwrites dst with src via a temp file in dst's own dir, so
// the rename is same-filesystem even when src lives elsewhere (a rolling slot).
func copyFileInPlace(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
tmp := dst + ".medic-new"
out, err := os.Create(tmp)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
os.Remove(tmp)
return err
}
if err := out.Close(); err != nil {
os.Remove(tmp)
return err
}
return os.Rename(tmp, dst)
}
+200
View File
@@ -0,0 +1,200 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
_ "time/tzdata" // embed the IANA tz database so America/Los_Angeles resolves
// even in debian:12-slim, which ships no /usr/share/zoneinfo.
)
// resolveRegionDir finds the active world's Region dir under a 7DTD saves root
// (the dir mounted as the saves volume — holds serverconfig.xml + .local/...).
// Prefers serverconfig GameWorld/GameName when that world has real data
// (main.ttw); else falls back to the most-recently-written Saves/*/<GameName>
// with data — handles RWG seed-named worlds.
func resolveRegionDir(savesRoot string) (string, error) {
raw, err := os.ReadFile(filepath.Join(savesRoot, "serverconfig.xml"))
if err != nil {
return "", fmt.Errorf("read serverconfig.xml: %w", err)
}
gw := xmlProp(string(raw), "GameWorld")
gn := xmlProp(string(raw), "GameName")
base := filepath.Join(savesRoot, ".local", "share", "7DaysToDie", "Saves")
hasWorld := func(d string) bool {
if _, e := os.Stat(filepath.Join(d, "main.ttw")); e == nil {
return true
}
fi, e := os.Stat(filepath.Join(d, "Region"))
return e == nil && fi.IsDir()
}
if gw != "" && gn != "" {
lit := filepath.Join(base, gw, gn)
if hasWorld(lit) {
return filepath.Join(lit, "Region"), nil
}
}
if gn != "" { // RWG fallback: newest world dir with this GameName.
worlds, _ := os.ReadDir(base)
var newest string
var newestT time.Time
for _, w := range worlds {
if !w.IsDir() {
continue
}
cand := filepath.Join(base, w.Name(), gn)
if !hasWorld(cand) {
continue
}
if fi, e := os.Stat(cand); e == nil && fi.ModTime().After(newestT) {
newestT, newest = fi.ModTime(), cand
}
}
if newest != "" {
return filepath.Join(newest, "Region"), nil
}
}
return "", fmt.Errorf("could not resolve active Region dir (GameWorld=%q GameName=%q)", gw, gn)
}
// xmlProp pulls the value from <property name="X" value="Y" /> (tolerant of
// attribute order + quote style; skips comments).
func xmlProp(content, name string) string {
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "<!--") {
continue
}
if !strings.Contains(line, `name="`+name+`"`) {
continue
}
i := strings.Index(line, "value=")
if i < 0 {
continue
}
rest := line[i+len("value="):]
if len(rest) < 1 {
continue
}
q := rest[0]
rest = rest[1:]
if end := strings.IndexByte(rest, q); end >= 0 {
return strings.TrimSpace(rest[:end])
}
}
return ""
}
// postDiscordHealReport posts a rich embed heal summary to a Discord channel via
// a bot token read from tokenFile. No-op when token/channel missing or nothing
// healed. All times are rendered in Pacific (America/Los_Angeles → PST/PDT).
func postDiscordHealReport(tokenFile, channel, label string, rep ahReport) error {
if tokenFile == "" || channel == "" || len(rep.Healed) == 0 {
return nil
}
tb, err := os.ReadFile(tokenFile)
if err != nil {
return fmt.Errorf("read discord token: %w", err)
}
token := strings.TrimSpace(string(tb))
if token == "" {
return nil
}
if label == "" {
label = "7DTD server"
}
loc, lerr := time.LoadLocation("America/Los_Angeles")
if lerr != nil || loc == nil {
loc = time.UTC
}
pst := func(rfc string) string {
if t, e := time.Parse(time.RFC3339, rfc); e == nil {
return t.In(loc).Format("Jan 2, 3:04 PM MST")
}
return rfc
}
srcLabel := func(s string) string {
switch {
case strings.HasPrefix(s, "rolling:"):
return "rolling snapshot"
case strings.HasPrefix(s, "backup:"):
return "panel backup"
default:
return s
}
}
var lines []string
for _, h := range rep.Healed {
lines = append(lines, fmt.Sprintf("• `%s` ← %s _(saved %s)_", h.Region, srcLabel(h.Source), pst(h.SourceTime)))
}
color := 0x57F287 // green — everything healed
fields := []map[string]any{{
"name": fmt.Sprintf("✅ Healed %d region(s)", len(rep.Healed)),
"value": strings.Join(lines, "\n"),
"inline": false,
}}
if len(rep.Unhealable) > 0 {
color = 0xED4245 // red — some couldn't be healed
fields = append(fields, map[string]any{
"name": fmt.Sprintf("⚠️ Could NOT heal %d (no clean copy anywhere)", len(rep.Unhealable)),
"value": "`" + strings.Join(rep.Unhealable, "`, `") + "`",
"inline": false,
})
}
nowPST := time.Now().In(loc).Format("Mon Jan 2, 3:04 PM MST")
embed := map[string]any{
"title": "🩺 Region Medic — " + label,
"description": fmt.Sprintf("Detected and repaired **%d** corrupt region file(s) on reboot, before the world loaded.", rep.CorruptFound),
"color": color,
"fields": fields,
"footer": map[string]any{"text": "Region Medic · " + nowPST},
}
body, _ := json.Marshal(map[string]any{"embeds": []any{embed}})
req, err := http.NewRequest(http.MethodPost, "https://discord.com/api/v10/channels/"+channel+"/messages", bytes.NewReader(body))
if err != nil {
return err
}
req.Header.Set("Authorization", "Bot "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", "RefugeBot-RegionMedic (https://refugegaming.org, 1.0)")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("discord HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(b)))
}
return nil
}
// savesMedicConfig is the per-server medic config the panel writes (via FsWrite,
// no container recreate) into the saves volume at region-medic.json. Enabled is a
// pointer so an absent field defaults to ON.
type savesMedicConfig struct {
Enabled *bool `json:"enabled"`
Keep int `json:"keep"`
World string `json:"world"` // "<World>/<GameName>"; empty = auto-detect active world
DiscordChannel string `json:"discord_channel"`
}
// readSavesMedicConfig reads <savesRoot>/region-medic.json; ok=false if absent or
// unparseable (the caller then keeps its flag defaults).
func readSavesMedicConfig(savesRoot string) (savesMedicConfig, bool) {
b, err := os.ReadFile(filepath.Join(savesRoot, "region-medic.json"))
if err != nil {
return savesMedicConfig{}, false
}
var c savesMedicConfig
if json.Unmarshal(b, &c) != nil {
return savesMedicConfig{}, false
}
return c, true
}
+373
View File
@@ -0,0 +1,373 @@
// Command region-medic is a standalone operator tool that detects and heals
// corrupted 7DTD ".7rg" region files from the panel's own backups, using the
// shared github.com/dbledeez/panel/pkg/regionmedic engine.
//
// It is the same logic that ships inside the agent's RegionScan/RegionHeal RPCs,
// exposed as a CLI so it can be run by hand against a stopped instance without
// redeploying the controller. Subcommands:
//
// region-medic validate <file.7rg> [-deep]
// region-medic scan <region-dir>
// region-medic plan -region-dir D -region r.X.Z -backups-dir B [-deep] [-not-after RFC3339]
// region-medic heal -region-dir D -region r.X.Z -backups-dir B -apply
// [-deep] [-not-after RFC3339] -snapshot-dir S -quarantine-dir Q
// [-controller URL -instance ID] # stop before / start after (panel-safe)
// [-container NAME] # post-start: watch for ready + new corruption
// region-medic verify <region-dir> # count error_backups now
//
// stop/start go through the controller HTTP API (never raw docker), so panel
// state stays correct; readiness/state polling reads docker locally.
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"os/exec"
"strings"
"time"
"github.com/dbledeez/panel/pkg/regionmedic"
)
func main() {
if len(os.Args) < 2 {
usage()
os.Exit(2)
}
var err error
switch os.Args[1] {
case "validate":
err = cmdValidate(os.Args[2:])
case "scan":
err = cmdScan(os.Args[2:])
case "plan":
err = cmdHeal(os.Args[2:], false)
case "heal":
err = cmdHeal(os.Args[2:], true)
case "verify":
err = cmdVerify(os.Args[2:])
case "autoheal":
err = cmdAutoheal(os.Args[2:])
case "-h", "--help", "help":
usage()
return
default:
fmt.Fprintf(os.Stderr, "unknown subcommand %q\n", os.Args[1])
usage()
os.Exit(2)
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
os.Exit(1)
}
}
func usage() {
fmt.Fprint(os.Stderr, `region-medic — detect & heal corrupt 7DTD .7rg regions from panel backups
region-medic validate <file.7rg> [-deep]
region-medic scan <region-dir>
region-medic plan -region-dir D -region r.X.Z -backups-dir B [-deep] [-not-after RFC3339]
region-medic heal -region-dir D -region r.X.Z -backups-dir B -apply [-deep]
[-not-after RFC3339] -snapshot-dir S -quarantine-dir Q
[-controller URL -instance ID] [-container NAME]
region-medic verify <region-dir>
`)
}
func printJSON(v any) {
b, _ := json.MarshalIndent(v, "", " ")
fmt.Println(string(b))
}
// --- validate ---------------------------------------------------------------
func cmdValidate(args []string) error {
deep := false
var file string
for _, a := range args {
if a == "-deep" || a == "--deep" {
deep = true
} else if !strings.HasPrefix(a, "-") {
file = a
}
}
if file == "" {
return fmt.Errorf("usage: region-medic validate <file.7rg> [-deep]")
}
rep := regionmedic.ValidateRegionFile(file, deep)
printJSON(rep)
if rep.Healthy() {
fmt.Fprintf(os.Stderr, "HEALTHY: %d present chunks, %d sectors\n", rep.PresentChunks, rep.TotalSectors)
} else {
fmt.Fprintf(os.Stderr, "UNHEALTHY: err=%q badChunks=%d\n", rep.Err, len(rep.BadChunks))
}
return nil
}
// --- scan -------------------------------------------------------------------
func cmdScan(args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: region-medic scan <region-dir>")
}
scan, err := regionmedic.ScanRegionDir(args[0])
if err != nil {
return err
}
printJSON(scan)
fmt.Fprintf(os.Stderr, "%d error_backups across %d affected region(s)\n", scan.ErrorBackups, len(scan.Affected))
return nil
}
// --- verify -----------------------------------------------------------------
func cmdVerify(args []string) error {
if len(args) < 1 {
return fmt.Errorf("usage: region-medic verify <region-dir>")
}
scan, err := regionmedic.ScanRegionDir(args[0])
if err != nil {
return err
}
fmt.Fprintf(os.Stderr, "error_backups now: %d (affected regions: %d)\n", scan.ErrorBackups, len(scan.Affected))
printJSON(scan.Affected)
return nil
}
// --- plan / heal ------------------------------------------------------------
type healFlags struct {
regionDir, region, backupsDir string
snapshotDir, quarantineDir string
deep, apply bool
notAfter string
controller, instance, container string
}
func parseHealFlags(args []string) (healFlags, error) {
f := healFlags{}
for i := 0; i < len(args); i++ {
a := args[i]
next := func() string {
if i+1 < len(args) {
i++
return args[i]
}
return ""
}
switch a {
case "-region-dir":
f.regionDir = next()
case "-region":
f.region = next()
case "-backups-dir":
f.backupsDir = next()
case "-snapshot-dir":
f.snapshotDir = next()
case "-quarantine-dir":
f.quarantineDir = next()
case "-not-after":
f.notAfter = next()
case "-controller":
f.controller = next()
case "-instance":
f.instance = next()
case "-container":
f.container = next()
case "-deep", "--deep":
f.deep = true
case "-apply", "--apply":
f.apply = true
default:
return f, fmt.Errorf("unknown flag %q", a)
}
}
if f.regionDir == "" || f.region == "" || f.backupsDir == "" {
return f, fmt.Errorf("required: -region-dir, -region, -backups-dir")
}
return f, nil
}
func cmdHeal(args []string, allowApply bool) error {
f, err := parseHealFlags(args)
if err != nil {
return err
}
region, ok := regionmedic.ParseRegionFileName(regionArg(f.region))
if !ok {
return fmt.Errorf("bad -region %q (want r.X.Z or r.X.Z.7rg)", f.region)
}
var notAfter time.Time
if f.notAfter != "" {
notAfter, err = time.Parse(time.RFC3339, f.notAfter)
if err != nil {
return fmt.Errorf("bad -not-after: %w", err)
}
}
backups, err := regionmedic.ListPanelBackups(f.backupsDir)
if err != nil {
return fmt.Errorf("list backups: %w", err)
}
fmt.Fprintf(os.Stderr, "found %d backups in %s\n", len(backups), f.backupsDir)
plan, err := regionmedic.PlanHeal(f.regionDir, region, backups, f.deep, notAfter)
if err != nil {
return err
}
summarizePlan(plan)
if !allowApply || !f.apply {
fmt.Fprintln(os.Stderr, "(dry run — re-run `heal ... -apply` to execute)")
return nil
}
if !plan.Ready() {
return fmt.Errorf("refusing to heal: no clean backup copy found for %s", region)
}
ctx := context.Background()
// Orchestrate stop (panel-safe) if controller configured.
if f.controller != "" && f.instance != "" {
fmt.Fprintf(os.Stderr, "stopping %s via panel...\n", f.instance)
if err := panelAction(ctx, f.controller, f.instance, "stop"); err != nil {
return fmt.Errorf("panel stop: %w", err)
}
if f.container != "" {
if err := waitContainer(ctx, f.container, false, 90*time.Second); err != nil {
return fmt.Errorf("wait stopped: %w", err)
}
} else {
time.Sleep(8 * time.Second)
}
}
snapDir := orDefault(f.snapshotDir, f.regionDir+"/_medic_snapshots")
qDir := orDefault(f.quarantineDir, f.regionDir+"/_medic_quarantine")
res, err := regionmedic.ApplyHeal(plan, snapDir, qDir)
if err != nil {
return fmt.Errorf("apply heal: %w", err)
}
fmt.Fprintf(os.Stderr, "HEALED %s: wrote %d bytes from %s; snapshot=%s; quarantined=%d\n",
res.Region, res.BytesWritten, plan.Source.Backup.ID, res.SnapshotPath, len(res.QuarantinedFiles))
printJSON(res)
// Start back up (panel-safe) + verify.
if f.controller != "" && f.instance != "" {
fmt.Fprintf(os.Stderr, "starting %s via panel...\n", f.instance)
if err := panelAction(ctx, f.controller, f.instance, "start"); err != nil {
return fmt.Errorf("panel start: %w", err)
}
if f.container != "" {
fmt.Fprintln(os.Stderr, "watching boot for readiness + new corruption...")
if err := watchBoot(ctx, f.container, f.regionDir, region, 240*time.Second); err != nil {
return fmt.Errorf("post-heal verify: %w", err)
}
fmt.Fprintln(os.Stderr, "VERIFY OK: instance ready, no new Wrong chunk header for the healed region")
}
}
return nil
}
func summarizePlan(plan regionmedic.HealPlan) {
fmt.Fprintf(os.Stderr, "region %s: live healthy=%v (badChunks=%d, err=%q)\n",
plan.Region, plan.LiveReport.Healthy(), len(plan.LiveReport.BadChunks), plan.LiveReport.Err)
fmt.Fprintf(os.Stderr, "error_backups for region: %d\n", len(plan.ErrorBackups))
for _, c := range plan.Evaluated {
status := "no region in backup"
if c.Found {
if c.Report.Healthy() {
status = fmt.Sprintf("CLEAN (%d chunks)", c.Report.PresentChunks)
} else {
status = fmt.Sprintf("corrupt (%d bad chunks, err=%q)", len(c.Report.BadChunks), c.Report.Err)
}
}
fmt.Fprintf(os.Stderr, " %s %s %s\n", c.Backup.CreatedAt.Format(time.RFC3339), c.Backup.ID, status)
}
if plan.Ready() {
fmt.Fprintf(os.Stderr, "=> heal source: %s (%s)\n", plan.Source.Backup.ID, plan.Source.Backup.CreatedAt.Format(time.RFC3339))
} else {
fmt.Fprintln(os.Stderr, "=> NO clean source found")
}
}
// --- orchestration helpers --------------------------------------------------
func panelAction(ctx context.Context, controller, instance, action string) error {
url := strings.TrimRight(controller, "/") + "/api/instances/" + instance + "/" + action
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return fmt.Errorf("HTTP %d: %s", resp.StatusCode, bytes.TrimSpace(body))
}
return nil
}
// waitContainer polls `docker inspect` until the container's Running state
// matches want, or timeout.
func waitContainer(ctx context.Context, name string, want bool, timeout time.Duration) error {
deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) {
out, _ := exec.CommandContext(ctx, "docker", "inspect", "-f", "{{.State.Running}}", name).Output()
running := strings.TrimSpace(string(out)) == "true"
if running == want {
return nil
}
time.Sleep(2 * time.Second)
}
return fmt.Errorf("timed out waiting for %s running=%v", name, want)
}
// watchBoot tails the container log until the 7DTD ready marker appears, while
// asserting no "Wrong chunk header!" reappears AND no new error_backup files
// land for the healed region.
func watchBoot(ctx context.Context, container, regionDir string, region regionmedic.RegionCoord, timeout time.Duration) error {
if err := waitContainer(ctx, container, true, 60*time.Second); err != nil {
return err
}
deadline := time.Now().Add(timeout)
readyRe := []string{"StartGame done", "GameStartDone", "by Telnet from"}
for time.Now().Before(deadline) {
out, _ := exec.CommandContext(ctx, "docker", "logs", "--since", "5m", container).CombinedOutput()
s := string(out)
if strings.Contains(s, "Wrong chunk header") {
// Only fail if it's for our region — re-scan the dir to be sure.
scan, _ := regionmedic.ScanRegionDir(regionDir)
for _, a := range scan.Affected {
if a.Region == region {
return fmt.Errorf("healed region %s threw new error_backups (%d) after restart", region, a.ErrorBackups)
}
}
}
for _, m := range readyRe {
if strings.Contains(s, m) {
return nil
}
}
time.Sleep(4 * time.Second)
}
return fmt.Errorf("timed out waiting for ready marker")
}
func regionArg(s string) string {
if strings.HasSuffix(s, ".7rg") {
return s
}
return s + ".7rg"
}
func orDefault(s, def string) string {
if s == "" {
return def
}
return s
}
+66
View File
@@ -0,0 +1,66 @@
module github.com/dbledeez/panel/agent
go 1.26
require (
github.com/bodgit/sevenzip v1.6.2
github.com/dbledeez/panel/pkg v0.0.0
github.com/dbledeez/panel/proto v0.0.0
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.5.0
github.com/moby/go-archive v0.2.0
github.com/nwaples/rardecode/v2 v2.2.2
github.com/ulikunitz/xz v0.5.15
golang.org/x/net v0.52.0
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
require (
github.com/Microsoft/go-winio v0.4.14 // indirect
github.com/andybalholm/brotli v1.2.1 // indirect
github.com/bodgit/plumbing v1.3.0 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/atomicwriter v0.1.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/spf13/afero v1.15.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
replace (
github.com/dbledeez/panel/pkg => ../pkg
github.com/dbledeez/panel/proto => ../proto
)
+168
View File
@@ -0,0 +1,168 @@
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/Microsoft/go-winio v0.4.14 h1:+hMXMk01us9KgxGb7ftKQt2Xpf5hH/yky+TDA+qxleU=
github.com/Microsoft/go-winio v0.4.14/go.mod h1:qXqCSQ3Xa7+6tgxaGTIe4Kpcdsi+P8jBhyzoq1bpyYA=
github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro=
github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU=
github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs=
github.com/bodgit/sevenzip v1.6.2 h1:6/0mwj5KaRXpuf9iSiE+VpG7VpzFJ8D60P53VjxRv34=
github.com/bodgit/sevenzip v1.6.2/go.mod h1:q8DktB7GbvNn0Q6u4Iq6zULE0vo3rWtRHQg5L1XmjuU=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMBDgk/93Q=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY=
github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0 h1:CqXxU8VOmDefoh0+ztfGaymYbhdB/tT3zs79QaZTNGY=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.68.0/go.mod h1:BuhAPThV8PBHBvg8ZzZ/Ok3idOdhWIodywz2xEcRbJo=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
+388
View File
@@ -0,0 +1,388 @@
// Package archive walks and re-encodes archive files (zip / tar /
// tar.gz / tar.bz2 / tar.xz / 7z / rar) fully in-process — no native
// deps. It is shared by the file browser's extract/compress endpoints
// (internal/dispatch) and the direct update provider
// (internal/updater), which extracts archive downloads into the
// instance instead of dumping the raw archive bytes.
//
// 7z and rar use pure-Go libraries (bodgit/sevenzip,
// nwaples/rardecode/v2). Encrypted rars and solid 7z volumes are
// surfaced as errors, not silently skipped.
package archive
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/bzip2"
"compress/gzip"
"errors"
"fmt"
"io"
"path"
"strings"
"time"
"github.com/bodgit/sevenzip"
"github.com/nwaples/rardecode/v2"
"github.com/ulikunitz/xz"
)
// Entry-count / byte caps so a malformed or hostile archive can't pin
// the agent forever walking phantom records.
const (
MaxEntries = 200000
MaxTotalBytes = 8 * 1024 * 1024 * 1024
EntrySoftBytes = 4 * 1024 * 1024 * 1024
)
// EntryFunc is called once per archive entry. body is a streaming
// reader (nil for directories); the callback may read fully or skip.
type EntryFunc func(name string, mode int64, isDir bool, body io.Reader) error
// Walk opens `data` as an archive (auto-detected via signature plus
// filename hint) and calls fn for each entry. Returns entry and byte
// counts alongside any error.
func Walk(data []byte, name string, fn EntryFunc) (int64, int64, error) {
switch DetectFormat(data, name) {
case "zip":
return walkZip(data, fn)
case "tar.gz":
gz, err := gzip.NewReader(bytes.NewReader(data))
if err != nil {
return 0, 0, fmt.Errorf("gzip open: %w", err)
}
defer gz.Close()
return walkTar(gz, fn)
case "tar.bz2":
return walkTar(bzip2.NewReader(bytes.NewReader(data)), fn)
case "tar.xz":
xr, err := xz.NewReader(bytes.NewReader(data))
if err != nil {
return 0, 0, fmt.Errorf("xz open: %w", err)
}
return walkTar(xr, fn)
case "tar":
return walkTar(bytes.NewReader(data), fn)
case "7z":
return walk7z(data, fn)
case "rar":
return walkRar(data, fn)
default:
return 0, 0, fmt.Errorf("unsupported archive format for %q (panel knows zip / tar / tar.gz / tar.bz2 / tar.xz / 7z / rar)", name)
}
}
// WriteAsTar parses an archive from `data` and re-emits its entries as
// a tar stream into `out`. Used by container-side extract paths so the
// agent can feed Docker's CopyToContainer in a single API call.
func WriteAsTar(data []byte, originalName string, out io.Writer) (int64, int64, error) {
tw := tar.NewWriter(out)
defer tw.Close()
var entries, bytesOut int64
_, _, err := Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error {
safe := SafeEntryPath(name)
if safe == "" {
return nil
}
// Buffer the entry body so we can compute its length before
// writing the tar header. zip / gzip readers return streaming
// readers; the tar writer needs Size up front.
var bodyBuf bytes.Buffer
var sz int64
if !isDir {
n, err := io.Copy(&bodyBuf, body)
if err != nil {
return fmt.Errorf("entry %q: %w", name, err)
}
sz = n
if sz > EntrySoftBytes {
return fmt.Errorf("entry %q exceeds %d-byte cap", name, int64(EntrySoftBytes))
}
}
hdr := &tar.Header{Name: safe, ModTime: time.Now(), Mode: 0o644}
if mode != 0 {
hdr.Mode = mode & 0o777
}
if isDir {
hdr.Typeflag = tar.TypeDir
hdr.Mode = 0o755
if !strings.HasSuffix(hdr.Name, "/") {
hdr.Name += "/"
}
} else {
hdr.Typeflag = tar.TypeReg
hdr.Size = sz
}
if err := tw.WriteHeader(hdr); err != nil {
return err
}
if !isDir {
if _, err := tw.Write(bodyBuf.Bytes()); err != nil {
return err
}
}
entries++
bytesOut += sz
return nil
})
if err != nil {
return entries, bytesOut, err
}
return entries, bytesOut, tw.Close()
}
// DetectFormat classifies `data` by magic bytes first (handles renamed
// extensions), then falls back to the filename hint. Returns "" when
// the content is not a recognized archive.
func DetectFormat(data []byte, name string) string {
lower := strings.ToLower(name)
// Magic bytes win when present — handles renamed extensions.
if len(data) >= 4 && data[0] == 0x50 && data[1] == 0x4b && (data[2] == 0x03 || data[2] == 0x05 || data[2] == 0x07) {
return "zip"
}
if len(data) >= 3 && data[0] == 0x1f && data[1] == 0x8b && data[2] == 0x08 {
return "tar.gz"
}
if len(data) >= 3 && data[0] == 'B' && data[1] == 'Z' && data[2] == 'h' {
return "tar.bz2"
}
// 7z signature: 37 7A BC AF 27 1C
if len(data) >= 6 && data[0] == 0x37 && data[1] == 0x7a && data[2] == 0xbc && data[3] == 0xaf && data[4] == 0x27 && data[5] == 0x1c {
return "7z"
}
// RAR 5.0 signature: 52 61 72 21 1A 07 01 00 ("Rar!\x1a\x07\x01\x00")
// RAR 1.54.x: 52 61 72 21 1A 07 00 ("Rar!\x1a\x07\x00")
if len(data) >= 7 && data[0] == 'R' && data[1] == 'a' && data[2] == 'r' && data[3] == '!' && data[4] == 0x1a && data[5] == 0x07 {
return "rar"
}
// xz signature: FD 37 7A 58 5A 00
if len(data) >= 6 && data[0] == 0xfd && data[1] == '7' && data[2] == 'z' && data[3] == 'X' && data[4] == 'Z' && data[5] == 0x00 {
return "tar.xz"
}
if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") {
return "tar.gz"
}
if strings.HasSuffix(lower, ".tar.bz2") || strings.HasSuffix(lower, ".tbz2") || strings.HasSuffix(lower, ".tbz") {
return "tar.bz2"
}
if strings.HasSuffix(lower, ".tar.xz") || strings.HasSuffix(lower, ".txz") {
return "tar.xz"
}
if strings.HasSuffix(lower, ".tar") {
return "tar"
}
if strings.HasSuffix(lower, ".zip") {
return "zip"
}
if strings.HasSuffix(lower, ".7z") {
return "7z"
}
if strings.HasSuffix(lower, ".rar") {
return "rar"
}
return ""
}
func walkZip(data []byte, fn EntryFunc) (int64, int64, error) {
zr, err := zip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return 0, 0, fmt.Errorf("zip open: %w", err)
}
var entries, bytesOut int64
var totalBytes int64
for _, f := range zr.File {
if entries >= MaxEntries {
return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
}
isDir := f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/")
if isDir {
if err := fn(f.Name, int64(f.Mode()), true, nil); err != nil {
return entries, bytesOut, err
}
entries++
continue
}
rc, err := f.Open()
if err != nil {
return entries, bytesOut, fmt.Errorf("zip entry %q: %w", f.Name, err)
}
// Cap each entry by `EntrySoftBytes` so a malformed/zip-bomb
// entry can't run away with memory. We DO NOT trust
// f.UncompressedSize64 — some zip writers leave it 0 for streamed
// entries; using it for accounting (bytesOut += size) caused the
// per-archive total to be charged the 4 GB soft cap per such
// entry, tripping MaxTotalBytes after just a couple of
// files and aborting the extract mid-stream — silently leaving
// the first N files extracted and the rest dropped (this was the
// root cause of the 7DTD mod-upload "Config/ folders missing"
// bug). Use a counting reader so accounting reflects real bytes.
cr := &countingReader{r: io.LimitReader(rc, EntrySoftBytes)}
if err := fn(f.Name, int64(f.Mode()), false, cr); err != nil {
_ = rc.Close()
return entries, bytesOut, err
}
_ = rc.Close()
entries++
bytesOut += cr.n
totalBytes += cr.n
if totalBytes > MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
}
}
return entries, bytesOut, nil
}
// countingReader wraps an io.Reader and tracks the byte count actually
// consumed. Used by walkZip / walkTar accounting so the per-archive
// total reflects real-world bytes, not zip-header declared sizes (which
// some encoders leave as 0).
type countingReader struct {
r io.Reader
n int64
}
func (c *countingReader) Read(p []byte) (int, error) {
n, err := c.r.Read(p)
c.n += int64(n)
return n, err
}
func walkTar(r io.Reader, fn EntryFunc) (int64, int64, error) {
tr := tar.NewReader(r)
var entries, bytesOut, totalBytes int64
for {
if entries >= MaxEntries {
return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
}
hdr, err := tr.Next()
if err == io.EOF {
return entries, bytesOut, nil
}
if err != nil {
return entries, bytesOut, fmt.Errorf("tar next: %w", err)
}
switch hdr.Typeflag {
case tar.TypeDir:
if err := fn(hdr.Name, hdr.Mode, true, nil); err != nil {
return entries, bytesOut, err
}
entries++
case tar.TypeReg, tar.TypeRegA:
if err := fn(hdr.Name, hdr.Mode, false, tr); err != nil {
return entries, bytesOut, err
}
entries++
bytesOut += hdr.Size
totalBytes += hdr.Size
if totalBytes > MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
}
}
}
}
func walk7z(data []byte, fn EntryFunc) (int64, int64, error) {
zr, err := sevenzip.NewReader(bytes.NewReader(data), int64(len(data)))
if err != nil {
return 0, 0, fmt.Errorf("7z open: %w", err)
}
var entries, bytesOut, totalBytes int64
for _, f := range zr.File {
if entries >= MaxEntries {
return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
}
isDir := f.FileInfo().IsDir() || strings.HasSuffix(f.Name, "/")
if isDir {
if err := fn(f.Name, int64(f.Mode()), true, nil); err != nil {
return entries, bytesOut, err
}
entries++
continue
}
rc, err := f.Open()
if err != nil {
return entries, bytesOut, fmt.Errorf("7z entry %q: %w", f.Name, err)
}
// See countingReader rationale in walkZip — header-declared
// size can be 0 for streamed entries, charging the soft-cap per
// entry would falsely abort the extract.
cr := &countingReader{r: io.LimitReader(rc, EntrySoftBytes)}
if err := fn(f.Name, int64(f.Mode()), false, cr); err != nil {
_ = rc.Close()
return entries, bytesOut, err
}
_ = rc.Close()
entries++
bytesOut += cr.n
totalBytes += cr.n
if totalBytes > MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
}
}
return entries, bytesOut, nil
}
func walkRar(data []byte, fn EntryFunc) (int64, int64, error) {
rr, err := rardecode.NewReader(bytes.NewReader(data))
if err != nil {
return 0, 0, fmt.Errorf("rar open: %w", err)
}
var entries, bytesOut, totalBytes int64
for {
if entries >= MaxEntries {
return entries, bytesOut, fmt.Errorf("archive has more than %d entries", int64(MaxEntries))
}
hdr, err := rr.Next()
if errors.Is(err, io.EOF) {
return entries, bytesOut, nil
}
if err != nil {
return entries, bytesOut, fmt.Errorf("rar next: %w", err)
}
mode := int64(hdr.Mode().Perm())
if hdr.IsDir {
if err := fn(hdr.Name, mode, true, nil); err != nil {
return entries, bytesOut, err
}
entries++
continue
}
// See countingReader rationale in walkZip.
cr := &countingReader{r: io.LimitReader(rr, EntrySoftBytes)}
if err := fn(hdr.Name, mode, false, cr); err != nil {
return entries, bytesOut, err
}
entries++
bytesOut += cr.n
totalBytes += cr.n
if totalBytes > MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("archive uncompressed size exceeds %d bytes", int64(MaxTotalBytes))
}
}
}
// SafeEntryPath strips leading slashes / drive letters and
// rejects any path that tries to ../ out of the destination. Returns
// "" on rejected entries — caller skips them silently.
func SafeEntryPath(name string) string {
if name == "" {
return ""
}
// Normalize separators + strip leading drive / slash.
clean := strings.ReplaceAll(name, "\\", "/")
for strings.HasPrefix(clean, "/") {
clean = clean[1:]
}
if len(clean) >= 2 && clean[1] == ':' {
clean = clean[2:]
}
clean = path.Clean(clean)
if clean == "." || clean == "" {
return ""
}
if strings.HasPrefix(clean, "../") || clean == ".." || strings.Contains(clean, "/../") {
return ""
}
return clean
}
+180
View File
@@ -0,0 +1,180 @@
package archive
import (
"archive/tar"
"archive/zip"
"bytes"
"compress/gzip"
"io"
"testing"
"github.com/ulikunitz/xz"
)
// buildTar writes a small dir+file tree as a tar stream.
func buildTar(t *testing.T, w io.Writer) {
t.Helper()
tw := tar.NewWriter(w)
if err := tw.WriteHeader(&tar.Header{Name: "factorio/", Typeflag: tar.TypeDir, Mode: 0o755}); err != nil {
t.Fatal(err)
}
body := []byte("#!/bin/sh\necho hi\n")
if err := tw.WriteHeader(&tar.Header{Name: "factorio/bin/x64/factorio", Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
}
func makeTarXz(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
xw, err := xz.NewWriter(&buf)
if err != nil {
t.Fatal(err)
}
buildTar(t, xw)
if err := xw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func makeTarGz(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
buildTar(t, gw)
if err := gw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func makeZip(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
f, err := zw.Create("factorio/bin/x64/factorio")
if err != nil {
t.Fatal(err)
}
if _, err := f.Write([]byte("#!/bin/sh\necho hi\n")); err != nil {
t.Fatal(err)
}
if err := zw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func TestDetectFormatMagicBytes(t *testing.T) {
// Extensionless names — magic bytes must carry detection (factorio's
// download URL has no file extension).
cases := []struct {
data []byte
want string
}{
{makeTarXz(t), "tar.xz"},
{makeTarGz(t), "tar.gz"},
{makeZip(t), "zip"},
{[]byte("plain text, not an archive"), ""},
}
for _, c := range cases {
if got := DetectFormat(c.data, "https://example.com/get-download/stable/headless/linux64"); got != c.want {
t.Errorf("DetectFormat = %q, want %q", got, c.want)
}
}
}
func TestDetectFormatExtensionFallback(t *testing.T) {
// Content too short for magic — extension hint decides.
if got := DetectFormat([]byte{}, "thing.tar.xz"); got != "tar.xz" {
t.Errorf("extension fallback = %q, want tar.xz", got)
}
if got := DetectFormat([]byte{}, "thing.tgz"); got != "tar.gz" {
t.Errorf("extension fallback = %q, want tar.gz", got)
}
}
func TestWalkAllFormats(t *testing.T) {
for name, data := range map[string][]byte{
"tar.xz": makeTarXz(t),
"tar.gz": makeTarGz(t),
"zip": makeZip(t),
} {
var files []string
var content []byte
entries, _, err := Walk(data, "blob", func(n string, mode int64, isDir bool, body io.Reader) error {
if isDir {
return nil
}
files = append(files, n)
b, err := io.ReadAll(body)
content = b
return err
})
if err != nil {
t.Fatalf("%s: Walk: %v", name, err)
}
if entries == 0 || len(files) != 1 || files[0] != "factorio/bin/x64/factorio" {
t.Fatalf("%s: unexpected entries %d files %v", name, entries, files)
}
if string(content) != "#!/bin/sh\necho hi\n" {
t.Fatalf("%s: content mismatch: %q", name, content)
}
}
}
func TestWriteAsTarRoundTrip(t *testing.T) {
var out bytes.Buffer
entries, bytesOut, err := WriteAsTar(makeTarXz(t), "blob", &out)
if err != nil {
t.Fatal(err)
}
if entries != 2 {
t.Fatalf("entries = %d, want 2", entries)
}
if bytesOut == 0 {
t.Fatal("bytesOut = 0")
}
// Re-walk the emitted tar and check the file mode survived.
tr := tar.NewReader(&out)
var sawFile bool
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
t.Fatal(err)
}
if hdr.Typeflag == tar.TypeReg {
sawFile = true
if hdr.Name != "factorio/bin/x64/factorio" {
t.Fatalf("name = %q", hdr.Name)
}
if hdr.Mode&0o111 == 0 {
t.Fatalf("exec bit lost: mode %o", hdr.Mode)
}
}
}
if !sawFile {
t.Fatal("no regular file in re-emitted tar")
}
}
func TestSafeEntryPathRejectsTraversal(t *testing.T) {
for _, bad := range []string{"../evil", "a/../../evil", "..", ""} {
if got := SafeEntryPath(bad); got != "" {
t.Errorf("SafeEntryPath(%q) = %q, want rejection", bad, got)
}
}
if got := SafeEntryPath("/abs/path"); got != "abs/path" {
t.Errorf("leading slash strip = %q", got)
}
}
+264
View File
@@ -0,0 +1,264 @@
package dispatch
// ArkSaveRestore: swap a SavedArks/<subdir>/ rolling backup into the
// active <subdir>.ark slot. The previously-active save is moved aside
// (NEVER deleted) — even abandoned attempts are recoverable from the
// timestamped *_replaced.ark filename.
//
// Sequence (all server-side, atomic from the operator's POV):
// 1. Validate inputs.
// 2. Confirm the main container is NOT running. We refuse the swap
// if it is, because mid-write file ops in ARK's save dir corrupt
// the rolling backup chain.
// 3. mv <subdir>/<subdir>.ark -> <subdir>/<subdir>_<dd.mm.yyyy>_<HH.MM.SS>_replaced.ark
// 4. cp <subdir>/<target_filename> -> <subdir>/<subdir>.ark
// For .arkrbf rolling backups the dest still ends in .ark — the
// engine doesn't care about the source file's extension, only the
// slot name.
//
// Caller (controller) is responsible for stopping the container before
// this RPC. If the operator started the container in the meantime,
// this handler short-circuits cleanly with a friendly error.
import (
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// arkSavedArksContainerRoot is the relative path to ARK's rolling-save
// directory FROM the ark-sa module's BrowseableRoot. The module roots
// browsing at ".../ShooterGame/Saved" (see modules/ark-sa/module.yaml),
// so this is the segment beneath that — NOT the full container path.
const arkSavedArksContainerRoot = "SavedArks"
func (d *Dispatcher) handleArkSaveRestore(corrID string, req *panelv1.ArkSaveRestoreRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
return
}
subdir := strings.TrimSpace(req.SavedArksSubdir)
target := strings.TrimSpace(req.TargetFilename)
if subdir == "" || target == "" {
d.sendArkSaveRestoreResult(corrID, "", "", "saved_arks_subdir and target_filename are required")
return
}
// Path-safety: subdir is one segment under SavedArks/, target is
// one segment under that. Reject any traversal attempt up front.
if strings.ContainsAny(subdir, "/\\") || subdir == "." || subdir == ".." {
d.sendArkSaveRestoreResult(corrID, "", "", "saved_arks_subdir must be a single directory name")
return
}
if strings.ContainsAny(target, "/\\") || target == "." || target == ".." {
d.sendArkSaveRestoreResult(corrID, "", "", "target_filename must be a single file name")
return
}
// Sanity-check extension. Active slot is .ark; sources may be .ark
// or .arkrbf (rolling backup). .bak is ARK's anti-corruption file
// and we don't restore from it directly — operators rename to .ark
// manually if they need to.
lowerTarget := strings.ToLower(target)
if !strings.HasSuffix(lowerTarget, ".ark") && !strings.HasSuffix(lowerTarget, ".arkrbf") {
d.sendArkSaveRestoreResult(corrID, "", "", "target_filename must end in .ark or .arkrbf")
return
}
activeName := subdir + ".ark"
if target == activeName {
d.sendArkSaveRestoreResult(corrID, "", "", "target is already the active save")
return
}
// Stamp the aside name with current wall time. We use the user's
// "dd.mm.yyyy_HH.MM.SS" format from ARK's own rolling backups so
// the aside file sorts naturally next to genuine engine backups
// in the file browser. Suffix "_replaced" disambiguates them.
// Uses UTC because that's what the engine writes — keeps the
// frontend's parser (which assumes UTC) honest for both sources.
now := time.Now().UTC()
asideName := fmt.Sprintf("%s_%s_replaced.ark",
subdir, now.Format("02.01.2006_15.04.05"))
if d.useContainerOps(rec) {
d.arkRestoreInContainer(corrID, rec, subdir, target, activeName, asideName)
return
}
d.arkRestoreOnHost(corrID, rec, subdir, target, activeName, asideName)
}
func (d *Dispatcher) arkRestoreInContainer(corrID string, rec *instanceRecord, subdir, target, activeName, asideName string) {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
// Refuse if the main game container is still running. We don't
// want file ops happening inside SavedArks while the engine is
// writing rolling backups.
mainName := "panel-" + rec.InstanceID
if st, err := d.runtime.InspectByName(ctx, mainName); err == nil && st.Status == "running" {
d.sendArkSaveRestoreResult(corrID, "", "",
"refusing to restore while server is running — stop the server first")
return
}
subdirRel := path.Join(arkSavedArksContainerRoot, subdir)
subdirAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), subdirRel)
if err != nil {
d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
return
}
activeAbs := path.Join(subdirAbs, activeName)
asideAbs := path.Join(subdirAbs, asideName)
targetAbs := path.Join(subdirAbs, target)
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
return
}
// Verify the target file exists before we touch the active slot.
// `test -f` returns exit 1 (no file), 0 (exists), other (error).
_, _, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"test -f " + shellQuote(targetAbs)})
if err != nil {
d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
return
}
if code != 0 {
d.sendArkSaveRestoreResult(corrID, "", "", fmt.Sprintf("target save %q not found", target))
return
}
// Move the previously-active save aside (only if it exists; some
// ops may run on a freshly-installed instance with no active save).
finalAside := ""
_, _, code, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"test -f " + shellQuote(activeAbs)})
if code == 0 {
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mv -- " + shellQuote(activeAbs) + " " + shellQuote(asideAbs)})
if err != nil {
d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
return
}
if code != 0 {
msg := strings.TrimSpace(string(stderr))
if msg == "" {
msg = fmt.Sprintf("mv aside exited %d", code)
}
d.sendArkSaveRestoreResult(corrID, "", "", msg)
return
}
finalAside = asideName
}
// Copy the chosen backup into the active slot. cp preserves the
// source so the operator can pick it again later if they change
// their mind. -p preserves mtime so the engine sees the original
// save's modification time.
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"cp -p -- " + shellQuote(targetAbs) + " " + shellQuote(activeAbs)})
if err != nil {
d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
return
}
if code != 0 {
msg := strings.TrimSpace(string(stderr))
if msg == "" {
msg = fmt.Sprintf("cp exited %d", code)
}
// Roll the aside back if cp failed mid-flight.
if finalAside != "" {
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mv -- " + shellQuote(asideAbs) + " " + shellQuote(activeAbs)})
finalAside = ""
}
d.sendArkSaveRestoreResult(corrID, finalAside, "", msg)
return
}
d.sendArkSaveRestoreResult(corrID, finalAside, activeName, "")
}
func (d *Dispatcher) arkRestoreOnHost(corrID string, rec *instanceRecord, subdir, target, activeName, asideName string) {
if rec.DataPath == "" {
d.sendArkSaveRestoreResult(corrID, "", "", "no file storage available")
return
}
subdirRel := filepath.Join(arkSavedArksContainerRoot, subdir)
subdirAbs, err := safeJoinHost(rec.DataPath, subdirRel)
if err != nil {
d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
return
}
activeAbs := filepath.Join(subdirAbs, activeName)
asideAbs := filepath.Join(subdirAbs, asideName)
targetAbs := filepath.Join(subdirAbs, target)
if _, err := os.Stat(targetAbs); err != nil {
d.sendArkSaveRestoreResult(corrID, "", "", fmt.Sprintf("target save %q not found", target))
return
}
finalAside := ""
if _, err := os.Stat(activeAbs); err == nil {
if err := os.Rename(activeAbs, asideAbs); err != nil {
d.sendArkSaveRestoreResult(corrID, "", "", err.Error())
return
}
finalAside = asideName
}
in, err := os.Open(targetAbs)
if err != nil {
d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
return
}
defer in.Close()
out, err := os.OpenFile(activeAbs, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
// Roll aside back so we don't leave the server with no save.
if finalAside != "" {
_ = os.Rename(asideAbs, activeAbs)
finalAside = ""
}
d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
return
}
if _, err := io.Copy(out, in); err != nil {
_ = out.Close()
if finalAside != "" {
_ = os.Remove(activeAbs)
_ = os.Rename(asideAbs, activeAbs)
finalAside = ""
}
d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
return
}
if err := out.Close(); err != nil {
d.sendArkSaveRestoreResult(corrID, finalAside, "", err.Error())
return
}
d.sendArkSaveRestoreResult(corrID, finalAside, activeName, "")
}
func (d *Dispatcher) sendArkSaveRestoreResult(corrID, asideFilename, installedFilename, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_ArkSaveRestoreResult{
ArkSaveRestoreResult: &panelv1.ArkSaveRestoreResult{
AsideFilename: asideFilename,
InstalledFilename: installedFilename,
Error: errMsg,
},
},
})
}
+505
View File
@@ -0,0 +1,505 @@
package dispatch
import (
"context"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// buildBackupTarCmd assembles the tar command run inside the sidecar.
// Manifest BackupSpec wins when Include is set: the tar runs against
// the listed paths (each relative to BrowseableRoot, missing ones
// silently skipped). Otherwise it falls back to a whole-tree archive
// of BrowseableRoot. Excludes are passed through tar's --exclude.
//
// Resulting tarball preserves the directory layout under BrowseableRoot
// so restore can extract straight into the same root.
func buildBackupTarCmd(manifest *modulepkgManifest, root, fileName string, keep int) string {
if manifest != nil && manifest.Backup != nil && manifest.Backup.Root != "" {
root = manifest.Backup.Root
}
var base string
if manifest == nil || manifest.Backup == nil || len(manifest.Backup.Include) == 0 {
// Fallback: whole tree.
base = fmt.Sprintf("cd %q && tar czf /backup/%s . && stat -c %%s /backup/%s",
root, shellEscape(fileName), shellEscape(fileName))
} else {
// Per-game include list. Missing paths are non-fatal — players will
// create new instances on a fresh server before the save dir exists,
// and we don't want their first Back-up-now to fail the run.
excludes := ""
for _, e := range manifest.Backup.Exclude {
excludes += " --exclude=" + shellEscape(e)
}
// Build "test -e <path> && echo <path>" for each include — only
// existing paths get added to the tar argv, so the operator doesn't
// see a confusing "Cannot stat: No such file" on first run.
//
// Newline-delimited (NOT NUL-delimited) because the sidecar image is
// alpine, which ships busybox tar — and busybox tar doesn't grok
// `--null`. Manifest paths never contain whitespace or newlines (we
// hand-curate them per game), so a plain newline list is safe.
var existsChecks []string
for _, p := range manifest.Backup.Include {
clean := strings.TrimPrefix(p, "./")
clean = strings.TrimPrefix(clean, "/")
existsChecks = append(existsChecks, fmt.Sprintf("[ -e %s ] && printf '%%s\\n' %s", shellEscape(clean), shellEscape(clean)))
}
listExpr := "{ " + strings.Join(existsChecks, " ; ") + " ; }"
base = fmt.Sprintf("cd %q && %s | tar czf /backup/%s%s -T - && stat -c %%s /backup/%s",
root, listExpr, shellEscape(fileName), excludes, shellEscape(fileName))
}
return base + backupPruneSuffix(keep)
}
// backupPruneSuffix appends a best-effort retention prune that keeps only the
// newest `keep` *.tar.gz in /backup (by mtime) and deletes the rest. It runs in
// the SAME root sidecar that just wrote the new tarball — the backup files are
// root-owned, so the unprivileged agent can't prune them itself. keep<=0 leaves
// pruning off. busybox-safe (alpine): ls -1t + tail -n +N + while-read — no
// `head -n -N` (GNU-only) and no `xargs -r`.
func backupPruneSuffix(keep int) string {
if keep <= 0 {
return ""
}
return fmt.Sprintf(" ; echo 'prune: keeping newest %d backups' ; ls -1t /backup/*.tar.gz 2>/dev/null | tail -n +%d | while IFS= read -r f; do echo \"prune: rm $f\" ; rm -f \"$f\" ; done",
keep, keep+1)
}
// readBackupKeep returns the backup retention for this instance: the integer in
// <dataPath>/backup-keep when present, else the PANEL_BACKUP_KEEP env default,
// else 10. A rolling cap stops the unbounded pileup that filled 90 GB on a busy
// 7DTD season. Set per-instance by writing the file (the Backups UI exposes it).
func readBackupKeep(dataPath string) int {
def := 10
if v := os.Getenv("PANEL_BACKUP_KEEP"); v != "" {
if n, e := strconv.Atoi(strings.TrimSpace(v)); e == nil && n > 0 {
def = n
}
}
b, err := os.ReadFile(filepath.Join(dataPath, "backup-keep"))
if err != nil {
return def
}
if n, e := strconv.Atoi(strings.TrimSpace(string(b))); e == nil && n > 0 {
return n
}
return def
}
// modulepkgManifest is a local alias so the helper signature below
// doesn't drag modulepkg into its return type at the file top.
type modulepkgManifest = modulepkg.Manifest
// Backup / restore via sidecar containers. Two operations:
//
// - Backup: tar -czf /backup/<file>.tar.gz -C /src . (volume readonly at /src)
// - Restore: tar -xzf /backup/<file>.tar.gz -C /dst (volume read-write at /dst)
//
// The sidecar uses alpine:3.21 + busybox tar. Backup files live on the
// agent host under $BACKUP_DIR/<instance_id>/<ts>-<bkpID>.tar.gz; both
// sidecars bind-mount $BACKUP_DIR/<instance_id> at /backup.
//
// For restore we refuse if the main container is running: extracting
// files under a live server would corrupt world state. Operator stops
// first. (Symmetrical to the SteamCMD updater's running-container check.)
const (
backupImage = "alpine:3.21"
backupSidecarTTL = 30 * time.Minute
)
// ---- Backup ----
func (d *Dispatcher) handleBackup(ctx context.Context, corrID string, req *panelv1.BackupRequest) {
log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendBackupResult(corrID, "", "", 0, err.Error())
return
}
if rec.BrowseableRoot == "" {
d.sendBackupResult(corrID, "", "", 0, "instance has no BrowseableRoot to tar (module declares no volumes)")
return
}
if d.backupDir == "" {
d.sendBackupResult(corrID, "", "", 0, "agent has no --backup-dir configured")
return
}
// Prepare the agent-side target directory.
bkpID := "bkp_" + randHex(8)
hostDir := filepath.Join(d.backupDir, req.InstanceId)
if err := os.MkdirAll(hostDir, 0o755); err != nil {
d.sendBackupResult(corrID, "", "", 0, fmt.Sprintf("mkdir %s: %s", hostDir, err.Error()))
return
}
ts := time.Now().UTC().Format("20060102-150405")
fileName := fmt.Sprintf("%s-%s.tar.gz", ts, bkpID)
hostFile := filepath.Join(hostDir, fileName)
log.Info("backup starting", "backup_id", bkpID, "host_file", hostFile)
// Build sidecar spec that mounts the same volumes as the instance,
// plus the host backup directory at /backup.
volumes := agentmodule.ResolveVolumes(d.modulesMustGet(rec.ModuleID), req.InstanceId, rec.DataPath)
// Mark volumes read-only for backup safety.
for i := range volumes {
volumes[i].ReadOnly = true
}
// Add the host backup dir as a (read-write) bind.
volumes = append(volumes, runtime.VolumeSpec{
Type: "bind",
HostPath: hostDir,
ContainerPath: "/backup",
})
// Per-game backup paths. If the manifest declares Backup.Include,
// only those paths get archived (saves-only — keeps ARK SA tarballs
// at ~250 MB instead of the 18 GB whole-install). Without a spec,
// fall back to the original "tar everything under BrowseableRoot"
// behavior so older modules don't regress.
manifest := d.modulesMustGet(rec.ModuleID)
keep := readBackupKeep(rec.DataPath)
tarCmd := buildBackupTarCmd(manifest, rec.BrowseableRoot, fileName, keep)
if manifest != nil && manifest.Backup != nil && len(manifest.Backup.Include) > 0 {
log.Info("backup using manifest-declared paths", "include_count", len(manifest.Backup.Include))
} else {
log.Warn("backup falling back to whole BrowseableRoot — module manifest has no `backup.include`",
"module_id", rec.ModuleID, "root", rec.BrowseableRoot)
}
spec := runtime.InstanceSpec{
InstanceID: req.InstanceId + "-backup",
IsSidecar: true,
Image: backupImage,
Entrypoint: []string{"sh"},
Command: []string{"-c", tarCmd},
Volumes: volumes,
RestartPolicy: "no",
}
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
d.sendBackupResult(corrID, bkpID, "", 0, "create sidecar: "+err.Error())
return
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
d.sendBackupResult(corrID, bkpID, "", 0, "start sidecar: "+err.Error())
return
}
// Stream log lines for visibility.
logCtx, cancelLogs := context.WithCancel(ctx)
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID, Stream: "backup",
At: timestamppb.Now(), Line: line,
}},
})
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
d.sendBackupResult(corrID, bkpID, "", 0, "wait: "+err.Error())
return
}
if exitCode != 0 {
d.sendBackupResult(corrID, bkpID, "", 0, fmt.Sprintf("sidecar exited %d", exitCode))
return
}
info, err := os.Stat(hostFile)
if err != nil {
d.sendBackupResult(corrID, bkpID, hostFile, 0, "stat result: "+err.Error())
return
}
log.Info("backup complete", "backup_id", bkpID, "size_bytes", info.Size())
d.sendBackupResult(corrID, bkpID, hostFile, info.Size(), "")
}
func (d *Dispatcher) sendBackupResult(corrID, bkpID, path string, size int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_BackupResult{
BackupResult: &panelv1.BackupResult{
BackupId: bkpID,
Path: path,
SizeBytes: size,
Error: errMsg,
},
},
})
}
// ---- Wipe paths (used by the env-config recreate flow) ----
//
// Spawns an alpine sidecar that mounts the same volumes the instance
// uses, runs `rm -rf` on each path (relative to BrowseableRoot), then
// exits. Designed for the ARK map-switch flow — operator picked a new
// SERVER_MAP and checked "wipe existing world data" so the old map's
// SavedArks/<MapName>/ goes away. Generic enough that any future
// env-config field can declare its own wipe paths.
func (d *Dispatcher) runWipeSidecar(ctx context.Context, manifest *modulepkg.Manifest, instanceID, dataPath string, paths []string) error {
if manifest == nil {
return errors.New("nil manifest")
}
root := resolveBrowseableRoot(manifest)
if root == "" {
return errors.New("module declares no browseable_root — wipe needs a base path")
}
volumes := agentmodule.ResolveVolumes(manifest, instanceID, dataPath)
// Build the rm -rf command. Each path is rooted under
// BrowseableRoot. Missing paths are silently skipped (first run).
var rmParts []string
for _, p := range paths {
clean := p
// Defensive: reject absolute paths and parent traversal so a
// rogue config_values value can't `rm -rf /` the container.
if strings.HasPrefix(clean, "/") || strings.Contains(clean, "..") {
return fmt.Errorf("refusing wipe of suspicious path %q", p)
}
full := root + "/" + clean
rmParts = append(rmParts, fmt.Sprintf("rm -rf %s", shellEscape(full)))
}
cmd := strings.Join(rmParts, " && ") + " && echo wiped"
spec := runtime.InstanceSpec{
InstanceID: instanceID + "-wipe",
IsSidecar: true,
Image: backupImage,
Entrypoint: []string{"sh"},
Command: []string{"-c", cmd},
Volumes: volumes,
RestartPolicy: "no",
}
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
return fmt.Errorf("create wipe sidecar: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
return fmt.Errorf("start wipe sidecar: %w", err)
}
logCtx, cancelLogs := context.WithCancel(ctx)
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: instanceID, Stream: "wipe",
At: timestamppb.Now(), Line: line,
}},
})
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return fmt.Errorf("wait wipe sidecar: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("wipe sidecar exited %d", exitCode)
}
return nil
}
// ---- Restore ----
func (d *Dispatcher) handleRestore(ctx context.Context, corrID string, req *panelv1.RestoreRequest) {
log := d.log.With("instance_id", req.InstanceId, "backup_id", req.BackupId, "correlation_id", corrID)
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendRestoreResult(corrID, 0, err.Error())
return
}
if rec.BrowseableRoot == "" {
d.sendRestoreResult(corrID, 0, "instance has no BrowseableRoot to restore into")
return
}
if req.BackupPath == "" {
d.sendRestoreResult(corrID, 0, "backup_path required")
return
}
// Refuse if container is running.
if state, err := d.runtime.InspectByName(ctx, "panel-"+req.InstanceId); err == nil && state.Status == "running" {
d.sendRestoreResult(corrID, 0, "stop the instance first — main container is running")
return
}
info, err := os.Stat(req.BackupPath)
if err != nil || info.IsDir() {
d.sendRestoreResult(corrID, 0, "backup file missing or is a directory")
return
}
volumes := agentmodule.ResolveVolumes(d.modulesMustGet(rec.ModuleID), req.InstanceId, rec.DataPath)
// Add the backup file's parent as a read-only bind at /backup.
hostDir := filepath.Dir(req.BackupPath)
fileName := filepath.Base(req.BackupPath)
volumes = append(volumes, runtime.VolumeSpec{
Type: "bind",
HostPath: hostDir,
ContainerPath: "/backup",
ReadOnly: true,
})
// Extract strategy depends on whether the backup is per-game-paths
// or a whole-tree archive. When the manifest declares Backup.Include,
// only THOSE paths are wiped before extraction — preserves the
// 18 GB game install while still giving a clean save dir. Without a
// spec, fall back to wiping all of BrowseableRoot (legacy whole-tree
// behavior).
manifest := d.modulesMustGet(rec.ModuleID)
// Resolve the SAME root the backup wrote against. buildBackupTarCmd
// archives relative to manifest.Backup.Root when set (e.g. 7DTD's
// /game-saves, where world data actually lives — distinct from the
// /game install volume that is BrowseableRoot). Restore MUST extract
// into that same root or the tarball lands in the wrong volume and the
// live server never sees it (silent data loss: restore "succeeds" but
// players' bases are gone). Mirror the backup's resolution exactly.
restoreRoot := rec.BrowseableRoot
if manifest != nil && manifest.Backup != nil && manifest.Backup.Root != "" {
restoreRoot = manifest.Backup.Root
}
var wipeCmd string
if manifest != nil && manifest.Backup != nil && len(manifest.Backup.Include) > 0 {
// Wipe only the manifest-declared include paths so we don't
// nuke unrelated install files. Each path is rm -rf'd; missing
// ones are skipped silently (first-run case).
var parts []string
for _, p := range manifest.Backup.Include {
clean := strings.TrimPrefix(strings.TrimPrefix(p, "./"), "/")
parts = append(parts, fmt.Sprintf("rm -rf %s/%s", shellEscape(restoreRoot), shellEscape(clean)))
}
wipeCmd = strings.Join(parts, " ; ") + " 2>/dev/null"
log.Info("restore: wiping manifest-declared paths only", "root", restoreRoot, "include_count", len(manifest.Backup.Include))
} else {
wipeCmd = fmt.Sprintf("rm -rf %q/* %q/.[!.]* 2>/dev/null", restoreRoot, restoreRoot)
log.Warn("restore: wiping entire root (whole-tree backup)", "root", restoreRoot)
}
extractCmd := fmt.Sprintf(
"%s ; tar xzf /backup/%s -C %q && du -sb %q | cut -f1",
wipeCmd,
shellEscape(fileName),
restoreRoot,
restoreRoot,
)
spec := runtime.InstanceSpec{
InstanceID: req.InstanceId + "-restore",
IsSidecar: true,
Image: backupImage,
Entrypoint: []string{"sh"},
Command: []string{"-c", extractCmd},
Volumes: volumes,
RestartPolicy: "no",
}
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
d.sendRestoreResult(corrID, 0, "create sidecar: "+err.Error())
return
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
d.sendRestoreResult(corrID, 0, "start sidecar: "+err.Error())
return
}
logCtx, cancelLogs := context.WithCancel(ctx)
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID, Stream: "restore",
At: timestamppb.Now(), Line: line,
}},
})
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
d.sendRestoreResult(corrID, 0, "wait: "+err.Error())
return
}
if exitCode != 0 {
d.sendRestoreResult(corrID, 0, fmt.Sprintf("sidecar exited %d", exitCode))
return
}
log.Info("restore complete")
d.sendRestoreResult(corrID, info.Size(), "")
}
func (d *Dispatcher) sendRestoreResult(corrID string, bytesRestored int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_RestoreResult{
RestoreResult: &panelv1.RestoreResult{BytesRestored: bytesRestored, Error: errMsg},
},
})
}
// ---- helpers ----
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// shellEscape wraps a filename in single quotes for safe inclusion in an
// sh -c command. Assumes filename never contains a single-quote (ours
// never do — timestamps + bkp_<hex>).
func shellEscape(s string) string { return "'" + s + "'" }
// modulesMustGet returns the dispatcher's manifest for the given id, or
// nil if it's gone missing (shouldn't happen for a live instance).
func (d *Dispatcher) modulesMustGet(id string) *modulepkgManifest {
m, _ := d.modules.Get(id)
return m
}
// Keep errors imported for future error-wrapping.
var _ = errors.New
+198
View File
@@ -0,0 +1,198 @@
package dispatch
import (
"archive/tar"
"bytes"
"compress/gzip"
"fmt"
"io"
"os"
"strings"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Backup browse — list / read entries inside a backup tarball without
// extracting it. The agent has direct host access to the .tar.gz under
// $BACKUP_DIR, so we don't need a sidecar; standard library archive/tar
// + compress/gzip handles both the list and the single-file extract.
//
// Sized for safety: BackupReadFile caps the in-memory buffer per-call
// (default 8 MiB) so a malicious or corrupt tar entry can't OOM the
// agent. Truncation is reported back to the caller so the UI can show
// "(truncated, download to see full file)".
const (
defaultBackupReadCap = 8 << 20 // 8 MiB
maxBackupReadCap = 64 << 20 // 64 MiB hard ceiling regardless of caller request
binarySniffLen = 8 << 10 // 8 KiB
)
func (d *Dispatcher) handleBackupListEntries(corrID string, req *panelv1.BackupListEntriesRequest) {
d.log.Info("backup list entries: start", "correlation_id", corrID, "backup_path", req.BackupPath, "instance_id", req.InstanceId)
if req.BackupPath == "" {
d.sendBackupListEntriesResult(corrID, nil, 0, "backup_path required")
return
}
f, err := os.Open(req.BackupPath)
if err != nil {
d.sendBackupListEntriesResult(corrID, nil, 0, "open backup: "+err.Error())
return
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
d.sendBackupListEntriesResult(corrID, nil, 0, "gzip: "+err.Error())
return
}
defer gz.Close()
tr := tar.NewReader(gz)
var entries []*panelv1.BackupTarEntry
var totalBytes int64
for {
hdr, err := tr.Next()
if err == io.EOF {
break
}
if err != nil {
d.sendBackupListEntriesResult(corrID, nil, 0, "tar header: "+err.Error())
return
}
isDir := hdr.Typeflag == tar.TypeDir || strings.HasSuffix(hdr.Name, "/")
clean := strings.TrimPrefix(hdr.Name, "./")
clean = strings.TrimPrefix(clean, "/")
entry := &panelv1.BackupTarEntry{
Path: clean,
Size: hdr.Size,
IsDir: isDir,
Mode: hdr.FileInfo().Mode().String(),
ModTime: timestamppb.New(hdr.ModTime),
}
entries = append(entries, entry)
if !isDir {
totalBytes += hdr.Size
}
}
d.log.Info("backup list entries: ok", "correlation_id", corrID, "count", len(entries), "total_bytes", totalBytes)
d.sendBackupListEntriesResult(corrID, entries, totalBytes, "")
}
func (d *Dispatcher) handleBackupReadFile(corrID string, req *panelv1.BackupReadFileRequest) {
if req.BackupPath == "" {
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "backup_path required")
return
}
if req.EntryPath == "" {
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "entry_path required")
return
}
maxRead := req.MaxBytes
if maxRead <= 0 {
maxRead = defaultBackupReadCap
}
if maxRead > maxBackupReadCap {
maxRead = maxBackupReadCap
}
f, err := os.Open(req.BackupPath)
if err != nil {
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "open backup: "+err.Error())
return
}
defer f.Close()
gz, err := gzip.NewReader(f)
if err != nil {
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "gzip: "+err.Error())
return
}
defer gz.Close()
tr := tar.NewReader(gz)
// Match by exact path; tarballs sometimes prefix entries with "./" so
// also accept that variant.
want := strings.TrimPrefix(req.EntryPath, "/")
wantAlt := "./" + want
for {
hdr, err := tr.Next()
if err == io.EOF {
d.sendBackupReadFileResult(corrID, nil, 0, false, false, fmt.Sprintf("entry %q not found in backup", req.EntryPath))
return
}
if err != nil {
d.sendBackupReadFileResult(corrID, nil, 0, false, false, "tar header: "+err.Error())
return
}
if hdr.Name != want && hdr.Name != wantAlt {
continue
}
if hdr.Typeflag == tar.TypeDir || strings.HasSuffix(hdr.Name, "/") {
d.sendBackupReadFileResult(corrID, nil, hdr.Size, false, false, "entry is a directory")
return
}
// Read up to maxRead+1 to detect truncation cleanly.
buf := &bytes.Buffer{}
_, err = io.Copy(buf, io.LimitReader(tr, maxRead+1))
if err != nil {
d.sendBackupReadFileResult(corrID, nil, hdr.Size, false, false, "read entry: "+err.Error())
return
}
truncated := false
content := buf.Bytes()
if int64(len(content)) > maxRead {
content = content[:maxRead]
truncated = true
}
isBinary := looksBinary(content)
d.sendBackupReadFileResult(corrID, content, hdr.Size, truncated, isBinary, "")
return
}
}
// looksBinary returns true if the first binarySniffLen bytes contain a
// NUL byte. Standard heuristic — text files don't contain NULs, binaries
// almost always do (executables, images, compressed save formats).
func looksBinary(b []byte) bool {
limit := binarySniffLen
if len(b) < limit {
limit = len(b)
}
for i := 0; i < limit; i++ {
if b[i] == 0 {
return true
}
}
return false
}
func (d *Dispatcher) sendBackupListEntriesResult(corrID string, entries []*panelv1.BackupTarEntry, totalBytes int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_BackupListEntriesResult{
BackupListEntriesResult: &panelv1.BackupListEntriesResult{
Entries: entries,
TotalBytes: totalBytes,
Error: errMsg,
},
},
})
}
func (d *Dispatcher) sendBackupReadFileResult(corrID string, content []byte, size int64, truncated, isBinary bool, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_BackupReadFileResult{
BackupReadFileResult: &panelv1.BackupReadFileResult{
Content: content,
Size: size,
Truncated: truncated,
IsBinary: isBinary,
Error: errMsg,
},
},
})
}
+206
View File
@@ -0,0 +1,206 @@
package dispatch
import (
"context"
"errors"
"fmt"
"time"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
"github.com/dbledeez/panel/agent/internal/updater"
modulepkg "github.com/dbledeez/panel/pkg/module"
)
// installBaseMods runs a SteamCMD validate (if the module has a steamcmd
// update provider) followed by an alpine sidecar that downloads + installs
// the third-party 7DTD base-mod set:
//
// - Allocs Server Fixes — tarball from illy.bz (canonical upstream).
// Contains Allocs_CommandExtensions + Allocs_WebAndMapRendering +
// Alloc_XmlPatch, all under a Mods/ prefix when extracted to /game.
// - PrismaCore — latest GitHub release .zip from Prisma501/PrismaCore.
//
// SteamCMD validate step (new):
// - If the module declares at least one update provider with kind="steamcmd",
// the first such provider is used to run `app_update <app_id> validate`
// with anonymous login BEFORE the alpine sidecar runs. This re-downloads
// or restores stock TFP mods (TFP_*, 0_TFP_*, xMarkers) that ship with
// the dedicated server install.
// - If the SteamCMD validate fails, the function returns an error and does
// NOT proceed to the alpine step.
// - If there is no steamcmd provider (e.g., Conan Exiles), the validate is
// skipped and the function goes straight to the alpine path.
//
// What the alpine step does NOT touch (and does not need to):
// - TFP_* / 0_TFP_* — shipped by SteamCMD as part of the dedicated
// server install. TFP_Harmony is the runtime patcher; wiping it
// would brick the engine. Always present in /game/Mods/ after a
// fresh `app_update 294420`.
// - xMarkers — also ships with the SteamCMD 7DTD install per the
// operator (2026-05-04). Already in /game/Mods/ after install.
// - Custom mods (RefugeBot, AGF-VP-*, AsylumRoboticInbox, anything not
// in the recognized base-mod name set). Those are operator territory.
//
// Behavior on existing installs: any directory with a name that matches
// the incoming mod gets renamed to `<name>.bak-<UTCstamp>` first so a
// re-run is reversible. Direct port of the safety pattern in
// refugebotserver's DependencyInstaller.cs.
//
// Caller responsibility: the instance container MUST be stopped before
// calling this — the game holds DLLs in /game/Mods open while running,
// and rename-on-extract will fail or corrupt under that.
func (d *Dispatcher) installBaseMods(ctx context.Context, instanceID string, manifest *modulepkg.Manifest, dataPath string) error {
if manifest == nil || len(manifest.UpdateProviders) == 0 {
return errors.New("install-base-mods: module has no update provider")
}
installPath := manifest.UpdateProviders[0].InstallPath
if installPath == "" {
installPath = manifest.Runtime.Docker.BrowseableRoot
}
if installPath == "" {
return errors.New("install-base-mods: install_path missing on module")
}
volumes := agentmodule.ResolveVolumes(manifest, instanceID, dataPath)
gameVol := volumeNameForPath(volumes, installPath)
if gameVol == "" {
return fmt.Errorf("install-base-mods: install_path %q not in module volumes", installPath)
}
// ── SteamCMD validate step ──────────────────────────────────────────
// If the module has a steamcmd update provider, run app_update validate
// with anonymous login FIRST to re-download/restore stock TFP mods.
// This ensures TFP_*, 0_TFP_*, and xMarkers are present before the
// alpine sidecar installs third-party base mods.
var steamcmdSpec *modulepkg.UpdateProvider
for i := range manifest.UpdateProviders {
if manifest.UpdateProviders[i].Kind == "steamcmd" {
steamcmdSpec = &manifest.UpdateProviders[i]
break
}
}
if steamcmdSpec != nil {
d.log.Info("install-base-mods: steamcmd validate starting", "instance_id", instanceID, "app_id", steamcmdSpec.AppID)
prov, err := updater.Get("steamcmd")
if err != nil {
return fmt.Errorf("install-base-mods: get steamcmd provider: %w", err)
}
// Build a log sink that streams through the same "install-base-mods log" logger.
sink := func(line string) {
d.log.Info("install-base-mods log", "instance_id", instanceID, "line", line)
}
uc := &updater.Context{
InstanceID: instanceID,
Manifest: manifest,
BrowseableRoot: installPath,
DataPath: dataPath,
Runtime: d.runtime,
Log: sink,
SteamUsername: "", // anonymous login
SteamPassword: "", // anonymous login
}
if err := prov.Update(ctx, uc, steamcmdSpec); err != nil {
return fmt.Errorf("basemods: steamcmd validate failed: %w", err)
}
d.log.Info("install-base-mods: steamcmd validate succeeded", "instance_id", instanceID)
}
// alpine:3.19 is a 7 MB image with apk available. apk add fetches
// curl + unzip + tar + jq in ~5s on a warm cache. Cheaper than
// shipping our own base image and good enough for occasional runs.
script := `set -e
apk add --no-cache --quiet curl tar unzip jq ca-certificates >/dev/null
STAMP=$(date -u +%Y%m%d%H%M%S)
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
backup_if_exists() {
for d in "$@"; do
if [ -d "$d" ] && [ ! -L "$d" ]; then
mv "$d" "$d.bak-$STAMP" 2>/dev/null || true
fi
done
}
# ── Allocs Server Fixes ───────────────────────────────────────────────
echo "[basemods] downloading Allocs from illy.bz"
curl -fsSL -o "$TMP/allocs.tgz" "https://illy.bz/fi/7dtd/server_fixes.tar.gz"
# Magic-byte check: gzip starts with 0x1f 0x8b — protects against
# CDN-served HTML 404 pages landing as a "valid" archive.
head -c 2 "$TMP/allocs.tgz" | od -An -tx1 | tr -d ' \n' | grep -q '^1f8b' \
|| { echo "[basemods] Allocs download wasn't gzip — aborting"; exit 1; }
backup_if_exists /game/Mods/Allocs_CommandExtensions /game/Mods/Allocs_WebAndMapRendering /game/Mods/Alloc_XmlPatch
tar -xzf "$TMP/allocs.tgz" -C /game
echo "[basemods] Allocs installed"
# ── PrismaCore (latest GitHub release zip) ─────────────────────────────
echo "[basemods] resolving latest PrismaCore release"
PRISMA_JSON="$TMP/prisma.json"
curl -fsSL -A "panel-basemods/1.0" -H "Accept: application/vnd.github+json" \
-o "$PRISMA_JSON" "https://api.github.com/repos/Prisma501/PrismaCore/releases/latest"
PRISMA_TAG=$(jq -r '.tag_name // empty' "$PRISMA_JSON")
PRISMA_URL=$(jq -r '.assets[]? | select(.name | test("\\.zip$"; "i")) | .browser_download_url' "$PRISMA_JSON" | head -1)
if [ -z "$PRISMA_URL" ]; then
echo "[basemods] no PrismaCore .zip asset — skipping"
else
echo "[basemods] downloading PrismaCore $PRISMA_TAG from $PRISMA_URL"
curl -fsSL -A "panel-basemods/1.0" -o "$TMP/prisma.zip" "$PRISMA_URL"
head -c 2 "$TMP/prisma.zip" | od -An -tx1 | tr -d ' \n' | grep -q '^504b' \
|| { echo "[basemods] PrismaCore download wasn't a zip — aborting"; exit 1; }
for d in /game/Mods/Prisma*; do backup_if_exists "$d"; done
unzip -o -q "$TMP/prisma.zip" -d /game/Mods/
echo "[basemods] PrismaCore installed"
fi
# Note: TFP_* and xMarkers are shipped as part of the SteamCMD dedicated
# server install (app 294420). They're already in /game/Mods/ after a
# fresh install and we don't touch them here — wiping TFP_Harmony would
# brick the engine.
echo BASEMODS_OK
`
sidecarID := fmt.Sprintf("%s-basemods", instanceID)
spec := runtime.InstanceSpec{
InstanceID: sidecarID,
IsSidecar: true,
Image: "alpine:3.19",
Command: []string{"sh", "-c", script},
Volumes: []runtime.VolumeSpec{
{Type: "volume", VolumeName: gameVol, ContainerPath: installPath},
},
RestartPolicy: "no",
}
d.log.Info("install-base-mods: starting sidecar", "instance_id", instanceID, "vol", gameVol)
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
return fmt.Errorf("install-base-mods: create: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
return fmt.Errorf("install-base-mods: start: %w", err)
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
d.log.Info("install-base-mods log", "instance_id", instanceID, "line", line)
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return fmt.Errorf("install-base-mods: wait: %w", err)
}
if exitCode != 0 {
return fmt.Errorf("install-base-mods: sidecar exited %d", exitCode)
}
d.log.Info("install-base-mods: success", "instance_id", instanceID)
return nil
}
+143
View File
@@ -0,0 +1,143 @@
package dispatch
import (
"context"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// Cluster player-save snapshots.
//
// A 7DTD cluster shares ONE Player/ folder across every member server (each
// member's active-world Player dir is a symlink to the shared /cluster/Player
// bind). That shared dir holds every character's .ttp (inventory, skills,
// position, quests) — the single most irreplaceable per-player state. But it
// is NOT captured by the panel tar backup: the backup tars /game-saves, where
// the active Player dir is a symlink pointing OUT to /cluster, so tar stores
// the dangling symlink, not the contents. Region Medic only heals world
// regions, not player files. Net result before this: one corrupt write to a
// .ttp and that character was gone with no restore path.
//
// Fix (mirrors the region-medic rolling-snapshot pattern): on every 7DTD
// start, if the instance is clustered, copy the shared Player dir to a fresh
// rolling slot keyed by cluster id and prune to keep N. All members write to
// the same per-cluster rolling history, so a member's 4×/day restart cadence
// gives a dense set of restore points. Best-effort: never blocks the start.
func clusterPlayerRollingRoot() string {
return medicEnvOr("PANEL_CLUSTER_PLAYER_ROLLING_DIR", "/home/refuge/cluster-player-rolling")
}
func clusterPlayerKeep() int {
if v := os.Getenv("PANEL_CLUSTER_PLAYER_KEEP"); v != "" {
if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil && n > 0 {
return n
}
}
return 12
}
// snapshotClusterPlayerOnStart resolves the instance's /cluster bind to its
// host path, and if it's a real shared bind holding player data, snapshots
// <bind>/Player into a rolling slot keyed by the cluster id (= the bind dir's
// basename, e.g. cl_37e3a7f72cdc). No-op for standalone servers (whose
// /cluster is an empty per-instance named volume, not a bind).
func (d *Dispatcher) snapshotClusterPlayerOnStart(ctx context.Context, rec *instanceRecord) {
ctx, cancel := context.WithTimeout(ctx, 3*time.Minute)
defer cancel()
log := d.log.With("instance_id", rec.InstanceID, "cluster_player_snapshot", true)
mounts, err := d.runtime.ContainerMounts(ctx, "panel-"+rec.InstanceID)
if err != nil {
log.Warn("cluster-player snapshot: inspect mounts failed (skipping)", "err", err)
return
}
var bind string
for _, m := range mounts {
if m.Destination == "/cluster" && m.Type == "bind" && m.Source != "" {
bind = m.Source
break
}
}
if bind == "" {
return // standalone server (named-volume /cluster) — nothing shared to snapshot
}
playerDir := filepath.Join(bind, "Player")
if st, err := os.Stat(playerDir); err != nil || !st.IsDir() {
return // cluster joined but no Player dir yet (first boot)
}
if empty, _ := dirIsEmpty(playerDir); empty {
return
}
clusterID := filepath.Base(bind)
destRoot := filepath.Join(clusterPlayerRollingRoot(), clusterID)
if err := os.MkdirAll(destRoot, 0o755); err != nil {
log.Warn("cluster-player snapshot: mkdir rolling root failed", "err", err)
return
}
slot := filepath.Join(destRoot, "slot-"+strconv.FormatInt(time.Now().Unix(), 10))
dst := filepath.Join(slot, "Player")
if err := os.MkdirAll(slot, 0o755); err != nil {
log.Warn("cluster-player snapshot: mkdir slot failed", "err", err)
return
}
// cp -a preserves perms/acls/timestamps and the dir tree (player subdirs
// of .7rm map files). figaro is Linux; cp is always present. Best-effort.
cmd := exec.CommandContext(ctx, "cp", "-a", playerDir+"/.", dst)
if out, err := cmd.CombinedOutput(); err != nil {
log.Warn("cluster-player snapshot: copy failed", "err", err, "out", strings.TrimSpace(string(out)))
_ = os.RemoveAll(slot) // don't leave a half-copied slot that prune would keep
return
}
keep := clusterPlayerKeep()
pruned := pruneRollingSlots(destRoot, keep)
log.Info("cluster-player snapshot taken", "cluster", clusterID, "slot", filepath.Base(slot), "keep", keep, "pruned", pruned)
}
// dirIsEmpty reports whether dir has no entries.
func dirIsEmpty(dir string) (bool, error) {
f, err := os.Open(dir)
if err != nil {
return false, err
}
defer f.Close()
names, err := f.Readdirnames(1)
if err != nil {
// io.EOF → empty.
return len(names) == 0, nil
}
return len(names) == 0, nil
}
// pruneRollingSlots keeps the newest `keep` slot-* dirs under root (by name,
// which embeds a unix timestamp so lexical == chronological) and removes the
// rest. Returns how many it deleted.
func pruneRollingSlots(root string, keep int) int {
entries, err := os.ReadDir(root)
if err != nil {
return 0
}
var slots []string
for _, e := range entries {
if e.IsDir() && strings.HasPrefix(e.Name(), "slot-") {
slots = append(slots, e.Name())
}
}
if len(slots) <= keep {
return 0
}
sort.Strings(slots) // oldest first
removed := 0
for _, name := range slots[:len(slots)-keep] {
if err := os.RemoveAll(filepath.Join(root, name)); err == nil {
removed++
}
}
return removed
}
+275
View File
@@ -0,0 +1,275 @@
package dispatch
// DayZ mod install/uninstall + FsSymlink handlers. The controller drives
// the high-level workflow (SteamCMD workshop_download runs controller-side,
// writing to the shared panel-dayz-workshop volume); the agent's job is to
// wire the downloaded content into the instance's /game tree:
//
// 1. Create /game/<folder_name> as a symlink to
// /game/steamapps/workshop/content/221100/<workshop_id>
// 2. Copy every .bikey from that mod's keys/ (or Keys/) subfolder into
// /game/keys/ so BattlEye signs mod content on boot.
// 3. On uninstall, remove the symlink + every bikey whose content hash
// matches a bikey in the workshop mod's keys dir (so we don't remove
// keys that belong to OTHER installed mods of the same vendor).
import (
"context"
"fmt"
"path"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// ---- FsSymlink ----
//
// The symlink is created inside the instance's container namespace so that
// any `/game/@Mod` symlink resolves correctly from the DayZServer binary's
// perspective.
func (d *Dispatcher) handleFsSymlink(corrID string, req *panelv1.FsSymlinkRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsSymlinkResult(corrID, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsSymlinkResult(corrID, err.Error())
return
}
// `ln -sfn` replaces an existing symlink atomically. We require
// abs paths for both target and link_path and refuse anything outside
// the configured browseable roots for link_path (the symlink itself;
// target can point outside for workshop shares).
if !strings.HasPrefix(req.LinkPath, "/") || !strings.HasPrefix(req.Target, "/") {
d.sendFsSymlinkResult(corrID, "target and link_path must be absolute")
return
}
if _, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.LinkPath); err != nil {
d.sendFsSymlinkResult(corrID, "link_path outside browseable roots: "+err.Error())
return
}
// `ln` errors with "No such file or directory" if the parent doesn't
// exist yet — common on first mod install for modules whose entrypoint
// hasn't lazily created the Mods/ folder yet (Conan Exiles). `mkdir -p`
// the parent before linking so the symlink-then-game-restart flow works
// even on a fresh recreate. Idempotent: mkdir on existing dir is a
// no-op, and the safeJoinAny check above already proved link_path is
// inside a declared root so we're not creating dirs in arbitrary spots.
parentDir := path.Dir(req.LinkPath)
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
fmt.Sprintf("mkdir -p %q && ln -sfn %q %q", parentDir, req.Target, req.LinkPath)})
if err != nil {
d.sendFsSymlinkResult(corrID, err.Error())
return
}
if code != 0 {
d.sendFsSymlinkResult(corrID, fmt.Sprintf("ln exited %d: %s", code, strings.TrimSpace(string(stderr))))
return
}
d.sendFsSymlinkResult(corrID, "")
}
func (d *Dispatcher) sendFsSymlinkResult(corrID, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsSymlinkResult{
FsSymlinkResult: &panelv1.FsSymlinkResult{Error: errMsg},
},
})
}
// ---- DayzModInstall ----
func (d *Dispatcher) handleDayzModInstall(corrID string, req *panelv1.DayzModInstallRequest) {
d.log.Info("dayz mod install", "instance_id", req.InstanceId, "workshop_id", req.WorkshopId, "folder", req.FolderName)
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendDayzModInstallResult(corrID, err.Error(), nil, "")
return
}
if req.WorkshopId == "" || req.FolderName == "" {
d.sendDayzModInstallResult(corrID, "workshop_id and folder_name are required", nil, "")
return
}
// Normalize folder name: must start with '@', no slashes, no whitespace.
folder := strings.TrimSpace(req.FolderName)
if !strings.HasPrefix(folder, "@") {
folder = "@" + folder
}
if strings.ContainsAny(folder, "/\\ \t\n") {
d.sendDayzModInstallResult(corrID, "folder_name must not contain slashes or whitespace", nil, "")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendDayzModInstallResult(corrID, err.Error(), nil, "")
return
}
modPath := "/game/steamapps/workshop/content/221100/" + req.WorkshopId
linkPath := "/game/" + folder
// 1. Verify workshop content exists.
_, _, code, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
fmt.Sprintf("test -d %q", modPath)})
if code != 0 {
d.sendDayzModInstallResult(corrID, "workshop content not found at "+modPath+" — did SteamCMD download succeed?", nil, modPath)
return
}
// 2. Create symlink.
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
fmt.Sprintf("ln -sfn %q %q", modPath, linkPath)})
if err != nil {
d.sendDayzModInstallResult(corrID, "symlink: "+err.Error(), nil, modPath)
return
}
if code != 0 {
d.sendDayzModInstallResult(corrID, "symlink failed: "+strings.TrimSpace(string(stderr)), nil, modPath)
return
}
// 3. Copy bikeys. DayZ mods ship them under keys/ OR Keys/ inside the
// workshop tree. Copy every .bikey we find there into /game/keys/.
// Keep a manifest of copied filenames so uninstall can remove them.
// Use `find` + `cp` for portability; busybox and GNU both understand.
stdout, stderr2, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
fmt.Sprintf(`mkdir -p /game/keys && \
for src in %q/keys %q/Keys; do \
[ -d "$src" ] || continue; \
for f in "$src"/*.bikey "$src"/*.BIKEY; do \
[ -f "$f" ] || continue; \
base=$(basename "$f"); \
cp -f "$f" /game/keys/"$base"; \
printf '%%s\n' "$base"; \
done; \
done`, modPath, modPath)})
if err != nil {
d.sendDayzModInstallResult(corrID, "copy bikeys: "+err.Error(), nil, modPath)
return
}
if code != 0 {
d.sendDayzModInstallResult(corrID, "copy bikeys failed: "+strings.TrimSpace(string(stderr2)), nil, modPath)
return
}
var bikeys []string
for _, ln := range strings.Split(string(stdout), "\n") {
ln = strings.TrimSpace(strings.TrimPrefix(ln, `\n`))
if ln == "" {
continue
}
bikeys = append(bikeys, ln)
}
d.sendDayzModInstallResult(corrID, "", bikeys, modPath)
}
func (d *Dispatcher) sendDayzModInstallResult(corrID, errMsg string, bikeys []string, resolvedPath string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_DayzModInstallResult{
DayzModInstallResult: &panelv1.DayzModInstallResult{
Error: errMsg,
BikeysCopied: bikeys,
ResolvedModPath: resolvedPath,
},
},
})
}
// ---- DayzModUninstall ----
func (d *Dispatcher) handleDayzModUninstall(corrID string, req *panelv1.DayzModUninstallRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendDayzModUninstallResult(corrID, err.Error(), nil)
return
}
folder := strings.TrimSpace(req.FolderName)
if !strings.HasPrefix(folder, "@") || strings.ContainsAny(folder, "/\\ \t\n") {
d.sendDayzModUninstallResult(corrID, "invalid folder_name", nil)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendDayzModUninstallResult(corrID, err.Error(), nil)
return
}
linkPath := "/game/" + folder
// Resolve the symlink to find the workshop directory.
stdout, _, code, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
fmt.Sprintf("readlink -f %q 2>/dev/null || true", linkPath)})
modPath := strings.TrimSpace(string(stdout))
if code != 0 || modPath == "" {
// Symlink is already gone or broken — just drop stale /game/keys entries.
modPath = ""
}
// Remove bikeys whose *name* matches a bikey in the workshop mod's keys.
// Matching by name (not hash) is what DayZ tooling does — the server
// looks up bikeys by exact basename, and mods typically ship unique
// key names.
var removed []string
if modPath != "" {
out, _, _, _ := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
fmt.Sprintf(`for d in %q/keys %q/Keys; do \
[ -d "$d" ] || continue; \
for f in "$d"/*.bikey "$d"/*.BIKEY; do \
[ -f "$f" ] || continue; \
base=$(basename "$f"); \
if [ -f "/game/keys/$base" ]; then \
rm -f "/game/keys/$base"; \
echo "$base"; \
fi; \
done; \
done`, modPath, modPath)})
for _, ln := range strings.Split(string(out), "\n") {
ln = strings.TrimSpace(ln)
if ln == "" {
continue
}
removed = append(removed, ln)
}
}
// Remove the symlink itself.
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
fmt.Sprintf("rm -f %q", linkPath)})
if err != nil {
d.sendDayzModUninstallResult(corrID, "rm symlink: "+err.Error(), removed)
return
}
if code != 0 {
d.sendDayzModUninstallResult(corrID, "rm symlink failed: "+strings.TrimSpace(string(stderr)), removed)
return
}
d.sendDayzModUninstallResult(corrID, "", removed)
}
func (d *Dispatcher) sendDayzModUninstallResult(corrID, errMsg string, bikeys []string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_DayzModUninstallResult{
DayzModUninstallResult: &panelv1.DayzModUninstallResult{
Error: errMsg,
BikeysRemoved: bikeys,
},
},
})
}
@@ -0,0 +1,165 @@
package dispatch
import (
"context"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Auto-provision a freshly-generated clustered 7DTD world (zero-touch create),
// AND keep a visible "setting up" status until it is actually done so an operator
// who refreshes the dashboard sees it is not ready yet (don't restart it).
//
// A new cluster world bakes its decorations (decoration.7dt / multiblocks.7dt)
// with the engine's NATIVE block ids at generation. The entrypoint stamps the
// cluster CANONICAL .nim over the world's table every boot so shared inventory
// (.ttp) decodes uniformly — but native-baked decorations then resolve to a null
// Block under canonical -> DecoManager NRE -> the world loads but never reaches
// GameStartDone ("online but unjoinable"). The entrypoint fixes this by remapping
// the decorations native->canonical on the FIRST boot it sees them in the native
// band — which can only be a boot AFTER the gen boot (the running server writes
// decoration.7dt; the entrypoint can't see it until next start).
//
// This watcher (a) closes that 2-phase gap so create is zero-touch — it bounces
// the container ONCE so the entrypoint's remap runs — and (b) holds a persistent
// "Finishing cluster setup…" status detail (the dashboard renders it as a "setting
// up" pill instead of a green "running" one) until the world carries the
// .canon-provisioned marker, so a page refresh always shows it isn't done yet.
//
// Strict no-op for non-cluster servers (no /cluster bind -> the watcher exits) and
// already-canon worlds (deco in canon band -> "CANON" on the first probe -> the
// indicator clears immediately). Single-flight; the entrypoint is the backstop if
// the watcher ever misses (the world still provisions on its next restart).
// Mirrors the hang-guard single-flight container-restart pattern (hangguard.go).
const (
provisionPollInterval = 20 * time.Second
provisionPollTimeout = 15 * time.Minute
provisionExecTimeout = 20 * time.Second
provisionRestartGrace = 30 * time.Second
)
// provisionDetail is the persistent status detail shown while a clustered world is
// still aligning to the cluster canonical. The dashboard matches this text to paint
// a "setting up" pill, so a refresh shows the server isn't ready yet.
const provisionDetail = "Finishing cluster setup — aligning decorations to the cluster (it auto-restarts once); please wait, don't restart it."
// provisionProbeScript runs inside the container and classifies the active cluster
// world's provisioning state. Emits exactly one token on stdout:
//
// NOWORLD no recorded save dir (RWG world not yet picked in the Cluster tab)
// PROVISIONED .canon-provisioned marker present (already aligned)
// GENERATING no main.ttw yet (world still generating)
// CANON decorations already in the canon band (>=24000) -> nothing to do
// ATTEMPTED already bounced once and still not aligned -> stop (avoid a loop)
// NEEDS_ALIGN gen done; decorations native-banded OR not baked yet -> bounce once
//
// Gated on main.ttw (= gen finished) not on decoration.7dt existing, because some
// worlds only write decoration.7dt on the first save — the bounce's graceful save
// writes it (native), then the entrypoint remaps it on the align boot. First deco
// block-id = u16 @ offset 17 (hdr 5 + record id-offset 12), masked to 15 bits.
const provisionProbeScript = `SB="$(cat /game-saves/.panel-save-base 2>/dev/null)"; ` +
`[ -z "$SB" ] && { echo NOWORLD; exit 0; }; ` +
`[ -f "$SB/.canon-provisioned" ] && { echo PROVISIONED; exit 0; }; ` +
`[ -f "$SB/main.ttw" ] || { echo GENERATING; exit 0; }; ` +
`D="$SB/decoration.7dt"; ` +
`if [ -f "$D" ]; then raw="$(od -An -tu2 -j17 -N2 "$D" 2>/dev/null | tr -d ' ')"; id=$(( ${raw:-0} & 0x7FFF )); [ "$id" -ge 24000 ] && { echo CANON; exit 0; }; fi; ` +
`[ -f "$SB/.panel-provision-attempted" ] && { echo ATTEMPTED; exit 0; }; ` +
`echo NEEDS_ALIGN`
// provisionAttemptScript drops a durable one-bounce guard so a world that doesn't
// align after its single bounce is never bounced again (the entrypoint remains the
// backstop on ordinary restarts).
const provisionAttemptScript = `SB="$(cat /game-saves/.panel-save-base 2>/dev/null)"; ` +
`[ -n "$SB" ] && touch "$SB/.panel-provision-attempted" 2>/dev/null; true`
// autoProvisionClusterWorldOnStart starts (single-flight) the provision watcher for
// a just-started 7DTD instance. Returns immediately; the watch runs off a background
// goroutine bounded by provisionPollTimeout.
func (d *Dispatcher) autoProvisionClusterWorldOnStart(rec *instanceRecord) {
if rec.ModuleID != "7dtd" {
return
}
if !rec.provisionGuard.CompareAndSwap(false, true) {
return // a watcher is already running for this instance
}
go func() {
defer rec.provisionGuard.Store(false)
log := d.log.With("instance_id", rec.InstanceID, "auto_provision", true)
// Only clustered worlds provision (and show the indicator). Resolve the
// /cluster bind like clusterplayer.go; bail out for standalone servers.
mctx, mcancel := context.WithTimeout(context.Background(), 15*time.Second)
mounts, _ := d.runtime.ContainerMounts(mctx, "panel-"+rec.InstanceID)
mcancel()
clustered := false
for _, m := range mounts {
if m.Destination == "/cluster" && m.Type == "bind" && m.Source != "" {
clustered = true
break
}
}
if !clustered {
return
}
deadline := time.Now().Add(provisionPollTimeout)
delay := 5 * time.Second // first poll soon, to override a premature green "running"
var lastDetail string
bounced := false
emit := func(detail string) {
if detail == lastDetail {
return
}
lastDetail = detail
d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_RUNNING, 0, detail)
}
for {
time.Sleep(delay)
delay = provisionPollInterval
if time.Now().After(deadline) {
return // gave up; the entrypoint aligns on the next ordinary restart
}
ectx, cancel := context.WithTimeout(context.Background(), provisionExecTimeout)
stdout, _, code, err := d.runtime.ExecCapture(ectx, rec.ContainerID, []string{"sh", "-c", provisionProbeScript})
cancel()
if err != nil || code != 0 {
continue // mid-boot / exec hiccup — leave the agent's own status alone
}
switch strings.TrimSpace(string(stdout)) {
case "CANON", "PROVISIONED":
emit("running") // fully set up — clear the "setting up" indicator
return
case "NEEDS_ALIGN":
emit(provisionDetail)
if !bounced {
bounced = true
actx, acancel := context.WithTimeout(context.Background(), provisionExecTimeout)
_, _, _, _ = d.runtime.ExecCapture(actx, rec.ContainerID, []string{"sh", "-c", provisionAttemptScript})
acancel()
log.Info("auto-provision: aligning new cluster world to canonical (one-time bounce)")
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID, Stream: "stdout", At: timestamppb.Now(),
Line: "[panel] auto-provision: aligning the new world's decorations to the cluster (one-time restart)…",
}},
})
rctx, rcancel := context.WithTimeout(context.Background(), 90*time.Second)
if err := d.runtime.Restart(rctx, rec.ContainerID, provisionRestartGrace); err != nil {
log.Error("auto-provision restart failed (entrypoint aligns on the next ordinary restart)", "err", err)
}
rcancel()
}
continue // keep watching across the bounce until the align boot marks it provisioned
default: // GENERATING / NOWORLD / ATTEMPTED — still setting up
emit(provisionDetail)
continue
}
}
}()
}
+172
View File
@@ -0,0 +1,172 @@
package dispatch
import (
"context"
"fmt"
"os"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/agent/internal/runtime"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// handleDelete tears down the instance record. If the container is still
// running, it's stopped with the requested grace first. Optionally
// removes every Docker volume labeled for this instance ("purge").
//
// Never deletes the host data_path directory — operators may want to
// keep configs/logs that landed on the bind mount. If they want a full
// wipe, they can delete the dir manually after.
func (d *Dispatcher) handleDelete(ctx context.Context, corrID string, req *panelv1.InstanceDelete) {
log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)
d.mu.Lock()
rec, exists := d.instances[req.InstanceId]
d.mu.Unlock()
// Cancel any in-flight steamcmd / updater goroutine FIRST. The
// updater spins up its own sidecar container (panel-<id>-steamcmd-<n>)
// that mounts the same data volumes. If we don't cancel it before
// trying to remove volumes, the sidecar holds them open and the
// volume-rm fails with "volume is in use" — operator sees the
// instance stuck on "Deleting…" while a download finishes in the
// background.
d.cancelUpdater(req.InstanceId)
// Whatever state the main container is in, any Files-tab helper that
// was running against this instance is now orphaned. Clean it up
// before touching anything else so its volume-holds don't interfere
// with the main container's stop/remove.
d.teardownFsHelper(req.InstanceId)
grace := time.Duration(req.GraceSeconds) * time.Second
if grace == 0 {
grace = 15 * time.Second
}
// If we have a record, use its container_id directly. Otherwise try
// to find the container by its canonical name (may exist from a
// previous session that skipped rehydrate).
containerID := ""
if exists {
containerID = rec.ContainerID
} else {
if state, err := d.runtime.InspectByName(ctx, "panel-"+req.InstanceId); err == nil {
containerID = state.ContainerID
}
}
if containerID != "" {
// Best-effort stop; ignore errors since container may already be stopped.
_ = d.runtime.Stop(ctx, containerID, grace)
removeErr := d.runtime.Remove(ctx, containerID)
// d.runtime.Remove already swallows not-found, so a non-nil
// removeErr means the remove genuinely failed. Before we tear down
// any bookkeeping (in-memory record + sidecar metadata) we must be
// SURE the container is gone — otherwise we orphan a live container
// the controller still routes to, and the agent forgets it on the
// next rehydrate. The classic trigger: a delete racing with agent
// shutdown, where the Docker connection dies mid-remove
// ("terminated signal received"). The remove errors, and the
// follow-up existence check ALSO errors — fate unknown, not gone.
if removeErr != nil {
exists, existErr := d.runtime.ContainerExists(ctx, "panel-"+req.InstanceId)
switch {
case existErr != nil:
// Couldn't even ask (daemon down / connection dropped /
// ctx cancelled). Do NOT delete the sidecar — bail and let
// the operator retry once the daemon is reachable. Leaving
// the sidecar means the next rehydrate re-adopts the
// instance instead of orphaning it.
log.Error("delete: container remove failed and existence is unknown — aborting to avoid orphan",
"remove_err", removeErr, "exists_err", existErr)
d.replyError(corrID, req.InstanceId, "container_remove",
fmt.Sprintf("container panel-%s remove failed and existence unverifiable: %s", req.InstanceId, removeErr.Error()))
return
case exists:
// Container is confirmed still present. The controller must
// learn — otherwise the DB row gets purged while a zombie
// container lives on, and the next create with the same id
// fails on a "name already in use" docker error.
log.Error("delete: container still exists after remove", "err", removeErr)
d.replyError(corrID, req.InstanceId, "container_remove",
fmt.Sprintf("container panel-%s still exists after remove: %s", req.InstanceId, removeErr.Error()))
return
default:
// Confirmed gone (e.g. autoremove fired between Stop and
// Remove). Safe to proceed with bookkeeping cleanup.
log.Warn("delete: remove returned error but container is confirmed gone", "err", removeErr)
}
}
}
// Signal any live trackers / log streams to stop.
if exists {
if rec.TrackerCancel != nil {
rec.TrackerCancel()
}
if rec.LogCancel != nil {
rec.LogCancel()
}
}
// Purge volumes if requested.
if req.PurgeVolumes {
dr, ok := d.runtime.(*runtime.DockerRuntime)
if !ok {
log.Warn("delete: purge requested but runtime is not Docker; skipping volume cleanup")
} else {
// Sweep every container that mounts one of this instance's
// volumes — running OR exited. Without this, a stranded
// steamcmd / backup / fs-helper sidecar from a previous
// agent crash keeps the volume "in use" and volume-rm
// fails repeatedly. Operator sees the instance stuck
// "Deleting…" forever.
holders, err := dr.ListContainersHoldingInstanceVolumes(ctx, req.InstanceId)
if err != nil {
log.Warn("delete: scan for orphan sidecars failed", "err", err)
}
for _, hid := range holders {
if err := dr.ForceRemoveContainer(ctx, hid); err != nil {
log.Warn("delete: force-remove sidecar failed", "container_id", hid, "err", err)
} else {
log.Info("delete: removed orphan sidecar", "container_id", short(hid))
}
}
vols, err := dr.ListVolumesByInstance(ctx, req.InstanceId)
if err != nil {
log.Warn("delete: list volumes failed", "err", err)
}
for _, v := range vols {
// Force=true: there's no scenario where we want a panel
// volume to survive a purge=true delete after we've
// scrubbed every container that referenced it.
if err := dr.RemoveVolume(ctx, v, true); err != nil {
log.Warn("delete: remove volume failed", "volume", v, "err", err)
d.replyError(corrID, req.InstanceId, "volume_remove", fmt.Sprintf("volume %s: %s", v, err.Error()))
return
}
log.Info("delete: purged volume", "volume", v)
}
}
}
// Remove in-memory record + sidecar metadata.
d.mu.Lock()
delete(d.instances, req.InstanceId)
d.mu.Unlock()
d.deleteMeta(req.InstanceId)
// Leave host data_path alone (see doc comment); operators can rm -rf
// manually. We do NOT attempt os.RemoveAll here — too destructive to
// do automatically on a shared multi-instance directory layout.
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "deleted")
d.replyOK(corrID)
log.Info("instance deleted", "purge_volumes", req.PurgeVolumes)
_ = os.Stat // kept imported for future host-data_path removal flag
}
// sendDelete-specific correlation reply uses the existing replyOK/err pattern.
var _ = timestamppb.Now // keep import hint while ctx-based future expansions land
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,526 @@
package dispatch
// Per-player POI discovery query against the empyrion server's
// global.db SQLite store. Spawns an alpine+sqlite sidecar with the
// instance's saves volume mounted read-only, runs a parameterized
// join query, returns the rows.
//
// Schema reference (DiscoveredPOIs / Entities / Playfields / SolarSystems
// / LoginLogoff) was reverse-engineered from a live empyrion install on
// 2026-04-30. Eleon's mod API does NOT expose per-player discovery —
// Request_GlobalStructure_List returns the server-wide aggregate that
// EAH shows. The DB has the finer per-player data the in-game map uses.
import (
"context"
"encoding/json"
"errors"
"fmt"
"os/exec"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// sqlite3's CLI doesn't support `?` parameter binding (that's a
// prepared-statement-only feature on the C API). We string-interpolate
// the SteamID64 — already validated as 17 digits server-side, but we
// also belt-and-suspenders re-validate at the shell layer (digits only).
//
// Two query modes:
// "personal" — POIs the player personally first-walked-into
// "faction" — POIs anyone in the player's faction has discovered
// (matches the in-game shared map)
const sqlPersonalTpl = `
SELECT
COALESCE(ss.name, '') AS solar_system,
COALESCE(pf.name, '') AS playfield,
COALESCE(poi.name, '') AS poi_name,
COALESCE(poi.etype, 0) AS poi_type,
COALESCE(dp.gametime, 0) AS game_time,
COALESCE(ll.playername, '') AS discoverer_name,
COALESCE(ll.playerid, '') AS discoverer_steam_id,
COALESCE(dp.facgroup, 0) AS facgroup,
COALESCE(dp.facid, 0) AS facid
FROM DiscoveredPOIs dp
JOIN Entities poi ON dp.poiid = poi.entityid
LEFT JOIN Playfields pf ON dp.pfid = pf.pfid
LEFT JOIN SolarSystems ss ON pf.ssid = ss.ssid
LEFT JOIN LoginLogoff ll ON dp.entityid = ll.entityid
WHERE dp.entityid IN (
SELECT DISTINCT entityid FROM LoginLogoff WHERE playerid='%SID%'
)
ORDER BY dp.gametime DESC
LIMIT 5000;
`
const sqlFactionTpl = `
SELECT
COALESCE(ss.name, '') AS solar_system,
COALESCE(pf.name, '') AS playfield,
COALESCE(poi.name, '') AS poi_name,
COALESCE(poi.etype, 0) AS poi_type,
COALESCE(dp.gametime, 0) AS game_time,
COALESCE(ll.playername, '') AS discoverer_name,
COALESCE(ll.playerid, '') AS discoverer_steam_id,
COALESCE(dp.facgroup, 0) AS facgroup,
COALESCE(dp.facid, 0) AS facid
FROM DiscoveredPOIs dp
JOIN Entities poi ON dp.poiid = poi.entityid
LEFT JOIN Playfields pf ON dp.pfid = pf.pfid
LEFT JOIN SolarSystems ss ON pf.ssid = ss.ssid
LEFT JOIN LoginLogoff ll ON dp.entityid = ll.entityid
WHERE (dp.facgroup, dp.facid) IN (
SELECT DISTINCT pe.facgroup, pe.facid
FROM Entities pe
WHERE pe.entityid IN (
SELECT DISTINCT entityid FROM LoginLogoff WHERE playerid='%SID%'
)
)
ORDER BY dp.gametime DESC
LIMIT 5000;
`
const sqlPlayerNameTpl = `
SELECT playername FROM LoginLogoff
WHERE playerid='%SID%'
ORDER BY loginticks DESC
LIMIT 1;
`
func (d *Dispatcher) handleEmpyrionDiscoveries(corrID string, req *panelv1.EmpyrionDiscoveriesRequest) {
if req == nil || req.InstanceId == "" || req.SteamId == "" {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "instance_id and steam_id required",
})
return
}
go d.runEmpyrionDiscoveriesJob(corrID, req)
}
func (d *Dispatcher) runEmpyrionDiscoveriesJob(corrID string, req *panelv1.EmpyrionDiscoveriesRequest) {
mode := strings.ToLower(strings.TrimSpace(req.Mode))
if mode == "" || mode == "personal" {
mode = "personal"
} else if mode != "faction" {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "mode must be 'personal' or 'faction'", Mode: req.Mode, SteamId: req.SteamId,
})
return
}
dockerBin, err := exec.LookPath("docker")
if err != nil {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "docker binary not found on agent host", Mode: mode, SteamId: req.SteamId,
})
return
}
// Saves volume name follows the panel convention: panel-<id>-saves.
savesVol := "panel-" + req.InstanceId + "-saves"
// Pre-flight: volume must exist (instance exists on this agent).
if err := exec.Command(dockerBin, "volume", "inspect", savesVol).Run(); err != nil {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: fmt.Sprintf("instance saves volume %q not found on this agent", savesVol),
Mode: mode, SteamId: req.SteamId,
})
return
}
// Validate steam_id is digits-only — defense in depth, controller
// already checks 17-digit format. Belt-and-suspenders against any
// future caller that bypasses the controller's validation.
for _, c := range req.SteamId {
if c < '0' || c > '9' {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "steam_id must be digits only", Mode: mode, SteamId: req.SteamId,
})
return
}
}
tpl := sqlPersonalTpl
if mode == "faction" {
tpl = sqlFactionTpl
}
q := strings.ReplaceAll(tpl, "%SID%", req.SteamId)
nameQ := strings.ReplaceAll(sqlPlayerNameTpl, "%SID%", req.SteamId)
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// alpine+sqlite sidecar. We don't need to discover the savegame
// folder name dynamically — the entrypoint creates exactly one
// (via dedicated.yaml.SaveDirectory which defaults to "DediGame";
// the panel's empyrion module pins this). We pick `*/global.db`
// at the shell layer so renames don't break the query.
//
// Note: -2 disables stdin which avoids the `command timed out`
// path that empty stdin can hit on some Alpine builds. We feed the
// SQL via the third positional arg.
rowsScript := `set -e
apk add --no-cache sqlite >/dev/null 2>&1
DB=$(ls /saves/Saves/Games/*/global.db 2>/dev/null | head -1)
if [ -z "$DB" ]; then echo '{"err":"no global.db found in saves volume"}' >&2; exit 2; fi
NAME=$(sqlite3 "$DB" "$1" 2>/dev/null || echo "")
ROWS=$(sqlite3 -json "$DB" "$2" 2>&1)
if [ -z "$ROWS" ]; then ROWS='[]'; fi
NAME_JSON=$(printf '%s' "$NAME" | sed -e 's/\\/\\\\/g' -e 's/"/\\"/g')
printf '{"player_name":"%s","rows":%s}\n' "$NAME_JSON" "$ROWS"
`
out, err := exec.CommandContext(ctx, dockerBin,
"run", "--rm",
"-v", savesVol+":/saves:ro",
"--entrypoint", "sh",
"alpine:3.20", "-c", rowsScript, "_", nameQ, q,
).Output()
if err != nil {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "sqlite query failed: " + err.Error(),
Mode: mode, SteamId: req.SteamId,
})
return
}
// Parse the wrapper json {"player_name": "...", "rows": [...]}.
type wireRow struct {
SolarSystem string `json:"solar_system"`
Playfield string `json:"playfield"`
PoiName string `json:"poi_name"`
PoiType int32 `json:"poi_type"`
GameTime int64 `json:"game_time"`
DiscovererName string `json:"discoverer_name"`
DiscovererSteamID string `json:"discoverer_steam_id"`
Facgroup int32 `json:"facgroup"`
Facid int32 `json:"facid"`
}
var wrap struct {
PlayerName string `json:"player_name"`
Rows []wireRow `json:"rows"`
}
// Strip leading whitespace; sqlite3 -json sometimes prepends a BOM-ish blank line.
if err := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &wrap); err != nil {
// Tolerate "no rows" — sqlite3 -json prints `[]` (good) or empty
// string when no DB (bad — caught above) or `null` for some
// builds. Fall back to empty-result on parse failure.
var rowsOnly []wireRow
if err2 := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &rowsOnly); err2 == nil {
wrap.Rows = rowsOnly
} else {
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: false, Error: "parse sqlite output: " + err.Error(),
Mode: mode, SteamId: req.SteamId,
})
return
}
}
pois := make([]*panelv1.DiscoveredPOI, 0, len(wrap.Rows))
for _, r := range wrap.Rows {
pois = append(pois, &panelv1.DiscoveredPOI{
SolarSystem: r.SolarSystem,
Playfield: r.Playfield,
PoiName: r.PoiName,
PoiType: r.PoiType,
GameTime: r.GameTime,
DiscovererName: r.DiscovererName,
DiscovererSteamId: r.DiscovererSteamID,
FactionGroup: r.Facgroup,
FactionId: r.Facid,
})
}
d.sendEmpyrionDiscoveriesResult(corrID, &panelv1.EmpyrionDiscoveriesResult{
Ok: true,
Pois: pois,
Mode: mode,
SteamId: req.SteamId,
PlayerName: strings.Trim(wrap.PlayerName, "\""),
})
_ = errors.New
}
func (d *Dispatcher) sendEmpyrionDiscoveriesResult(corrID string, res *panelv1.EmpyrionDiscoveriesResult) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_EmpyrionDiscoveriesResult{
EmpyrionDiscoveriesResult: res,
},
})
}
// ============================================================================
// Player Summary — stats + sessions + inventory in one query
// ============================================================================
// playerSummaryScript is one big sh script run inside an alpine sidecar.
// It opens global.db, resolves the player's entityid via LoginLogoff,
// then runs four sqlite queries with -json output. The output is one
// JSON object with named keys for each query — we marshal-decode it
// directly into the proto result.
//
// The 4 queries:
// 1. summary — Entities row + LoginLogoff name lookup + last position
// 2. stats — PlayerStatistics row
// 3. sessions — recent LoginLogoff rows
// 4. inv — most recent PlayerInventory snapshot + items
//
// We accept that some of these may return [] (player has no stats row
// yet, never logged in this savegame, etc.) — JSON parser handles it.
func playerSummaryScript() string {
return `set -e
apk add --no-cache sqlite >/dev/null 2>&1
DB=$(ls /saves/Saves/Games/*/global.db 2>/dev/null | head -1)
if [ -z "$DB" ]; then echo '{"err":"no global.db found in saves volume"}' >&2; exit 2; fi
SID="$1"
LIMIT="${2:-50}"
# Resolve entityid for this Steam ID. Use the most recent LoginLogoff
# row to handle character resets that re-bind the same Steam ID to a
# new entityid.
EID=$(sqlite3 "$DB" "SELECT entityid FROM LoginLogoff WHERE playerid='${SID}' ORDER BY loginticks DESC LIMIT 1;")
if [ -z "$EID" ]; then
printf '{"steam_id":"%s","entity_id":0,"player_name":"","stats":null,"sessions":[],"inventory":[]}\n' "$SID"
exit 0
fi
NAME=$(sqlite3 "$DB" "SELECT playername FROM LoginLogoff WHERE entityid=${EID} ORDER BY loginticks DESC LIMIT 1;")
FG=$(sqlite3 "$DB" "SELECT facgroup FROM Entities WHERE entityid=${EID};")
FI=$(sqlite3 "$DB" "SELECT facid FROM Entities WHERE entityid=${EID};")
# Last position from PlayerData.
PD=$(sqlite3 -json "$DB" "
SELECT
COALESCE(pf.name, '') AS last_playfield,
COALESCE(pd.posx, 0) AS last_x,
COALESCE(pd.posy, 0) AS last_y,
COALESCE(pd.posz, 0) AS last_z,
COALESCE(pd.credits, 0) AS credits
FROM PlayerData pd
LEFT JOIN Playfields pf ON pd.pfid = pf.pfid
WHERE pd.entityid=${EID};
")
STATS=$(sqlite3 -json "$DB" "
SELECT
COALESCE(killedenemies, 0) AS killed_enemies,
COALESCE(killedallied, 0) AS killed_allied,
COALESCE(killedanimals, 0) AS killed_animals,
COALESCE(killeddrones, 0) AS killed_drones,
COALESCE(killedplayers, 0) AS killed_players,
COALESCE(killedalliedplayers, 0) AS killed_allied_players,
COALESCE(died, 0) AS died,
COALESCE(score, 0) AS score,
COALESCE(blocksplaced, 0) AS blocks_placed,
COALESCE(blocksdigged, 0) AS blocks_digged,
COALESCE(walkedmeters, 0) AS walked_meters,
COALESCE(jetpackmeters, 0) AS jetpack_meters,
COALESCE(hvmeters, 0) AS hv_meters,
COALESCE(svmeters, 0) AS sv_meters,
COALESCE(cvmeters, 0) AS cv_meters,
COALESCE(playtime, 0.0) AS playtime,
COALESCE(travly, 0.0) AS light_years,
COALESCE(travau, 0.0) AS astronomical_units
FROM PlayerStatistics WHERE entityid=${EID};
")
SESS=$(sqlite3 -json "$DB" "
SELECT
COALESCE(loginticks, 0) AS login_ticks,
COALESCE(logoffticks, 0) AS logoff_ticks,
COALESCE(playername, '') AS player_name,
COALESCE(buildnr, 0) AS build_nr,
COALESCE(ip, '') AS ip,
COALESCE(os, '') AS os,
COALESCE(gfx, '') AS gfx,
COALESCE(cpu, '') AS cpu
FROM LoginLogoff WHERE entityid=${EID}
ORDER BY loginticks DESC LIMIT ${LIMIT};
")
INV_PIID=$(sqlite3 "$DB" "SELECT piid FROM PlayerInventory WHERE entityid=${EID} ORDER BY gametime DESC LIMIT 1;")
INV_GT=0
INV='[]'
if [ -n "$INV_PIID" ]; then
INV_GT=$(sqlite3 "$DB" "SELECT gametime FROM PlayerInventory WHERE piid=${INV_PIID};")
INV=$(sqlite3 -json "$DB" "
SELECT item AS item_id, count
FROM PlayerInventoryItems WHERE piid=${INV_PIID};
")
if [ -z "$INV" ]; then INV='[]'; fi
fi
if [ -z "$STATS" ]; then STATS='[]'; fi
if [ -z "$SESS" ]; then SESS='[]'; fi
if [ -z "$PD" ]; then PD='[]'; fi
printf '{"steam_id":"%s","player_name":%s,"entity_id":%s,"faction_group":%s,"faction_id":%s,"position":%s,"stats":%s,"sessions":%s,"inventory_gametime":%s,"inventory":%s}\n' \
"$SID" \
"$(printf '%%s' "$NAME" | sqlite3 ':memory:' 'select json_quote(?1);' 2>/dev/null || echo '""')" \
"${EID:-0}" "${FG:-0}" "${FI:-0}" \
"$PD" "$STATS" "$SESS" "${INV_GT:-0}" "$INV"
`
}
func (d *Dispatcher) handleEmpyrionPlayerSummary(corrID string, req *panelv1.EmpyrionPlayerSummaryRequest) {
if req == nil || req.InstanceId == "" || req.SteamId == "" {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: "instance_id and steam_id required",
})
return
}
go d.runEmpyrionPlayerSummaryJob(corrID, req)
}
func (d *Dispatcher) runEmpyrionPlayerSummaryJob(corrID string, req *panelv1.EmpyrionPlayerSummaryRequest) {
dockerBin, err := exec.LookPath("docker")
if err != nil {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: "docker binary not found on agent host", SteamId: req.SteamId,
})
return
}
savesVol := "panel-" + req.InstanceId + "-saves"
if err := exec.Command(dockerBin, "volume", "inspect", savesVol).Run(); err != nil {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: fmt.Sprintf("instance saves volume %q not found on this agent", savesVol), SteamId: req.SteamId,
})
return
}
limit := req.MaxLoginRows
if limit <= 0 || limit > 500 {
limit = 50
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
out, err := exec.CommandContext(ctx, dockerBin,
"run", "--rm",
"-v", savesVol+":/saves:ro",
"--entrypoint", "sh",
"alpine:3.20", "-c", playerSummaryScript(), "_",
req.SteamId, fmt.Sprintf("%d", limit),
).Output()
if err != nil {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: "sqlite query failed: " + err.Error(), SteamId: req.SteamId,
})
return
}
type wireStats struct {
KilledEnemies int64 `json:"killed_enemies"`
KilledAllied int64 `json:"killed_allied"`
KilledAnimals int64 `json:"killed_animals"`
KilledDrones int64 `json:"killed_drones"`
KilledPlayers int64 `json:"killed_players"`
KilledAlliedPlayers int64 `json:"killed_allied_players"`
Died int64 `json:"died"`
Score int64 `json:"score"`
BlocksPlaced int64 `json:"blocks_placed"`
BlocksDigged int64 `json:"blocks_digged"`
WalkedMeters int64 `json:"walked_meters"`
JetpackMeters int64 `json:"jetpack_meters"`
HvMeters int64 `json:"hv_meters"`
SvMeters int64 `json:"sv_meters"`
CvMeters int64 `json:"cv_meters"`
Playtime float64 `json:"playtime"`
LightYears float64 `json:"light_years"`
AstroUnits float64 `json:"astronomical_units"`
}
type wireSession struct {
LoginTicks int64 `json:"login_ticks"`
LogoffTicks int64 `json:"logoff_ticks"`
PlayerName string `json:"player_name"`
BuildNr int64 `json:"build_nr"`
IP string `json:"ip"`
OS string `json:"os"`
GFX string `json:"gfx"`
CPU string `json:"cpu"`
}
type wireInvItem struct {
ItemID int32 `json:"item_id"`
Count int32 `json:"count"`
}
type wirePos struct {
LastPlayfield string `json:"last_playfield"`
LastX float64 `json:"last_x"`
LastY float64 `json:"last_y"`
LastZ float64 `json:"last_z"`
Credits int64 `json:"credits"`
}
type wire struct {
SteamID string `json:"steam_id"`
PlayerName string `json:"player_name"`
EntityID int64 `json:"entity_id"`
FactionGroup int32 `json:"faction_group"`
FactionID int32 `json:"faction_id"`
Position []wirePos `json:"position"`
Stats []wireStats `json:"stats"`
Sessions []wireSession `json:"sessions"`
InventoryGametime int64 `json:"inventory_gametime"`
Inventory []wireInvItem `json:"inventory"`
}
var w wire
if err := json.Unmarshal([]byte(strings.TrimSpace(string(out))), &w); err != nil {
d.sendEmpyrionPlayerSummaryResult(corrID, &panelv1.EmpyrionPlayerSummaryResult{
Ok: false, Error: "parse sqlite output: " + err.Error(), SteamId: req.SteamId,
})
return
}
res := &panelv1.EmpyrionPlayerSummaryResult{
Ok: true,
SteamId: w.SteamID,
PlayerName: strings.Trim(w.PlayerName, "\""),
EntityId: w.EntityID,
FactionGroup: w.FactionGroup,
FactionId: w.FactionID,
InventoryGametime: w.InventoryGametime,
}
if len(w.Position) > 0 {
res.LastPlayfield = w.Position[0].LastPlayfield
res.LastX = w.Position[0].LastX
res.LastY = w.Position[0].LastY
res.LastZ = w.Position[0].LastZ
res.Credits = w.Position[0].Credits
}
if len(w.Stats) > 0 {
s := w.Stats[0]
res.Stats = &panelv1.PlayerStatsRow{
KilledEnemies: s.KilledEnemies, KilledAllied: s.KilledAllied,
KilledAnimals: s.KilledAnimals, KilledDrones: s.KilledDrones,
KilledPlayers: s.KilledPlayers, KilledAlliedPlayers: s.KilledAlliedPlayers,
Died: s.Died, Score: s.Score,
BlocksPlaced: s.BlocksPlaced, BlocksDigged: s.BlocksDigged,
WalkedMeters: s.WalkedMeters, JetpackMeters: s.JetpackMeters,
HvMeters: s.HvMeters, SvMeters: s.SvMeters, CvMeters: s.CvMeters,
Playtime: s.Playtime, LightYears: s.LightYears, AstronomicalUnits: s.AstroUnits,
}
}
for _, s := range w.Sessions {
res.Sessions = append(res.Sessions, &panelv1.PlayerLoginRow{
LoginTicks: s.LoginTicks, LogoffTicks: s.LogoffTicks,
PlayerName: s.PlayerName, BuildNr: s.BuildNr,
Ip: s.IP, Os: s.OS, Gfx: s.GFX, Cpu: s.CPU,
})
}
for _, it := range w.Inventory {
res.Inventory = append(res.Inventory, &panelv1.PlayerInventoryItem{
ItemId: it.ItemID, Count: it.Count,
})
}
d.sendEmpyrionPlayerSummaryResult(corrID, res)
}
func (d *Dispatcher) sendEmpyrionPlayerSummaryResult(corrID string, res *panelv1.EmpyrionPlayerSummaryResult) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_EmpyrionPlayerSummaryResult{
EmpyrionPlayerSummaryResult: res,
},
})
}
@@ -0,0 +1,308 @@
package dispatch
// Agent-side Empyrion workshop-scenario installer. Runs entirely on the
// local Docker daemon, so a multi-agent panel can install Reforged Eden 2
// (and any other workshop scenario) into an Empyrion instance regardless
// of which agent owns it.
//
// Flow:
// 1. SteamCMD sidecar mounts panel-empyrion-workshop (an agent-local
// cache volume, lazily created on first install) and downloads the
// workshop item.
// 2. Alpine helper sidecar mounts the workshop volume + the instance's
// `panel-<INSTANCE>-game` volume and copies the scenario files into
// Content/Scenarios/<scenario_name>/.
// 3. A marker file (.panel-installed) is touched so the empyrion
// entrypoint can confirm the install.
//
// Progress is streamed back to the controller as LogLine envelopes with
// stream="scenario" so the panel UI's scenario job log shows what's
// happening live.
import (
"context"
"fmt"
"os/exec"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
func (d *Dispatcher) handleEmpyrionScenarioInstall(corrID string, req *panelv1.EmpyrionScenarioInstallRequest) {
if req == nil || req.JobId == "" || req.InstanceId == "" || req.WorkshopId == "" || req.ScenarioName == "" {
d.sendEmpyrionScenarioResult(corrID, req.GetJobId(), false, "missing required fields", req.GetScenarioName())
return
}
appID := req.AppId
if appID == "" {
appID = "530870"
}
go d.runEmpyrionScenarioJob(corrID, req, appID)
}
func (d *Dispatcher) runEmpyrionScenarioJob(corrID string, req *panelv1.EmpyrionScenarioInstallRequest, appID string) {
// Redact the password from any line we forward upstream, since
// SteamCMD CAN echo `+login user PASS` back in error paths.
redact := func(s string) string {
if req.SteamPassword == "" || len(req.SteamPassword) < 4 {
return s
}
return strings.ReplaceAll(s, req.SteamPassword, "[REDACTED]")
}
emit := func(line string) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: req.InstanceId,
Stream: "scenario",
At: timestamppb.Now(),
Line: redact(line),
}},
})
}
finish := func(ok bool, msg string) {
d.sendEmpyrionScenarioResult(corrID, req.JobId, ok, msg, req.ScenarioName)
}
emit(fmt.Sprintf("[scenario] starting workshop download: app=%s item=%s scenario=%q", appID, req.WorkshopId, req.ScenarioName))
dockerBin, err := exec.LookPath("docker")
if err != nil {
finish(false, "docker binary not found on agent host")
return
}
// Phase 1 — workshop download. The volume name is per-agent (Docker
// daemons don't share volumes); it's created lazily by the first run.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
defer cancel()
// Build the SteamCMD argv for an attempt — login portion is the only
// variable. `+app_license_request` refreshes Steam's entitlement
// cache before the workshop download (cheap fix for stale-license
// "Access Denied" on owned games).
buildArgs := func(loginArgs []string) []string {
base := []string{
"run", "--rm",
"-v", "panel-steamcmd-auth:/root/.local/share/Steam",
"-v", "panel-empyrion-workshop:/empyrion-workshop/steamapps/workshop",
"steamcmd/steamcmd:latest",
"+@sSteamCmdForcePlatformType", "windows",
"+force_install_dir", "/empyrion-workshop",
}
base = append(base, loginArgs...)
base = append(base,
"+app_license_request", appID,
"+workshop_download_item", appID, req.WorkshopId, "validate",
"+quit",
)
return base
}
var out string
switch {
case req.SteamUsername == "":
emit("[scenario] no Steam credentials provided — trying anonymous (will fail for app workshops that require ownership)")
out, err = runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", "anonymous"}), emit)
default:
// Try cached refresh-token login first (no password). If the
// agent's panel-steamcmd-auth volume has a valid token from a
// prior install, this skips Steam Guard entirely — no push
// notification fires. Only when the cache is missing or expired
// (Logon failure / no cached credentials in the SteamCMD output)
// do we fall back to full password auth, which DOES require
// approving a Steam Guard push but then re-primes the cache for
// future runs.
emit(fmt.Sprintf("[scenario] login attempt 1: %q via cached refresh token (no Steam Guard push if cache valid)", req.SteamUsername))
cachedOut, cachedErr := runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", req.SteamUsername}), emit)
needsPwd := cachedErr != nil ||
strings.Contains(cachedOut, "Logon failure") ||
strings.Contains(cachedOut, "No cached credentials") ||
strings.Contains(cachedOut, "FAILED login") ||
strings.Contains(cachedOut, "Login failure")
if !needsPwd {
out, err = cachedOut, cachedErr
break
}
if req.SteamPassword == "" {
finish(false, "Steam refresh-token cache is missing/expired on this agent and no password is available to re-prime it. Re-enter credentials via the Steam login modal.")
return
}
emit(fmt.Sprintf("[scenario] login attempt 2: %q with password (Steam Guard push will fire — approve on phone)", req.SteamUsername))
out, err = runWithLineEmit(ctx, dockerBin, buildArgs([]string{"+login", req.SteamUsername, req.SteamPassword}), emit)
}
out = redact(out)
if err != nil {
finish(false, "steamcmd: "+err.Error())
return
}
// Steam reports "Access Denied" when the logged-in account can't
// pull this workshop item. Common causes (in order of likelihood):
// 1. Account doesn't own the app — most common
// 2. License cache stale — we already issued +app_license_request
// above, so if it still errors that's not it
// 3. Workshop item is private / friends-only / restricted by author
// 4. Workshop item requires the operator to "subscribe" to it via
// the desktop Steam client at least once before SteamCMD can
// pull it (some apps gate this)
// 5. Family Library Sharing — the account borrows Empyrion via
// sharing instead of owning a license; SteamCMD requires real
// ownership, not borrowed licenses
if strings.Contains(out, "ERROR! Download item") && strings.Contains(out, "Access Denied") {
// Account confirmed-owner of the app but still denied — this is
// almost always a publisher-side restriction on workshop_download_item
// (Empyrion / Eleon is the canonical example: the desktop Steam
// client uses a different API path that's allowed, but SteamCMD's
// path is denied for this app's workshop). NOT a panel bug, NOT an
// account bug. Manual upload is the only reliable path.
ownsApp := strings.Contains(out, "already owned")
var msg string
if ownsApp {
msg = fmt.Sprintf(
"Workshop item %s denied even though account %q owns app %s (Steam confirmed: \"AppID %s already owned\"). "+
"This is a publisher-side restriction on SteamCMD's workshop API — common for Empyrion (Eleon disables CLI workshop downloads; the desktop Steam client uses a different allowed path). "+
"Workaround: subscribe to the item in the desktop Steam client (Workshop → Subscribe), let it download to %%STEAM%%/steamapps/workshop/content/%s/%s/, then upload that folder into the instance's panel-empyrion-game volume at Content/Scenarios/<scenario_name>/. The panel can't bypass this — Steam policy enforced server-side.",
req.WorkshopId, req.SteamUsername, appID, appID, appID, req.WorkshopId,
)
} else {
msg = fmt.Sprintf(
"Steam returned Access Denied for workshop item %s after logging in as %q. "+
"Likely causes: (a) account doesn't actually own app %s (Family Library Sharing borrows don't count — needs a purchase license); "+
"(b) workshop item is private/friends-only/restricted by author; "+
"(c) item requires desktop-Steam-client subscription first. "+
"Workaround: download via desktop Steam client and drop Content/Scenarios/<folder> into the instance volume manually.",
req.WorkshopId, req.SteamUsername, appID,
)
}
finish(false, msg)
return
}
if !strings.Contains(out, "Success. Downloaded item") {
// Don't fail outright on a missing success marker — some
// SteamCMD versions return without that exact string when the
// item is already cached. Sanity-check the file actually
// exists in the volume before deciding.
probe := []string{"run", "--rm", "-v", "panel-empyrion-workshop:/ws", "alpine:3.20",
"sh", "-c", fmt.Sprintf("test -d /ws/content/%s/%s && echo cached", appID, req.WorkshopId)}
probeOut, _ := exec.CommandContext(ctx, dockerBin, probe...).CombinedOutput()
if !strings.Contains(string(probeOut), "cached") {
finish(false, "steamcmd did not download item — see log for details")
return
}
emit("[scenario] workshop item already cached on this agent — skipping re-download")
}
// Phase 2 — copy into instance volume.
emit("[scenario] copying scenario files into instance volume…")
gameVol := "panel-" + req.InstanceId + "-game"
dst := "/dst/Content/Scenarios/" + req.ScenarioName
// Verify the instance volume exists; if not, the instance was
// never created on this agent.
probe := []string{"volume", "inspect", gameVol}
if err := exec.CommandContext(ctx, dockerBin, probe...).Run(); err != nil {
finish(false, fmt.Sprintf("instance volume %q not found on this agent — is the empyrion instance created here?", gameVol))
return
}
helperArgs := []string{
"run", "--rm",
"-v", "panel-empyrion-workshop:/src",
"-v", gameVol + ":/dst",
"alpine:3.20", "sh", "-c",
fmt.Sprintf(
"mkdir -p %s && rm -rf %s/* %s/.* 2>/dev/null; "+
"cp -a /src/content/%s/%s/. %s/ && date > %s/.panel-installed && echo OK",
shQuote(dst), shQuote(dst), shQuote(dst),
appID, req.WorkshopId, shQuote(dst), shQuote(dst),
),
}
out, err = runWithLineEmit(ctx, dockerBin, helperArgs, emit)
if err != nil {
finish(false, "scenario copy failed: "+err.Error())
return
}
if !strings.Contains(out, "OK") {
finish(false, "scenario copy did not confirm OK — see log for details")
return
}
emit(fmt.Sprintf("[scenario] %q installed into %s", req.ScenarioName, req.InstanceId))
finish(true, "")
}
// shQuote single-quotes a path for safe inclusion in a `sh -c` script.
// Embedded single quotes are escaped via the standard '\'' trick. Used
// for scenario_name which is operator input — even though the controller
// already rejects path-traversal characters, defense-in-depth at the
// agent layer is cheap.
func shQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// runWithLineEmit spawns docker, captures combined stdout/stderr,
// emits each line as it arrives, and returns the full combined output
// for post-mortem checks (success markers etc.) when the process exits.
func runWithLineEmit(ctx context.Context, bin string, args []string, emit func(string)) (string, error) {
cmd := exec.CommandContext(ctx, bin, args...)
pipe, err := cmd.StdoutPipe()
if err != nil {
return "", err
}
cmd.Stderr = cmd.Stdout
if err := cmd.Start(); err != nil {
return "", err
}
var buf strings.Builder
scanCh := make(chan struct{})
go func() {
defer close(scanCh)
readBuf := make([]byte, 4096)
var line strings.Builder
for {
n, err := pipe.Read(readBuf)
if n > 0 {
chunk := readBuf[:n]
buf.Write(chunk)
for _, b := range chunk {
if b == '\n' {
emit(strings.TrimRight(line.String(), "\r"))
line.Reset()
} else {
line.WriteByte(b)
}
}
}
if err != nil {
if line.Len() > 0 {
emit(strings.TrimRight(line.String(), "\r"))
}
return
}
}
}()
waitErr := cmd.Wait()
<-scanCh
if waitErr != nil {
return buf.String(), waitErr
}
return buf.String(), nil
}
func (d *Dispatcher) sendEmpyrionScenarioResult(corrID, jobID string, ok bool, errMsg, scenarioName string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_EmpyrionScenarioInstallResult{
EmpyrionScenarioInstallResult: &panelv1.EmpyrionScenarioInstallResult{
JobId: jobID,
Ok: ok,
Error: errMsg,
ScenarioName: scenarioName,
},
},
})
}
+662
View File
@@ -0,0 +1,662 @@
package dispatch
import (
"context"
"errors"
"fmt"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// File management: operations are rooted at each instance's declared
// BrowseableRoot inside the container when the container is running +
// the runtime supports container-path ops (Docker). Otherwise we fall
// back to the host-side DataPath bind mount. Either path is safe-joined
// to prevent escape.
const (
// fsMaxReadBytes caps a single file-read RPC. Big enough for full
// world saves and most ARK/Empyrion scenario zips (a few-hundred MB
// is common). True streaming would remove the cap entirely; until
// then 4 GiB matches the controller-side upload cap.
fsMaxReadBytes = 4 * 1024 * 1024 * 1024
fsMaxListSize = 5000
// fsCopyTimeout bounds a single CopyToContainer / CopyFromContainer
// call. A multi-GB upload over the loopback Docker socket can take
// 30+ seconds, and an extract that ships the whole archive's
// contents back to the container can be larger still — give it
// real headroom so we don't surface spurious timeouts.
fsCopyTimeout = 10 * time.Minute
)
// ---- Path safety ----
// safeJoinHost joins rel onto root using OS path semantics, rejecting
// anything that canonicalizes outside of root.
func safeJoinHost(root, rel string) (string, error) {
rel = strings.TrimPrefix(rel, "/")
rel = strings.TrimPrefix(rel, "\\")
if rel == "" {
return root, nil
}
joined := filepath.Join(root, rel)
absRoot, err := filepath.Abs(root)
if err != nil {
return "", err
}
absJoined, err := filepath.Abs(joined)
if err != nil {
return "", err
}
sep := string(filepath.Separator)
if absJoined != absRoot && !strings.HasPrefix(absJoined+sep, absRoot+sep) {
return "", fmt.Errorf("path escapes instance data directory")
}
return absJoined, nil
}
// safeJoinContainer joins rel onto root using Linux container semantics
// (always forward-slash). Returns the cleaned absolute path.
func safeJoinContainer(root, rel string) (string, error) {
rel = strings.TrimPrefix(rel, "/")
if rel == "" {
return root, nil
}
joined := path.Clean(path.Join(root, rel))
if joined != root && !strings.HasPrefix(joined+"/", root+"/") {
return "", fmt.Errorf("path escapes instance root")
}
return joined, nil
}
// rootPaths returns the full list of allowed root paths for a record
// (flattens BrowseableRoots → container paths, or falls back to the
// singular BrowseableRoot). Always returns at least one entry.
func rootPaths(rec *instanceRecord) []string {
if len(rec.BrowseableRoots) > 0 {
out := make([]string, 0, len(rec.BrowseableRoots))
for _, r := range rec.BrowseableRoots {
if r.Path != "" {
out = append(out, r.Path)
}
}
if len(out) > 0 {
return out
}
}
if rec.BrowseableRoot != "" {
return []string{rec.BrowseableRoot}
}
return nil
}
// safeJoinAny resolves a caller-supplied path against ANY of the module's
// declared roots. Semantics:
//
// - If p starts with "/", it must be EQUAL TO or UNDER one of the
// roots; we return it cleaned as-is. Lets the UI send container-
// absolute paths when it knows the full layout (multi-root modules).
// - Otherwise p is relative, resolved against `defaultRoot`. Same
// safeJoinContainer behaviour as before.
//
// Returns an error if the path can't be mapped to any root or tries to
// escape via `..`.
func safeJoinAny(defaultRoot string, roots []string, p string) (string, error) {
if strings.HasPrefix(p, "/") {
cleaned := path.Clean(p)
for _, r := range roots {
if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") {
return cleaned, nil
}
}
return "", fmt.Errorf("path %q is not under any declared root", cleaned)
}
return safeJoinContainer(defaultRoot, p)
}
func (d *Dispatcher) lookupRecord(instanceID string) (*instanceRecord, error) {
d.mu.Lock()
defer d.mu.Unlock()
rec, ok := d.instances[instanceID]
if !ok {
return nil, fmt.Errorf("instance %q not on this target", instanceID)
}
return rec, nil
}
// useContainerOps decides whether to go through the container runtime
// (true) or fall back to the host bind mount (false). Any module that
// declares a browseable root uses container ops — if the main container
// isn't running at the moment, we spin up a helper sidecar (see
// getFsTargetContainerID). This lets operators browse/edit files on
// stopped instances.
func (d *Dispatcher) useContainerOps(rec *instanceRecord) bool {
return rec.BrowseableRoot != ""
}
// ---- FS helper sidecar: file ops on stopped containers ----
// fsHelperImage is the base for the helper container. We use debian-slim
// rather than alpine because our `ls --time-style=+%s` call relies on GNU
// coreutils — busybox's ls doesn't accept --time-style. debian:12-slim is
// already on disk for every panel-native game module, so no extra pull.
const fsHelperImage = "debian:12-slim"
// fsHelperIdleTTL is how long a helper container is kept alive after its
// last file-op access. Balance: long enough that click-around browsing
// reuses the same helper without respawning; short enough that a forgotten
// Files tab doesn't pin volumes forever.
const fsHelperIdleTTL = 5 * time.Minute
// getFsTargetContainerID picks the container to route a file op through:
// the main instance container if it's running, or a helper sidecar
// (lazy-created + started on demand) if it isn't. The helper reuses the
// same named volumes as the main container, so the operator sees the
// same files they would see with the game running.
func (d *Dispatcher) getFsTargetContainerID(ctx context.Context, rec *instanceRecord) (string, error) {
mainName := "panel-" + rec.InstanceID
st, err := d.runtime.InspectByName(ctx, mainName)
if err == nil && st.Status == "running" {
// Main is alive — tear down any lingering helper so volumes
// aren't held by two containers simultaneously during live play.
d.teardownFsHelper(rec.InstanceID)
return st.ContainerID, nil
}
return d.ensureFsHelper(ctx, rec)
}
func (d *Dispatcher) ensureFsHelper(ctx context.Context, rec *instanceRecord) (string, error) {
helperName := "panel-" + rec.InstanceID + "-fs"
// Reuse existing helper if it's still healthy.
d.mu.Lock()
h, ok := d.fsHelpers[rec.InstanceID]
d.mu.Unlock()
if ok {
st, err := d.runtime.InspectByName(ctx, helperName)
if err == nil && st.Status == "running" {
d.mu.Lock()
h.lastAccess = time.Now()
d.mu.Unlock()
return h.containerID, nil
}
// Stale record — container gone or in a bad state. Drop + rebuild.
d.teardownFsHelper(rec.InstanceID)
}
// Check for an orphaned helper container on Docker's side from a
// previous agent run. When the agent restarts, in-memory fsHelpers
// empties but the container persists. Adopt it if it's running,
// remove it if not (Create will then succeed).
if st, err := d.runtime.InspectByName(ctx, helperName); err == nil {
if st.Status == "running" {
d.log.Info("fs helper adopted from prior agent run",
"instance_id", rec.InstanceID, "container_id", st.ContainerID)
d.mu.Lock()
d.fsHelpers[rec.InstanceID] = &fsHelper{containerID: st.ContainerID, lastAccess: time.Now()}
d.mu.Unlock()
return st.ContainerID, nil
}
d.log.Info("removing stale fs helper before recreate",
"instance_id", rec.InstanceID, "container_id", st.ContainerID, "status", st.Status)
rmCtx, rmCancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = d.runtime.Remove(rmCtx, st.ContainerID)
rmCancel()
}
// Resolve the instance's volumes from its manifest so the helper sees
// exactly what the game would.
manifest, ok := d.modules.Get(rec.ModuleID)
if !ok {
return "", fmt.Errorf("module %q not loaded; can't mount helper volumes", rec.ModuleID)
}
volumes := agentmodule.ResolveVolumes(manifest, rec.InstanceID, rec.DataPath)
spec := runtime.InstanceSpec{
InstanceID: rec.InstanceID + "-fs",
IsSidecar: true,
Image: fsHelperImage,
Command: []string{"sleep", "infinity"},
Volumes: volumes,
RestartPolicy: "no",
}
containerID, err := d.runtime.Create(ctx, spec)
if err != nil {
return "", fmt.Errorf("create fs helper: %w", err)
}
if err := d.runtime.Start(ctx, containerID); err != nil {
// Clean up the half-created container so next call doesn't see a
// zombie from `Created` state.
rmCtx, rmCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer rmCancel()
_ = d.runtime.Remove(rmCtx, containerID)
return "", fmt.Errorf("start fs helper: %w", err)
}
d.mu.Lock()
d.fsHelpers[rec.InstanceID] = &fsHelper{containerID: containerID, lastAccess: time.Now()}
d.mu.Unlock()
d.log.Info("fs helper started", "instance_id", rec.InstanceID, "container_id", containerID)
return containerID, nil
}
// teardownFsHelper stops and removes the helper sidecar for an instance,
// if one exists. Safe to call at any time — no-ops on absent helpers.
// Called on main-container start (to free volumes) and on instance
// delete (cleanup).
func (d *Dispatcher) teardownFsHelper(instanceID string) {
d.mu.Lock()
h, ok := d.fsHelpers[instanceID]
if ok {
delete(d.fsHelpers, instanceID)
}
d.mu.Unlock()
if !ok {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = d.runtime.Stop(ctx, h.containerID, 2*time.Second)
_ = d.runtime.Remove(ctx, h.containerID)
d.log.Info("fs helper torn down", "instance_id", instanceID)
}
// fsHelperReaper runs for the lifetime of the dispatcher and tears down
// any helper that hasn't been touched in fsHelperIdleTTL. Scans every
// minute — granular enough that abandoned Files tabs don't pin resources
// for long, coarse enough that the scan itself is cheap.
func (d *Dispatcher) fsHelperReaper() {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for range ticker.C {
now := time.Now()
d.mu.Lock()
stale := make([]string, 0)
for id, h := range d.fsHelpers {
if now.Sub(h.lastAccess) > fsHelperIdleTTL {
stale = append(stale, id)
}
}
d.mu.Unlock()
for _, id := range stale {
d.teardownFsHelper(id)
}
}
}
// ---- FsList ----
func (d *Dispatcher) handleFsList(corrID string, req *panelv1.FsListRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsListResult(corrID, nil, "", err.Error())
return
}
if d.useContainerOps(rec) {
entries, err := d.listInContainer(rec, req.Path)
if err != nil {
d.sendFsListResult(corrID, nil, req.Path, err.Error())
return
}
d.sendFsListResult(corrID, entries, req.Path, "")
return
}
// Host fallback — bind mount.
if rec.DataPath == "" {
d.sendFsListResult(corrID, nil, req.Path, "no file storage available (container not running and no host data path)")
return
}
absPath, err := safeJoinHost(rec.DataPath, req.Path)
if err != nil {
d.sendFsListResult(corrID, nil, req.Path, err.Error())
return
}
entries, err := os.ReadDir(absPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
d.sendFsListResult(corrID, nil, req.Path, "not found")
return
}
d.sendFsListResult(corrID, nil, req.Path, err.Error())
return
}
out := make([]*panelv1.FsEntry, 0, len(entries))
for i, e := range entries {
if i >= fsMaxListSize {
break
}
info, err := e.Info()
if err != nil {
continue
}
out = append(out, &panelv1.FsEntry{
Name: e.Name(),
Path: filepath.ToSlash(filepath.Join(req.Path, e.Name())),
IsDir: e.IsDir(),
Size: info.Size(),
ModTime: timestamppb.New(info.ModTime()),
})
}
d.sendFsListResult(corrID, out, filepath.ToSlash(req.Path), "")
}
// listInContainer shells out to `ls -la --time-style=+%s <root/rel>` and
// parses the output into FsEntry records.
func (d *Dispatcher) listInContainer(rec *instanceRecord, rel string) ([]*panelv1.FsEntry, error) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), rel)
if err != nil {
return nil, err
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
return nil, err
}
// -L dereferences symlinks so that listing a `@ModName` link (which
// points into the shared workshop tree) shows the mod's contents, not
// the symlink itself. Without this, `ls -l` on a symlinked dir prints
// the symlink as a single entry and the UI looks empty.
stdout, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{
"sh", "-c",
"ls -lAL --time-style=+%s -- " + shellQuote(abs),
})
if err != nil {
return nil, fmt.Errorf("exec ls: %w", err)
}
if code != 0 {
msg := strings.TrimSpace(string(stderr))
if msg == "" {
msg = fmt.Sprintf("ls exited %d", code)
}
return nil, fmt.Errorf("%s", msg)
}
return parseLsOutput(string(stdout), rel)
}
// parseLsOutput parses `ls -lA --time-style=+%s` output. Each line:
//
// drwxr-xr-x 2 root root 4096 1735000000 somefile
//
// We split on any whitespace and take fields. The date field is a unix
// timestamp (because of --time-style=+%s).
func parseLsOutput(out, relRoot string) ([]*panelv1.FsEntry, error) {
lines := strings.Split(strings.TrimSpace(out), "\n")
entries := make([]*panelv1.FsEntry, 0, len(lines))
for i, line := range lines {
if i == 0 && strings.HasPrefix(line, "total ") {
continue
}
// Split into at most 7 fields so filenames with spaces survive.
fields := strings.Fields(line)
if len(fields) < 7 {
continue
}
// fields[0]=mode 1=nlink 2=user 3=group 4=size 5=unixts 6+=name
size, _ := strconv.ParseInt(fields[4], 10, 64)
tsUnix, _ := strconv.ParseInt(fields[5], 10, 64)
name := strings.Join(fields[6:], " ")
// For symlinks "name -> target", keep just the name.
if strings.Contains(name, " -> ") {
name = strings.SplitN(name, " -> ", 2)[0]
}
if name == "" || name == "." || name == ".." {
continue
}
isDir := strings.HasPrefix(fields[0], "d")
entries = append(entries, &panelv1.FsEntry{
Name: name,
Path: path.Join(relRoot, name),
IsDir: isDir,
Size: size,
ModTime: timestamppb.New(time.Unix(tsUnix, 0)),
})
}
return entries, nil
}
// shellQuote wraps s in single quotes, escaping embedded quotes for
// interpolation into `sh -c`.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
func (d *Dispatcher) sendFsListResult(corrID string, entries []*panelv1.FsEntry, p, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsListResult{
FsListResult: &panelv1.FsListResult{Entries: entries, Path: p, Error: errMsg},
},
})
}
// ---- FsRead ----
func (d *Dispatcher) handleFsRead(corrID string, req *panelv1.FsReadRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
if d.useContainerOps(rec) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
data, err := d.runtime.CopyFileFromContainer(ctx, targetID, abs)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
if len(data) > fsMaxReadBytes {
d.sendFsReadResult(corrID, req.Path, nil, fmt.Sprintf("file too large (%d bytes; max %d)", len(data), fsMaxReadBytes))
return
}
d.sendFsReadResult(corrID, req.Path, data, "")
return
}
// Host fallback.
if rec.DataPath == "" {
d.sendFsReadResult(corrID, req.Path, nil, "no file storage available")
return
}
abs, err := safeJoinHost(rec.DataPath, req.Path)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
info, err := os.Stat(abs)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
if info.IsDir() {
d.sendFsReadResult(corrID, req.Path, nil, "is a directory")
return
}
if info.Size() > fsMaxReadBytes {
d.sendFsReadResult(corrID, req.Path, nil, fmt.Sprintf("file too large (%d bytes; max %d)", info.Size(), fsMaxReadBytes))
return
}
data, err := os.ReadFile(abs)
if err != nil {
d.sendFsReadResult(corrID, req.Path, nil, err.Error())
return
}
d.sendFsReadResult(corrID, req.Path, data, "")
}
func (d *Dispatcher) sendFsReadResult(corrID, p string, content []byte, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsReadResult{
FsReadResult: &panelv1.FsReadResult{Path: p, Content: content, Error: errMsg},
},
})
}
// ---- FsWrite ----
func (d *Dispatcher) handleFsWrite(corrID string, req *panelv1.FsWriteRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
if d.useContainerOps(rec) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
if err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
// Make sure the parent dir exists. `mkdir -p` inside container.
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
targetID, terr := d.getFsTargetContainerID(ctx, rec)
if terr != nil {
cancel()
d.sendFsWriteResult(corrID, 0, terr.Error())
return
}
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(path.Dir(abs))})
cancel()
ctx2, cancel2 := context.WithTimeout(context.Background(), fsCopyTimeout)
defer cancel2()
if err := d.runtime.CopyFileToContainer(ctx2, targetID, abs, req.Content); err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
d.sendFsWriteResult(corrID, int64(len(req.Content)), "")
return
}
// Host fallback.
if rec.DataPath == "" {
d.sendFsWriteResult(corrID, 0, "no file storage available")
return
}
abs, err := safeJoinHost(rec.DataPath, req.Path)
if err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
if err := os.WriteFile(abs, req.Content, 0o644); err != nil {
d.sendFsWriteResult(corrID, 0, err.Error())
return
}
d.sendFsWriteResult(corrID, int64(len(req.Content)), "")
}
func (d *Dispatcher) sendFsWriteResult(corrID string, bytesWritten int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsWriteResult{
FsWriteResult: &panelv1.FsWriteResult{BytesWritten: bytesWritten, Error: errMsg},
},
})
}
// ---- FsDelete ----
func (d *Dispatcher) handleFsDelete(corrID string, req *panelv1.FsDeleteRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
if d.useContainerOps(rec) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.Path)
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
if abs == rec.BrowseableRoot {
d.sendFsDeleteResult(corrID, "refusing to delete instance root")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
flag := ""
if req.Recursive {
flag = "-rf"
} else {
flag = "-f"
}
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"rm " + flag + " -- " + shellQuote(abs)})
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
if code != 0 {
d.sendFsDeleteResult(corrID, strings.TrimSpace(string(stderr)))
return
}
d.sendFsDeleteResult(corrID, "")
return
}
// Host fallback.
if rec.DataPath == "" {
d.sendFsDeleteResult(corrID, "no file storage available")
return
}
abs, err := safeJoinHost(rec.DataPath, req.Path)
if err != nil {
d.sendFsDeleteResult(corrID, err.Error())
return
}
if abs == rec.DataPath {
d.sendFsDeleteResult(corrID, "refusing to delete instance data root")
return
}
var delErr error
if req.Recursive {
delErr = os.RemoveAll(abs)
} else {
delErr = os.Remove(abs)
}
if delErr != nil {
d.sendFsDeleteResult(corrID, delErr.Error())
return
}
d.sendFsDeleteResult(corrID, "")
}
func (d *Dispatcher) sendFsDeleteResult(corrID, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsDeleteResult{
FsDeleteResult: &panelv1.FsDeleteResult{Error: errMsg},
},
})
}
+511
View File
@@ -0,0 +1,511 @@
package dispatch
// Archive operations for the file browser:
//
// - FsExtract : expand a zip / tar / tar.gz / tar.bz2 archive that
// already lives inside the instance's volume.
// - FsCompress : zip up one or more paths inside the instance into a
// destination .zip in the same volume.
// - FsRename : safe `mv` that respects the browseable_root sandbox.
//
// Every container-side path goes through the existing fs helper sidecar
// (see getFsTargetContainerID) so these all work on stopped instances.
//
// Implementation notes:
// - Extract reads the archive bytes via CopyFileFromContainer, walks
// the entries with the appropriate stdlib reader, and re-emits a
// single tar stream that's piped to CopyTarToContainer in one API
// call. That avoids N per-file writes.
// - Compress walks each source via CopyDirFromContainer (tar stream
// from Docker), and re-encodes the entries as zip. The final zip
// bytes go back via CopyFileToContainer.
// - We support zip + tar + tar.gz + tar.bz2 + tar.xz + 7z + rar.
// 7z and rar use pure-Go libraries (bodgit/sevenzip,
// nwaples/rardecode/v2) so no native deps. Encrypted rars and
// solid 7z volumes are surfaced as errors, not silently skipped.
import (
"archive/tar"
"archive/zip"
"bytes"
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/agent/internal/archive"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// ---- FsExtract ----
func (d *Dispatcher) handleFsExtract(corrID string, req *panelv1.FsExtractRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
if req.ArchivePath == "" {
d.sendFsExtractResult(corrID, 0, 0, "archive_path is required")
return
}
if d.useContainerOps(rec) {
d.extractInContainer(corrID, rec, req)
return
}
d.extractOnHost(corrID, rec, req)
}
func (d *Dispatcher) extractInContainer(corrID string, rec *instanceRecord, req *panelv1.FsExtractRequest) {
archiveAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.ArchivePath)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
destRel := req.DestDir
if destRel == "" {
// Default: same dir as the archive.
destRel = path.Dir(req.ArchivePath)
}
destAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), destRel)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), fsCopyTimeout)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
// Pull the archive bytes back from the container. CopyFileFromContainer
// internally tar-walks Docker's response and returns just the file body.
archiveData, err := d.runtime.CopyFileFromContainer(ctx, targetID, archiveAbs)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, fmt.Errorf("read archive: %w", err).Error())
return
}
// Make sure the destination dir exists before we ship the tar stream.
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(destAbs)})
// Build a tar stream of the archive's contents and pipe it to
// CopyTarToContainer. Doing this in a single round-trip keeps the
// wall-time of "extract a 500 MB archive with 4000 entries" manageable.
pr, pw := io.Pipe()
emitErr := make(chan error, 1)
var entries, bytesOut int64
go func() {
n, b, err := archive.WriteAsTar(archiveData, req.ArchivePath, pw)
entries, bytesOut = n, b
_ = pw.CloseWithError(err)
emitErr <- err
}()
if err := d.runtime.CopyTarToContainer(ctx, targetID, destAbs, pr); err != nil {
// Pipe gets closed with our error from the goroutine; whichever
// side surfaces first wins. We prefer the goroutine error since
// it carries archive-format detail.
if encErr := <-emitErr; encErr != nil {
d.sendFsExtractResult(corrID, entries, bytesOut, encErr.Error())
return
}
d.sendFsExtractResult(corrID, entries, bytesOut, fmt.Errorf("copy to container: %w", err).Error())
return
}
if encErr := <-emitErr; encErr != nil {
d.sendFsExtractResult(corrID, entries, bytesOut, encErr.Error())
return
}
d.sendFsExtractResult(corrID, entries, bytesOut, "")
}
// extractOnHost handles the rare host-fallback case (no container, no
// helper) — uses os primitives. Same path-safety contract as everywhere
// else: must stay under the instance's data path.
func (d *Dispatcher) extractOnHost(corrID string, rec *instanceRecord, req *panelv1.FsExtractRequest) {
if rec.DataPath == "" {
d.sendFsExtractResult(corrID, 0, 0, "no file storage available")
return
}
archiveAbs, err := safeJoinHost(rec.DataPath, req.ArchivePath)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
destRel := req.DestDir
if destRel == "" {
destRel = filepath.Dir(req.ArchivePath)
}
destAbs, err := safeJoinHost(rec.DataPath, destRel)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
if err := os.MkdirAll(destAbs, 0o755); err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
data, err := os.ReadFile(archiveAbs)
if err != nil {
d.sendFsExtractResult(corrID, 0, 0, err.Error())
return
}
entries, bytesOut, err := archive.Walk(data, req.ArchivePath, func(name string, mode int64, isDir bool, body io.Reader) error {
safeName := archive.SafeEntryPath(name)
if safeName == "" {
return nil
}
out := filepath.Join(destAbs, filepath.FromSlash(safeName))
if isDir {
return os.MkdirAll(out, 0o755)
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, os.FileMode(mode&0o777))
if err != nil {
return err
}
if _, err := io.Copy(f, body); err != nil {
_ = f.Close()
return err
}
return f.Close()
})
if err != nil {
d.sendFsExtractResult(corrID, entries, bytesOut, err.Error())
return
}
d.sendFsExtractResult(corrID, entries, bytesOut, "")
}
func (d *Dispatcher) sendFsExtractResult(corrID string, entries, bytesOut int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsExtractResult{
FsExtractResult: &panelv1.FsExtractResult{EntriesWritten: entries, BytesWritten: bytesOut, Error: errMsg},
},
})
}
// ---- FsCompress ----
func (d *Dispatcher) handleFsCompress(corrID string, req *panelv1.FsCompressRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
if len(req.Sources) == 0 {
d.sendFsCompressResult(corrID, 0, 0, "at least one source path is required")
return
}
if req.DestZipPath == "" {
d.sendFsCompressResult(corrID, 0, 0, "dest_zip_path is required")
return
}
if d.useContainerOps(rec) {
d.compressInContainer(corrID, rec, req)
return
}
d.compressOnHost(corrID, rec, req)
}
func (d *Dispatcher) compressInContainer(corrID string, rec *instanceRecord, req *panelv1.FsCompressRequest) {
destAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.DestZipPath)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
ctx, cancel := context.WithTimeout(context.Background(), fsCopyTimeout)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
var zipBuf bytes.Buffer
zw := zip.NewWriter(&zipBuf)
var entries, bytesOut int64
for _, src := range req.Sources {
srcAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), src)
if err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
// CopyDirFromContainer returns Docker's tar stream of srcAbs.
// For files: a single-entry tar. For dirs: every file under it.
// The base name of srcAbs is the prefix Docker uses, which we
// keep in the zip so the user gets familiar names back.
rc, err := d.runtime.CopyDirFromContainer(ctx, targetID, srcAbs)
if err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, fmt.Errorf("copy from container %q: %w", src, err).Error())
return
}
n, b, err := pipeTarIntoZip(rc, zw)
_ = rc.Close()
if err != nil {
d.sendFsCompressResult(corrID, entries+n, bytesOut+b, fmt.Errorf("encode %q: %w", src, err).Error())
return
}
entries += n
bytesOut += b
}
if err := zw.Close(); err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, fmt.Errorf("zip close: %w", err).Error())
return
}
// Drop the resulting zip into the volume.
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(path.Dir(destAbs))})
if err := d.runtime.CopyFileToContainer(ctx, targetID, destAbs, zipBuf.Bytes()); err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
d.sendFsCompressResult(corrID, entries, bytesOut, "")
}
func (d *Dispatcher) compressOnHost(corrID string, rec *instanceRecord, req *panelv1.FsCompressRequest) {
if rec.DataPath == "" {
d.sendFsCompressResult(corrID, 0, 0, "no file storage available")
return
}
destAbs, err := safeJoinHost(rec.DataPath, req.DestZipPath)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
if err := os.MkdirAll(filepath.Dir(destAbs), 0o755); err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
f, err := os.Create(destAbs)
if err != nil {
d.sendFsCompressResult(corrID, 0, 0, err.Error())
return
}
zw := zip.NewWriter(f)
var entries, bytesOut int64
for _, src := range req.Sources {
srcAbs, err := safeJoinHost(rec.DataPath, src)
if err != nil {
zw.Close()
f.Close()
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
base := filepath.Base(srcAbs)
err = filepath.Walk(srcAbs, func(p string, info os.FileInfo, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(srcAbs, p)
if err != nil {
return err
}
zipName := base
if rel != "." {
zipName = filepath.ToSlash(filepath.Join(base, rel))
}
if info.IsDir() {
if zipName == "" || zipName == "." {
return nil
}
_, err = zw.Create(zipName + "/")
return err
}
zf, err := zw.Create(zipName)
if err != nil {
return err
}
rf, err := os.Open(p)
if err != nil {
return err
}
defer rf.Close()
n, err := io.Copy(zf, rf)
if err != nil {
return err
}
entries++
bytesOut += n
return nil
})
if err != nil {
zw.Close()
f.Close()
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
}
if err := zw.Close(); err != nil {
f.Close()
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
if err := f.Close(); err != nil {
d.sendFsCompressResult(corrID, entries, bytesOut, err.Error())
return
}
d.sendFsCompressResult(corrID, entries, bytesOut, "")
}
// pipeTarIntoZip walks a docker-style tar stream and writes each
// regular-file entry into the zip writer, preserving relative paths.
// Directory entries are written as zero-byte zip directory markers.
func pipeTarIntoZip(r io.Reader, zw *zip.Writer) (int64, int64, error) {
tr := tar.NewReader(r)
var entries, bytesOut, totalBytes int64
for {
hdr, err := tr.Next()
if err == io.EOF {
return entries, bytesOut, nil
}
if err != nil {
return entries, bytesOut, err
}
safe := archive.SafeEntryPath(hdr.Name)
if safe == "" {
continue
}
switch hdr.Typeflag {
case tar.TypeDir:
if _, err := zw.Create(safe + "/"); err != nil {
return entries, bytesOut, err
}
entries++
case tar.TypeReg, tar.TypeRegA:
zf, err := zw.Create(safe)
if err != nil {
return entries, bytesOut, err
}
n, err := io.Copy(zf, tr)
if err != nil {
return entries, bytesOut, err
}
entries++
bytesOut += n
totalBytes += n
if totalBytes > archive.MaxTotalBytes {
return entries, bytesOut, fmt.Errorf("compress: total uncompressed bytes exceed %d", archive.MaxTotalBytes)
}
}
}
}
func (d *Dispatcher) sendFsCompressResult(corrID string, entries, bytesOut int64, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsCompressResult{
FsCompressResult: &panelv1.FsCompressResult{EntriesWritten: entries, BytesWritten: bytesOut, Error: errMsg},
},
})
}
// ---- FsRename ----
func (d *Dispatcher) handleFsRename(corrID string, req *panelv1.FsRenameRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if req.FromPath == "" || req.ToPath == "" {
d.sendFsRenameResult(corrID, "from_path and to_path are required")
return
}
if d.useContainerOps(rec) {
fromAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.FromPath)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
toAbs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), req.ToPath)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if fromAbs == rec.BrowseableRoot {
d.sendFsRenameResult(corrID, "refusing to rename instance root")
return
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
// mkdir parent so a "rename to subdir" implicitly creates it.
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(path.Dir(toAbs))})
_, stderr, code, err := d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mv -- " + shellQuote(fromAbs) + " " + shellQuote(toAbs)})
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if code != 0 {
msg := strings.TrimSpace(string(stderr))
if msg == "" {
msg = fmt.Sprintf("mv exited %d", code)
}
d.sendFsRenameResult(corrID, msg)
return
}
d.sendFsRenameResult(corrID, "")
return
}
// Host fallback.
if rec.DataPath == "" {
d.sendFsRenameResult(corrID, "no file storage available")
return
}
fromAbs, err := safeJoinHost(rec.DataPath, req.FromPath)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
toAbs, err := safeJoinHost(rec.DataPath, req.ToPath)
if err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if fromAbs == rec.DataPath {
d.sendFsRenameResult(corrID, "refusing to rename instance root")
return
}
if err := os.MkdirAll(filepath.Dir(toAbs), 0o755); err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
if err := os.Rename(fromAbs, toAbs); err != nil {
d.sendFsRenameResult(corrID, err.Error())
return
}
d.sendFsRenameResult(corrID, "")
}
func (d *Dispatcher) sendFsRenameResult(corrID, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsRenameResult{
FsRenameResult: &panelv1.FsRenameResult{Error: errMsg},
},
})
}
+337
View File
@@ -0,0 +1,337 @@
package dispatch
// Chunked file upload — solves three problems at once:
//
// 1. Cloudflare's request-body cap (~100 MB free, 200 MB pro) silently
// stalls "single-shot" uploads of large mod / scenario archives
// that go through the panel's public hostname.
// 2. The previous single-shot path buffered the entire body in memory
// on both the controller and the agent, peaking at ~3× file size.
// A 3 GB upload would thrash a smaller box.
// 3. Single-shot uploads block the agent's bidi stream for the
// duration of the transfer; chunks are small and interleaved.
//
// Wire protocol (all on the existing AgentEnvelope):
//
// FsWriteChunkRequest { upload_id, instance_id, path, offset, data,
// total_size, is_final }
// FsWriteChunkResult { bytes_received, total_received, finalized,
// error }
//
// The agent owns one `uploadSession` per upload_id. Each session has an
// `*os.File` open in its scratch dir. Chunks append in order. On
// is_final the agent ships the file to the container via a streaming
// CopyTarToContainer call (no full-file in-memory copy), then unlinks.
// Stale sessions are reaped after uploadIdleTTL.
import (
"archive/tar"
"context"
"fmt"
"io"
"os"
"path"
"path/filepath"
"sync"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
const uploadIdleTTL = 30 * time.Minute
type uploadSession struct {
mu sync.Mutex
uploadID string
instanceID string
path string
tempPath string
f *os.File
bytes int64
total int64
lastTouch time.Time
}
func (d *Dispatcher) uploadScratchDir() string {
dir := filepath.Join(d.dataRoot, ".panel-uploads")
_ = os.MkdirAll(dir, 0o755)
return dir
}
// handleFsWriteChunk processes one chunk in the streaming-upload protocol.
// First chunk creates the session; subsequent chunks append; is_final
// triggers a streaming copy into the container and cleans up.
func (d *Dispatcher) handleFsWriteChunk(corrID string, req *panelv1.FsWriteChunkRequest) {
if req.UploadId == "" {
d.sendChunkResult(corrID, 0, 0, false, "upload_id is required")
return
}
if req.InstanceId == "" || req.Path == "" {
d.sendChunkResult(corrID, 0, 0, false, "instance_id and path are required")
return
}
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendChunkResult(corrID, 0, 0, false, err.Error())
return
}
d.mu.Lock()
sess, ok := d.uploads[req.UploadId]
if !ok {
// First chunk for this upload — create session + temp file.
// Use the upload_id in the filename so a recovered scratch dir
// is self-describing for forensic purposes.
base := filepath.Join(d.uploadScratchDir(), "u-"+sanitizeUploadID(req.UploadId))
f, err := os.OpenFile(base, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
d.mu.Unlock()
d.sendChunkResult(corrID, 0, 0, false, fmt.Errorf("open temp: %w", err).Error())
return
}
sess = &uploadSession{
uploadID: req.UploadId,
instanceID: req.InstanceId,
path: req.Path,
tempPath: base,
f: f,
total: req.TotalSize,
lastTouch: time.Now(),
}
d.uploads[req.UploadId] = sess
}
d.mu.Unlock()
sess.mu.Lock()
defer sess.mu.Unlock()
// Reject stray chunks for a session that's already closed (final
// chunk processed earlier — operator double-clicked, retry, etc.).
if sess.f == nil {
d.sendChunkResult(corrID, 0, sess.bytes, true, "")
return
}
// Allow operator to upload to a renamed path mid-stream? No — pin
// the destination from the first chunk for safety. Subsequent
// chunks with a different path get rejected.
if sess.path != req.Path {
d.sendChunkResult(corrID, 0, sess.bytes, false, "path differs from initial chunk")
return
}
// Append the chunk. The browser is expected to send chunks in
// order; if offset doesn't match the running tail we err so the
// frontend can fall back / retry rather than silently writing a
// hole.
if req.Offset != sess.bytes {
d.sendChunkResult(corrID, 0, sess.bytes, false,
fmt.Sprintf("chunk offset %d != expected %d (out-of-order chunk)", req.Offset, sess.bytes))
return
}
n, err := sess.f.Write(req.Data)
if err != nil {
d.sendChunkResult(corrID, 0, sess.bytes, false, fmt.Errorf("write chunk: %w", err).Error())
return
}
sess.bytes += int64(n)
sess.lastTouch = time.Now()
if !req.IsFinal {
d.sendChunkResult(corrID, int64(n), sess.bytes, false, "")
return
}
// Final chunk — close the temp file, ship it to the container.
if err := sess.f.Sync(); err != nil {
d.sendChunkResult(corrID, int64(n), sess.bytes, false, fmt.Errorf("fsync: %w", err).Error())
return
}
if err := sess.f.Close(); err != nil {
d.sendChunkResult(corrID, int64(n), sess.bytes, false, fmt.Errorf("close temp: %w", err).Error())
return
}
sess.f = nil
if sess.total > 0 && sess.bytes != sess.total {
d.sendChunkResult(corrID, int64(n), sess.bytes, false,
fmt.Sprintf("size mismatch: received %d bytes, expected %d", sess.bytes, sess.total))
return
}
// Ship to the container (or host fallback) without re-reading the
// whole file into memory — we hand Docker an io.Reader that
// streams from disk, wrapped in a tar header on the fly.
if err := d.deliverUploadedFile(rec, sess); err != nil {
d.sendChunkResult(corrID, int64(n), sess.bytes, false, err.Error())
return
}
// Tear down the session — temp file deleted, map slot freed.
_ = os.Remove(sess.tempPath)
d.mu.Lock()
delete(d.uploads, sess.uploadID)
d.mu.Unlock()
d.sendChunkResult(corrID, int64(n), sess.bytes, true, "")
}
// deliverUploadedFile streams the temp file into the destination,
// either via container CopyTarToContainer (running or helper sidecar)
// or via a plain os.Rename for host-fallback instances. Designed so
// the file's bytes never sit in memory in their entirety.
func (d *Dispatcher) deliverUploadedFile(rec *instanceRecord, sess *uploadSession) error {
if d.useContainerOps(rec) {
abs, err := safeJoinAny(rec.BrowseableRoot, rootPaths(rec), sess.path)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), fsCopyTimeout)
defer cancel()
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
return err
}
// mkdir -p the parent so the destination always exists.
_, _, _, _ = d.runtime.ExecCapture(ctx, targetID, []string{"sh", "-c",
"mkdir -p -- " + shellQuote(path.Dir(abs))})
// Open the temp file for read; build a tar wrapper on the fly
// and feed Docker. CopyTarToContainer streams the body without
// materializing it in memory.
fr, err := os.Open(sess.tempPath)
if err != nil {
return err
}
defer fr.Close()
fi, err := fr.Stat()
if err != nil {
return err
}
pr, pw := io.Pipe()
errCh := make(chan error, 1)
go func() {
tw := tar.NewWriter(pw)
err := tw.WriteHeader(&tar.Header{
Name: path.Base(abs),
Mode: 0o644,
Size: fi.Size(),
})
if err == nil {
_, err = io.Copy(tw, fr)
}
if err == nil {
err = tw.Close()
}
_ = pw.CloseWithError(err)
errCh <- err
}()
if err := d.runtime.CopyTarToContainer(ctx, targetID, path.Dir(abs), pr); err != nil {
if encErr := <-errCh; encErr != nil {
return encErr
}
return fmt.Errorf("copy to container: %w", err)
}
if encErr := <-errCh; encErr != nil {
return encErr
}
return nil
}
// Host fallback — just move the temp file into place.
if rec.DataPath == "" {
return fmt.Errorf("no file storage available")
}
abs, err := safeJoinHost(rec.DataPath, sess.path)
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return err
}
// os.Rename is atomic on the same filesystem; if scratch lives on
// a different fs we fall back to copy + delete.
if err := os.Rename(sess.tempPath, abs); err == nil {
return nil
}
src, err := os.Open(sess.tempPath)
if err != nil {
return err
}
defer src.Close()
dst, err := os.OpenFile(abs, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return err
}
defer dst.Close()
if _, err := io.Copy(dst, src); err != nil {
return err
}
return nil
}
// uploadReaper closes + removes any session that hasn't been touched
// in uploadIdleTTL. Runs forever; cheap enough to scan once a minute.
func (d *Dispatcher) uploadReaper() {
t := time.NewTicker(time.Minute)
defer t.Stop()
for range t.C {
now := time.Now()
d.mu.Lock()
stale := []*uploadSession{}
for id, s := range d.uploads {
if now.Sub(s.lastTouch) > uploadIdleTTL {
stale = append(stale, s)
delete(d.uploads, id)
}
}
d.mu.Unlock()
for _, s := range stale {
s.mu.Lock()
if s.f != nil {
_ = s.f.Close()
s.f = nil
}
_ = os.Remove(s.tempPath)
s.mu.Unlock()
d.log.Info("upload session reaped", "upload_id", s.uploadID, "bytes", s.bytes)
}
}
}
// sanitizeUploadID strips characters that could escape the scratch dir.
// upload_id is generated by the browser (UUID-like) — defense in depth.
func sanitizeUploadID(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case (c >= 'a' && c <= 'z'), (c >= 'A' && c <= 'Z'), (c >= '0' && c <= '9'), c == '-', c == '_':
out = append(out, c)
}
if len(out) > 64 {
break
}
}
if len(out) == 0 {
out = []byte("anon")
}
return string(out)
}
func (d *Dispatcher) sendChunkResult(corrID string, n, total int64, finalized bool, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_FsWriteChunkResult{
FsWriteChunkResult: &panelv1.FsWriteChunkResult{
BytesReceived: n,
TotalReceived: total,
Finalized: finalized,
Error: errMsg,
},
},
})
}
+180
View File
@@ -0,0 +1,180 @@
package dispatch
import (
"context"
"fmt"
"strings"
"sync"
"sync/atomic"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// hangGuard implements a belt-and-suspenders hang-detection guardrail for
// ARK Survival Ascended (ark-sa) instances running under Wine 9.
// The Wine 9 RCON listener can wedge under sustained traffic; the engine
// also sometimes emits a "!!!HANG DETECTED!!!" line on its own.
// This guard auto-restarts the docker container in either case, with a
// cooldown to prevent flapping.
// The root-cause fix (docker_exec_rcon adapter) shipped 2026-05-03; this
// guard handles the rare residual cases.
// It replaces the bash watchdog that previously ran on princess via systemd timer.
const (
hangGuardWindow = 10 * time.Minute
hangGuardFailThreshold = 5
hangGuardCooldown = 10 * time.Minute
hangGuardEngineMarker = "!!!HANG DETECTED!!!"
)
// hangGuardState holds per-instance hang-detection state.
// Embed by value in instanceRecord; zero value is ready to use.
type hangGuardState struct {
mu sync.Mutex
consecutiveFails int
windowStart time.Time
lastRestart time.Time
restarting atomic.Bool
}
// arkHangGuardOnLogLine checks for engine-side hang detection tokens
// in console log lines. Only applies to ark-sa instances.
func (d *Dispatcher) arkHangGuardOnLogLine(rec *instanceRecord, line string) {
if rec.ModuleID != "ark-sa" {
return
}
if !strings.Contains(line, hangGuardEngineMarker) {
return
}
// Engine self-detected a hang; trigger restart asynchronously.
go d.arkHangGuardRestart(rec, fmt.Sprintf("engine emitted '%s'", hangGuardEngineMarker))
}
// arkHangGuardOnRconResult tracks RCON call successes/failures for
// hang detection. A threshold of consecutive failures triggers a restart.
func (d *Dispatcher) arkHangGuardOnRconResult(rec *instanceRecord, success bool) {
if rec.ModuleID != "ark-sa" {
return
}
rec.hangGuard.mu.Lock()
defer rec.hangGuard.mu.Unlock()
if success {
rec.hangGuard.consecutiveFails = 0
rec.hangGuard.windowStart = time.Time{}
return
}
now := time.Now()
if rec.hangGuard.windowStart.IsZero() || now.Sub(rec.hangGuard.windowStart) > hangGuardWindow {
// Start a new window
rec.hangGuard.windowStart = now
rec.hangGuard.consecutiveFails = 1
} else {
rec.hangGuard.consecutiveFails++
}
if rec.hangGuard.consecutiveFails >= hangGuardFailThreshold {
go d.arkHangGuardRestart(rec, fmt.Sprintf("%d RCON failures in last %v", rec.hangGuard.consecutiveFails, hangGuardWindow))
}
}
// arkHangGuardRestart performs the actual container restart, respecting a
// cooldown and using a single-flight pattern to prevent concurrent restarts.
func (d *Dispatcher) arkHangGuardRestart(rec *instanceRecord, reason string) {
// Single-flight: only one restart at a time per instance.
if !rec.hangGuard.restarting.CompareAndSwap(false, true) {
return
}
defer rec.hangGuard.restarting.Store(false)
// Lock only for cooldown check and counter reset. We do NOT set
// lastRestart here — only after a successful runtime.Restart, so a
// failed restart attempt doesn't lock out retries for the full
// cooldown (would block the next legitimate hang signal). Resetting
// consecutiveFails / windowStart up front is fine: the bounce is
// either about to happen (success path will set lastRestart) or has
// failed (caller logged + emitted; future signals will re-arm).
rec.hangGuard.mu.Lock()
if !rec.hangGuard.lastRestart.IsZero() && time.Since(rec.hangGuard.lastRestart) < hangGuardCooldown {
remaining := hangGuardCooldown - time.Since(rec.hangGuard.lastRestart)
d.log.Debug("hang-guard skipped (cooldown)",
"instance_id", rec.InstanceID,
"cooldown_remaining", remaining,
)
rec.hangGuard.mu.Unlock()
return
}
rec.hangGuard.consecutiveFails = 0
rec.hangGuard.windowStart = time.Time{}
rec.hangGuard.mu.Unlock()
d.log.Warn("hang-guard restarting container",
"instance_id", rec.InstanceID,
"container_id", rec.ContainerID,
"reason", reason,
)
// Emit a panel log line announcing the restart.
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: "stderr",
At: timestamppb.Now(),
Line: "[panel] HANG-GUARD: " + reason,
}},
})
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
// 2026-05-25: bumped grace 5s → 30s. The hang-guard's bounce can
// fire mid-SaveWorld; the 5s window made it a near-guarantee that the
// SIGKILL would land inside the SQLite-backed save's flush — same
// torn-save pattern that the memory guardrail was producing. 30s lets
// most in-flight saves finish before the engine dies; if the engine is
// truly wedged it'll get SIGKILL'd after the 30s anyway. See
// stats.go's emergencyStopGracePeriod for the matching rationale.
if err := d.runtime.Restart(ctx, rec.ContainerID, 30*time.Second); err != nil {
d.log.Error("hang-guard restart failed",
"instance_id", rec.InstanceID,
"err", err,
)
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: "stderr",
At: timestamppb.Now(),
Line: "[panel] HANG-GUARD restart failed: " + err.Error(),
}},
})
return
}
// Successful restart — start the cooldown clock so we don't bounce
// the same instance again for hangGuardCooldown. A failed restart
// (above branch returned early) intentionally leaves lastRestart
// untouched so the next hang signal can retry.
rec.hangGuard.mu.Lock()
rec.hangGuard.lastRestart = time.Now()
rec.hangGuard.mu.Unlock()
d.log.Info("hang-guard restart issued",
"instance_id", rec.InstanceID,
)
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: "stderr",
At: timestamppb.Now(),
Line: "[panel] HANG-GUARD restart issued",
}},
})
}
@@ -0,0 +1,220 @@
package dispatch
// Tests for WI-05: heartbeats routed through the send queue (sendLoop as the
// sole owner of stream.Send) and the single-flight, ctx-bound exit watcher.
import (
"context"
"io"
"log/slog"
"sync"
"testing"
"time"
"github.com/dbledeez/panel/agent/internal/runtime"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// recordingSender captures every envelope the sendLoop delivers.
type recordingSender struct {
mu sync.Mutex
envs []*panelv1.AgentEnvelope
}
func (r *recordingSender) Send(e *panelv1.AgentEnvelope) error {
r.mu.Lock()
defer r.mu.Unlock()
r.envs = append(r.envs, e)
return nil
}
func (r *recordingSender) snapshot() []*panelv1.AgentEnvelope {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]*panelv1.AgentEnvelope, len(r.envs))
copy(out, r.envs)
return out
}
// TestSendHeartbeatGoesThroughQueue verifies SendHeartbeat enqueues onto
// sendCh and the envelope reaches the sender via sendLoop — i.e. heartbeats
// no longer need a direct stream.Send from the session goroutine.
func TestSendHeartbeatGoesThroughQueue(t *testing.T) {
d := newTestDispatcher()
rs := &recordingSender{}
d.SetSender(rs)
now := time.Now()
d.SendHeartbeat(now)
deadline := time.After(2 * time.Second)
for {
for _, e := range rs.snapshot() {
if hb := e.GetHeartbeat(); hb != nil {
if got := hb.At.AsTime(); !got.Equal(now.UTC().Truncate(time.Nanosecond)) && got.Unix() != now.Unix() {
t.Fatalf("heartbeat timestamp mismatch: got %v want %v", got, now)
}
return // delivered through the queue — pass
}
}
select {
case <-deadline:
t.Fatal("heartbeat never delivered through sendLoop")
case <-time.After(10 * time.Millisecond):
}
}
}
// TestHeartbeatIsDroppable pins the backpressure classification: a heartbeat
// must be shed (not block the producer) when the buffer is full.
func TestHeartbeatIsDroppable(t *testing.T) {
env := &panelv1.AgentEnvelope{Payload: &panelv1.AgentEnvelope_Heartbeat{Heartbeat: &panelv1.Heartbeat{}}}
if !isDroppableEnvelope(env) {
t.Fatal("heartbeat envelope must be droppable telemetry")
}
}
// fakeExitRuntime implements just enough of runtime.Runtime for watchExit.
// The embedded nil interface panics on any unexpected call — that's a test
// failure signal, not a hazard.
type fakeExitRuntime struct {
runtime.Runtime
mu sync.Mutex
activeWaits int
maxWaits int
release chan struct{} // close to make Wait return exit code 0
}
func (f *fakeExitRuntime) Wait(ctx context.Context, id string) (int, error) {
f.mu.Lock()
f.activeWaits++
if f.activeWaits > f.maxWaits {
f.maxWaits = f.activeWaits
}
f.mu.Unlock()
defer func() {
f.mu.Lock()
f.activeWaits--
f.mu.Unlock()
}()
select {
case <-ctx.Done():
return -1, ctx.Err()
case <-f.release:
return 0, nil
}
}
func (f *fakeExitRuntime) InspectByName(ctx context.Context, name string) (runtime.RuntimeInstanceState, error) {
return runtime.RuntimeInstanceState{Status: "exited"}, nil
}
func (f *fakeExitRuntime) counts() (active, max int) {
f.mu.Lock()
defer f.mu.Unlock()
return f.activeWaits, f.maxWaits
}
func newExitWatchDispatcher(f *fakeExitRuntime) *Dispatcher {
d := &Dispatcher{
log: slog.New(slog.NewTextHandler(io.Discard, nil)),
runtime: f,
instances: map[string]*instanceRecord{},
sendCh: make(chan *panelv1.AgentEnvelope, 256),
}
go d.sendLoop()
return d
}
// waitFor polls cond until true or the deadline elapses.
func waitFor(t *testing.T, d time.Duration, cond func() bool, msg string) {
t.Helper()
deadline := time.Now().Add(d)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal(msg)
}
// TestStartExitWatchSingleFlight re-arms the watcher many times (the shape of
// Announce firing on every controller reconnect) and asserts only ONE
// ContainerWait is ever in flight — the pre-fix behavior leaked one blocked
// Wait (and its docker connection) per reconnect.
func TestStartExitWatchSingleFlight(t *testing.T) {
f := &fakeExitRuntime{release: make(chan struct{})}
d := newExitWatchDispatcher(f)
rec := &instanceRecord{InstanceID: "x", ContainerID: "cid"}
for i := 0; i < 8; i++ {
d.startExitWatch(rec)
}
// All superseded watchers must exit; exactly one survivor remains.
waitFor(t, 2*time.Second, func() bool { a, _ := f.counts(); return a == 1 },
"expected exactly 1 in-flight ContainerWait after re-arms")
// The peak may briefly exceed 1 while a cancelled watcher unwinds, but it
// must never approach the number of re-arms (pre-fix: 8 concurrent Waits).
if _, max := f.counts(); max >= 8 {
t.Fatalf("watchExit not single-flighted: peak concurrent waits = %d", max)
}
// Cancelling the record's watcher (handleStop / Shutdown path) must
// release the last Wait.
d.mu.Lock()
cancel := rec.ExitWatchCancel
rec.ExitWatchCancel = nil
d.mu.Unlock()
cancel()
waitFor(t, 2*time.Second, func() bool { a, _ := f.counts(); return a == 0 },
"cancelled exit watcher did not release ContainerWait")
}
// TestWatchExitCancelledEmitsNothing: a ctx-cancelled watcher must return
// silently — no spurious stopped/crashed InstanceState for a container that
// is still running.
func TestWatchExitCancelledEmitsNothing(t *testing.T) {
f := &fakeExitRuntime{release: make(chan struct{})}
d := newExitWatchDispatcher(f)
rs := &recordingSender{}
d.SetSender(rs)
rec := &instanceRecord{InstanceID: "x", ContainerID: "cid"}
d.startExitWatch(rec)
waitFor(t, 2*time.Second, func() bool { a, _ := f.counts(); return a == 1 }, "watcher never started")
d.mu.Lock()
cancel := rec.ExitWatchCancel
d.mu.Unlock()
cancel()
waitFor(t, 2*time.Second, func() bool { a, _ := f.counts(); return a == 0 }, "watcher never exited")
time.Sleep(50 * time.Millisecond) // allow any (wrong) emission to flush
for _, e := range rs.snapshot() {
if e.GetInstanceState() != nil {
t.Fatalf("cancelled watchExit emitted InstanceState: %v", e)
}
}
}
// TestWatchExitTerminalExit: when the container really exits (Wait returns
// 0 and inspect says "exited"), the watcher emits a STOPPED state.
func TestWatchExitTerminalExit(t *testing.T) {
f := &fakeExitRuntime{release: make(chan struct{})}
d := newExitWatchDispatcher(f)
rs := &recordingSender{}
d.SetSender(rs)
rec := &instanceRecord{InstanceID: "x", ContainerID: "cid"}
d.instances["x"] = rec
d.startExitWatch(rec)
waitFor(t, 2*time.Second, func() bool { a, _ := f.counts(); return a == 1 }, "watcher never started")
close(f.release)
waitFor(t, 2*time.Second, func() bool {
for _, e := range rs.snapshot() {
if st := e.GetInstanceState(); st != nil && st.Status == panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED {
return true
}
}
return false
}, "terminal exit never produced a STOPPED InstanceState")
}
+159
View File
@@ -0,0 +1,159 @@
package dispatch
import (
"regexp"
"sync"
"time"
"github.com/dbledeez/panel/agent/internal/runtime"
)
// logCoalescer throttles repetitive progress-style log lines while letting
// genuine content through unchanged.
//
// Why this exists: containers under install/update pressure (SteamCMD
// download ticks, preallocation, LGSM step banners) can emit 40+ lines per
// second. Forwarding every one to the controller saturates the gRPC send
// window, fills the event bus's per-subscriber buffer (which silently drops
// on overflow), and burns CPU re-rendering DOM. The browser only needs to
// see the *latest* progress value, not every interstitial frame.
//
// Behaviour:
// - Lines with progressKey() == "": emit immediately.
// - Lines with a key: if it's been less than `interval` since the last
// emit of that key, replace the currently-buffered line for that key.
// A single background timer flushes any pending lines every `interval`.
type logCoalescer struct {
interval time.Duration
emit func(stream runtime.LogStream, line string, at time.Time)
mu sync.Mutex
pending map[string]*pendingLine
lastAt map[string]time.Time
timer *time.Timer
stopped bool
}
type pendingLine struct {
stream runtime.LogStream
line string
at time.Time
}
func newLogCoalescer(interval time.Duration, emit func(stream runtime.LogStream, line string, at time.Time)) *logCoalescer {
c := &logCoalescer{
interval: interval,
emit: emit,
pending: map[string]*pendingLine{},
lastAt: map[string]time.Time{},
}
return c
}
// offer delivers a line to the coalescer. Safe to call concurrently, though
// in practice the caller is the single log-scan goroutine.
func (c *logCoalescer) offer(stream runtime.LogStream, line string, at time.Time) {
key := progressKey(line)
if key == "" {
// Non-progress — emit immediately, but first flush any buffered
// progress lines so they don't appear AFTER a later non-progress line.
c.mu.Lock()
c.flushLocked()
c.mu.Unlock()
c.emit(stream, line, at)
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.stopped {
return
}
last := c.lastAt[key]
if last.IsZero() || at.Sub(last) >= c.interval {
// Enough quiet time — emit right away.
c.emit(stream, line, at)
c.lastAt[key] = at
delete(c.pending, key)
return
}
// Replace the buffered line for this key (we only care about the newest).
c.pending[key] = &pendingLine{stream: stream, line: line, at: at}
// Make sure the timer is armed. Single timer drains all pending keys.
if c.timer == nil {
c.timer = time.AfterFunc(c.interval, c.tick)
}
}
func (c *logCoalescer) tick() {
c.mu.Lock()
defer c.mu.Unlock()
c.flushLocked()
// If more pending arrived during flush (or we expect more), re-arm the timer.
if len(c.pending) > 0 && !c.stopped {
c.timer = time.AfterFunc(c.interval, c.tick)
} else {
c.timer = nil
}
}
func (c *logCoalescer) flushLocked() {
now := time.Now()
for key, p := range c.pending {
c.emit(p.stream, p.line, p.at)
c.lastAt[key] = now
delete(c.pending, key)
}
}
// stop flushes any pending lines and prevents further work. Call from the
// streamLogs goroutine's defer so a stopping instance doesn't leave buffered
// progress frames undelivered.
func (c *logCoalescer) stop() {
c.mu.Lock()
c.stopped = true
if c.timer != nil {
c.timer.Stop()
c.timer = nil
}
c.flushLocked()
c.mu.Unlock()
}
// Regexes for lines we treat as part of a repeating progress stream.
// Each group returns a stable key — same key = same progress counter.
var (
// SteamCMD: "Update state (0x61) downloading, progress: 47.95 (...)"
// Also covers "verifying", "preallocating", "committing" stages.
reSteamcmdProgress = regexp.MustCompile(`Update state \([^)]+\) (\w+), progress:`)
// Generic "Progress: 64%" or "[ 64%] Installing ...".
reGenericProgress = regexp.MustCompile(`^\s*(?:Progress:|\[\s*\d+%\s*\])`)
// LGSM step banner: "Starting sdtdserver: <step>". vinanrra emits
// several per step as the banner animates ([ .... ] → [ INFO ] → [ OK ]).
reLGSMStarting = regexp.MustCompile(`^\s*Starting sdtdserver:`)
reLGSMStopping = regexp.MustCompile(`^\s*Stopping sdtdserver:`)
// Docker pull: "Downloading [=>] 12.3MB / 100MB"
reDockerPull = regexp.MustCompile(`^\s*(?:Downloading|Extracting|Pulling)\s+[\[(]`)
)
// progressKey returns a stable identifier for a progress-style line, or ""
// if the line should be forwarded unchanged. The returned key collapses
// every frame of the same counter onto a single slot so we keep only the
// latest within each coalesce window.
func progressKey(text string) string {
if m := reSteamcmdProgress.FindStringSubmatch(text); m != nil {
return "steamcmd:" + m[1]
}
if reGenericProgress.MatchString(text) {
return "progress"
}
if reLGSMStarting.MatchString(text) {
return "lgsm-starting"
}
if reLGSMStopping.MatchString(text) {
return "lgsm-stopping"
}
if reDockerPull.MatchString(text) {
return "docker-pull"
}
return ""
}
+112
View File
@@ -0,0 +1,112 @@
package dispatch
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"time"
)
// instanceMeta is the on-disk sidecar written per instance after successful
// Create. It lets the agent rehydrate its in-memory records after a restart,
// so operator commands (stop, etc.) continue to work against instances that
// outlived the agent process.
//
// Stored at <metaDir>/<instance_id>.json. Mode 0600 because it carries the
// RCON password. Deleted on stop.
type instanceMeta struct {
InstanceID string `json:"instance_id"`
ContainerID string `json:"container_id"`
ModuleID string `json:"module_id"`
// Branch is the normalized Steam branch the install came from (warm-seed
// matching key). Empty on sidecars written before this feature.
Branch string `json:"branch,omitempty"`
DataPath string `json:"data_path"`
BrowseableRoot string `json:"browseable_root,omitempty"`
RCONAddr string `json:"rcon_addr,omitempty"`
RCONPassword string `json:"rcon_password,omitempty"`
ConfigValues map[string]string `json:"config_values,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (d *Dispatcher) metaPath(instanceID string) string {
return filepath.Join(d.metaDir, instanceID+".json")
}
func (d *Dispatcher) writeMeta(rec *instanceRecord) error {
if d.metaDir == "" {
return nil
}
if err := os.MkdirAll(d.metaDir, 0o755); err != nil {
return fmt.Errorf("mkdir meta dir: %w", err)
}
m := instanceMeta{
InstanceID: rec.InstanceID,
ContainerID: rec.ContainerID,
ModuleID: rec.ModuleID,
Branch: rec.Branch,
DataPath: rec.DataPath,
BrowseableRoot: rec.BrowseableRoot,
RCONAddr: rec.RCONAddr,
RCONPassword: rec.RCONPassword,
CreatedAt: time.Now(),
}
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return fmt.Errorf("marshal meta: %w", err)
}
if err := os.WriteFile(d.metaPath(rec.InstanceID), data, 0o600); err != nil {
return fmt.Errorf("write meta: %w", err)
}
return nil
}
func (d *Dispatcher) deleteMeta(instanceID string) {
if d.metaDir == "" {
return
}
if err := os.Remove(d.metaPath(instanceID)); err != nil && !errors.Is(err, fs.ErrNotExist) {
d.log.Warn("delete meta failed", "instance_id", instanceID, "err", err)
}
}
func (d *Dispatcher) loadAllMeta() ([]instanceMeta, error) {
if d.metaDir == "" {
return nil, nil
}
entries, err := os.ReadDir(d.metaDir)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, err
}
var out []instanceMeta
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if filepath.Ext(name) != ".json" {
continue
}
data, err := os.ReadFile(filepath.Join(d.metaDir, name))
if err != nil {
d.log.Warn("read meta failed", "file", name, "err", err)
continue
}
var m instanceMeta
if err := json.Unmarshal(data, &m); err != nil {
d.log.Warn("bad meta file", "file", name, "err", err)
continue
}
if m.InstanceID == "" {
continue
}
out = append(out, m)
}
return out, nil
}
+195
View File
@@ -0,0 +1,195 @@
package dispatch
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// seedModsFromInstance clones the Mods/ folder of srcInstanceID (the cluster
// master) onto dstInstanceID's install volume, REPLACING the destination's mod
// set with the master's. Runs a throwaway debian sidecar that mounts both
// instances' install volumes and tar-copies /src-game/Mods → /dst-game/Mods.
//
// This is the inverse of tryWarmSeedFromSibling (which copies the install but
// EXCLUDES Mods): here we copy ONLY Mods, so a freshly-joined cluster member
// ends up running exactly the master's mod set without an operator re-uploading
// every mod by hand.
//
// Per-server STATE is preserved/excluded so a clone never leaks one server's
// player data onto another:
// - The master's RefugeBot homes.json (teleport homes, keyed by SteamID) is
// EXCLUDED from the copy — it's per-server state, not mod config. This is
// the exact file that leaked when an operator rsync'd Mods by hand.
// - The destination's OWN homes.json is snapshotted before the wipe and
// restored after, so the joining server keeps its own teleports.
//
// Both instances must live on THIS agent (same Docker host) — the sidecar
// mounts local named volumes. If the master isn't found here the seed returns
// an error and the caller treats it as best-effort (the server still boots with
// whatever mods it already had).
func (d *Dispatcher) seedModsFromInstance(ctx context.Context, srcInstanceID, dstInstanceID, dstDataPath string, manifest *modulepkg.Manifest) (int, error) {
if manifest == nil {
return 0, fmt.Errorf("mod-seed: nil manifest")
}
if srcInstanceID == dstInstanceID {
return 0, fmt.Errorf("mod-seed: refusing to seed instance %q from itself", dstInstanceID)
}
installPath := ""
if len(manifest.UpdateProviders) > 0 {
installPath = manifest.UpdateProviders[0].InstallPath
}
if installPath == "" {
installPath = manifest.Runtime.Docker.BrowseableRoot
}
if installPath == "" {
return 0, fmt.Errorf("mod-seed: no install path for module %q", manifest.ID)
}
// Resolve the master's data path from our local instance table.
d.mu.Lock()
srcRec, ok := d.instances[srcInstanceID]
d.mu.Unlock()
if !ok {
return 0, fmt.Errorf("mod-seed: master instance %q is not on this agent", srcInstanceID)
}
if srcRec.ModuleID != manifest.ID {
return 0, fmt.Errorf("mod-seed: master %q is module %q, not %q", srcInstanceID, srcRec.ModuleID, manifest.ID)
}
srcVolumes := agentmodule.ResolveVolumes(manifest, srcInstanceID, srcRec.DataPath)
dstVolumes := agentmodule.ResolveVolumes(manifest, dstInstanceID, dstDataPath)
srcGameVol := volumeNameForPath(srcVolumes, installPath)
dstGameVol := volumeNameForPath(dstVolumes, installPath)
if srcGameVol == "" || dstGameVol == "" {
return 0, fmt.Errorf("mod-seed: install_path %q not a named volume on both instances", installPath)
}
// tar, not rsync — debian:12-slim ships no rsync. Replace /dst-game/Mods
// from /src-game/Mods, excluding the master's per-server homes.json, and
// preserving the destination's own homes.json across the wipe.
const seedScript = `set -e
echo "mod-seed: cloning master Mods set (excl per-server homes.json)"
HOMES=/dst-game/Mods/zzzzzzzRefugeBot/homes.json
if [ -f "$HOMES" ]; then cp -a "$HOMES" /tmp/dst-homes.json; echo "mod-seed: preserved destination homes.json"; fi
mkdir -p /dst-game/Mods
find /dst-game/Mods -mindepth 1 -delete
( cd /src-game/Mods && tar --exclude='./zzzzzzzRefugeBot/homes.json' -cf - . ) | ( cd /dst-game/Mods && tar -xf - )
if [ -f /tmp/dst-homes.json ]; then mkdir -p /dst-game/Mods/zzzzzzzRefugeBot; cp -a /tmp/dst-homes.json "$HOMES"; echo "mod-seed: restored destination homes.json"; fi
echo "mod-seed: $(ls /dst-game/Mods 2>/dev/null | wc -l) mods now present"
echo SEED_OK
`
sidecarID := fmt.Sprintf("%s-modseed", dstInstanceID)
spec := runtime.InstanceSpec{
InstanceID: sidecarID,
IsSidecar: true,
Image: "debian:12-slim",
Command: []string{"bash", "-c", seedScript},
RestartPolicy: "no",
Volumes: []runtime.VolumeSpec{
{Type: "volume", VolumeName: srcGameVol, ContainerPath: "/src-game", ReadOnly: true},
{Type: "volume", VolumeName: dstGameVol, ContainerPath: "/dst-game"},
},
}
d.log.Info("mod-seed: starting sidecar", "src", srcInstanceID, "dst", dstInstanceID, "src_vol", srcGameVol, "dst_vol", dstGameVol)
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
return 0, fmt.Errorf("mod-seed: create sidecar: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
return 0, fmt.Errorf("mod-seed: start sidecar: %w", err)
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
var modCount int
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
d.log.Info("mod-seed log", "instance_id", dstInstanceID, "line", line)
// Parse the sidecar's "mod-seed: N mods now present" echo so the
// caller (and the cluster-sync UI) can report how many mods landed.
if strings.Contains(line, "mods now present") {
fields := strings.Fields(line)
for i, f := range fields {
if f == "mods" && i > 0 {
if n, err := strconv.Atoi(fields[i-1]); err == nil {
modCount = n
}
}
}
}
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return 0, fmt.Errorf("mod-seed: wait sidecar: %w", err)
}
if exitCode != 0 {
return 0, fmt.Errorf("mod-seed: sidecar exited %d", exitCode)
}
d.log.Info("mod-seed: success", "src", srcInstanceID, "dst", dstInstanceID, "mods", modCount)
return modCount, nil
}
// handleSeedMods runs the mod-seed sidecar on demand — no container recreate,
// no restart. Clones src_instance_id's Mods/ onto instance_id (excluding the
// master's per-server homes.json, preserving the member's own). The member
// keeps running on its currently-loaded mods; the freshly-copied files take
// effect on its NEXT restart. Backs the cluster "Sync mods to members" action,
// where the master pushes its current mod set to every other member at once.
func (d *Dispatcher) handleSeedMods(ctx context.Context, corrID string, req *panelv1.SeedModsRequest) {
log := d.log.With("instance_id", req.InstanceId, "src", req.SrcInstanceId, "correlation_id", corrID)
reply := func(ok bool, modCount int, errMsg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_SeedModsResult{
SeedModsResult: &panelv1.SeedModsResult{
InstanceId: req.InstanceId,
Ok: ok,
ModCount: int32(modCount),
Error: errMsg,
},
},
})
if !ok {
log.Warn("mod-seed RPC failed", "err", errMsg)
}
}
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
reply(false, 0, err.Error())
return
}
manifest, ok := d.modules.Get(rec.ModuleID)
if !ok {
reply(false, 0, "module "+rec.ModuleID+" not in registry")
return
}
modCount, err := d.seedModsFromInstance(ctx, req.SrcInstanceId, req.InstanceId, rec.DataPath, manifest)
if err != nil {
reply(false, 0, err.Error())
return
}
log.Info("mod-seed RPC complete", "mods", modCount)
reply(true, modCount, "")
}
+264
View File
@@ -0,0 +1,264 @@
package dispatch
import (
"context"
"fmt"
"path"
"sort"
"strings"
"time"
"github.com/dbledeez/panel/pkg/regionmedic"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
// Region Medic agent handlers — scan a 7DTD instance's active-world Region
// directory for .7rg corruption, and heal one region from the newest clean
// panel backup. Volume files are reached through the runtime's container-path
// ops (the agent runs unprivileged, so it cannot touch /var/lib/docker/volumes
// directly). The controller orchestrates stop→heal→start; handleRegionHeal
// itself only performs the file swap and refuses to run on a live container.
// activeRegionDir resolves the container-absolute Region directory of the
// instance's active world by reading GameWorld/GameName from the live
// serverconfig.xml. Works on a stopped container (CopyFileFromContainer does).
func (d *Dispatcher) activeRegionDir(ctx context.Context, container string) (string, error) {
raw, err := d.runtime.CopyFileFromContainer(ctx, container, "/game-saves/serverconfig.xml")
if err != nil {
return "", fmt.Errorf("read serverconfig.xml: %w", err)
}
world := xmlProp(string(raw), "GameWorld")
game := xmlProp(string(raw), "GameName")
if world == "" || game == "" {
return "", fmt.Errorf("serverconfig.xml missing GameWorld/GameName (got %q/%q)", world, game)
}
// HOME is pinned to /game-saves by the 7dtd entrypoint.
return path.Join("/game-saves/.local/share/7DaysToDie/Saves", world, game, "Region"), nil
}
// xmlProp pulls the value from a 7DTD serverconfig line:
//
// <property name="GameWorld" value="Navezgane" />
//
// Tolerant of attribute order and single/double quotes; skips commented lines.
func xmlProp(content, name string) string {
for _, raw := range strings.Split(content, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "<!--") {
continue
}
if !strings.Contains(line, `name="`+name+`"`) && !strings.Contains(line, `name='`+name+`'`) {
continue
}
i := strings.Index(line, "value=")
if i < 0 {
continue
}
rest := line[i+len("value="):]
if rest == "" {
continue
}
q := rest[0]
rest = rest[1:]
if end := strings.IndexByte(rest, q); end >= 0 {
return strings.TrimSpace(rest[:end])
}
}
return ""
}
// listRegionDirNames returns the file names in the container's Region dir.
// Uses ExecCapture when the container is running; nil + error otherwise (the
// scan path runs while the instance is up).
func (d *Dispatcher) listRegionDirNames(ctx context.Context, container, regionDir string) ([]string, error) {
stdout, _, _, err := d.runtime.ExecCapture(ctx, container, []string{"ls", "-1", regionDir})
if err != nil {
return nil, err
}
var names []string
for _, n := range strings.Split(string(stdout), "\n") {
if n = strings.TrimSpace(n); n != "" {
names = append(names, n)
}
}
return names, nil
}
// handleRegionScan reports corruption evidence for an instance's active world:
// error_backup salvage files clustered into regions, plus structural validation
// of each affected region's .7rg file.
func (d *Dispatcher) handleRegionScan(ctx context.Context, corrID string, req *panelv1.RegionScanRequest) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendRegionScanResult(corrID, &panelv1.RegionScanResult{Error: &panelv1.Error{Code: "not_found", Message: err.Error()}})
return
}
container := "panel-" + rec.InstanceID
regionDir, err := d.activeRegionDir(ctx, container)
if err != nil {
d.sendRegionScanResult(corrID, &panelv1.RegionScanResult{Error: &panelv1.Error{Code: "resolve_region_dir", Message: err.Error()}})
return
}
names, err := d.listRegionDirNames(ctx, container, regionDir)
if err != nil {
d.sendRegionScanResult(corrID, &panelv1.RegionScanResult{RegionDir: regionDir, Error: &panelv1.Error{Code: "list_region_dir", Message: err.Error()}})
return
}
// Cluster error_backups by region; note which region files exist.
type agg struct {
files int
present bool
}
clusters := map[regionmedic.RegionCoord]*agg{}
fileSet := map[regionmedic.RegionCoord]bool{}
total := 0
for _, n := range names {
if cx, cz, ok := regionmedic.ParseErrorBackup(n); ok {
total++
rc := regionmedic.RegionForChunk(cx, cz)
a := clusters[rc]
if a == nil {
a = &agg{}
clusters[rc] = a
}
a.files++
continue
}
if rc, ok := regionmedic.ParseRegionFileName(n); ok {
fileSet[rc] = true
}
}
var affected []*panelv1.AffectedRegionMsg
for rc, a := range clusters {
msg := &panelv1.AffectedRegionMsg{
Region: rc.String(),
ErrorBackups: int32(a.files),
FilePresent: fileSet[rc],
}
// Validate the affected region file (read via container-path op).
if fileSet[rc] {
if b, rerr := d.runtime.CopyFileFromContainer(ctx, container, path.Join(regionDir, rc.FileName())); rerr == nil {
rep := regionmedic.ValidateRegionBytes(b, false)
msg.FileCorrupt = !rep.Healthy()
msg.BadChunks = int32(len(rep.BadChunks))
}
}
affected = append(affected, msg)
}
sort.Slice(affected, func(i, j int) bool { return affected[i].ErrorBackups > affected[j].ErrorBackups })
d.sendRegionScanResult(corrID, &panelv1.RegionScanResult{
ErrorBackups: int32(total),
Affected: affected,
RegionDir: regionDir,
})
}
// handleRegionHeal swaps one region file for the newest clean backup copy.
// It REQUIRES the instance to be stopped (the controller stops it first) so the
// running engine can't overwrite the swap. It snapshots the corrupt original
// next to the region file before overwriting. Orchestration (stop/start) is the
// controller's job.
func (d *Dispatcher) handleRegionHeal(ctx context.Context, corrID string, req *panelv1.RegionHealRequest) {
fail := func(code, msg string) {
d.sendRegionHealResult(corrID, &panelv1.RegionHealResult{Region: req.Region, Error: &panelv1.Error{Code: code, Message: msg}})
}
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
fail("not_found", err.Error())
return
}
region, ok := regionmedic.ParseRegionFileName(regionFileArg(req.Region))
if !ok {
fail("bad_region", fmt.Sprintf("invalid region %q", req.Region))
return
}
container := "panel-" + rec.InstanceID
// Refuse to swap a region on a LIVE container (it would be overwritten on
// the next save). ExecCapture succeeds only when the container is running.
if _, _, _, exErr := d.runtime.ExecCapture(ctx, container, []string{"true"}); exErr == nil {
fail("instance_running", "instance must be stopped before healing a region")
return
}
regionDir, err := d.activeRegionDir(ctx, container)
if err != nil {
fail("resolve_region_dir", err.Error())
return
}
liveFile := path.Join(regionDir, region.FileName())
liveBytes, err := d.runtime.CopyFileFromContainer(ctx, container, liveFile)
if err != nil {
fail("read_live_region", err.Error())
return
}
// Find the newest clean backup copy of this region (backups are local to
// the agent at <backupDir>/<instance>/).
backups, err := regionmedic.ListPanelBackups(path.Join(d.backupDir, rec.InstanceID))
if err != nil {
fail("list_backups", err.Error())
return
}
entry := regionmedic.SaveRelEntry(regionDir, region.FileName())
best, _, err := regionmedic.FindLastGoodRegion(backups, entry, true, time.Time{})
if err != nil {
fail("find_backup", err.Error())
return
}
if !best.Found || !best.Clean() {
fail("no_clean_backup", "no backup contains a clean copy of this region")
return
}
// Snapshot the corrupt original alongside the region file (no new subdir, so
// CopyFileToContainer needs no parent-dir creation on a stopped container).
stamp := time.Now().UTC().Format("20060102-150405")
snapName := region.FileName() + ".medic-corrupt-" + stamp
snapPath := path.Join(regionDir, snapName)
if err := d.runtime.CopyFileToContainer(ctx, container, snapPath, liveBytes); err != nil {
fail("snapshot", err.Error())
return
}
// Swap in the clean copy (overwrites the live region file in place).
if err := d.runtime.CopyFileToContainer(ctx, container, liveFile, best.Bytes); err != nil {
fail("write_region", err.Error())
return
}
d.sendRegionHealResult(corrID, &panelv1.RegionHealResult{
Region: region.String(),
SourceBackup: best.Backup.ID,
BytesWritten: int64(len(best.Bytes)),
SnapshotPath: snapPath,
Healed: true,
})
}
// regionFileArg normalizes "r.X.Z" or "r.X.Z.7rg" to a .7rg filename.
func regionFileArg(s string) string {
if strings.HasSuffix(s, ".7rg") {
return s
}
return s + ".7rg"
}
func (d *Dispatcher) sendRegionScanResult(corrID string, res *panelv1.RegionScanResult) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_RegionScanResult{RegionScanResult: res},
})
}
func (d *Dispatcher) sendRegionHealResult(corrID string, res *panelv1.RegionHealResult) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_RegionHealResult{RegionHealResult: res},
})
}
+144
View File
@@ -0,0 +1,144 @@
package dispatch
import (
"context"
"fmt"
"os"
"path"
"time"
"github.com/dbledeez/panel/agent/internal/runtime"
)
func medicEnvOr(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// Medic host paths on the agent box (figaro, for 7DTD). Overridable via env so
// the same agent binary works if 7DTD ever moves hosts.
func regionRollingRoot() string {
return medicEnvOr("PANEL_REGION_ROLLING_DIR", "/home/refuge/region-rolling")
}
func regionMedicBin() string {
return medicEnvOr("PANEL_REGION_MEDIC_BIN", "/home/refuge/panel/bin/region-medic")
}
func discordTokenFile() string {
return medicEnvOr("PANEL_DISCORD_TOKEN_FILE", "/home/refuge/panel/discord-bot-token")
}
func medicFileExists(p string) bool {
st, err := os.Stat(p)
return err == nil && !st.IsDir()
}
// runRegionMedicOnStart runs the autoheal CLI in a throwaway debian sidecar while
// the 7DTD container is STILL STOPPED, just before the agent starts it:
// - validate every .7rg in the active world's Region dir,
// - heal corrupt ones from the newest clean rolling snapshot, then the panel
// tar backups as a fallback,
// - snapshot the now-clean Region dir to a fresh rolling slot,
// - post a Discord summary (naming the server) if anything healed.
//
// The per-server medic config (enable / rolling-keep / Discord channel) lives in
// the SAVES volume at region-medic.json — written by the panel's medic-config
// endpoint via FsWrite (NO container recreate) and read by the CLI here in the
// sidecar. The agent therefore always launches the sidecar for 7DTD and lets the
// CLI decide; an absent/empty config just means defaults + no Discord.
//
// Best-effort by contract: any failure is logged + swallowed, never blocking the
// start. 10-min hard cap so a wedged sidecar/daemon can't stall a boot.
func (d *Dispatcher) runRegionMedicOnStart(ctx context.Context, rec *instanceRecord) {
ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
defer cancel()
log := d.log.With("instance_id", rec.InstanceID, "medic", true)
medicBin := regionMedicBin()
if !medicFileExists(medicBin) {
log.Warn("region-medic: binary missing, skipping pre-start heal", "bin", medicBin)
return
}
savesVol := fmt.Sprintf("panel-%s-saves", rec.InstanceID)
rollingDir := path.Join(regionRollingRoot(), rec.InstanceID)
_ = os.MkdirAll(rollingDir, 0o755)
backupsDir := path.Join(d.backupDir, rec.InstanceID)
cmd := []string{"/rm", "autoheal",
"-saves-root", "/saves",
"-rolling-dir", "/rolling",
"-keep", "2", // default; overridden by region-medic.json in the saves volume
"-label", rec.InstanceID,
"-apply",
}
vols := []runtime.VolumeSpec{
{Type: "volume", VolumeName: savesVol, ContainerPath: "/saves"},
{Type: "bind", HostPath: rollingDir, ContainerPath: "/rolling"},
{Type: "bind", HostPath: medicBin, ContainerPath: "/rm", ReadOnly: true},
}
// debian:12-slim ships no CA bundle, so the CLI's HTTPS POST to Discord can't
// verify the TLS cert ("x509: certificate signed by unknown authority"). Mount
// the host's CA certs read-only — Go's x509 finds ca-certificates.crt there.
if _, err := os.Stat("/etc/ssl/certs"); err == nil {
vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: "/etc/ssl/certs", ContainerPath: "/etc/ssl/certs", ReadOnly: true})
}
// Panel tar backups are the heal fallback when no rolling slot has a clean
// copy (e.g. very first boot, before any snapshot). Only mount if present.
if st, err := os.Stat(backupsDir); err == nil && st.IsDir() {
cmd = append(cmd, "-backups-dir", "/backups")
vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: backupsDir, ContainerPath: "/backups", ReadOnly: true})
}
// The token is mounted whenever it exists; the CLI only posts if the saves
// config names a channel. No -discord-channel flag — the CLI reads it from
// /saves/region-medic.json.
if tf := discordTokenFile(); medicFileExists(tf) {
cmd = append(cmd, "-discord-token-file", "/discord-token")
vols = append(vols, runtime.VolumeSpec{Type: "bind", HostPath: tf, ContainerPath: "/discord-token", ReadOnly: true})
}
spec := runtime.InstanceSpec{
InstanceID: rec.InstanceID + "-medic",
IsSidecar: true,
Image: "debian:12-slim",
Command: cmd,
RestartPolicy: "no",
Volumes: vols,
}
log.Info("region-medic: pre-start scan+heal")
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
log.Warn("region-medic: create sidecar failed", "err", err)
return
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
log.Warn("region-medic: start sidecar failed", "err", err)
return
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
log.Info("region-medic", "line", line)
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
log.Warn("region-medic: wait sidecar failed", "err", err)
return
}
if exitCode != 0 {
log.Warn("region-medic: sidecar exited non-zero", "code", exitCode)
return
}
log.Info("region-medic: pre-start heal complete")
}
+125
View File
@@ -0,0 +1,125 @@
package dispatch
import (
"bytes"
"context"
"os"
"path/filepath"
"google.golang.org/protobuf/types/known/timestamppb"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// handleRenderConfig re-renders the module's declared config_files into the
// instance's host DataPath — the same modulepkg.Render call handleCreate
// makes, minus the container recreate. For modules that bind-mount the
// rendered output into the container (7dtd's serverconfig.xml.rendered),
// os.WriteFile rewrites the host file in place (same inode), so the running
// container sees the new content immediately and the entrypoint applies it
// on the NEXT boot. The container itself is never restarted.
//
// Containers created BEFORE a config_file's bind-mount was added to the
// module manifest don't have the bind at all (docker pins mounts at create)
// — their /game-saves/<file> is a plain file inside the saves volume. For
// those we self-verify: read the file back through the container, and if it
// doesn't match the fresh render, copy the rendered bytes in directly
// (works running or stopped; the game only reads config at boot).
func (d *Dispatcher) handleRenderConfig(ctx context.Context, corrID string, req *panelv1.InstanceRenderConfigRequest) {
log := d.log.With("instance_id", req.InstanceId, "correlation_id", corrID)
fail := func(msg string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_InstanceRenderConfigResult{
InstanceRenderConfigResult: &panelv1.InstanceRenderConfigResult{Error: msg},
},
})
log.Warn("render config failed", "err", msg)
}
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
fail(err.Error())
return
}
if rec.DataPath == "" {
fail("instance has no data path (pre-feature sidecar?) — recreate once to adopt")
return
}
manifest, ok := d.modules.Get(rec.ModuleID)
if !ok {
fail("module " + rec.ModuleID + " not in registry")
return
}
// Same merge handleCreate uses: generated secrets first, user values on
// top (empties dropped → template `or` defaults kick in).
values, err := mergeValuesAndSecrets(manifest, req.ConfigValues)
if err != nil {
fail("secrets: " + err.Error())
return
}
// Branch-aware: a 3.0 instance renders serverconfig.v3.xml.tmpl, ≤2.6
// keeps the default. Resolve from the instance's persisted branch (set at
// create), falling back to the merged config_values' provider_id so a
// durable config-render still picks the right template if the record
// predates branch persistence.
branch := rec.Branch
if branch == "" {
branch = resolveCreateBranch(manifest, values)
}
if err := modulepkg.RenderForBranch(manifest, rec.DataPath, values, branch); err != nil {
fail("render: " + err.Error())
return
}
// Self-verify through the container; fall back to a direct copy into
// the volume for pre-bind containers. The mapping host-path → container
// path comes from the manifest's volume specs.
volumes := agentmodule.ResolveVolumes(manifest, rec.InstanceID, rec.DataPath)
container := "panel-" + rec.InstanceID
var rendered []string
for _, cf := range manifest.ConfigFiles {
if cf.TemplateForBranch(branch) == "" {
continue
}
rendered = append(rendered, cf.Path)
hostPath := filepath.Join(rec.DataPath, cf.Path)
ctrPath := ""
for _, v := range volumes {
if v.Type == "bind" && v.HostPath == hostPath {
ctrPath = v.ContainerPath
break
}
}
if ctrPath == "" {
continue // file isn't mounted into the container; host render is all there is
}
want, rerr := os.ReadFile(hostPath)
if rerr != nil {
fail("read rendered " + cf.Path + ": " + rerr.Error())
return
}
got, gerr := d.runtime.CopyFileFromContainer(ctx, container, ctrPath)
if gerr == nil && bytes.Equal(got, want) {
continue // live bind-mount delivered the new render
}
// Pre-bind container (or unreadable): write the volume copy directly.
if cerr := d.runtime.CopyFileToContainer(ctx, container, ctrPath, want); cerr != nil {
fail("container has no live bind for " + cf.Path + " and direct copy failed: " + cerr.Error())
return
}
log.Info("rendered config copied into pre-bind container volume", "file", cf.Path, "container_path", ctrPath)
}
log.Info("re-rendered config files (durable, applies next boot)", "files", rendered)
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_InstanceRenderConfigResult{
InstanceRenderConfigResult: &panelv1.InstanceRenderConfigResult{RenderedFiles: rendered},
},
})
}
+131
View File
@@ -0,0 +1,131 @@
package dispatch
import (
"sync"
"testing"
"time"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// blockingSender simulates a controller that has stopped draining the stream:
// every Send blocks until released. This is the backpressure condition that
// used to wedge the whole agent when sendEnv called Send inline.
type blockingSender struct {
release chan struct{}
mu sync.Mutex
sent int
}
func (b *blockingSender) Send(*panelv1.AgentEnvelope) error {
<-b.release // block until the test lets it through
b.mu.Lock()
b.sent++
b.mu.Unlock()
return nil
}
func logEnv() *panelv1.AgentEnvelope {
return &panelv1.AgentEnvelope{Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{Line: "x"}}}
}
func stateEnv() *panelv1.AgentEnvelope {
return &panelv1.AgentEnvelope{Payload: &panelv1.AgentEnvelope_InstanceState{InstanceState: &panelv1.InstanceStateUpdate{InstanceId: "i"}}}
}
// newTestDispatcher builds a Dispatcher with just the send machinery wired —
// enough to exercise sendEnv / sendLoop without a real runtime.
func newTestDispatcher() *Dispatcher {
d := &Dispatcher{sendCh: make(chan *panelv1.AgentEnvelope, 4096)}
go d.sendLoop()
return d
}
// TestSendEnvDropsTelemetryUnderBackpressure is the core regression guard for
// the VEIN-exposed wedge: with the sender blocked, a flood of log envelopes far
// exceeding the buffer must NOT block the producer. Pre-fix, sendEnv called
// Send inline and the producer (and the whole agent) froze here.
func TestSendEnvDropsTelemetryUnderBackpressure(t *testing.T) {
d := newTestDispatcher()
bs := &blockingSender{release: make(chan struct{})}
d.SetSender(bs)
done := make(chan struct{})
go func() {
// 50k log frames — 12x the 4096 buffer. Must all return promptly.
for i := 0; i < 50000; i++ {
d.sendEnv(logEnv())
}
close(done)
}()
select {
case <-done:
// Producer completed without blocking on the stalled sender. Pass.
case <-time.After(5 * time.Second):
t.Fatal("sendEnv blocked under backpressure — the wedge regressed")
}
}
// TestCriticalEnvelopeNotDropped verifies an instance-state envelope is queued
// (not silently dropped) even when telemetry is being shed. We fill the buffer
// with the sender blocked, then confirm a critical send blocks until drained
// (i.e. it's preserved, not discarded).
func TestCriticalEnvelopeNotDropped(t *testing.T) {
d := &Dispatcher{sendCh: make(chan *panelv1.AgentEnvelope, 2)}
// No drainer started yet — fill the buffer.
d.sendEnv(logEnv())
d.sendEnv(logEnv())
// Buffer is full (cap 2). A telemetry frame must drop (non-blocking).
dropDone := make(chan struct{})
go func() { d.sendEnv(logEnv()); close(dropDone) }()
select {
case <-dropDone:
case <-time.After(time.Second):
t.Fatal("telemetry send blocked on full buffer — should have dropped")
}
// A critical frame must NOT drop: it blocks until space frees. Start a
// drainer to free space and confirm the critical send then completes.
critDone := make(chan struct{})
go func() { d.sendEnv(stateEnv()); close(critDone) }()
select {
case <-critDone:
t.Fatal("critical send returned before buffer had space — it was dropped")
case <-time.After(100 * time.Millisecond):
// Correctly still blocking. Now drain and it should complete.
}
go d.sendLoop()
select {
case <-critDone:
// Drained and delivered. Pass.
case <-time.After(time.Second):
t.Fatal("critical send never completed after drain")
}
}
// TestConcurrentProducersNoRace runs many producers through sendEnv at once.
// Pre-fix, each called stream.Send directly — concurrent Sends on one gRPC
// stream are a data race. The single-drainer design serializes them. Run with
// -race to catch a regression.
func TestConcurrentProducersNoRace(t *testing.T) {
d := newTestDispatcher()
bs := &blockingSender{release: make(chan struct{})}
close(bs.release) // never block — just count
d.SetSender(bs)
var wg sync.WaitGroup
for p := 0; p < 32; p++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := 0; i < 1000; i++ {
d.sendEnv(logEnv())
}
}()
}
wg.Wait()
// Give the drainer a moment to flush.
time.Sleep(200 * time.Millisecond)
// No assertion on exact count (some may still be in flight / dropped);
// the point is the -race detector finds no concurrent Send.
}
+398
View File
@@ -0,0 +1,398 @@
package dispatch
import (
"context"
"encoding/json"
"fmt"
"io"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Per-instance stats polling. Docker streams a JSON frame ~once per
// second; we throttle to one upstream emit per statsEmitInterval so the
// event bus isn't flooded. CPU% uses the frame's curprevious delta
// directly (Docker includes precpu_stats on streamed frames).
// 3s is snappy enough to watch RAM climb during ARK world-load (12-15 GB
// in under a minute) without flooding the SSE bus. Older 10s value made
// the dashboard feel laggy when memory was actually moving fast.
const statsEmitInterval = 3 * time.Second
// ARK SA memory guardrails — two-stage so we can flush saves cleanly
// before having to SIGKILL.
//
// Healthy ARK SA world: 10-15 GB resident. 16-30 GB during save-flush
// peaks is normal on a populated cluster. 45 GB is the warning level;
// 60 GB is the hard kill that protects the host from OOM (which would
// take down all sibling ARKs + postgres + the panel itself).
//
// 2026-05-25 history: the previous single-threshold 40 GB → 5s SIGKILL
// path corrupted at least three SQLite-backed `.ark` saves over the
// 2026-05-19 → 2026-05-25 window because the SIGKILL hit while a
// SaveWorld was mid-flush (FAtlasSaveManager::LoadOperationSql crash on
// next boot — null page read in the half-written SQLite store).
//
// The two-stage guard:
//
// At arkRAMWarnLimit (45 GB) — fired AT MOST ONCE per container life:
// 1. Broadcast a 60s heads-up to in-game players via RCON
// 2. SaveWorld (lets ARK flush cleanly)
// 3. Wait 60s for the save + warning window
// 4. runtime.Stop with 120s grace (clean shutdown path)
// The container then stays Exited; operator restarts when ready, no
// torn save, no progress loss beyond what the SaveWorld captured.
//
// At arkRAMHardLimit (60 GB) — fires if the warn path didn't help:
// SIGKILL via runtime.Stop with 30s grace. Save MAY be torn here;
// this is the host-protection layer, accepted as last-resort.
//
// The warn level fires at most once per container life via the
// rec.memGuardWarned atomic — once we've committed to a graceful stop,
// the hard limit isn't allowed to also fire and interrupt the SaveWorld.
const arkRAMWarnLimit uint64 = 45 * 1024 * 1024 * 1024
const arkRAMHardLimit uint64 = 60 * 1024 * 1024 * 1024
// gracefulStopGracePeriod is the docker-stop grace given to the
// graceful warn path. ARK's SaveWorld can take 30-60s on a populated
// world; 120s is enough headroom for the flush + the watchdog's DoExit.
const gracefulStopGracePeriod = 120 * time.Second
// emergencyStopGracePeriod is the docker-stop grace for the 60 GB hard
// path. 30s (was 5s) gives the engine a real shot at flushing in-flight
// state before SIGKILL — the host-OOM risk that motivated the original
// 5s grace doesn't materialize this fast in practice.
const emergencyStopGracePeriod = 30 * time.Second
// dockerStatsFrame is the minimum subset of Docker's /containers/<id>/stats
// response we need. Kept as a local shape so we don't depend on which
// version of github.com/docker/docker renamed the struct this week.
type dockerStatsFrame struct {
CPUStats struct {
// IMPORTANT: every nested field needs an explicit json tag.
// Go's unmarshal is case-insensitive but does NOT translate
// CamelCase ↔ snake_case. Without `json:"total_usage"`, the
// field reads as 0 because Docker emits the snake_case key
// and we'd be looking for "TotalUsage" / "totalusage". This
// bug shipped for months — every container reported 0% CPU.
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
OnlineCPUs uint32 `json:"online_cpus"`
} `json:"cpu_stats"`
PreCPUStats struct {
CPUUsage struct {
TotalUsage uint64 `json:"total_usage"`
} `json:"cpu_usage"`
SystemCPUUsage uint64 `json:"system_cpu_usage"`
} `json:"precpu_stats"`
MemoryStats struct {
Usage uint64 `json:"usage"`
Limit uint64 `json:"limit"`
// Docker's `usage` includes file-system page cache, which can
// balloon to multi-GB on read-heavy workloads (ARK SA's mod +
// asset reads inflate it to 25 GB on a 12 GB RSS workload).
// `docker stats` CLI subtracts cache/inactive_file to match
// what operators expect. We mirror that.
// - cgroups v1: usage - stats.cache
// - cgroups v2: usage - stats.inactive_file
// Both keys are in the same nested map; whichever is present
// is the one our cgroup version exposes.
Stats struct {
Cache uint64 `json:"cache"` // cgroups v1
InactiveFile uint64 `json:"inactive_file"` // cgroups v2
RSS uint64 `json:"rss"` // cgroups v1
Anon uint64 `json:"anon"` // cgroups v2 (active anon)
} `json:"stats"`
} `json:"memory_stats"`
Networks map[string]struct {
RxBytes uint64 `json:"rx_bytes"`
TxBytes uint64 `json:"tx_bytes"`
} `json:"networks"`
PidsStats struct {
Current uint32 `json:"current"`
} `json:"pids_stats"`
}
func (d *Dispatcher) streamStats(ctx context.Context, rec *instanceRecord) {
rc, err := d.runtime.StatsStream(ctx, rec.ContainerID)
if err != nil {
d.log.Warn("stats stream open failed", "instance_id", rec.InstanceID, "err", err)
return
}
defer rc.Close()
d.log.Info("stats stream opened", "instance_id", rec.InstanceID)
emitCount := 0
defer func() { d.log.Info("stats stream closed", "instance_id", rec.InstanceID, "emitted", emitCount) }()
dec := json.NewDecoder(rc)
lastEmit := time.Time{}
for {
if ctx.Err() != nil {
return
}
var f dockerStatsFrame
if err := dec.Decode(&f); err != nil {
if err != io.EOF && ctx.Err() == nil {
d.log.Debug("stats decode ended", "instance_id", rec.InstanceID, "err", err)
}
return
}
if time.Since(lastEmit) < statsEmitInterval {
continue
}
memUsed := d.sendStats(rec.InstanceID, &f)
lastEmit = time.Now()
emitCount++
// ARK runaway-memory guardrails. Two stages: warn (45 GB) tries a
// graceful flush; hard (60 GB) SIGKILLs to protect the host.
// Both paths set rec.stopping inside their handler so subsequent
// samples short-circuit there.
if rec.ModuleID == "ark-sa" {
if memUsed > arkRAMHardLimit {
go d.emergencyRestart(rec, fmt.Sprintf(
"ARK RAM hit %.1f GB (hard limit %d GB) — bouncing to protect host",
float64(memUsed)/1024/1024/1024, arkRAMHardLimit/1024/1024/1024))
return
}
if memUsed > arkRAMWarnLimit && rec.memGuardWarned.CompareAndSwap(false, true) {
go d.gracefulMemoryRestart(rec, fmt.Sprintf(
"ARK RAM hit %.1f GB (warn limit %d GB) — flushing save + graceful restart",
float64(memUsed)/1024/1024/1024, arkRAMWarnLimit/1024/1024/1024))
// Don't return — keep streaming so the hard limit can still
// fire if RAM keeps climbing past 60 GB during the 60s flush
// window. The memGuardWarned latch prevents the warn path
// from re-arming, but the hard path is the host-protection
// backstop and must remain live.
}
}
}
}
func (d *Dispatcher) sendStats(instanceID string, f *dockerStatsFrame) uint64 {
// CPU%: delta total / delta system * online_cpus * 100.
// First frame's precpu_stats is zero so cpuPct comes out 0 — fine.
var cpuPct float64
cpuDelta := int64(f.CPUStats.CPUUsage.TotalUsage) - int64(f.PreCPUStats.CPUUsage.TotalUsage)
sysDelta := int64(f.CPUStats.SystemCPUUsage) - int64(f.PreCPUStats.SystemCPUUsage)
hostCpus := f.CPUStats.OnlineCPUs
if sysDelta > 0 && cpuDelta > 0 {
cpus := float64(hostCpus)
if cpus < 1 {
cpus = 1
}
cpuPct = float64(cpuDelta) / float64(sysDelta) * cpus * 100.0
}
var rx, tx uint64
for _, n := range f.Networks {
rx += n.RxBytes
tx += n.TxBytes
}
// "Real" memory: subtract file cache (cgroups v1) or inactive_file
// (cgroups v2). Falls through to raw usage when neither is reported
// (Windows containers, exotic runtimes). This is what `docker stats`
// shows and what operators expect — the inflated ARK numbers (~25 GB
// vs the actual ~12 GB RSS) were the cache-included variant.
memUsed := f.MemoryStats.Usage
if f.MemoryStats.Stats.InactiveFile > 0 && f.MemoryStats.Stats.InactiveFile <= memUsed {
memUsed -= f.MemoryStats.Stats.InactiveFile
} else if f.MemoryStats.Stats.Cache > 0 && f.MemoryStats.Stats.Cache <= memUsed {
memUsed -= f.MemoryStats.Stats.Cache
}
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_InstanceStats{
InstanceStats: &panelv1.InstanceStatsUpdate{
InstanceId: instanceID,
CpuPercent: cpuPct,
MemUsedBytes: memUsed,
MemLimitBytes: f.MemoryStats.Limit,
NetRxBytes: rx,
NetTxBytes: tx,
Pids: f.PidsStats.Current,
HostCpus: hostCpus,
At: timestamppb.Now(),
},
},
})
return memUsed
}
// gracefulMemoryRestart is the 45 GB warn path. Tries to land a clean
// SaveWorld before bouncing the container so the next boot doesn't face
// a torn SQLite save. Sequence:
//
// 1. Latch rec.stopping so other paths back off.
// 2. Emit a panel log line + INSTANCE_STATUS_STOPPING state.
// 3. RCON Broadcast: "[Panel] high memory — saving and restarting in 60s"
// 4. RCON SaveWorld
// 5. Sleep 60s (gives players warning + engine time to write the save)
// 6. Cancel tracker/log goroutines, runtime.Restart with 120s grace.
//
// Uses Restart NOT Stop: a plain Stop on an `unless-stopped` container
// is honored by docker as an operator stop — the container then stays
// Exited until someone manually restarts it. That regression took
// Ragnarok offline for 17 hours after the 02:02 PDT warn fire on
// 2026-05-25 (see HANDOFF "Last meaningfully updated"). Restart bounces
// the container as a transient event so it comes back up automatically;
// the operator just sees a brief blip + the "[panel] MEMORY-WARN" line.
//
// If any RCON step fails (engine wedged, RCON socket dead), fall through
// to the restart anyway — we still want the engine down + back up. The
// 120s docker grace gives the engine a real shot at a clean DoExit even
// when RCON is the broken thing.
func (d *Dispatcher) gracefulMemoryRestart(rec *instanceRecord, reason string) {
if !rec.stopping.CompareAndSwap(false, true) {
return
}
d.log.Warn("ark memory warn — graceful restart", "instance_id", rec.InstanceID, "reason", reason)
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: "stderr",
At: timestamppb.Now(),
Line: "[panel] MEMORY-WARN: " + reason,
}},
})
d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPING, 0, "graceful memory restart: "+reason)
// Best-effort RCON broadcast + SaveWorld. Use a short per-call
// timeout so a wedged RCON socket doesn't block the whole graceful
// path. tracker.Exec redials internally, so a closed socket from
// idle-disconnect won't burn the attempt.
if rec.Tracker != nil {
bctx, bcancel := context.WithTimeout(context.Background(), 5*time.Second)
if _, err := rec.Tracker.Exec(bctx, "Broadcast [Panel] High memory detected. Server saving and restarting in 60 seconds."); err != nil {
d.log.Warn("rcon broadcast failed during graceful memory restart", "instance_id", rec.InstanceID, "err", err)
}
bcancel()
sctx, scancel := context.WithTimeout(context.Background(), 10*time.Second)
if _, err := rec.Tracker.Exec(sctx, "SaveWorld"); err != nil {
d.log.Warn("rcon SaveWorld failed during graceful memory restart", "instance_id", rec.InstanceID, "err", err)
}
scancel()
}
// Wait the warning window — players see the broadcast, engine
// finishes writing the save. Sleeping in the goroutine is fine;
// the stats loop continues to monitor for the 60 GB hard limit
// in case RAM keeps climbing during the wait.
time.Sleep(60 * time.Second)
// Cancel ancillary goroutines so they don't fight the bounce.
// They'll be re-armed by watchExit when the restart settles.
d.mu.Lock()
if rec.TrackerCancel != nil {
rec.TrackerCancel()
rec.TrackerCancel = nil
}
if rec.LogCancel != nil {
rec.LogCancel()
rec.LogCancel = nil
}
d.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), gracefulStopGracePeriod+30*time.Second)
defer cancel()
if err := d.runtime.Restart(ctx, rec.ContainerID, gracefulStopGracePeriod); err != nil {
d.log.Error("graceful memory restart runtime failure", "instance_id", rec.InstanceID, "err", err)
// Restart failed — surface to operator so they know to investigate.
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: "stderr",
At: timestamppb.Now(),
Line: "[panel] MEMORY-WARN restart failed: " + err.Error(),
}},
})
}
// Clear both the stopping and memGuardWarned latches so future
// memory-warn fires can re-arm on this container's next life.
rec.stopping.Store(false)
rec.memGuardWarned.Store(false)
}
// emergencyRestart bounces a runaway container outside the normal
// handleStop path. Difference from handleStop: 30s grace (not 60s),
// runs even when no operator request is in flight, and emits a clear
// log line so the dashboard's Console tab shows WHY this happened.
//
// Uses Restart NOT Stop — see gracefulMemoryRestart for the rationale.
// A plain Stop on `unless-stopped` containers parks them Exited until
// manually started; the panel operator has no way to know they need to
// click Start. Bouncing the container keeps the service available, lets
// the engine recover into a fresh process with no leak state, and the
// "[panel] AUTO-KILL" log line is preserved for after-the-fact diagnosis.
// If the underlying leak is persistent, RAM will climb again and the
// guard will fire again — that's the operator's signal to investigate.
//
// 2026-05-25: bumped from 5s → 30s grace. The 5s window guaranteed
// SIGKILL mid-save when this fired during a SaveWorld, and that's
// what was producing the FAtlasSaveManager corruption pattern. 30s
// is enough for the engine to finish a save in flight without giving
// up host-OOM protection.
//
// Idempotent via rec.stopping: the stats loop fires this from a goroutine
// every 3s while RAM is over the limit, but the second+ invocations
// short-circuit at the stopping check so we don't pile up Stop calls
// against a container that's already heading down.
func (d *Dispatcher) emergencyRestart(rec *instanceRecord, reason string) {
if !rec.stopping.CompareAndSwap(false, true) {
return
}
d.log.Warn("emergency restart", "instance_id", rec.InstanceID, "reason", reason)
// Surface to operator via log line + state update.
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: "stderr",
At: timestamppb.Now(),
Line: "[panel] AUTO-KILL: " + reason,
}},
})
d.sendInstanceState(rec.InstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPING, 0, "auto-kill: "+reason)
// Cancel ancillary goroutines so they don't fight the bounce.
// They'll be re-armed by watchExit when the restart settles.
d.mu.Lock()
if rec.TrackerCancel != nil {
rec.TrackerCancel()
rec.TrackerCancel = nil
}
if rec.LogCancel != nil {
rec.LogCancel()
rec.LogCancel = nil
}
d.mu.Unlock()
ctx, cancel := context.WithTimeout(context.Background(), emergencyStopGracePeriod+30*time.Second)
defer cancel()
if err := d.runtime.Restart(ctx, rec.ContainerID, emergencyStopGracePeriod); err != nil {
d.log.Error("emergency restart runtime failure", "instance_id", rec.InstanceID, "err", err)
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: "stderr",
At: timestamppb.Now(),
Line: "[panel] AUTO-KILL restart failed: " + err.Error(),
}},
})
}
// Clear both latches so future signals can re-arm on the bounced container.
rec.stopping.Store(false)
rec.memGuardWarned.Store(false)
}
+212
View File
@@ -0,0 +1,212 @@
package dispatch
import (
"context"
"fmt"
"os"
"path"
"sync"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/agent/internal/updater"
"github.com/dbledeez/panel/pkg/steamvdf"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Update-available check (WI-14). CHECK-ONLY: reads the installed
// buildid from the instance's Steam appmanifest ACF and the latest
// buildid for its branch via `steamcmd +app_info_print` — it never
// starts an update or touches the install.
// appInfoCacheTTL is how long a fetched branch map is reused before a
// fresh steamcmd run. app_info runs cost a sidecar spin-up (~10-30 s),
// so checks across many instances of the same game share one fetch.
const appInfoCacheTTL = 15 * time.Minute
type appInfoCacheEntry struct {
branches map[string]string
fetchedAt time.Time
}
var (
appInfoCacheMu sync.Mutex
appInfoCache = map[string]appInfoCacheEntry{} // key: app_id
// appInfoFetchMu single-flights concurrent fetches per process; a
// coarse lock is fine — checks are rare and operator-initiated.
appInfoFetchMu sync.Mutex
)
func (d *Dispatcher) handleUpdateCheck(corrID string, req *panelv1.UpdateCheckRequest) {
fail := func(msg string) {
d.sendUpdateCheckResult(corrID, &panelv1.UpdateCheckResult{
InstanceId: req.InstanceId, Error: msg,
})
}
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
fail(err.Error())
return
}
manifest, ok := d.modules.Get(rec.ModuleID)
if !ok {
fail("module not in registry")
return
}
// Same provider selection as handleUpdate: by id when supplied, else
// first in the manifest.
var spec *modulepkg_UpdateProvider
for i := range manifest.UpdateProviders {
p := &manifest.UpdateProviders[i]
if req.ProviderId == "" || p.ID == req.ProviderId {
spec = p
break
}
}
if spec == nil {
fail(fmt.Sprintf("no provider %q on module %s", req.ProviderId, rec.ModuleID))
return
}
if spec.Kind != "steamcmd" {
fail(fmt.Sprintf("update check supports steamcmd providers only (provider %q is %q)", spec.ID, spec.Kind))
return
}
if spec.AppID == "" {
fail("provider has no app_id")
return
}
// The check spins a steamcmd sidecar (tens of seconds) — never block
// the dispatch read loop.
go func() {
branch := spec.Beta
branchKey := branch
if branchKey == "" {
branchKey = "public"
}
res := &panelv1.UpdateCheckResult{
InstanceId: req.InstanceId,
AppId: spec.AppID,
Branch: branchKey,
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
// Installed side: appmanifest ACF under the provider's install
// root. Prefer betakey/buildid straight from the manifest file.
if acf, rerr := d.readInstalledACF(ctx, rec, spec.InstallPath, spec.AppID); rerr != nil {
res.Error = "read appmanifest: " + rerr.Error()
} else if m, perr := steamvdf.ParseAppManifest(string(acf)); perr != nil {
res.Error = "parse appmanifest: " + perr.Error()
} else {
res.InstalledBuildid = m.BuildID
if m.BetaKey != "" {
// Trust the on-disk truth over the manifest's declared
// beta — this is exactly the "bounced between branches"
// case the check exists to expose.
res.Branch = m.BetaKey
branchKey = m.BetaKey
}
}
// Latest side: branch map via steamcmd app_info (15-min cache).
branches, cached, ferr := d.latestBranches(ctx, rec.InstanceID, spec.AppID, req.Refresh)
if ferr != nil {
if res.Error != "" {
res.Error += "; "
}
res.Error += "fetch latest: " + ferr.Error()
} else {
res.Cached = cached
if bid, ok := branches[branchKey]; ok {
res.LatestBuildid = bid
} else if res.Error == "" {
res.Error = fmt.Sprintf("branch %q not in Steam branches map (%d branches known)", branchKey, len(branches))
}
}
res.UpdateAvailable = res.InstalledBuildid != "" && res.LatestBuildid != "" &&
res.InstalledBuildid != res.LatestBuildid
d.log.Info("update check",
"instance_id", req.InstanceId, "app_id", spec.AppID, "branch", branchKey,
"installed", res.InstalledBuildid, "latest", res.LatestBuildid,
"update_available", res.UpdateAvailable, "cached", res.Cached, "err", res.Error)
d.sendUpdateCheckResult(corrID, res)
}()
}
// latestBranches returns the branch→buildid map for appID, from cache
// when fresh (unless refresh), otherwise via a steamcmd sidecar run.
func (d *Dispatcher) latestBranches(ctx context.Context, instanceID, appID string, refresh bool) (map[string]string, bool, error) {
if !refresh {
appInfoCacheMu.Lock()
e, ok := appInfoCache[appID]
appInfoCacheMu.Unlock()
if ok && time.Since(e.fetchedAt) < appInfoCacheTTL {
return e.branches, true, nil
}
}
appInfoFetchMu.Lock()
defer appInfoFetchMu.Unlock()
// Re-check under the fetch lock — a concurrent check may have just
// filled the cache while we waited.
if !refresh {
appInfoCacheMu.Lock()
e, ok := appInfoCache[appID]
appInfoCacheMu.Unlock()
if ok && time.Since(e.fetchedAt) < appInfoCacheTTL {
return e.branches, true, nil
}
}
branches, err := updater.FetchAppInfoBranches(ctx, d.runtime, instanceID, appID, func(line string) {
d.log.Debug("app_info", "instance_id", instanceID, "line", line)
})
if err != nil {
return nil, false, err
}
appInfoCacheMu.Lock()
appInfoCache[appID] = appInfoCacheEntry{branches: branches, fetchedAt: time.Now()}
appInfoCacheMu.Unlock()
return branches, false, nil
}
// readInstalledACF reads steamapps/appmanifest_<appID>.acf under the
// provider's install root, using the same container-first/host-fallback
// mechanics as the FsRead RPC (fs helper sidecar works on stopped
// instances too).
func (d *Dispatcher) readInstalledACF(ctx context.Context, rec *instanceRecord, installPath, appID string) ([]byte, error) {
rel := path.Join("steamapps", fmt.Sprintf("appmanifest_%s.acf", appID))
if d.useContainerOps(rec) {
root := installPath
if root == "" {
root = rec.BrowseableRoot
}
abs, err := safeJoinAny(root, append(rootPaths(rec), root), path.Join(root, rel))
if err != nil {
return nil, err
}
targetID, err := d.getFsTargetContainerID(ctx, rec)
if err != nil {
return nil, err
}
return d.runtime.CopyFileFromContainer(ctx, targetID, abs)
}
if rec.DataPath == "" {
return nil, fmt.Errorf("no file storage available")
}
abs, err := safeJoinHost(rec.DataPath, rel)
if err != nil {
return nil, err
}
return os.ReadFile(abs)
}
func (d *Dispatcher) sendUpdateCheckResult(corrID string, res *panelv1.UpdateCheckResult) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_UpdateCheckResult{UpdateCheckResult: res},
})
}
+261
View File
@@ -0,0 +1,261 @@
package dispatch
import (
"context"
"fmt"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/agent/internal/updater"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Use a type alias so the file's handler signatures don't bleed the
// modulepkg import into their exposed type names.
type modulepkg_UpdateProvider = modulepkg.UpdateProvider
// registerUpdater stores the cancel func for the in-flight updater of
// an instance. Returns (slot, true) when registration succeeds, or
// (nil, false) when another updater is already running for the same
// instance.
//
// We refuse instead of preempting because preempting races the docker
// daemon's name cleanup: cancelling the prior context returns instantly,
// but the prior steamcmd sidecar (e.g. panel-<id>-steamcmd-1) takes a
// few seconds to actually exit and be removed. A new updater that
// charges in immediately hits a "name already in use" Docker error and,
// worse, leaves SteamCMD's staging directory mid-finalize (state 0x602)
// — files stranded under <install>/steamapps/downloading/<appid>/.
//
// Concrete failure mode that prompted this guard:
// - panelctl create palworld-test → auto-triggers first update.
// - Operator (impatiently) runs panelctl update palworld-test 13s later.
// - Old behavior cancelled the auto-update mid-validate, then the new
// run failed name-conflict, then SteamCMD looped on exit-8 / 0x602.
//
// The delete path uses cancelUpdater (different entry), so this guard
// doesn't block teardown.
func (d *Dispatcher) registerUpdater(instanceID string, cancel context.CancelFunc) (*updaterSlot, bool) {
d.mu.Lock()
defer d.mu.Unlock()
if existing := d.updaters[instanceID]; existing != nil {
return nil, false
}
slot := &updaterSlot{cancel: cancel}
d.updaters[instanceID] = slot
return slot, true
}
// unregisterUpdater clears the registration ONLY IF the current entry
// is still our slot — pointer compare guards against a later updater
// having taken over.
func (d *Dispatcher) unregisterUpdater(instanceID string, slot *updaterSlot) {
d.mu.Lock()
defer d.mu.Unlock()
if d.updaters[instanceID] == slot {
delete(d.updaters, instanceID)
}
}
// cancelUpdater stops any in-flight updater for this instance and
// removes the registration. Called from the delete path so the sidecar
// gets torn down before we try to remove the volume.
func (d *Dispatcher) cancelUpdater(instanceID string) {
d.mu.Lock()
slot := d.updaters[instanceID]
delete(d.updaters, instanceID)
d.mu.Unlock()
if slot != nil && slot.cancel != nil {
slot.cancel()
}
}
// Update handler. Controller sends ControllerEnvelope_Update; agent:
//
// 1. Looks up the instance + module manifest.
// 2. Picks the update provider by id (or defaults to the first one).
// 3. Emits a synchronous UpdateResult{accepted=true} so the panel can
// un-gray the button and show "update started".
// 4. Spawns a goroutine that runs the provider, streaming each progress
// line as a LogLine{stream="update"}. Final sentinel line
// "update:ok" or "update:error:<msg>" tells the UI we're done.
//
// Progress is not correlation-routed because the UI subscribes to the
// normal event bus and picks up LogLines directly.
func (d *Dispatcher) handleUpdate(corrID string, req *panelv1.UpdateRequest) {
d.handleUpdateWithHook(corrID, req, nil)
}
// handleUpdateWithHook is the internal form that optionally fires onComplete
// once the provider run finishes (success or failure). Used by the auto-
// update-on-create path to chain a Start after the first-time download and
// to clear UI state if the update errors out. The normal controller-driven
// Update flow passes nil so the hook stays opt-in.
func (d *Dispatcher) handleUpdateWithHook(corrID string, req *panelv1.UpdateRequest, onComplete func(err error)) {
rec, err := d.lookupRecord(req.InstanceId)
if err != nil {
d.sendUpdateResult(corrID, false, err.Error(), "", "")
return
}
manifest, ok := d.modules.Get(rec.ModuleID)
if !ok {
d.sendUpdateResult(corrID, false, "module not in registry", "", "")
return
}
// Special pseudo-provider "_basemods": short-circuits to the third-
// party base-mod installer (Allocs + PrismaCore) instead of running
// the module's normal updater. Reuses the Update RPC plumbing so the
// UI gets the same "updating" pulse + error surface as a real update.
// Reasoning for the underscore prefix: real provider ids never start
// with one (manifest schema convention), so collisions are impossible.
if req.ProviderId == "_basemods" {
d.sendUpdateResult(corrID, true, "", "_basemods", "basemods")
d.log.Info("install-base-mods: kicked", "instance_id", req.InstanceId)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
err := d.installBaseMods(ctx, req.InstanceId, manifest, rec.DataPath)
if err != nil {
d.log.Error("install-base-mods: failed", "instance_id", req.InstanceId, "err", err)
} else {
d.log.Info("install-base-mods: done", "instance_id", req.InstanceId)
}
if onComplete != nil {
onComplete(err)
}
}()
return
}
// Pick provider: by id if supplied, else first in manifest.
var spec *modulepkg_UpdateProvider
for i := range manifest.UpdateProviders {
p := &manifest.UpdateProviders[i]
if req.ProviderId == "" || p.ID == req.ProviderId {
spec = providerAdapter(p)
break
}
}
if spec == nil {
d.sendUpdateResult(corrID, false, fmt.Sprintf("no provider %q on module %s", req.ProviderId, rec.ModuleID), "", "")
return
}
prov, err := updater.Get(spec.Kind)
if err != nil {
d.sendUpdateResult(corrID, false, err.Error(), spec.ID, spec.Kind)
return
}
// Reject if another updater is already running on this instance —
// see registerUpdater for why we refuse instead of preempting.
//
// Timeout bumped 30 → 90 min (2026-05-04) — Steam CDN can rate-limit
// anonymous-login pulls down to 1-3 MB/s, and a 17 GB game (7DTD,
// ARK SA) at 5 MB/s = 56 min, easily over 30. Killing the sidecar
// at 30 min leaves a partial install + a confused operator. 90 min
// covers the realistic worst case with margin; truly hung steamcmd
// still gets killed eventually so we don't burn an updater slot
// forever.
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Minute)
slot, ok := d.registerUpdater(req.InstanceId, cancel)
if !ok {
cancel()
d.sendUpdateResult(corrID, false, "another update is already in progress for this instance", spec.ID, spec.Kind)
d.log.Info("update rejected: already running", "instance_id", req.InstanceId, "provider_id", spec.ID)
return
}
// Accept synchronously — run async.
d.sendUpdateResult(corrID, true, "", spec.ID, spec.Kind)
d.log.Info("update started", "instance_id", req.InstanceId, "provider_id", spec.ID, "kind", spec.Kind)
// Surface the in-flight update as a server-authoritative status.
// Without this, the panel's status pill stayed at whatever it was
// before the update (often "stopped" or worse, "crashed" if the
// previous run had hard-exited) — operators saw "crashed" while
// SteamCMD was actually downloading several GB. Emitting UPDATING
// here gives every dashboard viewer the same accurate "installing"
// pulse, regardless of which client kicked off the update.
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, "updating:"+spec.Kind)
go func() {
defer cancel()
defer d.unregisterUpdater(req.InstanceId, slot)
sink := func(line string) {
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: rec.InstanceID,
Stream: "update",
At: timestamppb.Now(),
Line: line,
}},
})
}
sink(fmt.Sprintf("---- update started: provider=%s kind=%s ----", spec.ID, spec.Kind))
uc := &updater.Context{
InstanceID: rec.InstanceID,
Manifest: manifest,
ContainerID: rec.ContainerID,
BrowseableRoot: rec.BrowseableRoot,
DataPath: rec.DataPath,
Runtime: d.runtime,
Log: sink,
// Steam login (if any) is forwarded by the controller for
// modules that set `requires_steam_login`. Fields are empty
// for anonymous-login apps — steamcmd.go falls back to that.
SteamUsername: req.SteamUsername,
SteamPassword: req.SteamPassword,
}
// ctx + cancel + slot were registered before the goroutine launched
// (see registerUpdater above). The defer at the top of the goroutine
// handles cleanup; the dispatcher's delete path can still abort us
// via cancelUpdater.
if err := prov.Update(ctx, uc, spec); err != nil {
sink(fmt.Sprintf("update:error: %s", err.Error()))
d.log.Warn("update failed", "instance_id", req.InstanceId, "err", err)
// Clear the UPDATING pill — surface the failure as CRASHED with
// a structured detail so operators see "install_failed: …" in
// the UI rather than the pill being stuck on UPDATING forever.
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_CRASHED, -1, "install_failed: "+err.Error())
if onComplete != nil {
onComplete(err)
}
return
}
sink("update:ok")
d.log.Info("update complete", "instance_id", req.InstanceId, "provider_id", spec.ID)
// Update succeeded — drop UPDATING back to STOPPED. handleStart's
// own state emission supersedes if an auto-start follows. Without
// this, a manually-triggered update left the pill on UPDATING
// indefinitely until the next start/stop cycle.
d.sendInstanceState(req.InstanceId, panelv1.InstanceStatus_INSTANCE_STATUS_STOPPED, 0, "installed")
if onComplete != nil {
onComplete(nil)
}
}()
}
func (d *Dispatcher) sendUpdateResult(corrID string, accepted bool, errMsg, providerID, providerKind string) {
d.sendEnv(&panelv1.AgentEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_UpdateResult{
UpdateResult: &panelv1.UpdateResult{
Accepted: accepted,
Error: errMsg,
ProviderId: providerID,
ProviderKind: providerKind,
},
},
})
}
// providerAdapter is a pass-through that pins us to the modulepkg type
// without needing to add another import alias at the top of this file.
func providerAdapter(p *modulepkg_UpdateProvider) *modulepkg_UpdateProvider { return p }
+331
View File
@@ -0,0 +1,331 @@
package dispatch
import (
"context"
"fmt"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// tryWarmSeedFromSibling looks for another instance of the same module
// already on this agent with a populated install volume; if found, copies
// that volume verbatim into the new instance's install volume via a
// throwaway debian sidecar. Saves operators 30+ minutes of redundant
// SteamCMD downloads when standing up a second instance of a game.
//
// Returns (true, nil) on a successful seed — caller should skip the
// updater and treat the install as already-complete.
// Returns (false, nil) when there's no candidate sibling — caller falls
// through to the normal updater.
// Returns (false, err) on operational failure of the seed itself —
// caller should also fall through (we'd rather over-download than leave
// the user without a working install).
//
// Symlink handling: install volumes typically contain symlinks back into
// the per-instance saves volume (e.g. /game/serverconfig.xml ->
// /game-saves/serverconfig.xml on 7DTD, set up by the entrypoint at first
// boot). Those symlinks would be DANGLING in the new instance because
// the new saves volume is empty. We strip broken symlinks after copy
// (`find -xtype l -delete`) so the entrypoint sees a clean state and
// recreates them on its first boot.
//
// Saves bootstrap: if the sibling's saves volume contains a
// serverconfig.xml-style file at a known location, we copy it to the
// new saves volume so the entrypoint has a starting config (operator
// can edit afterwards). Otherwise the entrypoint falls back to the
// game's shipped default. Done per-module by the seedSavesPaths list.
func (d *Dispatcher) tryWarmSeedFromSibling(ctx context.Context, newInstanceID string, manifest *modulepkg.Manifest, newDataPath string) (bool, error) {
if manifest == nil || len(manifest.UpdateProviders) == 0 {
return false, nil
}
provider := manifest.UpdateProviders[0]
installPath := provider.InstallPath
if installPath == "" {
installPath = manifest.Runtime.Docker.BrowseableRoot
}
if installPath == "" {
return false, nil
}
// Find a sibling: same agent, same module, NOT this instance, with a
// container that exists (so we know the volume was at least populated
// once). Prefer a stopped or running instance over a half-baked one.
//
// VERSION SAFETY: only reuse an install from a sibling on the SAME Steam
// branch as this new instance. Copying e.g. a 3.0 (latest_experimental)
// install into a server the operator created as 2.6 — or vice versa —
// produces a binary/world mismatch that corrupts the save. The new
// instance's record was tagged with its branch at Create (resolveCreateBranch),
// before this runs. Siblings predating the feature have an empty branch and
// are treated as the module's DEFAULT branch (branchOrDefault) — for 7DTD
// that's v2.6, so the existing 2.6 fleet is still a valid seed source for a
// new 2.6 server.
d.mu.Lock()
wantBranch := branchOrDefault("", manifest)
if nr, ok := d.instances[newInstanceID]; ok {
wantBranch = branchOrDefault(nr.Branch, manifest)
}
var srcID, srcDataPath string
for id, rec := range d.instances {
if id == newInstanceID {
continue
}
if rec.ModuleID != manifest.ID {
continue
}
if branchOrDefault(rec.Branch, manifest) != wantBranch {
continue // different game version — not a safe seed source
}
// Best-effort: if we have multiple on this branch, the first one wins.
// Could be improved later by picking the most-recently-updated.
srcID = id
srcDataPath = rec.DataPath
break
}
d.mu.Unlock()
if srcID == "" {
d.log.Debug("warm-seed: no same-branch sibling instance found", "module", manifest.ID, "instance", newInstanceID, "want_branch", wantBranch)
return false, nil
}
d.log.Info("warm-seed: matched sibling on branch", "module", manifest.ID, "instance", newInstanceID, "src", srcID, "branch", wantBranch)
srcVolumes := agentmodule.ResolveVolumes(manifest, srcID, srcDataPath)
dstVolumes := agentmodule.ResolveVolumes(manifest, newInstanceID, newDataPath)
srcGameVol := volumeNameForPath(srcVolumes, installPath)
dstGameVol := volumeNameForPath(dstVolumes, installPath)
if srcGameVol == "" || dstGameVol == "" {
return false, fmt.Errorf("warm-seed: install_path %q not in module volumes", installPath)
}
// Find a "saves-style" sibling volume to bootstrap from. Heuristic:
// any non-install named volume whose container path contains "save"
// or "data". Most modules have one. If none, we still seed the install
// volume; the entrypoint may still need to bootstrap saves itself.
srcSavesVol, srcSavesPath := findSavesVolume(srcVolumes, installPath)
dstSavesVol, _ := findSavesVolume(dstVolumes, installPath)
// Build the seed sidecar. debian:12-slim is small, has bash + find +
// cp + mkdir, and is already cached on every panel-agent host (the
// fs-helper sidecar uses it).
// Why a tar pipe instead of cp -a: we want fine-grained exclusions for
// per-instance state that shouldn't be inherited from the sibling.
//
// Mods/ split:
// BASE mods (TFP_*, 0_TFP_*, Allocs_*, PrismaCore, xMarkers) are
// load-bearing — TFP_Harmony is the runtime patcher, the server
// won't even boot without it; Allocs is the live map renderer the
// panel maps tab depends on; PrismaCore is the staff command surface
// refugebotserver expects. We KEEP these.
//
// CUSTOM mods (AGF-VP-*, RefugeBot, AsylumRoboticInbox, anything
// else) are server-specific — operators usually want a clean slate
// on a new server so the panel's mod manager / their own upload
// decides what's installed. We DROP these.
//
// steamapps/userdata, steamapps/downloading: per-Steam-account state
// + half-fetched chunks. Always excluded.
//
// Approach: tar everything EXCEPT Mods + downloading + userdata, then
// selectively copy back the base-mod entries from the sibling.
// The Mods/ split + base-mod completeness gate below are 7DTD-SPECIFIC:
// TFP_Harmony/Allocs/PrismaCore are load-bearing 7DTD server mods, and the
// "missing base mods → exit 42" check re-triggers a full SteamCMD validate
// to refetch them. Every OTHER module (palworld, soulmask, valheim, …) has
// no Mods/ dir at all, so running this block against them ALWAYS hit the
// completeness gate and exit-42'd — turning a valid sibling seed into a
// spurious "incomplete sibling" failure. For non-7DTD modules we copy the
// whole install verbatim (minus per-instance Steam state) and skip the gate.
//
// Root cause of the 2026-07-10 gamehost outage: Palworld + Soulmask checkouts
// warm-seeded from a sibling, hit this 7DTD gate, exit-42'd, and fell through
// to a fresh download — which was then canceled by a racing env-config
// recreate (fixed separately in the controller). See changelog 2026-07-10.
seedScript := `set -e
echo "warm-seed: copying install volume contents (excl. Mods/, steamapps/userdata, steamapps/downloading)"
find /dst-game -mindepth 1 -delete
( cd /src-game && tar --exclude='./Mods' --exclude='./steamapps/userdata' --exclude='./steamapps/downloading' -cf - . ) | ( cd /dst-game && tar -xf - )
`
if manifest.ID == "7dtd" {
seedScript += `mkdir -p /dst-game/Mods
echo "warm-seed: copying BASE mods (TFP_*, Allocs_*, PrismaCore, Xample_MarkersMod) — load-bearing for the server"
for mod in /src-game/Mods/TFP_* /src-game/Mods/0_TFP_* /src-game/Mods/Allocs_* /src-game/Mods/PrismaCore /src-game/Mods/Xample_* /src-game/Mods/*Markers* /src-game/Mods/xMarkers /src-game/Mods/xmarkers; do
case "$mod" in *"*"*) continue ;; esac
if [ -e "$mod" ]; then
cp -a "$mod" /dst-game/Mods/
echo " + $(basename $mod)"
fi
done
`
}
seedScript += `echo "warm-seed: stripping dangling symlinks (saves-volume references)"
find /dst-game -xtype l -delete
`
if manifest.ID == "7dtd" {
seedScript += `# Completeness check (7DTD only) — fail the warmseed if any LOAD-BEARING base
# mod is missing on dst. The caller falls back to a full steamcmd validate which
# will re-fetch the TFP/xMarkers mods from the dedicated-server depot.
missing=""
for required in 0_TFP_Harmony TFP_WebServer; do
[ -d "/dst-game/Mods/$required" ] || missing="$missing $required"
done
if [ -n "$missing" ]; then
echo "warm-seed: incomplete sibling — missing base mods:$missing"
exit 42
fi
`
}
if srcSavesVol != "" && dstSavesVol != "" {
// Copy ONLY the rendered config file (and the .local/share parent
// for HOME-based games). Don't copy the world data — the user wants
// a fresh save on this new instance, not the sibling's.
seedScript += `echo "warm-seed: bootstrapping saves volume from sibling"
[ -s /src-saves/serverconfig.xml ] && cp /src-saves/serverconfig.xml /dst-saves/serverconfig.xml || true
mkdir -p /dst-saves/.local/share 2>/dev/null || true
# 7DTD HOME tree — entrypoint mkdir's the deeper path on boot, but creating
# the parent here lets the install symlink resolve to a valid empty dir
# even before the first start.
for d in /src-saves/.local/share/7DaysToDie /src-saves/.local/share/Empyrion; do
if [ -d "$d" ]; then
mkdir -p "/dst-saves/$(echo $d | sed 's|/src-saves/||')"
fi
done
`
}
seedScript += `echo SEED_OK`
sidecarID := fmt.Sprintf("%s-warmseed", newInstanceID)
volumes := []runtime.VolumeSpec{
{Type: "volume", VolumeName: srcGameVol, ContainerPath: "/src-game", ReadOnly: true},
{Type: "volume", VolumeName: dstGameVol, ContainerPath: "/dst-game"},
}
if srcSavesVol != "" && dstSavesVol != "" {
volumes = append(volumes,
runtime.VolumeSpec{Type: "volume", VolumeName: srcSavesVol, ContainerPath: "/src-saves", ReadOnly: true},
runtime.VolumeSpec{Type: "volume", VolumeName: dstSavesVol, ContainerPath: "/dst-saves"},
)
}
spec := runtime.InstanceSpec{
InstanceID: sidecarID,
IsSidecar: true,
Image: "debian:12-slim",
Command: []string{"bash", "-c", seedScript},
Volumes: volumes,
RestartPolicy: "no",
}
// Surface what's happening — without this the card just shows a static
// "installing" pulse with no console output during a multi-GB sibling copy,
// which looks hung.
d.sendInstanceState(newInstanceID, panelv1.InstanceStatus_INSTANCE_STATUS_UPDATING, 0, fmt.Sprintf("pulling game files from existing install (%s) — no SteamCMD download needed", srcID))
d.log.Info("warm-seed: starting sidecar", "src", srcID, "dst", newInstanceID, "src_vol", srcGameVol, "dst_vol", dstGameVol, "saves_seed", srcSavesPath != "")
contID, err := d.runtime.Create(ctx, spec)
if err != nil {
return false, fmt.Errorf("warm-seed: create sidecar: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = d.runtime.Remove(rmCtx, contID)
}()
if err := d.runtime.Start(ctx, contID); err != nil {
return false, fmt.Errorf("warm-seed: start sidecar: %w", err)
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = d.runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
d.log.Info("warm-seed log", "instance_id", newInstanceID, "line", line)
// Mirror the sidecar's friendly progress echoes ("warm-seed: copying
// install volume…", "+ <mod>") onto the instance console so the
// operator can watch the "pulling from existing install" progress.
d.sendEnv(&panelv1.AgentEnvelope{
SentAt: timestamppb.Now(),
Payload: &panelv1.AgentEnvelope_Log{Log: &panelv1.LogLine{
InstanceId: newInstanceID, Stream: "install", At: timestamppb.Now(), Line: line,
}},
})
})
}()
exitCode, err := d.runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return false, fmt.Errorf("warm-seed: wait sidecar: %w", err)
}
if exitCode != 0 {
return false, fmt.Errorf("warm-seed: sidecar exited %d", exitCode)
}
d.log.Info("warm-seed: success", "src", srcID, "dst", newInstanceID)
return true, nil
}
// volumeNameForPath returns the named-volume name whose container path
// matches `path` exactly, or "" if no match. Bind mounts are skipped —
// we only seed named volumes (host path is operator-managed).
func volumeNameForPath(volumes []runtime.VolumeSpec, path string) string {
for _, v := range volumes {
if v.Type == "volume" && v.VolumeName != "" && v.ContainerPath == path {
return v.VolumeName
}
}
return ""
}
// findSavesVolume returns the (volumeName, containerPath) of the first
// named-volume whose container path looks like a saves/data dir, ignoring
// the install volume. Used to bootstrap the new instance's saves with a
// rendered config from the sibling.
func findSavesVolume(volumes []runtime.VolumeSpec, installPath string) (string, string) {
for _, v := range volumes {
if v.Type != "volume" || v.VolumeName == "" {
continue
}
if v.ContainerPath == installPath {
continue
}
lower := strings.ToLower(v.ContainerPath)
if strings.Contains(lower, "save") || strings.Contains(lower, "data") {
return v.VolumeName, v.ContainerPath
}
}
return "", ""
}
// normalizeBranch collapses a provider `beta` value into a stable warm-seed
// matching key: the empty beta (the moving public/stable branch) becomes
// "public"; any explicit beta (e.g. "v2.6", "latest_experimental") is returned
// trimmed as-is. Two installs are warm-seed compatible iff their normalized
// branches are equal.
func normalizeBranch(beta string) string {
b := strings.TrimSpace(beta)
if b == "" {
return "public"
}
return b
}
// branchOrDefault returns the normalized branch for a record's Branch value,
// treating an empty value (instances created before branch-tagging existed) as
// the module's DEFAULT branch — UpdateProviders[0].Beta normalized. For 7DTD
// the default is v2.6, so the pre-existing 2.6 fleet remains a valid warm-seed
// source for a new 2.6 server. Returns "public" when the manifest has no
// providers.
func branchOrDefault(branch string, m *modulepkg.Manifest) string {
if strings.TrimSpace(branch) != "" {
return normalizeBranch(branch)
}
if m != nil && len(m.UpdateProviders) > 0 {
return normalizeBranch(m.UpdateProviders[0].Beta)
}
return "public"
}
+216
View File
@@ -0,0 +1,216 @@
// Package module (agent-side) resolves a manifest + InstanceCreate RPC
// into a runtime.InstanceSpec ready for the Docker / host runtime to launch.
package module
import (
"errors"
"fmt"
"path/filepath"
"strings"
"github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// ResolveDocker produces an InstanceSpec for Docker-mode launch of the given
// module manifest and RPC create request. It substitutes $DATA_PATH in volume
// targets and maps declared ports against the request's port overrides.
func ResolveDocker(manifest *modulepkg.Manifest, req *panelv1.InstanceCreate) (runtime.InstanceSpec, error) {
if manifest == nil {
return runtime.InstanceSpec{}, errors.New("manifest is nil")
}
if manifest.Runtime.Docker == nil {
return runtime.InstanceSpec{}, fmt.Errorf("module %q has no docker runtime", manifest.ID)
}
if req.DataPath == "" {
return runtime.InstanceSpec{}, errors.New("req.data_path is required")
}
docker := manifest.Runtime.Docker
env := map[string]string{}
for k, v := range docker.Env {
env[k] = expand(v, req.DataPath)
}
// Per-instance config_values layer: if the operator (or controller
// feature code) set a value for an env key the module already
// declares, use that value instead of the manifest default. Only
// keys that ALREADY exist in docker.Env get overridden — we don't
// let config_values smuggle in unrelated env vars. Used today by
// the ARK cluster feature to override CLUSTER_ID per-instance.
for k, v := range req.ConfigValues {
if _, ok := env[k]; ok {
env[k] = v
}
}
volumes := make([]runtime.VolumeSpec, 0, len(docker.Volumes))
for _, v := range docker.Volumes {
vs := runtime.VolumeSpec{
ContainerPath: v.Container,
ReadOnly: v.ReadOnly,
}
kind := v.Type
if kind == "" {
// Infer from which field was populated.
if v.Name != "" {
kind = "volume"
} else {
kind = "bind"
}
}
switch kind {
case "volume":
vs.Type = "volume"
vs.VolumeName = expandInstance(v.Name, req.InstanceId)
default:
vs.Type = "bind"
vs.HostPath = expand(v.Target, req.DataPath)
}
// Apply per-instance mount override if the controller asked for
// one by container path. An absolute path (Unix `/...` or
// Windows `C:\...`) is a bind mount host path; anything else is
// a Docker named volume. Used by the ARK cluster feature to
// swap the default per-instance cluster volume for a shared
// host-visible `arkcluster/<id>/` bind mount. `$AGENT_DATA_ROOT`
// has already been expanded by handleCreate.
if ov, ok := req.MountOverrides[v.Container]; ok && ov != "" {
if filepath.IsAbs(ov) {
vs.Type = "bind"
vs.HostPath = ov
vs.VolumeName = ""
} else {
vs.Type = "volume"
vs.VolumeName = ov
vs.HostPath = ""
}
}
volumes = append(volumes, vs)
}
portOverrides := indexPortOverrides(req.Ports)
ports := make([]runtime.PortSpec, 0, len(manifest.Ports))
for _, p := range manifest.Ports {
containerPort := uint16(p.Default)
hostPort := containerPort
if ov, ok := portOverrides[p.Name]; ok {
if ov.ContainerPort != 0 {
containerPort = uint16(ov.ContainerPort)
}
if ov.HostPort != 0 {
hostPort = uint16(ov.HostPort)
}
}
// Internal ports (e.g. 7DTD's telnet RCON on 8081) must still be
// reachable from the Target agent — it runs on the same host — so we
// bind them to 127.0.0.1 instead of 0.0.0.0. Public ports bind to
// all interfaces (HostIP left empty).
hostIP := ""
if p.Internal {
hostIP = "127.0.0.1"
}
ports = append(ports, runtime.PortSpec{
ContainerPort: containerPort,
HostPort: hostPort,
Proto: strings.ToLower(p.Proto),
HostIP: hostIP,
})
}
// Preserve nil when the manifest doesn't override — passing []string{} to
// Docker ContainerCreate replaces the image's default entrypoint/cmd with
// empty, which errors out as "no command specified".
var entrypoint, command []string
if len(docker.Entrypoint) > 0 {
entrypoint = append([]string{}, docker.Entrypoint...)
}
if len(docker.Command) > 0 {
command = append([]string{}, docker.Command...)
}
spec := runtime.InstanceSpec{
InstanceID: req.InstanceId,
Image: docker.Image,
Entrypoint: entrypoint,
Command: command,
Env: env,
User: docker.User,
Volumes: volumes,
Ports: ports,
NetworkMode: docker.NetworkMode,
BuildContext: manifest.Dir, // module's directory holds the Dockerfile
}
// Auto-enable OpenStdin whenever the module's RCON adapter is stdio —
// it's the one feature the adapter needs from the runtime, and making
// it implicit saves a second knob every stdio-based module would have
// to remember to set.
if manifest.RCON != nil && manifest.RCON.Adapter == "stdio" {
spec.OpenStdin = true
}
if lim := req.Limits; lim != nil {
spec.Resources = runtime.ResourceLimits{
CPUShares: lim.CpuShares,
MemoryBytes: lim.MemBytes,
}
}
return spec, nil
}
// expand substitutes $DATA_PATH in the given string.
func expand(s, dataPath string) string {
return strings.ReplaceAll(s, "$DATA_PATH", dataPath)
}
// expandInstance substitutes $INSTANCE_ID in the given string. Used for
// named-volume templating so each instance gets its own volume.
func expandInstance(s, instanceID string) string {
return strings.ReplaceAll(s, "$INSTANCE_ID", instanceID)
}
// ResolveVolumes translates manifest volumes into runtime.VolumeSpec form
// for a specific instance. Exposed so sidecar launchers (SteamCMD updater,
// future backup-runner, etc.) can mount the same volumes the main
// container uses, putting files where the main container expects them.
func ResolveVolumes(manifest *modulepkg.Manifest, instanceID, dataPath string) []runtime.VolumeSpec {
if manifest == nil || manifest.Runtime.Docker == nil {
return nil
}
out := make([]runtime.VolumeSpec, 0, len(manifest.Runtime.Docker.Volumes))
for _, v := range manifest.Runtime.Docker.Volumes {
vs := runtime.VolumeSpec{
ContainerPath: v.Container,
ReadOnly: v.ReadOnly,
}
kind := v.Type
if kind == "" {
if v.Name != "" {
kind = "volume"
} else {
kind = "bind"
}
}
switch kind {
case "volume":
vs.Type = "volume"
vs.VolumeName = expandInstance(v.Name, instanceID)
default:
vs.Type = "bind"
vs.HostPath = expand(v.Target, dataPath)
}
out = append(out, vs)
}
return out
}
// indexPortOverrides builds a by-name lookup of PortMap entries from the RPC.
func indexPortOverrides(ports []*panelv1.PortMap) map[string]*panelv1.PortMap {
out := map[string]*panelv1.PortMap{}
for _, p := range ports {
if p == nil {
continue
}
out[p.Name] = p
}
return out
}
+280
View File
@@ -0,0 +1,280 @@
package rcon
import (
"context"
"encoding/binary"
"errors"
"fmt"
"hash/crc32"
"net"
"strings"
"sync"
"sync/atomic"
"time"
)
// BattlEyeDialer speaks the BattlEye RCON protocol used by DayZ, Arma 2/3,
// Squad, Rising Storm 2, and a handful of other BE-protected games.
// Protocol reference: https://www.battleye.com/downloads/BERConProtocol.txt
//
// Wire format (UDP, all frames start with "BE" + CRC32 + 0xFF + type + payload):
//
// 0x42 'B'
// 0x45 'E'
// uint32 crc32 — little-endian CRC32 of bytes from offset 6 onward
// 0xFF — packet-header sentinel
// uint8 type — 0x00 login, 0x01 command, 0x02 server message
// payload — varies by type
//
// Auth (type 0x00): client sends its admin password; server replies with
// a single-byte 0x01 = success, 0x00 = failure.
//
// Commands (type 0x01): client sends a 1-byte sequence id + the command
// text; server echoes the same seq id with the response text.
//
// Server-pushed messages (type 0x02): the server streams chat/join/leave
// events. Client MUST ack each one by echoing the seq id with an empty
// payload or the server will disconnect.
type BattlEyeDialer struct{}
// Name identifies this adapter in the module manifest.
func (BattlEyeDialer) Name() string { return "be_rcon" }
const (
bePacketHeader = 0xFF
bePacketLogin = 0x00
bePacketCmd = 0x01
bePacketServer = 0x02
)
// Dial performs the login handshake and returns a ready-to-use BE RCON client.
func (BattlEyeDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
if opts.Host == "" || opts.Port == 0 {
return nil, errors.New("be rcon dial: host and port are required")
}
connectTimeout := opts.ConnectTimeout
if connectTimeout == 0 {
connectTimeout = 5 * time.Second
}
addr := &net.UDPAddr{IP: net.ParseIP(opts.Host), Port: opts.Port}
if addr.IP == nil {
// Allow hostnames too.
resolved, err := net.ResolveUDPAddr("udp", fmt.Sprintf("%s:%d", opts.Host, opts.Port))
if err != nil {
return nil, fmt.Errorf("resolve %s: %w", opts.Host, err)
}
addr = resolved
}
conn, err := net.DialUDP("udp", nil, addr)
if err != nil {
return nil, fmt.Errorf("be rcon dial %s:%d: %w", opts.Host, opts.Port, err)
}
c := &beClient{conn: conn, addr: addr}
if err := c.login(opts.Password, connectTimeout); err != nil {
_ = conn.Close()
return nil, err
}
// Kick off the keep-alive goroutine — BattlEye disconnects clients
// that don't send traffic for ~45s. We ping every 30s.
go c.keepAlive()
return c, nil
}
type beClient struct {
conn *net.UDPConn
addr *net.UDPAddr
seqMu sync.Mutex
seq uint8 // next command sequence id
execMu sync.Mutex
closed atomic.Bool
// stopKA fires when Close is called; keep-alive loop watches it.
stopKA chan struct{}
}
// login sends the auth packet and waits for a 1-byte success response.
func (c *beClient) login(password string, timeout time.Duration) error {
pkt := buildBEPacket(bePacketLogin, []byte(password))
if _, err := c.conn.Write(pkt); err != nil {
return fmt.Errorf("be rcon login write: %w", err)
}
_ = c.conn.SetReadDeadline(time.Now().Add(timeout))
buf := make([]byte, 256)
n, err := c.conn.Read(buf)
if err != nil {
return fmt.Errorf("be rcon login read: %w", err)
}
payloadType, payload, ok := parseBEPacket(buf[:n])
if !ok {
return errors.New("be rcon login: malformed response")
}
if payloadType != bePacketLogin {
return fmt.Errorf("be rcon login: unexpected packet type 0x%02x", payloadType)
}
if len(payload) < 1 {
return errors.New("be rcon login: empty response payload")
}
if payload[0] != 0x01 {
return errors.New("be rcon login: bad password")
}
c.stopKA = make(chan struct{})
return nil
}
// Exec runs one command and returns the server's response text. BattlEye
// doesn't multi-frame short responses; long ones arrive as multiple packets
// with the same seq id + a 1-byte 'part_count / part_index' header. We
// concatenate parts in order.
func (c *beClient) Exec(ctx context.Context, cmd string) (string, error) {
if c.closed.Load() {
return "", errors.New("be rcon: client closed")
}
c.execMu.Lock()
defer c.execMu.Unlock()
c.seqMu.Lock()
seq := c.seq
c.seq++
c.seqMu.Unlock()
body := make([]byte, 0, len(cmd)+1)
body = append(body, seq)
body = append(body, []byte(cmd)...)
pkt := buildBEPacket(bePacketCmd, body)
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(10 * time.Second)
}
_ = c.conn.SetDeadline(deadline)
defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck
if _, err := c.conn.Write(pkt); err != nil {
return "", fmt.Errorf("be rcon write: %w", err)
}
// Collect response parts. We loop until we've got every declared part
// for this seq, or an idle timeout fires with partial data.
parts := map[uint8][]byte{}
var totalParts uint8
for {
buf := make([]byte, 4096)
n, err := c.conn.Read(buf)
if err != nil {
if len(parts) > 0 {
// Return whatever we got — better than nothing.
return reassembleBEParts(parts, totalParts), nil
}
return "", fmt.Errorf("be rcon read: %w", err)
}
pType, payload, ok := parseBEPacket(buf[:n])
if !ok {
continue
}
if pType == bePacketServer {
// Server-pushed event (chat/join/leave). Ack it + keep reading.
if len(payload) >= 1 {
ack := buildBEPacket(bePacketServer, []byte{payload[0]})
_, _ = c.conn.Write(ack)
}
continue
}
if pType != bePacketCmd || len(payload) < 1 || payload[0] != seq {
continue
}
// Multi-part response format: [seq, 0x00, total_parts, part_index, ...body...]
if len(payload) >= 4 && payload[1] == 0x00 {
totalParts = payload[2]
idx := payload[3]
parts[idx] = payload[4:]
if uint8(len(parts)) >= totalParts {
return reassembleBEParts(parts, totalParts), nil
}
continue
}
// Single-frame response: [seq, ...body...]
return string(payload[1:]), nil
}
}
// Close tears down the UDP connection + stops the keep-alive loop.
func (c *beClient) Close() error {
if !c.closed.CompareAndSwap(false, true) {
return nil
}
if c.stopKA != nil {
close(c.stopKA)
}
return c.conn.Close()
}
// keepAlive sends a zero-length command every 30s to keep BE from dropping
// the session for inactivity. The protocol explicitly documents this.
func (c *beClient) keepAlive() {
tick := time.NewTicker(30 * time.Second)
defer tick.Stop()
for {
select {
case <-c.stopKA:
return
case <-tick.C:
if c.closed.Load() {
return
}
c.execMu.Lock()
c.seqMu.Lock()
seq := c.seq
c.seq++
c.seqMu.Unlock()
pkt := buildBEPacket(bePacketCmd, []byte{seq})
_ = c.conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
_, _ = c.conn.Write(pkt)
c.execMu.Unlock()
}
}
}
// ---- wire helpers ----
// buildBEPacket assembles a BE RCON UDP frame: "BE" + CRC32(body) + body,
// where body = 0xFF + type + payload.
func buildBEPacket(pType uint8, payload []byte) []byte {
body := make([]byte, 0, 2+len(payload))
body = append(body, bePacketHeader, pType)
body = append(body, payload...)
crc := crc32.ChecksumIEEE(body)
out := make([]byte, 6+len(body))
out[0], out[1] = 'B', 'E'
binary.LittleEndian.PutUint32(out[2:6], crc)
copy(out[6:], body)
return out
}
// parseBEPacket validates a "BE..." frame and returns (type, payload, ok).
func parseBEPacket(buf []byte) (uint8, []byte, bool) {
if len(buf) < 7 || buf[0] != 'B' || buf[1] != 'E' {
return 0, nil, false
}
crc := binary.LittleEndian.Uint32(buf[2:6])
if crc32.ChecksumIEEE(buf[6:]) != crc {
// Corrupt packet — accept it anyway for lenience; some mods' BE
// extensions are known to skip CRC. Emit a debug break here
// later if we start seeing real-world issues.
_ = crc
}
if buf[6] != bePacketHeader {
return 0, nil, false
}
return buf[7], buf[8:], true
}
// reassembleBEParts joins multi-frame command responses in order.
func reassembleBEParts(parts map[uint8][]byte, total uint8) string {
var b strings.Builder
for i := uint8(0); i < total; i++ {
b.Write(parts[i])
}
return b.String()
}
+97
View File
@@ -0,0 +1,97 @@
package rcon
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"regexp"
"strconv"
"strings"
"sync"
)
// DockerExecDialer runs per command, opening
// a fresh socket inside the container's network namespace each time and
// closing it on completion. Fresh process, fresh fd, fresh TCP conn — nothing
// is held open between calls.
//
// Why: ARK Survival Ascended's RCON listener wedges if a polling client holds
// a long-lived TCP session — the engine accumulates CLOSE-WAIT sockets and
// the listener thread itself dies after enough accumulation. The wedge is
// not recoverable without restarting the engine. Per-command exec avoids the
// wedge entirely because nothing persists between invocations.
//
// Trade-off: ~50200ms of overhead per command for docker exec + rcon-cli
// startup. Negligible at ARK SA's polling cadence (30s+); not appropriate
// for high-frequency command paths (none exist in panel today).
//
// Requirements:
// - Container has rcon-cli on PATH (acekorneya/asa_server: yes).
// - Container env exposes the RCON password to the in-container shell, OR
// the agent supplies it via DialOptions.Password (we pass it in).
// - Agent process is in the docker group (the panel-agent systemd unit
// already declares SupplementaryGroups=docker).
type DockerExecDialer struct{}
func (DockerExecDialer) Name() string { return "docker_exec_rcon" }
func (DockerExecDialer) Dial(_ context.Context, opts DialOptions) (Client, error) {
if opts.ContainerID == "" {
return nil, errors.New("docker_exec_rcon dial: ContainerID required")
}
port := ""
if opts.Port != 0 {
port = strconv.Itoa(opts.Port)
}
return &dockerExecClient{
containerID: opts.ContainerID,
password: opts.Password,
port: port,
}, nil
}
type dockerExecClient struct {
containerID string
password string
port string
mu sync.Mutex
}
// ansiRe matches CSI escape sequences. rcon-cli emits ANSI color resets
// after each response (\033[0m); strip them so the panel's parsers see
// clean text.
var ansiRe = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]`)
// Exec runs and returns the cleaned stdout.
func (c *dockerExecClient) Exec(ctx context.Context, cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
args := []string{"exec", c.containerID, "rcon-cli", "--host", "localhost"}
if c.port != "" {
args = append(args, "--port", c.port)
}
if c.password != "" {
args = append(args, "--password", c.password)
}
args = append(args, cmd)
dockerCmd := exec.CommandContext(ctx, "docker", args...)
var stdout, stderr bytes.Buffer
dockerCmd.Stdout = &stdout
dockerCmd.Stderr = &stderr
if err := dockerCmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg == "" {
errMsg = err.Error()
}
return "", fmt.Errorf("docker exec rcon-cli: %s", errMsg)
}
out := ansiRe.ReplaceAllString(stdout.String(), "")
return strings.TrimSpace(out), nil
}
// Close is a no-op — there's nothing held open between Exec calls.
func (c *dockerExecClient) Close() error { return nil }
+81
View File
@@ -0,0 +1,81 @@
// Package rcon provides pluggable RCON adapters. Games speak different
// dialects — Source RCON (Counter-Strike, Minecraft with Mojang auth),
// telnet line protocol (7 Days to Die), HTTP admin APIs (some community
// implementations), stdin-pipe for headless servers without a network
// surface. The Agent picks an adapter based on the module manifest's
// `rcon.adapter` field.
package rcon
import (
"context"
"fmt"
"io"
"time"
)
// Client is a connected RCON session. Exec runs one command and returns its
// raw text response. Clients must be safe for concurrent Exec calls — the
// poll loop and a future ad-hoc-command path both need to call through the
// same session.
type Client interface {
Exec(ctx context.Context, cmd string) (string, error)
Close() error
}
// DialOptions configures one RCON connection attempt.
type DialOptions struct {
Host string
Port int
Password string
ConnectTimeout time.Duration
// ReadIdle is how long to wait with no new bytes before treating an Exec
// response as complete. Telnet has no packet framing, so this heuristic
// is needed; Source RCON overrides it to 0 (ignored).
ReadIdle time.Duration
// ContainerID is the Docker container name or ID for stdio/exec-based
// adapters that don't use TCP. Ignored by telnet/source_rcon.
ContainerID string
// Stdio is the backend that knows how to attach to a container's
// stdin/stdout. Provided by the Docker runtime when the adapter is
// "stdio". Nil for TCP adapters.
Stdio StdioBackend
}
// StdioBackend attaches to a running container's combined stdin/stdout/stderr
// stream. Needed by the stdio adapter for games whose only admin console is
// stdin (Valheim, Terraria, Barotrauma, Enshrouded, Sons Of The Forest).
//
// Returned ReadWriteCloser writes go to the container's stdin; reads are the
// demux-multiplexed stdout+stderr. Close shuts down the hijacked connection.
type StdioBackend interface {
AttachStdio(ctx context.Context, containerID string) (io.ReadWriteCloser, error)
}
// Dialer establishes RCON connections of a specific adapter kind.
type Dialer interface {
Dial(ctx context.Context, opts DialOptions) (Client, error)
Name() string
}
// DialerFor returns the dialer for the given adapter id, or an error if the
// adapter is unknown. Adapter ids come directly from the module manifest's
// `rcon.adapter` field.
func DialerFor(adapter string) (Dialer, error) {
switch adapter {
case "telnet":
return &TelnetDialer{}, nil
case "source_rcon":
return &SourceDialer{}, nil
case "stdio":
return &StdioDialer{}, nil
case "websocket_rcon":
return &WebSocketDialer{}, nil
case "docker_exec_rcon":
return &DockerExecDialer{}, nil
case "be_rcon":
return &BattlEyeDialer{}, nil
default:
return nil, fmt.Errorf("unknown rcon adapter %q (known: telnet, source_rcon, docker_exec_rcon, stdio, websocket_rcon, be_rcon)", adapter)
}
}
+229
View File
@@ -0,0 +1,229 @@
package rcon
import (
"bufio"
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
)
// SourceDialer speaks Valve's Source RCON protocol — the standard RCON of
// Minecraft, CS2, Rust, Valheim-with-plugins, and most Source-engine games.
//
// Protocol summary (little-endian throughout):
//
// int32 size = bytes that follow (i.e. frame length 4)
// int32 id = client-chosen, server echoes
// int32 type = 3 auth-request, 2 exec, 0 response, 2 auth-response
// body ASCIIZ = command string or response body, null-terminated
// byte trailing null = required empty string sentinel
//
// size = 4 (id) + 4 (type) + len(body) + 1 (body NUL) + 1 (sentinel NUL)
type SourceDialer struct{}
// Name identifies this adapter in the module manifest.
func (SourceDialer) Name() string { return "source_rcon" }
const (
pktAuth int32 = 3
pktExecCommand int32 = 2
pktResponseValue int32 = 0
pktAuthResponse int32 = 2
minPktSize = 10
maxPktSize = 4110
)
func (SourceDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
if opts.Host == "" || opts.Port == 0 {
return nil, errors.New("source rcon dial: host and port are required")
}
timeout := opts.ConnectTimeout
if timeout == 0 {
timeout = 5 * time.Second
}
d := &net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%d", opts.Host, opts.Port))
if err != nil {
return nil, fmt.Errorf("source rcon dial %s:%d: %w", opts.Host, opts.Port, err)
}
c := &sourceClient{conn: conn, reader: bufio.NewReader(conn), nextID: 1}
if err := c.authenticate(opts.Password); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("source rcon auth: %w", err)
}
return c, nil
}
type sourceClient struct {
conn net.Conn
reader *bufio.Reader
mu sync.Mutex
nextID int32
}
// authenticate sends SERVERDATA_AUTH and waits for SERVERDATA_AUTH_RESPONSE.
// Valve's spec says servers send an empty RESPONSE_VALUE first (id echoed),
// then the AUTH_RESPONSE (id = original on success, 1 on failure). Some
// implementations skip the probe — we accept either ordering.
func (c *sourceClient) authenticate(password string) error {
id := c.assignID()
if err := c.writePacket(id, pktAuth, password); err != nil {
return err
}
// ASA's RCON handler is slow to answer AUTH during the first few
// minutes after "Server has completed startup" — sometimes 3-4s per
// reply when the server is still loading tribe data / saving a
// snapshot. The previous 2s per-read deadline + 5s overall was
// tripping the tracker into its 32s exponential backoff on every
// auth, stranding the panel with "RCON not yet connected" for 5-10
// min even though the server was otherwise fine. Give AUTH a 12s
// outer window and 8s per-read — enough slack for ASA's slowest
// responses without letting a truly dead connection hang forever.
deadline := time.Now().Add(12 * time.Second)
for time.Now().Before(deadline) {
_ = c.conn.SetReadDeadline(time.Now().Add(8 * time.Second))
gotID, gotType, _, err := c.readPacket()
if err != nil {
return err
}
if gotType == pktAuthResponse {
// Spec: id == -1 means auth failed (bad password).
// Any other id means success. Most servers echo our request id
// but some (Conan Exiles, Palworld, a few others) return 0 or
// an unrelated id — treat that as success, don't reject.
if gotID == -1 {
return errors.New("invalid password")
}
return nil
}
// Probe echo (RESPONSE_VALUE with body ""), keep reading for the
// AUTH_RESPONSE that follows.
}
return errors.New("auth timeout")
}
// Exec sends a SERVERDATA_EXECCOMMAND and concatenates RESPONSE_VALUE bodies
// until the server goes silent. For responses that straddle multiple packets
// the spec recommends a "bogus packet trick" (send a second empty packet and
// watch for its echo) but short command output fits in one packet in practice.
func (c *sourceClient) Exec(ctx context.Context, cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if dl, ok := ctx.Deadline(); ok {
_ = c.conn.SetDeadline(dl)
defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck
}
id := c.assignID()
if err := c.writePacket(id, pktExecCommand, cmd); err != nil {
return "", fmt.Errorf("write: %w", err)
}
var out strings.Builder
// First read waits longer — some servers (Palworld especially) take 13s
// to build the response for commands like ShowPlayers. Subsequent reads
// use a tight idle timeout so we exit promptly once the stream goes quiet.
const (
firstTimeout = 5 * time.Second
idleTimeout = 800 * time.Millisecond
)
readDeadline := firstTimeout
for {
_ = c.conn.SetReadDeadline(time.Now().Add(readDeadline))
readDeadline = idleTimeout
gotID, gotType, body, err := c.readPacket()
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() && out.Len() > 0 {
return out.String(), nil
}
if errors.Is(err, io.EOF) && out.Len() > 0 {
return out.String(), nil
}
return out.String(), err
}
// Source RCON spec says the server MUST echo our request id on
// RESPONSE_VALUE. Palworld always returns id=0; Conan Exiles
// returns a different arbitrary id. We hold mu.Lock() for the
// whole Exec so only one command is ever in flight — ID matching
// isn't needed for correctness, so accept any id the server
// hands us (except -1, which is reserved for auth failures).
if gotID == -1 {
continue
}
// Spec: RESPONSE_VALUE (type 0) carries command output. Conan
// Exiles sends command output via AUTH_RESPONSE (type 2) instead —
// non-spec but functional. We're past the auth phase by this point
// so accepting either type is safe; anything else we skip.
if gotType != pktResponseValue && gotType != pktAuthResponse {
continue
}
out.WriteString(body)
// Body shorter than ~4000 bytes → likely single-packet response.
if len(body) < 3800 {
return out.String(), nil
}
}
}
// Close shuts down the underlying TCP connection.
func (c *sourceClient) Close() error { return c.conn.Close() }
func (c *sourceClient) assignID() int32 {
c.nextID++
if c.nextID <= 0 {
c.nextID = 1
}
return c.nextID
}
// writePacket wire-encodes a Source RCON packet.
func (c *sourceClient) writePacket(id, ptype int32, body string) error {
bodyBytes := []byte(body)
pktSize := int32(4 + 4 + len(bodyBytes) + 2) // id + type + body + 2×NUL
if pktSize > maxPktSize {
return fmt.Errorf("source rcon: body too large (%d bytes, max %d)", len(bodyBytes), maxPktSize-10)
}
buf := make([]byte, 4+pktSize)
binary.LittleEndian.PutUint32(buf[0:4], uint32(pktSize))
binary.LittleEndian.PutUint32(buf[4:8], uint32(id))
binary.LittleEndian.PutUint32(buf[8:12], uint32(ptype))
copy(buf[12:], bodyBytes)
// trailing two NUL bytes already zero
_, err := c.conn.Write(buf)
return err
}
// readPacket reads one framed Source RCON packet from the underlying reader.
// Returns (id, type, body, err). Body has trailing NULs stripped.
func (c *sourceClient) readPacket() (int32, int32, string, error) {
var sizeBuf [4]byte
if _, err := io.ReadFull(c.reader, sizeBuf[:]); err != nil {
return 0, 0, "", err
}
size := int32(binary.LittleEndian.Uint32(sizeBuf[:]))
if size < minPktSize || size > maxPktSize {
return 0, 0, "", fmt.Errorf("source rcon: bogus packet size %d", size)
}
rest := make([]byte, size)
if _, err := io.ReadFull(c.reader, rest); err != nil {
return 0, 0, "", err
}
id := int32(binary.LittleEndian.Uint32(rest[0:4]))
ptype := int32(binary.LittleEndian.Uint32(rest[4:8]))
bodyEnd := size - 2 // trim both trailing NULs
if bodyEnd < 8 {
bodyEnd = 8
}
body := string(rest[8:bodyEnd])
return id, ptype, body, nil
}
+110
View File
@@ -0,0 +1,110 @@
package rcon
import (
"context"
"errors"
"fmt"
"io"
"sync"
"time"
)
// StdioDialer "RCON"s a game by attaching to the container's stdin and writing
// the admin console command, trusting the game to execute it. It's the correct
// choice for games whose admin surface is stdin-only — Valheim, Terraria,
// Barotrauma, Enshrouded, Sons Of The Forest — where no TCP RCON exists.
//
// v1 is write-only: Exec returns empty string. Command output arrives via the
// panel's log pipeline (docker logs → agent → bus → SSE) because the game's
// replies appear on the same stdout the log stream is tailing. A future
// revision could correlate a response window with each write if we ever need
// structured replies for state polling.
//
// Requires the container to have been created with OpenStdin=true; the module
// resolver auto-enables that whenever manifest.Rcon.Adapter == "stdio".
type StdioDialer struct{}
// Name identifies this adapter in the module manifest.
func (StdioDialer) Name() string { return "stdio" }
// Dial attaches to the container's stdio stream. A background goroutine drains
// the multiplexed stdout/stderr so the stream never blocks from our side; the
// docker logs pipeline is the canonical consumer of that output.
func (StdioDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
if opts.Stdio == nil {
return nil, errors.New("stdio dial: Stdio backend required (agent must provide one)")
}
if opts.ContainerID == "" {
return nil, errors.New("stdio dial: ContainerID required")
}
session, err := opts.Stdio.AttachStdio(ctx, opts.ContainerID)
if err != nil {
return nil, fmt.Errorf("stdio attach %q: %w", opts.ContainerID, err)
}
c := &stdioClient{session: session}
go func() {
// Drain the attached stream so Docker doesn't buffer indefinitely.
// We deliberately discard: the log pipeline already surfaces this
// output to the panel via a separate docker logs call.
_, _ = io.Copy(io.Discard, session)
}()
return c, nil
}
type stdioClient struct {
session io.ReadWriteCloser
mu sync.Mutex
closed bool
}
// Exec writes cmd + "\n" to the container's stdin. Returns empty string; the
// game's response (if any) lands in the log stream and is surfaced in the
// Console tab.
//
// The write is bounded by ctx (and a hard 10s fallback). A raw Write to a
// hijacked stdin pipe can block FOREVER if the container's stdin read side
// stalls (game paused, buffer full, or the process not draining stdin). Because
// the agent dispatches RCON inline on its single recv loop, an unbounded write
// here freezes the WHOLE agent — no stop/start/anything — until restart. That's
// the Valheim+stdio "stop never lands" hang. We run the write in a goroutine and
// abandon it on timeout so the dispatcher stays responsive.
func (c *stdioClient) Exec(ctx context.Context, cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return "", errors.New("stdio: session closed")
}
if ctx == nil {
ctx = context.Background()
}
wctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := c.session.Write([]byte(cmd + "\n"))
done <- err
}()
select {
case err := <-done:
if err != nil {
return "", fmt.Errorf("stdio write: %w", err)
}
return "", nil
case <-wctx.Done():
// The write is wedged — leave the goroutine parked (it'll unblock if the
// pipe ever drains, or die when the session closes) but DON'T let it hold
// up the caller / the agent's recv loop.
return "", fmt.Errorf("stdio write timed out after 10s (container stdin not draining): %w", wctx.Err())
}
}
// Close shuts down the hijacked attach connection.
func (c *stdioClient) Close() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return nil
}
c.closed = true
return c.session.Close()
}
+168
View File
@@ -0,0 +1,168 @@
package rcon
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net"
"strings"
"sync"
"time"
)
// TelnetDialer speaks 7DTD-style line-oriented telnet: on connect the server
// sends a "password:" prompt; after the client writes the password and a
// newline, commands are one-line writes and responses are everything read
// until the connection goes quiet for a short idle period.
type TelnetDialer struct{}
func (TelnetDialer) Name() string { return "telnet" }
func (TelnetDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
if opts.Host == "" || opts.Port == 0 {
return nil, errors.New("telnet dial: host and port are required")
}
addr := fmt.Sprintf("%s:%d", opts.Host, opts.Port)
timeout := opts.ConnectTimeout
if timeout == 0 {
timeout = 5 * time.Second
}
d := &net.Dialer{Timeout: timeout}
conn, err := d.DialContext(ctx, "tcp", addr)
if err != nil {
return nil, fmt.Errorf("telnet dial %s: %w", addr, err)
}
idle := opts.ReadIdle
if idle == 0 {
idle = 250 * time.Millisecond
}
c := &telnetClient{
conn: conn,
readIdle: idle,
}
if err := c.authenticate(opts.Password); err != nil {
_ = conn.Close()
return nil, fmt.Errorf("telnet auth: %w", err)
}
return c, nil
}
// telnetClient is safe for concurrent Exec via mu serialization.
type telnetClient struct {
conn net.Conn
readIdle time.Duration
mu sync.Mutex
}
func (c *telnetClient) authenticate(password string) error {
// Read until the first "password:" prompt arrives, with a 5s cap.
if _, err := c.readUntil("password:", 5*time.Second); err != nil {
return fmt.Errorf("read password prompt: %w", err)
}
if _, err := c.conn.Write([]byte(password + "\n")); err != nil {
return fmt.Errorf("write password: %w", err)
}
// Drain whatever welcome/banner text the server sends after auth.
_, _ = c.readQuiet(1500*time.Millisecond, 1500*time.Millisecond)
return nil
}
// Exec sends one line-terminated command and returns everything the server
// writes back before going quiet for readIdle.
func (c *telnetClient) Exec(ctx context.Context, cmd string) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
// Wire ctx deadline into the conn's read/write timeouts.
if dl, ok := ctx.Deadline(); ok {
_ = c.conn.SetDeadline(dl)
defer c.conn.SetDeadline(time.Time{}) //nolint:errcheck
}
if _, err := c.conn.Write([]byte(cmd + "\n")); err != nil {
return "", fmt.Errorf("write cmd: %w", err)
}
return c.readQuiet(c.firstByteTimeout(), c.readIdle)
}
func (c *telnetClient) Close() error {
return c.conn.Close()
}
// firstByteTimeout is how long we wait for the server to START replying to a
// command. A busy 7DTD server (heavy load, blood moon, GC pause) can take well
// over readIdle to send its first byte, so this must be generous — otherwise
// readQuiet times out with zero bytes and returns an empty response even though
// the command executed fine on the server.
func (c *telnetClient) firstByteTimeout() time.Duration {
return 4 * time.Second
}
// readQuiet reads a command response. It waits up to `first` for the first byte
// (server may be slow to start replying under load), then once bytes arrive it
// uses the short `idle` gap to detect end-of-response. Returns what it read, or
// an error on a real (non-timeout) failure.
func (c *telnetClient) readQuiet(first, idle time.Duration) (string, error) {
var buf bytes.Buffer
tmp := make([]byte, 4096)
for {
// Before any data, allow the longer first-byte window; after data
// starts flowing, switch to the short idle gap.
wait := idle
if buf.Len() == 0 {
wait = first
}
_ = c.conn.SetReadDeadline(time.Now().Add(wait))
n, err := c.conn.Read(tmp)
if n > 0 {
buf.Write(tmp[:n])
}
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
// Timeout with data → normal end of response.
// Timeout with NO data even after the first-byte window → the
// server genuinely sent nothing (or is unreachable); return
// empty and let the caller decide.
return buf.String(), nil
}
if errors.Is(err, io.EOF) {
return buf.String(), nil
}
return buf.String(), err
}
}
}
// readUntil reads up to `overall` time waiting for `substr` (case-insensitive)
// to appear in the accumulated buffer. Returns what it saw either way.
func (c *telnetClient) readUntil(substr string, overall time.Duration) (string, error) {
var buf bytes.Buffer
tmp := make([]byte, 4096)
deadline := time.Now().Add(overall)
want := strings.ToLower(substr)
for time.Now().Before(deadline) {
_ = c.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
n, err := c.conn.Read(tmp)
if n > 0 {
buf.Write(tmp[:n])
if strings.Contains(strings.ToLower(buf.String()), want) {
return buf.String(), nil
}
}
if err != nil {
var ne net.Error
if errors.As(err, &ne) && ne.Timeout() {
continue
}
return buf.String(), err
}
}
return buf.String(), fmt.Errorf("timed out after %s waiting for %q", overall, substr)
}
+128
View File
@@ -0,0 +1,128 @@
package rcon
import (
"bufio"
"context"
"fmt"
"net"
"strings"
"testing"
"time"
)
// fakeTelnetServer speaks enough of the 7DTD protocol to exercise dial+auth+exec:
// - sends "Please enter password:" on accept
// - waits for client password (any line), then sends "Logon successful.\n"
// - for each subsequent command line, responds with its registered output
type fakeTelnetServer struct {
addr string
responses map[string]string
}
func startFakeTelnet(t *testing.T, responses map[string]string) *fakeTelnetServer {
t.Helper()
ln, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("listen: %v", err)
}
t.Cleanup(func() { _ = ln.Close() })
fs := &fakeTelnetServer{addr: ln.Addr().String(), responses: responses}
go func() {
for {
conn, err := ln.Accept()
if err != nil {
return
}
go fs.handle(conn)
}
}()
return fs
}
func (fs *fakeTelnetServer) handle(conn net.Conn) {
defer conn.Close()
_, _ = conn.Write([]byte("*** Connected with 7DTD server.\r\nPlease enter password:"))
br := bufio.NewReader(conn)
if _, err := br.ReadString('\n'); err != nil {
return
}
_, _ = conn.Write([]byte("\nLogon successful.\n"))
for {
_ = conn.SetReadDeadline(time.Now().Add(5 * time.Second))
line, err := br.ReadString('\n')
if err != nil {
return
}
cmd := strings.TrimSpace(line)
resp, ok := fs.responses[cmd]
if !ok {
resp = fmt.Sprintf("(unknown command: %s)\n", cmd)
}
_, _ = conn.Write([]byte(resp))
}
}
func TestTelnetDialAndExec(t *testing.T) {
srv := startFakeTelnet(t, map[string]string{
"lp": "Total of 3 in the game\n\n1. id=1, Alice, pos=(0,0,0)\n2. id=2, Bob, pos=(1,0,0)\n3. id=3, Carol, pos=(2,0,0)\n\n",
"say \"hello\"": "*Server: hello\n",
})
host, port := splitAddr(t, srv.addr)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
client, err := TelnetDialer{}.Dial(ctx, DialOptions{
Host: host,
Port: port,
Password: "test",
ConnectTimeout: 2 * time.Second,
ReadIdle: 150 * time.Millisecond,
})
if err != nil {
t.Fatalf("Dial: %v", err)
}
defer client.Close()
out, err := client.Exec(ctx, "lp")
if err != nil {
t.Fatalf("Exec lp: %v", err)
}
if !strings.Contains(out, "Total of 3 in the game") {
t.Errorf("lp output missing expected substring, got: %q", out)
}
out, err = client.Exec(ctx, `say "hello"`)
if err != nil {
t.Fatalf(`Exec say: %v`, err)
}
if !strings.Contains(out, "*Server: hello") {
t.Errorf("say output missing, got: %q", out)
}
}
func TestDialerForUnknown(t *testing.T) {
if _, err := DialerFor("source_rcon"); err == nil {
t.Error("expected error for unknown adapter")
}
if d, err := DialerFor("telnet"); err != nil || d.Name() != "telnet" {
t.Errorf("DialerFor(telnet): d=%v err=%v", d, err)
}
}
func splitAddr(t *testing.T, addr string) (string, int) {
t.Helper()
host, portStr, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("split %q: %v", addr, err)
}
var port int
if _, err := fmt.Sscanf(portStr, "%d", &port); err != nil {
t.Fatalf("port parse: %v", err)
}
return host, port
}
+179
View File
@@ -0,0 +1,179 @@
package rcon
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/url"
"sync"
"sync/atomic"
"time"
"golang.org/x/net/websocket"
)
// WebSocketDialer speaks Facepunch's WebSocket RCON — the protocol used
// by Rust (app 258550) since they removed Source RCON years ago. A small
// subset of games have adopted the same pattern (a few Oxide-compatible
// community servers, some Unity-based survival games).
//
// Wire protocol (JSON text frames over a plain websocket):
//
// client → server: {"Identifier": N, "Message": "command", "Name": "panel"}
// server → client: {"Identifier": N, "Message": "result text",
// "Type": "Generic", "Stacktrace": ""}
//
// The password is passed as the URL path, not a header:
//
// ws://<host>:<port>/<password>
//
// Connections are long-lived — Rust's RCON doesn't close after each command
// the way Conan's does — but we still redial transparently on EOF/reset
// via the tracker's existing logic.
type WebSocketDialer struct{}
// Name identifies this adapter in the module manifest.
func (WebSocketDialer) Name() string { return "websocket_rcon" }
// Dial opens a websocket to ws://host:port/password. Returns a client that
// serializes Exec calls; Rust sometimes interleaves async log spam into the
// same connection as command replies, so we match responses back by id.
func (WebSocketDialer) Dial(ctx context.Context, opts DialOptions) (Client, error) {
if opts.Host == "" || opts.Port == 0 {
return nil, errors.New("websocket rcon dial: host and port are required")
}
connectTimeout := opts.ConnectTimeout
if connectTimeout == 0 {
connectTimeout = 5 * time.Second
}
wsURL := fmt.Sprintf("ws://%s:%d/%s", opts.Host, opts.Port, url.PathEscape(opts.Password))
origin := fmt.Sprintf("http://%s:%d/", opts.Host, opts.Port)
cfg, err := websocket.NewConfig(wsURL, origin)
if err != nil {
return nil, fmt.Errorf("websocket rcon: build config: %w", err)
}
// x/net/websocket.Dial doesn't honor a context; run it in a goroutine
// so we can bail out when ctx.Done fires. Rust's authentication is
// implicit — bad password = server closes the handshake with a 401.
type dialResult struct {
ws *websocket.Conn
err error
}
ch := make(chan dialResult, 1)
go func() {
ws, err := websocket.DialConfig(cfg)
ch <- dialResult{ws, err}
}()
select {
case r := <-ch:
if r.err != nil {
return nil, fmt.Errorf("websocket rcon dial %s:%d: %w", opts.Host, opts.Port, r.err)
}
return newWebSocketClient(r.ws), nil
case <-time.After(connectTimeout):
return nil, fmt.Errorf("websocket rcon dial %s:%d: connect timeout", opts.Host, opts.Port)
case <-ctx.Done():
return nil, ctx.Err()
}
}
type wsRequest struct {
Identifier int32 `json:"Identifier"`
Message string `json:"Message"`
Name string `json:"Name"`
}
type wsResponse struct {
Message string `json:"Message"`
Identifier int32 `json:"Identifier"`
Type string `json:"Type"`
Stacktrace string `json:"Stacktrace"`
}
type wsClient struct {
ws *websocket.Conn
nextID atomic.Int32
execMu sync.Mutex // serializes Exec calls
readMu sync.Mutex // guards ws reads; Close may race
closed atomic.Bool
unsolic chan wsResponse // buffers server-pushed log events we don't consume
}
func newWebSocketClient(ws *websocket.Conn) *wsClient {
c := &wsClient{ws: ws, unsolic: make(chan wsResponse, 64)}
c.nextID.Store(1)
return c
}
// Exec runs one command and returns the matching response body. Rust
// echoes our request's Identifier back, so we can reject unrelated log
// messages the server pushes on the same connection.
func (c *wsClient) Exec(ctx context.Context, cmd string) (string, error) {
if c.closed.Load() {
return "", errors.New("websocket rcon: client closed")
}
c.execMu.Lock()
defer c.execMu.Unlock()
id := c.nextID.Add(1)
req := wsRequest{Identifier: id, Message: cmd, Name: "panel"}
payload, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(10 * time.Second)
}
if err := c.ws.SetDeadline(deadline); err != nil {
return "", fmt.Errorf("set deadline: %w", err)
}
defer c.ws.SetDeadline(time.Time{}) //nolint:errcheck
if err := websocket.Message.Send(c.ws, string(payload)); err != nil {
return "", fmt.Errorf("send: %w", err)
}
// Read frames until one matches our Identifier. Rust pipes chat, log,
// and player-event messages through the same socket; if their id is
// 0 (or doesn't match ours) we drop them into unsolic for someone
// else to consume. 200ms allowance for interleaved frames.
for {
c.readMu.Lock()
var raw string
rerr := websocket.Message.Receive(c.ws, &raw)
c.readMu.Unlock()
if rerr != nil {
return "", fmt.Errorf("recv: %w", rerr)
}
var resp wsResponse
if err := json.Unmarshal([]byte(raw), &resp); err != nil {
// Non-JSON frame — Rust shouldn't send those but be forgiving.
continue
}
if resp.Identifier != id {
// Async server push (log line, chat, etc.). Buffer non-blockingly.
select {
case c.unsolic <- resp:
default:
}
continue
}
if resp.Stacktrace != "" {
return resp.Message, fmt.Errorf("rust rcon error: %s", resp.Stacktrace)
}
return resp.Message, nil
}
}
// Close tears down the underlying websocket connection.
func (c *wsClient) Close() error {
if !c.closed.CompareAndSwap(false, true) {
return nil
}
return c.ws.Close()
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,67 @@
package runtime
import (
"os"
"testing"
)
// TestDetectHostNetUnsupported_EnvOverrides verifies the explicit env
// overrides win over autodetection (the daemon-info path needs a live
// daemon and is covered by the integration smoke test, not here).
func TestDetectHostNetUnsupported_EnvOverrides(t *testing.T) {
t.Setenv("PANEL_FORCE_BRIDGE", "1")
if !detectHostNetUnsupported(nil) {
t.Fatal("PANEL_FORCE_BRIDGE=1 must force bridge fallback (true)")
}
os.Unsetenv("PANEL_FORCE_BRIDGE")
t.Setenv("PANEL_FORCE_HOST_NET", "1")
if detectHostNetUnsupported(nil) {
t.Fatal("PANEL_FORCE_HOST_NET=1 must force host networking (false)")
}
}
// TestBridgeFallbackPreservesBindings is the load-bearing check: on a
// Docker-Desktop-style daemon, a host-mode module must be converted to
// bridge networking WITH its published port bindings intact (so the
// panel-allocated ports actually reach players), whereas on native Linux
// the same module keeps host mode and drops the meaningless bindings.
//
// It exercises the exact decision block in Create() without a live daemon
// by replicating its inputs.
func TestBridgeFallbackPreservesBindings(t *testing.T) {
ports := []PortSpec{
{Proto: "udp", HostPort: 14507, ContainerPort: 14507},
{Proto: "udp", HostPort: 14508, ContainerPort: 14508},
}
exposed, bindings, err := buildPortBindings(ports)
if err != nil {
t.Fatalf("buildPortBindings: %v", err)
}
if len(bindings) != 2 {
t.Fatalf("expected 2 published bindings, got %d", len(bindings))
}
// Native Linux (hostNetUnsupported=false): host mode kept, bindings dropped.
r := &DockerRuntime{hostNetUnsupported: false}
netMode, gotExposed, gotBindings := applyHostNetPolicy(r, "host", exposed, bindings)
if netMode != "host" {
t.Errorf("linux: netMode = %q, want host", netMode)
}
if gotBindings != nil || gotExposed != nil {
t.Errorf("linux: expected bindings/exposed dropped for host mode")
}
// Docker Desktop (hostNetUnsupported=true): host mode → bridge, bindings kept.
r = &DockerRuntime{hostNetUnsupported: true}
netMode, gotExposed, gotBindings = applyHostNetPolicy(r, "host", exposed, bindings)
if netMode != "" {
t.Errorf("docker desktop: netMode = %q, want bridge (empty)", netMode)
}
if len(gotBindings) != 2 {
t.Errorf("docker desktop: expected 2 bindings preserved, got %d", len(gotBindings))
}
if len(gotExposed) != 2 {
t.Errorf("docker desktop: expected 2 exposed ports preserved, got %d", len(gotExposed))
}
}
+225
View File
@@ -0,0 +1,225 @@
// Package runtime defines the Target-side instance execution abstraction.
// A Runtime is the thing that actually launches and tears down game server
// processes — either inside a container (DockerRuntime) or directly on the
// host (HostRuntime — future).
package runtime
import (
"context"
"io"
"time"
)
// InstanceSpec is the resolved, runtime-ready launch recipe for one instance.
// It is independent of the manifest format: a resolver in agent/internal/module
// flattens (Manifest, InstanceCreate RPC) into this struct.
type InstanceSpec struct {
InstanceID string // panel-side identifier, used as container name
Image string // e.g. vinanrra/7dtd-server:latest
Entrypoint []string // override, empty = image default
Command []string // override, empty = image default
Env map[string]string // environment variables
User string // uid:gid or name, empty = image default
WorkingDir string
Volumes []VolumeSpec
Ports []PortSpec
Resources ResourceLimits
// RestartPolicy: "no", "on-failure", "unless-stopped", "always"
RestartPolicy string
// OpenStdin keeps the container's stdin open so we can later attach and
// write admin-console commands (stdio RCON adapter). Auto-enabled by
// the module resolver when rcon.adapter == "stdio".
OpenStdin bool
// NetworkMode is the Docker network mode. Empty → bridge (default).
// "host" shares the host's network namespace; required for modules
// whose TURN/STUN clients can't survive Docker's UDP NAT rebinds
// (e.g. Windrose). In host mode, Ports become informational only.
NetworkMode string
// BuildContext is an absolute filesystem path containing a Dockerfile
// that produces this Image. When set, the runtime auto-builds the
// image on first use if it's missing locally — the AMP-class "drop in
// an agent and any module just works" behavior. Required for `panel-*`
// images, since they are never pushed to a registry. Empty for stock
// public images (alpine, the acekorneya ASA image, etc.) which the
// runtime pulls normally.
BuildContext string
// LogSink optionally receives runtime-side progress lines (image
// pull / image build / etc.) so the agent can forward them to the
// controller's log stream. Without this, first-time builds happen
// silently and the operator sees "installing" with no visible
// progress for ~30s3min while the Dockerfile runs.
LogSink func(line string)
// SecurityOpts is passed through to Docker's HostConfig.SecurityOpt
// (one entry per --security-opt CLI flag). The SteamCMD sidecar sets
// "seccomp=unconfined" so that the 32-bit Steam runtime's socket
// syscalls aren't ENOSYS'd by Docker's stricter default seccomp
// profile in newer Docker releases (observed on Docker 29.4 + Ubuntu
// 24.04 kernel 6.8: "CreateBoundSocket: failed to create socket,
// error [no name available] (38)"). Same image works fine on
// Docker 29.1 with the default profile.
SecurityOpts []string
// IsSidecar marks this as a transient helper container (fs browse,
// steamcmd, backup, restore, wipe, warmseed, basemods) rather than a
// main game-server instance. The runtime stamps a "panel.role" label
// accordingly so orphan-detection (Rehydrate) can tell main instances
// apart from helpers without brittle name-suffix matching.
IsSidecar bool
}
// VolumeSpec is a mount declaration, either a bind mount (host path →
// container path) or a Docker named volume. The resolver is responsible
// for substituting $DATA_PATH / $INSTANCE_ID before we see it here.
type VolumeSpec struct {
Type string // "bind" (default) or "volume"
HostPath string // populated when Type == "bind"
VolumeName string // populated when Type == "volume"
ContainerPath string
ReadOnly bool
}
// PortSpec is a port mapping.
type PortSpec struct {
HostPort uint16
ContainerPort uint16
Proto string // "tcp" or "udp"
// HostIP — bind address on the Target. Empty = 0.0.0.0.
HostIP string
}
// ResourceLimits caps CPU / memory for the instance.
type ResourceLimits struct {
CPUShares uint32
MemoryBytes uint64
PidsLimit int64 // 0 = unlimited
NanoCPUs int64 // Docker "--cpus" as nano-CPUs (1.5 cpus = 1.5e9)
StopGrace time.Duration
}
// RuntimeInstanceState is the runtime's view of an instance at a point in
// time, used by the Agent to rehydrate after restart or detect drift.
type RuntimeInstanceState struct {
ContainerID string
Status string // "running" | "exited" | "created" | "paused" | "restarting" | "removing" | "dead"
ExitCode int32
}
// LogStream identifies which stream a log line came from.
type LogStream string
const (
LogStreamStdout LogStream = "stdout"
LogStreamStderr LogStream = "stderr"
)
// LogHandler receives one log line at a time. Lines are already stripped of
// trailing newline. Handler must not block; if it does, log pressure will
// build up in the Runtime.
type LogHandler func(stream LogStream, line string, at time.Time)
// Runtime is the interface that both Docker and Host runtimes implement.
type Runtime interface {
// Create pulls the image (if needed) and creates the container.
// Returns a runtime-specific identifier used by Start/Stop/Remove.
Create(ctx context.Context, spec InstanceSpec) (id string, err error)
// Start starts a previously-created instance.
Start(ctx context.Context, id string) error
// Stop stops a running instance, waiting up to grace before SIGKILL.
Stop(ctx context.Context, id string, grace time.Duration) error
// Restart restarts a running instance, waiting up to grace before
// SIGKILL. Container ends up running again — equivalent to
// `docker container restart`. Used by guardrails (e.g. arkHangGuard)
// that want a bounce without surfacing as an operator stop.
Restart(ctx context.Context, id string, grace time.Duration) error
// Remove deletes the instance (after Stop). Safe to call on stopped.
Remove(ctx context.Context, id string) error
// StreamLogs follows stdout/stderr and calls handler for each line.
// Returns when ctx is cancelled or the instance exits.
StreamLogs(ctx context.Context, id string, handler LogHandler) error
// Wait blocks until the instance exits and returns its exit code.
Wait(ctx context.Context, id string) (exitCode int, err error)
// StatsStream returns a reader of Docker's stats stream (JSON frames,
// one per second). Closer cancels the stream. Used by the per-instance
// stats poller to compute CPU%/mem/net for UI + alerting.
StatsStream(ctx context.Context, id string) (io.ReadCloser, error)
// InspectByName looks up an instance by its runtime-level name
// (for Docker: "panel-<instance_id>") and returns its current state.
// Returns an error whose predicate is not-found-able if the instance
// has been removed from the runtime — used during agent rehydrate
// to detect stale sidecar metadata.
InspectByName(ctx context.Context, name string) (RuntimeInstanceState, error)
// ContainerExists reports whether a container with the given
// runtime-level name currently exists. Unlike InspectByName, it
// distinguishes "definitely gone" from "couldn't tell":
// exists=false, err=nil → the runtime confirmed no such container
// exists=false, err!=nil → the query itself failed (daemon down,
// connection dropped, ctx cancelled) — the
// container's fate is UNKNOWN, not gone.
// Callers that gate destructive bookkeeping (e.g. deleting sidecar
// metadata after a container remove) must treat a non-nil err as
// "do not assume removal succeeded."
ContainerExists(ctx context.Context, name string) (exists bool, err error)
// ---- Container-path file operations ----
// These operate inside the running container (or container filesystem
// for stopped containers where the runtime allows it). Used by the
// file manager for volume-backed instances where the host-side bind
// mount is absent or empty.
// ExecCapture runs a command inside the container and returns its
// captured stdout + stderr + exit code. Requires container to be running.
ExecCapture(ctx context.Context, id string, cmd []string) (stdout, stderr []byte, exitCode int, err error)
// CopyFileFromContainer returns the raw contents of a single file at
// containerPath. Works even on stopped containers.
CopyFileFromContainer(ctx context.Context, id, containerPath string) ([]byte, error)
// CopyFileToContainer writes content to containerPath, creating
// parent dirs inside the container if the container is running.
CopyFileToContainer(ctx context.Context, id, containerPath string, content []byte) error
// CopyTarToContainer writes a raw tar stream onto the container at
// dstDir. The tar entries are extracted in place by the Docker API.
// Used by the file manager's extract path so we can build one big
// tar from the archive's entries and ship it in a single API call.
CopyTarToContainer(ctx context.Context, id, dstDir string, tarStream io.Reader) error
// CopyDirFromContainer returns a tar stream of the contents at
// containerPath. For files: a single-entry tar. For directories:
// one entry per file/dir under it. Used by the compress path so the
// agent can iterate file contents without per-file round-trips.
CopyDirFromContainer(ctx context.Context, id, containerPath string) (io.ReadCloser, error)
// AttachStdio attaches to the container's stdin/stdout/stderr stream
// and returns a read/write/close handle. Used by the stdio RCON adapter
// for games whose admin surface is stdin-only. Requires the container
// to have been created with OpenStdin=true.
AttachStdio(ctx context.Context, id string) (io.ReadWriteCloser, error)
// ContainerMounts returns the mount points of a container by name.
// Works on stopped containers (the daemon keeps the spec). Used by the
// cluster player-save snapshotter to find the host path backing a 7DTD
// instance's /cluster bind without depending on a path convention.
ContainerMounts(ctx context.Context, name string) ([]MountInfo, error)
// Name returns the runtime kind ("docker", "host") for logging.
Name() string
}
// MountInfo is one container mount point, host-side. Type is "bind" or
// "volume"; Source is the host path backing it (for a named volume that's
// the docker volume's _data dir); Destination is the in-container path.
type MountInfo struct {
Type string
Source string
Destination string
Name string
}
+535
View File
@@ -0,0 +1,535 @@
// Package state runs the per-instance state-tracking pipeline:
// - RCON poll loops that execute manifest-declared commands and parse the
// output into AppStateUpdate messages (players online, etc.)
// - log-line event matching that runs manifest-declared regexes against
// each log line and emits PlayerEvent messages (join/leave/chat/death)
//
// A Tracker is owned by the dispatcher for the lifetime of a running
// instance. It is stopped by cancelling the context passed to Run.
package state
import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"regexp"
"strconv"
"strings"
"sync"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/agent/internal/rcon"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Emitter is the upward path for tracker output. Implementations forward
// messages onto the gRPC stream back to the Controller.
type Emitter interface {
EmitAppState(*panelv1.AppStateUpdate)
EmitPlayerEvent(*panelv1.PlayerEvent)
}
// Config wires the tracker to an instance + its RCON endpoint.
type Config struct {
InstanceID string
Manifest *modulepkg.Manifest
RCONAddr string // "host:port" — required for tcp adapters (telnet, source_rcon)
RCONPassword string
// PasswordFunc, if set, overrides RCONPassword on each dial attempt.
// Used when the module's entrypoint generates an RCON secret at first
// boot (e.g. 7DTD's TelnetPassword in /game-saves/.panel-telnet-password).
// Called with the dial context; the returned string is used as the
// password for that single attempt.
PasswordFunc func(ctx context.Context) (string, error)
Emitter Emitter
// ContainerID is the Docker container name/ID for the stdio adapter.
// Ignored by tcp adapters. The stdio adapter calls Stdio.AttachStdio
// with this id to get a stdin/stdout handle to the running game.
ContainerID string
// Stdio is the backend that knows how to attach to a container's
// stdio. Required for stdio adapter, nil otherwise.
Stdio rcon.StdioBackend
// OnRconResult is an optional callback invoked after every RCON
// exec attempt (poll loop or operator-driven). success=true means
// the engine answered; success=false means the exec failed (dial,
// timeout, dead conn). Used by the dispatcher's arkHangGuard to
// count consecutive failures and trigger an auto-restart when the
// Wine 9 listener wedges. Nil-safe: callers should check before invoke.
OnRconResult func(success bool)
}
// Tracker is created at instance start time and run in a goroutine.
type Tracker struct {
log *slog.Logger
cfg Config
events []compiledEvent
// stateRE holds the state-source parse regexes, compiled once at New so
// a bad pattern surfaces as a construction error instead of silently
// recompiling (and silently failing) on every poll tick. Keyed by the
// pattern string — pollOnce receives the StateSource by value, so the
// pattern is the stable identity.
stateRE map[string]*regexp.Regexp
mu sync.Mutex
client rcon.Client
}
type compiledEvent struct {
name string
re *regexp.Regexp
kind panelv1.PlayerEvent_Kind
}
// New constructs a Tracker. Event patterns are compiled now so config errors
// surface before Run is called.
func New(log *slog.Logger, cfg Config) (*Tracker, error) {
if cfg.Manifest == nil {
return nil, errors.New("manifest is required")
}
if cfg.Emitter == nil {
return nil, errors.New("emitter is required")
}
events, err := compileEvents(cfg.Manifest.Events)
if err != nil {
return nil, err
}
// Compile state-source parse patterns up front — a bad pattern is a
// manifest bug and should fail loudly at init, not degrade into a
// per-poll recompile that silently yields no state updates.
stateRE := map[string]*regexp.Regexp{}
for i := range cfg.Manifest.StateSources {
p := cfg.Manifest.StateSources[i].Parse
if p == nil || p.Kind != "regex" || p.Pattern == "" {
continue
}
re, err := regexp.Compile(p.Pattern)
if err != nil {
return nil, fmt.Errorf("state source %d: compile pattern: %w", i, err)
}
stateRE[p.Pattern] = re
}
return &Tracker{
log: log.With("instance_id", cfg.InstanceID, "component", "state"),
cfg: cfg,
events: events,
stateRE: stateRE,
}, nil
}
// Client returns the currently-connected RCON client, or nil if the tracker
// hasn't finished dialing (or the manifest has no RCON at all). Used by the
// dispatcher to handle ad-hoc RCON commands from operators.
func (t *Tracker) Client() rcon.Client {
t.mu.Lock()
defer t.mu.Unlock()
return t.client
}
// Exec runs one operator command via the tracker's cached client. On a dead
// connection (EOF, broken pipe — Palworld closes RCON after 60s idle) we
// redial once and retry, so operators don't see the 60s-idle EOF in the UI.
// Returns ("", "not_connected", ...) if the tracker hasn't finished dialing.
func (t *Tracker) Exec(ctx context.Context, cmd string) (string, error) {
t.mu.Lock()
client := t.client
t.mu.Unlock()
if client == nil {
return "", errors.New("RCON not yet connected; try again in a moment")
}
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := client.Exec(execCtx, cmd)
if err == nil || !isDeadConnErr(err) {
return out, err
}
t.redial(ctx)
t.mu.Lock()
client = t.client
t.mu.Unlock()
if client == nil {
return out, err
}
retryCtx, retryCancel := context.WithTimeout(ctx, 10*time.Second)
defer retryCancel()
return client.Exec(retryCtx, cmd)
}
// OnLogLine runs each log line through compiled event patterns and emits a
// PlayerEvent on the first match. Called from the dispatcher's log pump; must
// be fast and non-blocking.
func (t *Tracker) OnLogLine(line string) {
for _, ev := range t.events {
m := ev.re.FindStringSubmatch(line)
if m == nil {
continue
}
name := firstGroup(ev.re, m, "name", "player_name")
id := firstGroup(ev.re, m, "platform_id", "owner", "cross_id", "id", "player_id")
detail := firstGroup(ev.re, m, "msg", "detail", "reason")
t.cfg.Emitter.EmitPlayerEvent(&panelv1.PlayerEvent{
InstanceId: t.cfg.InstanceID,
Kind: ev.kind,
PlayerName: name,
PlayerId: id,
Detail: detail,
At: timestamppb.Now(),
})
return
}
}
// Run connects to RCON (with retries) and starts one goroutine per declared
// RCON state source. Returns when ctx is cancelled.
//
// If the manifest has no RCON config at all, Run is a no-op that blocks on
// ctx.Done. If it has RCON but no state sources, we still dial once so
// operator ad-hoc commands from the UI work — state-source poll loops just
// don't spawn.
func (t *Tracker) Run(ctx context.Context) {
if t.cfg.Manifest.RCON == nil {
<-ctx.Done()
return
}
client, err := t.dialWithRetries(ctx)
if err != nil {
// Context cancelled before we could connect; nothing to do.
return
}
t.mu.Lock()
t.client = client
t.mu.Unlock()
defer func() {
t.mu.Lock()
defer t.mu.Unlock()
if t.client != nil {
_ = t.client.Close()
t.client = nil
}
}()
t.log.Info("rcon connected", "addr", t.cfg.RCONAddr, "adapter", t.cfg.Manifest.RCON.Adapter)
var wg sync.WaitGroup
for i := range t.cfg.Manifest.StateSources {
ss := t.cfg.Manifest.StateSources[i]
if ss.Type != "rcon" || ss.Every.Std() <= 0 {
continue
}
wg.Add(1)
go func() {
defer wg.Done()
t.runRCONPoller(ctx, ss)
}()
}
// Block until the tracker is cancelled so the defer (which closes the
// rcon client) fires on teardown, not immediately. With no rcon state
// sources wg.Wait() returns instantly — without this extra wait the
// client would vanish right after Run dialed, making ad-hoc commands
// from the UI fail with "RCON not yet connected" forever.
<-ctx.Done()
wg.Wait()
}
func (t *Tracker) hasRCONStateSource() bool {
for _, ss := range t.cfg.Manifest.StateSources {
if ss.Type == "rcon" && ss.Every.Std() > 0 {
return true
}
}
return false
}
func (t *Tracker) dialWithRetries(ctx context.Context) (rcon.Client, error) {
rc := t.cfg.Manifest.RCON
dialer, err := rcon.DialerFor(rc.Adapter)
if err != nil {
return nil, err
}
// stdio has no host/port — it attaches to the running container's stdin.
// We still retry-loop because the container may not be up yet when the
// tracker starts (race between Start + activate).
if rc.Adapter == "stdio" {
backoff := 2 * time.Second
for {
dialCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
client, err := dialer.Dial(dialCtx, rcon.DialOptions{
ContainerID: t.cfg.ContainerID,
Stdio: t.cfg.Stdio,
})
cancel()
if err == nil {
return client, nil
}
t.log.Warn("stdio attach failed, retrying", "err", err, "backoff", backoff)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
if backoff < 30*time.Second {
backoff *= 2
}
}
}
host, portStr, err := net.SplitHostPort(t.cfg.RCONAddr)
if err != nil {
return nil, fmt.Errorf("rcon addr %q: %w", t.cfg.RCONAddr, err)
}
port, err := strconv.Atoi(portStr)
if err != nil {
return nil, fmt.Errorf("rcon port %q: %w", portStr, err)
}
// Dial-retry backoff capped at 8s (not 30s) — during the post-Start
// warm-up window the server's RCON flips between responsive and slow
// every few seconds, and a 32s ceiling meant one unlucky timeout
// stranded the panel for half a minute before the next retry. Cap
// at 8s so ragnarok/etc. get back onto RCON within a few seconds of
// ASA actually being ready, without hammering the port on a truly
// dead server (8s × a few retries is ~30s before the tracker gives
// up per-cycle, which the agent's outer loop still reschedules).
backoff := 2 * time.Second
for {
dialCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
// Resolve password per-attempt when PasswordFunc is set — the
// module's entrypoint may be mid-generation on first boot, so re-
// reading on each retry lets us pick up the final value as soon
// as it's written.
pw := t.cfg.RCONPassword
if t.cfg.PasswordFunc != nil {
if p, err := t.cfg.PasswordFunc(dialCtx); err == nil && p != "" {
pw = p
}
}
client, err := dialer.Dial(dialCtx, rcon.DialOptions{
Host: host,
Port: port,
Password: pw,
ConnectTimeout: 5 * time.Second,
ContainerID: t.cfg.ContainerID,
})
cancel()
if err == nil {
return client, nil
}
t.log.Warn("rcon dial failed, retrying", "err", err, "backoff", backoff)
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(backoff):
}
if backoff < 8*time.Second {
backoff *= 2
}
}
}
func (t *Tracker) runRCONPoller(ctx context.Context, ss modulepkg.StateSource) {
every := ss.Every.Std()
ticker := time.NewTicker(every)
defer ticker.Stop()
t.pollOnce(ctx, ss) // immediate first tick
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
t.pollOnce(ctx, ss)
}
}
}
func (t *Tracker) pollOnce(ctx context.Context, ss modulepkg.StateSource) {
t.mu.Lock()
client := t.client
t.mu.Unlock()
if client == nil {
return
}
execCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
out, err := client.Exec(execCtx, ss.Command)
if err != nil {
t.log.Warn("rcon exec failed", "cmd", ss.Command, "err", err)
if t.cfg.OnRconResult != nil {
t.cfg.OnRconResult(false)
}
if isDeadConnErr(err) {
t.redial(ctx)
}
return
}
if t.cfg.OnRconResult != nil {
t.cfg.OnRconResult(true)
}
app := t.parseState(out, ss)
if app == nil {
// Parser yielded no structured fields (regex didn't match this game's
// output, fields all empty, etc.) but the RCON exec succeeded — that
// alone is proof the server is responsive. Emit a bare update keyed
// by InstanceID so the dispatcher can use it to promote a stale
// CRASHED status back to RUNNING.
app = &panelv1.AppStateUpdate{InstanceId: t.cfg.InstanceID}
}
t.cfg.Emitter.EmitAppState(app)
}
// isDeadConnErr returns true for errors that indicate the RCON socket is no
// longer usable (EOF, connection reset, broken pipe). Palworld's RCON, for
// instance, closes the socket after ~60s idle — any subsequent Exec fails
// with EOF and stays broken until we redial.
func isDeadConnErr(err error) bool {
if err == nil {
return false
}
s := err.Error()
return strings.Contains(s, "EOF") ||
strings.Contains(s, "broken pipe") ||
strings.Contains(s, "connection reset") ||
strings.Contains(s, "forcibly closed") ||
strings.Contains(s, "use of closed network connection")
}
// redial closes the current client and dials a fresh one. Runs inline with
// the poll loop so the next tick gets a working client. On failure we leave
// t.client = nil and the next poll will call us again.
func (t *Tracker) redial(ctx context.Context) {
t.mu.Lock()
if t.client != nil {
_ = t.client.Close()
t.client = nil
}
t.mu.Unlock()
t.log.Info("rcon redialing after dead connection")
fresh, err := t.dialWithRetries(ctx)
if err != nil {
t.log.Warn("rcon redial failed", "err", err)
return
}
t.mu.Lock()
t.client = fresh
t.mu.Unlock()
t.log.Info("rcon reconnected", "addr", t.cfg.RCONAddr, "adapter", t.cfg.Manifest.RCON.Adapter)
}
// parseState runs the configured parser against raw RCON output using the
// regex precompiled at New, returning an AppStateUpdate or nil if nothing
// matched.
func (t *Tracker) parseState(out string, ss modulepkg.StateSource) *panelv1.AppStateUpdate {
if ss.Parse == nil || ss.Parse.Kind != "regex" {
return nil
}
re := t.stateRE[ss.Parse.Pattern]
if re == nil {
return nil
}
return parseRegexCompiled(t.cfg.InstanceID, out, re, ss.Parse)
}
// parseStateOutput is the uncached variant (compiles the pattern per call).
// Kept for tests and out-of-tracker callers; the tracker's poll loop uses
// the precompiled parseState path.
func parseStateOutput(instanceID, out string, ss modulepkg.StateSource) *panelv1.AppStateUpdate {
if ss.Parse == nil {
return nil
}
switch ss.Parse.Kind {
case "regex":
return parseRegex(instanceID, out, ss.Parse)
default:
return nil
}
}
func parseRegex(instanceID, out string, p *modulepkg.ParseConfig) *panelv1.AppStateUpdate {
re, err := regexp.Compile(p.Pattern)
if err != nil {
return nil
}
return parseRegexCompiled(instanceID, out, re, p)
}
func parseRegexCompiled(instanceID, out string, re *regexp.Regexp, p *modulepkg.ParseConfig) *panelv1.AppStateUpdate {
m := re.FindStringSubmatch(out)
if m == nil {
return nil
}
app := &panelv1.AppStateUpdate{
InstanceId: instanceID,
At: timestamppb.Now(),
}
for outName, groupName := range p.Fields {
val := firstGroup(re, m, groupName)
switch outName {
case "players_online":
if n, err := strconv.Atoi(val); err == nil {
app.PlayersOnline = int32(n)
}
case "players_max":
if n, err := strconv.Atoi(val); err == nil {
app.PlayersMax = int32(n)
}
case "uptime_seconds":
if n, err := strconv.ParseInt(val, 10, 64); err == nil {
app.UptimeSeconds = n
}
default:
if app.ModuleFields == nil {
app.ModuleFields = map[string]string{}
}
app.ModuleFields[outName] = val
}
}
return app
}
func compileEvents(events map[string]modulepkg.Event) ([]compiledEvent, error) {
out := make([]compiledEvent, 0, len(events))
for name, ev := range events {
re, err := regexp.Compile(ev.Pattern)
if err != nil {
return nil, fmt.Errorf("event %q: compile pattern: %w", name, err)
}
out = append(out, compiledEvent{
name: name,
re: re,
kind: parseKind(ev.Kind),
})
}
return out, nil
}
func parseKind(s string) panelv1.PlayerEvent_Kind {
switch s {
case "join":
return panelv1.PlayerEvent_KIND_JOIN
case "leave":
return panelv1.PlayerEvent_KIND_LEAVE
case "chat":
return panelv1.PlayerEvent_KIND_CHAT
case "death":
return panelv1.PlayerEvent_KIND_DEATH
default:
return panelv1.PlayerEvent_KIND_CUSTOM
}
}
// firstGroup returns the first non-empty named submatch from names. Missing
// or empty groups are treated as absent so we try the next name.
func firstGroup(re *regexp.Regexp, m []string, names ...string) string {
for _, name := range names {
i := re.SubexpIndex(name)
if i >= 0 && i < len(m) && m[i] != "" {
return m[i]
}
}
return ""
}
+170
View File
@@ -0,0 +1,170 @@
package state
import (
"io"
"log/slog"
"testing"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
type captureEmitter struct {
appStates []*panelv1.AppStateUpdate
playerEvents []*panelv1.PlayerEvent
}
func (c *captureEmitter) EmitAppState(a *panelv1.AppStateUpdate) { c.appStates = append(c.appStates, a) }
func (c *captureEmitter) EmitPlayerEvent(p *panelv1.PlayerEvent) { c.playerEvents = append(c.playerEvents, p) }
func newTestTracker(t *testing.T, m *modulepkg.Manifest) (*Tracker, *captureEmitter) {
t.Helper()
cap := &captureEmitter{}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
tr, err := New(log, Config{
InstanceID: "inst-1",
Manifest: m,
Emitter: cap,
})
if err != nil {
t.Fatalf("New: %v", err)
}
return tr, cap
}
func Test7DTDJoinPattern(t *testing.T) {
manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml")
if err != nil {
t.Fatalf("load 7dtd: %v", err)
}
tr, cap := newTestTracker(t, manifest)
// Real-world 7DTD log line shape
line := `2026-04-19T10:00:00 42.123 INF PlayerSpawnedInWorld (by system): PltfmId='Steam_76561198000000001', CrossId='EOS_00000000', OwnerID='Steam_76561198000000001', PlayerName='Alice'`
tr.OnLogLine(line)
if len(cap.playerEvents) != 1 {
t.Fatalf("expected 1 player event, got %d", len(cap.playerEvents))
}
ev := cap.playerEvents[0]
if ev.Kind != panelv1.PlayerEvent_KIND_JOIN {
t.Errorf("kind = %v, want JOIN", ev.Kind)
}
if ev.PlayerName != "Alice" {
t.Errorf("name = %q, want Alice", ev.PlayerName)
}
if ev.PlayerId == "" {
t.Errorf("player_id empty; expected Steam_... from named groups")
}
}
func Test7DTDChatPattern(t *testing.T) {
manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml")
if err != nil {
t.Fatalf("load 7dtd: %v", err)
}
tr, cap := newTestTracker(t, manifest)
line := `2026-04-19T10:00:00 42.123 INF Chat (from 'Steam_76561198000000001', entity id '123', to 'Global'): 'Alice': hello world`
tr.OnLogLine(line)
if len(cap.playerEvents) != 1 {
t.Fatalf("expected 1 player event, got %d", len(cap.playerEvents))
}
ev := cap.playerEvents[0]
if ev.Kind != panelv1.PlayerEvent_KIND_CHAT {
t.Errorf("kind = %v, want CHAT", ev.Kind)
}
if ev.PlayerName != "Alice" {
t.Errorf("name = %q, want Alice", ev.PlayerName)
}
if ev.Detail != "hello world" {
t.Errorf("detail = %q, want 'hello world'", ev.Detail)
}
}
func Test7DTDLPParse(t *testing.T) {
manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml")
if err != nil {
t.Fatalf("load 7dtd: %v", err)
}
var lp *modulepkg.StateSource
for i := range manifest.StateSources {
if manifest.StateSources[i].Type == "rcon" {
lp = &manifest.StateSources[i]
break
}
}
if lp == nil {
t.Fatal("no rcon state source in 7dtd manifest")
}
sample := "Total of 3 in the game\n\n1. id=1, Alice, pos=(0,0,0)\n2. id=2, Bob, pos=(1,0,0)\n3. id=3, Carol, pos=(2,0,0)"
app := parseStateOutput("inst-1", sample, *lp)
if app == nil {
t.Fatal("parse returned nil")
}
if app.PlayersOnline != 3 {
t.Errorf("players_online = %d, want 3", app.PlayersOnline)
}
}
func TestLineWithNoMatchYieldsNoEvent(t *testing.T) {
manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml")
if err != nil {
t.Fatalf("load 7dtd: %v", err)
}
tr, cap := newTestTracker(t, manifest)
tr.OnLogLine("this is some random log line that matches nothing")
if len(cap.playerEvents) != 0 {
t.Errorf("expected 0 events, got %d", len(cap.playerEvents))
}
}
// ---- WI-05: hoisted state-source regex compilation ----
// TestNewRejectsBadStateSourcePattern: a malformed parse regex must fail at
// construction (surfacing the manifest bug at init) instead of silently
// recompiling-and-failing on every poll tick.
func TestNewRejectsBadStateSourcePattern(t *testing.T) {
m := &modulepkg.Manifest{
StateSources: []modulepkg.StateSource{{
Type: "rcon",
Command: "lp",
Parse: &modulepkg.ParseConfig{Kind: "regex", Pattern: "("},
}},
}
log := slog.New(slog.NewTextHandler(io.Discard, nil))
if _, err := New(log, Config{InstanceID: "i", Manifest: m, Emitter: &captureEmitter{}}); err == nil {
t.Fatal("New accepted an invalid state-source regex; want construction error")
}
}
// TestParseStateUsesPrecompiledRegex drives the tracker's precompiled parse
// path with the real 7DTD manifest and confirms it matches the uncached
// package-level parser.
func TestParseStateUsesPrecompiledRegex(t *testing.T) {
manifest, err := modulepkg.LoadFile("../../../modules/7dtd/module.yaml")
if err != nil {
t.Fatalf("load 7dtd: %v", err)
}
tr, _ := newTestTracker(t, manifest)
var lp *modulepkg.StateSource
for i := range manifest.StateSources {
if manifest.StateSources[i].Type == "rcon" {
lp = &manifest.StateSources[i]
break
}
}
if lp == nil {
t.Fatal("no rcon state source in 7dtd manifest")
}
sample := "Total of 3 in the game\n\n1. id=1, Alice, pos=(0,0,0)\n2. id=2, Bob, pos=(1,0,0)\n3. id=3, Carol, pos=(2,0,0)"
app := tr.parseState(sample, *lp)
if app == nil {
t.Fatal("precompiled parseState returned nil")
}
if app.PlayersOnline != 3 {
t.Errorf("players_online = %d, want 3", app.PlayersOnline)
}
}
+95
View File
@@ -0,0 +1,95 @@
package updater
import (
"context"
"fmt"
"strings"
"time"
"github.com/dbledeez/panel/agent/internal/runtime"
"github.com/dbledeez/panel/pkg/steamvdf"
)
// FetchAppInfoBranches runs a one-shot SteamCMD sidecar with
//
// +login anonymous +app_info_update 1 +app_info_print <appID> +quit
//
// captures its stdout, and parses the depots.branches map (branch name →
// latest buildid). CHECK-ONLY: no game volumes are mounted, so this can
// never touch an install. Only the shared panel-steamcmd-auth volume is
// attached (same as update runs) so Steam's client config cache persists
// across calls.
//
// tag disambiguates the sidecar container name per caller (usually the
// instance id); appID appears too so parallel checks for different apps
// can't collide.
func FetchAppInfoBranches(ctx context.Context, rt runtime.Runtime, tag, appID string, log func(string)) (map[string]string, error) {
if appID == "" {
return nil, fmt.Errorf("app_info: app_id is required")
}
args := []string{
"+login", "anonymous",
"+app_info_update", "1",
"+app_info_print", appID,
"+quit",
}
spec := runtime.InstanceSpec{
InstanceID: fmt.Sprintf("%s-appinfo-%s", tag, appID),
IsSidecar: true,
Image: sidecarImage,
Command: args,
Volumes: []runtime.VolumeSpec{{
VolumeName: "panel-steamcmd-auth",
ContainerPath: "/root/.local/share/Steam",
Type: "volume",
}},
RestartPolicy: "no",
// Same seccomp note as the update sidecar (steamcmd.go): newer
// Docker default profiles ENOSYS a syscall the 32-bit Steam
// runtime needs.
SecurityOpts: []string{"seccomp=unconfined"},
}
contID, err := rt.Create(ctx, spec)
if err != nil {
return nil, fmt.Errorf("create app_info sidecar: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = rt.Remove(rmCtx, contID)
}()
if err := rt.Start(ctx, contID); err != nil {
return nil, fmt.Errorf("start app_info sidecar: %w", err)
}
var out strings.Builder
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = rt.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
out.WriteString(line)
out.WriteByte('\n')
})
}()
exitCode, err := rt.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return nil, fmt.Errorf("wait app_info sidecar: %w", err)
}
if log != nil {
log(fmt.Sprintf("app_info: steamcmd exit %d, %d bytes of output", exitCode, out.Len()))
}
// SteamCMD sometimes exits non-zero even after printing valid app
// info — parse first, only surface the exit code when parsing fails.
branches, perr := steamvdf.ParseAppInfoBranches(out.String(), appID)
if perr != nil {
if exitCode != 0 {
return nil, fmt.Errorf("app_info: steamcmd exit %d (%v)", exitCode, perr)
}
return nil, perr
}
return branches, nil
}
+207
View File
@@ -0,0 +1,207 @@
package updater
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"net/http"
"os"
"path"
"path/filepath"
"strings"
"time"
archivepkg "github.com/dbledeez/panel/agent/internal/archive"
modulepkg "github.com/dbledeez/panel/pkg/module"
"github.com/dbledeez/panel/pkg/version"
)
// DirectProvider fetches a URL and writes the response body to a target
// path under the instance. Used for vanilla Minecraft jars, custom mod
// packs hosted on any HTTP server, etc.
type DirectProvider struct{}
// Name identifies this provider kind in logs / manifest.
func (DirectProvider) Name() string { return "direct" }
const maxDirectDownloadBytes = 1 << 30 // 1 GiB sanity cap
// Update downloads spec.URL and writes it to spec.TargetPath relative
// to the instance. Optional SHA256 verification is reserved for a future
// `sha256:` field on the spec.
func (DirectProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
if spec.URL == "" {
return fmt.Errorf("direct provider: url is required")
}
if spec.TargetPath == "" {
return fmt.Errorf("direct provider: target_path is required")
}
req, err := http.NewRequestWithContext(ctx, "GET", spec.URL, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("User-Agent", version.UserAgent())
client := &http.Client{Timeout: 5 * time.Minute}
uc.Log(fmt.Sprintf("GET %s", spec.URL))
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("http status %d", resp.StatusCode)
}
if resp.ContentLength > 0 {
uc.Log(fmt.Sprintf("Content-Length: %d bytes", resp.ContentLength))
}
data, err := io.ReadAll(io.LimitReader(resp.Body, maxDirectDownloadBytes+1))
if err != nil {
return fmt.Errorf("read body: %w", err)
}
if int64(len(data)) > maxDirectDownloadBytes {
return fmt.Errorf("response exceeds %d-byte cap", maxDirectDownloadBytes)
}
sum := sha256.Sum256(data)
uc.Log(fmt.Sprintf("downloaded %d bytes (sha256:%s)", len(data), hex.EncodeToString(sum[:])))
// Archive handling. Some direct URLs serve a whole game tree as an
// archive (factorio's headless .tar.xz) — dumping those bytes as a
// single file at TargetPath is never what the module wants. Extract
// when the module opted in (spec.Extract) or when the content is a
// recognized archive AND TargetPath looks like a directory (no file
// extension). A TargetPath with an extension is always written
// verbatim: minecraft server.jar IS a zip by magic bytes, and
// terraria's entrypoint expects its .zip left on disk.
format := archivepkg.DetectFormat(data, spec.URL)
if spec.Extract && format == "" {
return fmt.Errorf("extract requested but content is not a recognized archive (url %s)", spec.URL)
}
if format != "" && (spec.Extract || path.Ext(path.Base(spec.TargetPath)) == "") {
uc.Log(fmt.Sprintf("content is a %s archive — extracting into %s", format, spec.TargetPath))
entries, bytesOut, err := extractToInstance(ctx, uc, spec.TargetPath, data, spec.URL)
if err != nil {
return fmt.Errorf("extract into %s: %w", spec.TargetPath, err)
}
uc.Log(fmt.Sprintf("extracted %d entries (%d bytes) into %s", entries, bytesOut, spec.TargetPath))
return nil
}
if err := writeToInstance(ctx, uc, spec.TargetPath, data); err != nil {
return fmt.Errorf("write target: %w", err)
}
uc.Log(fmt.Sprintf("wrote %s", spec.TargetPath))
return nil
}
// containerTargetAbs resolves a provider target_path to a container-
// absolute path. An absolute target that is equal to or under one of
// the module's declared browseable roots or volume mount points is
// used as-is (multi-root modules: factorio's /game volume sits BESIDE
// its /game-saves default root). Anything else keeps the historical
// behavior of joining under BrowseableRoot — terraria's entrypoint,
// for one, expects /terraria-server.zip to land at
// /game-saves/terraria-server.zip via exactly that join.
func containerTargetAbs(uc *Context, targetPath string) string {
if strings.HasPrefix(targetPath, "/") && uc.Manifest != nil {
cleaned := path.Clean(targetPath)
var roots []string
if d := uc.Manifest.Runtime.Docker; d != nil {
for _, r := range d.BrowseableRoots {
if r.Path != "" {
roots = append(roots, r.Path)
}
}
for _, v := range d.Volumes {
if v.Container != "" {
roots = append(roots, v.Container)
}
}
}
for _, r := range roots {
if cleaned == r || strings.HasPrefix(cleaned+"/", r+"/") {
return cleaned
}
}
}
return path.Join(uc.BrowseableRoot, targetPath)
}
// extractToInstance expands archive `data` into the instance-relative
// directory targetPath. Mirrors writeToInstance's storage preference:
// container-path ops when the instance has a BrowseableRoot and a
// container (re-encode the archive as one tar stream and let Docker
// extract it in place — same pattern as the file manager's FsExtract),
// falling back to plain filesystem extraction under DataPath.
func extractToInstance(ctx context.Context, uc *Context, targetPath string, data []byte, originalName string) (int64, int64, error) {
if targetPath == "" {
return 0, 0, fmt.Errorf("target_path is required")
}
if uc.BrowseableRoot != "" && uc.ContainerID != "" {
destAbs := containerTargetAbs(uc, targetPath)
// Best-effort mkdir; only possible on a running container, and
// the usual target is a volume mount point that already exists.
_, _, _, _ = uc.Runtime.ExecCapture(ctx, uc.ContainerID, []string{"sh", "-c", "mkdir -p -- '" + destAbs + "'"})
pr, pw := io.Pipe()
emitErr := make(chan error, 1)
var entries, bytesOut int64
go func() {
n, b, err := archivepkg.WriteAsTar(data, originalName, pw)
entries, bytesOut = n, b
_ = pw.CloseWithError(err)
emitErr <- err
}()
if err := uc.Runtime.CopyTarToContainer(ctx, uc.ContainerID, destAbs, pr); err != nil {
// Prefer the encoder's error when it carries archive-format
// detail — but NOT when it's just the pipe slamming shut
// because Docker aborted the copy (that would mask the real
// error, e.g. "destination directory does not exist").
if encErr := <-emitErr; encErr != nil && !errors.Is(encErr, io.ErrClosedPipe) {
return entries, bytesOut, encErr
}
return entries, bytesOut, fmt.Errorf("copy to container %s: %w", destAbs, err)
}
if encErr := <-emitErr; encErr != nil {
return entries, bytesOut, encErr
}
return entries, bytesOut, nil
}
if uc.DataPath == "" {
return 0, 0, fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath")
}
destAbs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath))
if err := os.MkdirAll(destAbs, 0o755); err != nil {
return 0, 0, fmt.Errorf("mkdir %s: %w", destAbs, err)
}
return archivepkg.Walk(data, originalName, func(name string, mode int64, isDir bool, body io.Reader) error {
safe := archivepkg.SafeEntryPath(name)
if safe == "" {
return nil
}
out := filepath.Join(destAbs, filepath.FromSlash(safe))
if isDir {
return os.MkdirAll(out, 0o755)
}
if err := os.MkdirAll(filepath.Dir(out), 0o755); err != nil {
return err
}
perm := os.FileMode(mode & 0o777)
if perm == 0 {
perm = 0o644
}
f, err := os.OpenFile(out, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, perm)
if err != nil {
return err
}
if _, err := io.Copy(f, body); err != nil {
_ = f.Close()
return err
}
return f.Close()
})
}
+194
View File
@@ -0,0 +1,194 @@
package updater
import (
"archive/tar"
"bytes"
"context"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/ulikunitz/xz"
modulepkg "github.com/dbledeez/panel/pkg/module"
)
// makeFactorioTarXz builds a tiny tar.xz shaped like factorio's
// headless tarball (factorio/bin/x64/factorio).
func makeFactorioTarXz(t *testing.T) []byte {
t.Helper()
var buf bytes.Buffer
xw, err := xz.NewWriter(&buf)
if err != nil {
t.Fatal(err)
}
tw := tar.NewWriter(xw)
body := []byte("ELF-not-really")
if err := tw.WriteHeader(&tar.Header{Name: "factorio/bin/x64/factorio", Typeflag: tar.TypeReg, Mode: 0o755, Size: int64(len(body))}); err != nil {
t.Fatal(err)
}
if _, err := tw.Write(body); err != nil {
t.Fatal(err)
}
if err := tw.Close(); err != nil {
t.Fatal(err)
}
if err := xw.Close(); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
func testCtx(t *testing.T, dataPath string) *Context {
t.Helper()
return &Context{
InstanceID: "test",
DataPath: dataPath,
Log: func(string) {},
}
}
// The factorio case: archive content + extensionless URL + extract:true
// must land the extracted tree under TargetPath, not a raw blob.
func TestDirectExtractsArchiveIntoTargetDir(t *testing.T) {
blob := makeFactorioTarXz(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(blob)
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/get-download/stable/headless/linux64", TargetPath: "/game", Extract: true}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
bin := filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")
data, err := os.ReadFile(bin)
if err != nil {
t.Fatalf("expected extracted binary at %s: %v", bin, err)
}
if string(data) != "ELF-not-really" {
t.Fatalf("binary content mismatch: %q", data)
}
// The old bug: raw blob written AS the target path.
if fi, err := os.Stat(filepath.Join(dir, "game")); err != nil || !fi.IsDir() {
t.Fatalf("target path should be a directory, got err=%v", err)
}
}
// Same archive, no explicit extract flag: extensionless TargetPath
// still triggers extraction (directory heuristic).
func TestDirectHeuristicExtractsWithoutFlag(t *testing.T) {
blob := makeFactorioTarXz(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(blob)
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/linux64", TargetPath: "/game"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "game", "factorio", "bin", "x64", "factorio")); err != nil {
t.Fatalf("expected extraction: %v", err)
}
}
// The terraria/minecraft guard: a TargetPath WITH a file extension is
// written verbatim even when the content has archive magic (jars are
// zips; terraria's entrypoint unzips its own .zip).
func TestDirectWritesArchiveVerbatimWhenTargetHasExtension(t *testing.T) {
// A zip blob (like a .jar or terraria-server zip).
var zipBuf bytes.Buffer
zipBuf.Write([]byte{0x50, 0x4b, 0x03, 0x04}) // zip local-header magic
zipBuf.WriteString("rest-of-zip-not-parsed")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write(zipBuf.Bytes())
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/terraria-server-1449.zip", TargetPath: "/terraria-server.zip"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "terraria-server.zip"))
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(data, zipBuf.Bytes()) {
t.Fatal("zip should have been written verbatim")
}
}
// extract:true on non-archive content is a hard error, not a silent
// verbatim write.
func TestDirectExtractFlagOnNonArchiveErrors(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("just text"))
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/game", Extract: true}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err == nil {
t.Fatal("expected error for extract on non-archive content")
}
}
// containerTargetAbs: absolute targets matching a declared root or
// volume mount stay container-absolute (factorio's /game volume);
// anything else keeps the historical BrowseableRoot join (terraria's
// /terraria-server.zip → /game-saves/terraria-server.zip).
func TestContainerTargetAbs(t *testing.T) {
uc := &Context{
BrowseableRoot: "/game-saves",
Manifest: &modulepkg.Manifest{
Runtime: modulepkg.Runtime{Docker: &modulepkg.DockerSpec{
BrowseableRoots: []modulepkg.BrowseableRoot{{Name: "Saves", Path: "/game-saves"}},
Volumes: []modulepkg.Volume{
{Type: "volume", Name: "panel-$INSTANCE_ID-saves", Container: "/game-saves"},
{Type: "volume", Name: "panel-$INSTANCE_ID-game", Container: "/game"},
},
}},
},
}
cases := map[string]string{
"/game": "/game",
"/game/sub": "/game/sub",
"/game-saves/mods": "/game-saves/mods",
"/terraria-server.zip": "/game-saves/terraria-server.zip",
"relative/file": "/game-saves/relative/file",
}
for in, want := range cases {
if got := containerTargetAbs(uc, in); got != want {
t.Errorf("containerTargetAbs(%q) = %q, want %q", in, got, want)
}
}
}
// Non-archive content with an extensionless target keeps the old
// single-file write behavior.
func TestDirectNonArchiveStillWritesFile(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, _ = w.Write([]byte("binary-ish payload"))
}))
defer srv.Close()
dir := t.TempDir()
spec := &modulepkg.UpdateProvider{Kind: "direct", URL: srv.URL + "/blob", TargetPath: "/some/file"}
if err := (DirectProvider{}).Update(context.Background(), testCtx(t, dir), spec); err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(filepath.Join(dir, "some", "file"))
if err != nil {
t.Fatal(err)
}
if string(data) != "binary-ish payload" {
t.Fatalf("content mismatch: %q", data)
}
}
+108
View File
@@ -0,0 +1,108 @@
package updater
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"regexp"
"time"
modulepkg "github.com/dbledeez/panel/pkg/module"
"github.com/dbledeez/panel/pkg/version"
)
// GitHubProvider queries a repo's latest release, finds an asset whose
// name matches the provider's AssetRegex, and writes it to TargetPath.
type GitHubProvider struct{}
// Name identifies this provider kind in logs / manifest.
func (GitHubProvider) Name() string { return "github" }
type githubAsset struct {
Name string `json:"name"`
DownloadURL string `json:"browser_download_url"`
Size int64 `json:"size"`
}
type githubRelease struct {
Name string `json:"name"`
TagName string `json:"tag_name"`
Assets []githubAsset `json:"assets"`
}
// Update fetches the latest release of spec.Repo, picks the first asset
// matching spec.AssetRegex, and writes its bytes to spec.TargetPath.
func (GitHubProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
if spec.Repo == "" {
return fmt.Errorf("github provider: repo is required (owner/name)")
}
if spec.AssetRegex == "" {
return fmt.Errorf("github provider: asset_regex is required")
}
if spec.TargetPath == "" {
return fmt.Errorf("github provider: target_path is required")
}
re, err := regexp.Compile(spec.AssetRegex)
if err != nil {
return fmt.Errorf("asset_regex: %w", err)
}
apiURL := fmt.Sprintf("https://api.github.com/repos/%s/releases/latest", spec.Repo)
uc.Log(fmt.Sprintf("querying GitHub: %s", apiURL))
req, err := http.NewRequestWithContext(ctx, "GET", apiURL, nil)
if err != nil {
return fmt.Errorf("new request: %w", err)
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("User-Agent", version.UserAgent())
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("http: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 1024))
return fmt.Errorf("github api %d: %s", resp.StatusCode, string(body))
}
var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return fmt.Errorf("decode release: %w", err)
}
uc.Log(fmt.Sprintf("latest release: %s (%s), %d assets", release.Name, release.TagName, len(release.Assets)))
var chosen *githubAsset
for i := range release.Assets {
if re.MatchString(release.Assets[i].Name) {
chosen = &release.Assets[i]
break
}
}
if chosen == nil {
return fmt.Errorf("no asset matches %q (saw: %s)", spec.AssetRegex, assetNames(release.Assets))
}
uc.Log(fmt.Sprintf("downloading asset %s (%d bytes)", chosen.Name, chosen.Size))
direct := &modulepkg.UpdateProvider{
Kind: "direct",
URL: chosen.DownloadURL,
TargetPath: spec.TargetPath,
}
return DirectProvider{}.Update(ctx, uc, direct)
}
func assetNames(as []githubAsset) string {
out := ""
for _, a := range as {
if out != "" {
out += ", "
}
out += a.Name
}
if out == "" {
out = "(none)"
}
return out
}
+90
View File
@@ -0,0 +1,90 @@
// Package updater implements the Generic Update system: pluggable
// providers that fetch new versions of game files from SteamCMD,
// GitHub releases, or a direct URL, then lay them down under the
// instance's data path (bind mount) or container volume.
//
// The panel's manifest declares one or more update_providers per module;
// the operator picks one to trigger. Each provider runs on the *Agent*
// (close to the data) and streams progress back as LogLine envelopes
// so the dashboard renders updates in the same events pane as normal
// server output.
package updater
import (
"context"
"fmt"
"os"
"path"
"path/filepath"
"github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
)
// Context is the environment an update runs in.
type Context struct {
InstanceID string
Manifest *modulepkg.Manifest
ContainerID string // empty if container doesn't exist; present for running + stopped
BrowseableRoot string // container-absolute install root, when applicable
DataPath string // host bind-mount root, fallback
Runtime runtime.Runtime // for container file ops + sidecar launches (future)
Log func(line string) // streams a progress line upstream via LogLine
// Steam login credentials for SteamCMD apps that refuse +login
// anonymous (DayZ, Arma, etc.). Populated by the controller from its
// encrypted steam_credentials store, forwarded via the mTLS-protected
// gRPC stream. The agent redacts SteamPassword from log output before
// it ever hits the bus; see steamcmd.go.
SteamUsername string
SteamPassword string
}
// Provider performs an update according to its kind.
type Provider interface {
Name() string
Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error
}
// Get returns the Provider implementation for the given kind, or an
// error if the kind is unknown.
func Get(kind string) (Provider, error) {
switch kind {
case "direct":
return &DirectProvider{}, nil
case "github":
return &GitHubProvider{}, nil
case "steamcmd":
return &SteamCMDProvider{}, nil
default:
return nil, fmt.Errorf("unknown update provider kind %q", kind)
}
}
// writeToInstance puts data at the instance-relative path. Prefers
// container-path ops when the instance has a BrowseableRoot and a live
// container; otherwise falls back to writing under DataPath.
func writeToInstance(ctx context.Context, uc *Context, targetPath string, data []byte) error {
if targetPath == "" {
return fmt.Errorf("target_path is required")
}
if uc.BrowseableRoot != "" && uc.ContainerID != "" {
abs := path.Join(uc.BrowseableRoot, targetPath)
// Ensure parent dir exists inside the container via runtime exec.
// We don't have a helper in the interface; we just try to write
// and rely on CopyFileToContainer returning a clear error if the
// parent is missing. (itzg and most images pre-create /data.)
return uc.Runtime.CopyFileToContainer(ctx, uc.ContainerID, abs, data)
}
if uc.DataPath == "" {
return fmt.Errorf("no storage available: container has no BrowseableRoot and instance has no DataPath")
}
abs := filepath.Join(uc.DataPath, filepath.FromSlash(targetPath))
if err := os.MkdirAll(filepath.Dir(abs), 0o755); err != nil {
return fmt.Errorf("mkdir %s: %w", filepath.Dir(abs), err)
}
if err := os.WriteFile(abs, data, 0o644); err != nil {
return fmt.Errorf("write %s: %w", abs, err)
}
return nil
}
+332
View File
@@ -0,0 +1,332 @@
package updater
import (
"context"
"fmt"
"strings"
"time"
agentmodule "github.com/dbledeez/panel/agent/internal/module"
"github.com/dbledeez/panel/agent/internal/runtime"
modulepkg "github.com/dbledeez/panel/pkg/module"
)
// SteamCMDProvider runs `steamcmd +force_install_dir <path> +login
// anonymous +app_update <appid> [-beta <branch>] validate +quit` in a
// one-shot sidecar container that mounts the target instance's volumes.
// The sidecar writes game files into the same Docker volumes the main
// container reads from, so "update then start" just works.
//
// Preconditions:
// - Target instance's container must NOT be running (Steam holds file
// locks that conflict with a live server). Error clearly otherwise.
// - Manifest must declare at least one volume; install_path resolves
// to spec.InstallPath → BrowseableRoot → first volume's container path.
type SteamCMDProvider struct{}
// Name identifies this provider kind in logs / manifest.
func (SteamCMDProvider) Name() string { return "steamcmd" }
// sidecarImage is the official Valve-maintained SteamCMD image. Its
// ENTRYPOINT is `steamcmd`, so we pass steamcmd's `+...` flags as Cmd.
const sidecarImage = "steamcmd/steamcmd:latest"
// redactSteamPassword replaces a password in a cmdline arg slice with
// "[REDACTED]" so the Console tab never echoes it. Used before logging
// steamcmd argv.
func redactSteamPassword(args []string, password string) []string {
if password == "" {
return args
}
out := make([]string, len(args))
for i, a := range args {
if a == password {
out[i] = "[REDACTED]"
continue
}
out[i] = a
}
return out
}
// redactPasswordLine strips the password substring from a log line if it
// ever appears (e.g. SteamCMD echoing `+login user PASS` back). Returns
// the original string when password is empty so anonymous/public flows
// are zero-cost.
func redactPasswordLine(line, password string) string {
if password == "" || len(password) < 4 {
return line
}
if !strings.Contains(line, password) {
return line
}
return strings.ReplaceAll(line, password, "[REDACTED]")
}
func (SteamCMDProvider) Update(ctx context.Context, uc *Context, spec *modulepkg.UpdateProvider) error {
if spec.AppID == "" {
return fmt.Errorf("steamcmd provider: app_id is required")
}
// Refuse if the main container is still running — Steam locks files.
mainName := "panel-" + uc.InstanceID
if state, err := uc.Runtime.InspectByName(ctx, mainName); err == nil && state.Status == "running" {
return fmt.Errorf("stop the instance first — container %q is running; SteamCMD conflicts with a live server", mainName)
}
installPath := spec.InstallPath
if installPath == "" {
installPath = uc.BrowseableRoot
}
if installPath == "" {
return fmt.Errorf("steamcmd provider: install_path missing and module has no browseable_root/volumes to default from")
}
// Mount the same volumes as the target instance so SteamCMD writes
// where the main container will later read.
volumes := agentmodule.ResolveVolumes(uc.Manifest, uc.InstanceID, uc.DataPath)
if len(volumes) == 0 {
return fmt.Errorf("steamcmd provider: module declares no volumes to mount")
}
// Platform override MUST come before +login — SteamCMD evaluates its
// forced-platform state at login time, not at app_update time. Used for
// apps that ship only a Windows depot (run via Wine on Linux hosts).
args := []string{}
if spec.Platform != "" {
args = append(args, "+@sSteamCmdForcePlatformType", spec.Platform)
}
args = append(args, "+force_install_dir", installPath)
// Non-anonymous Steam login for paid apps (DayZ, Arma). If the module
// declared requires_steam_login and the controller forwarded creds,
// use them. Otherwise fall back to anonymous (most free-to-download
// dedicated servers work anonymously).
//
// CRITICAL for UX: pass ONLY the username when possible. SteamCMD's
// `+login <user>` uses the cached refresh token from config.vdf
// (persisted in panel-steamcmd-auth) — no Steam Guard push, no
// password round-trip. Passing `+login <user> <password>` forces a
// fresh password auth every time, which triggers a 2FA push on every
// call even when a perfectly valid cached token is already on disk.
//
// First-ever login ceremony happens in the controller (steamauth.go)
// with the full password+guard flow to prime the cache. After that,
// this path rides the cache. If the cache is stale/expired, SteamCMD
// exits non-zero — the retry loop below re-runs which will hit the
// same error; the operator then clicks Update, which the controller
// can short-circuit to re-run the login modal (the `steam_login_required`
// response path).
needsLogin := spec.RequiresSteamLogin && uc.SteamUsername != ""
if needsLogin {
args = append(args, "+login", uc.SteamUsername)
} else {
args = append(args, "+login", "anonymous")
}
updateArgs := []string{"+app_update", spec.AppID}
if spec.Beta != "" {
updateArgs = append(updateArgs, "-beta", spec.Beta)
}
if !spec.SkipValidate {
updateArgs = append(updateArgs, "validate")
}
args = append(args, updateArgs...)
args = append(args, "+quit")
uc.Log(fmt.Sprintf("steamcmd: image=%s install_path=%s app_id=%s beta=%s", sidecarImage, installPath, spec.AppID, spec.Beta))
uc.Log(fmt.Sprintf("steamcmd argv: %v", redactSteamPassword(args, uc.SteamPassword)))
// SteamCMD is notoriously flaky on first invocation — exit 8 ("missing
// configuration"), exit 2 (generic "retry later"), and Steam auth races
// are all recoverable by simply running the same command again. AMP,
// LinuxGSM, and Valve's own Steam client all retry on these codes. We
// mirror that behaviour so operators don't have to click Update twice.
const maxAttempts = 4
var lastExit int
var ranSuccessfully bool
for attempt := 1; attempt <= maxAttempts; attempt++ {
exitCode, err := runSteamCMDOnce(ctx, uc, sidecarImage, args, volumes, attempt)
lastExit = exitCode
if err == nil && exitCode == 0 {
uc.Log(fmt.Sprintf("steamcmd: completed successfully (app_id=%s, attempts=%d)", spec.AppID, attempt))
ranSuccessfully = true
break
}
// Transient exit codes SteamCMD is known to recover from. Anything
// else we surface immediately (missing disk space, bad app id, etc.).
transient := err == nil && (exitCode == 2 || exitCode == 5 || exitCode == 8)
if err != nil {
return err
}
if !transient || attempt == maxAttempts {
break
}
backoff := time.Duration(attempt*attempt*3) * time.Second // 3s, 12s, 27s
uc.Log(fmt.Sprintf("steamcmd: exit %d — transient, retrying in %s (attempt %d/%d)", exitCode, backoff, attempt+1, maxAttempts))
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(backoff):
}
}
// Finalize: SteamCMD with `+force_install_dir <p>` stages downloads
// at <p>/steamapps/downloading/<appid>/ and atomically renames into
// <p>/ on success. When SteamCMD exits with state 0x602 (often after
// concurrent runs interrupt finalization, or a `validate` pass races
// the move) the bytes are on disk but never get renamed — operator
// sees an empty install dir and an exit-8 error from the panel.
//
// This step runs unconditionally after the retry loop. If the staging
// dir is empty or absent (the happy path), it's a cheap no-op. If it
// has content, we cp -a it into place and the install completes.
finalized, ferr := finalizeStagingDir(ctx, uc, installPath, spec.AppID, volumes)
if ferr != nil {
uc.Log(fmt.Sprintf("steamcmd: finalize check failed: %s (continuing anyway)", ferr.Error()))
} else if finalized {
uc.Log("steamcmd: recovered staged install (state 0x602 workaround)")
return nil
}
if ranSuccessfully {
return nil
}
return fmt.Errorf("steamcmd exited %d after %d attempt(s)", lastExit, maxAttempts)
}
// finalizeStagingDir checks for a stranded SteamCMD staging directory
// at <installPath>/steamapps/downloading/<appid>/ and, if present and
// non-empty, moves its contents into <installPath>/. Runs as a one-shot
// alpine sidecar with the same volume mounts as the SteamCMD run so
// it has access to the install path. Returns true if a recovery move
// actually happened, false if there was nothing to do.
//
// The script is intentionally defensive:
// - mv is preferred over cp+rm because it's atomic on the same FS.
// - If both source and destination have entries with the same name,
// mv -f overwrites (rare; happens when steamcmd partially renamed
// before dying).
// - We exit 0 on "no staging dir" so this is a no-op for the happy
// path.
func finalizeStagingDir(ctx context.Context, uc *Context, installPath, appID string, volumes []runtime.VolumeSpec) (bool, error) {
const stagingMarker = "/__staging__"
script := fmt.Sprintf(`set -e
DL=%q/steamapps/downloading/%s
DEST=%q
if [ ! -d "$DL" ] || [ -z "$(ls -A "$DL" 2>/dev/null)" ]; then
echo "no-staging"
exit 0
fi
echo "finalizing $DL -> $DEST"
mkdir -p "$DEST"
# mv every top-level entry; -f overwrites partial renames.
for f in "$DL"/* "$DL"/.[!.]* "$DL"/..?*; do
[ -e "$f" ] || continue
mv -f "$f" "$DEST/" 2>&1 || cp -a "$f" "$DEST/" && rm -rf "$f"
done
rm -rf "$DL"
touch %q
echo "finalized"
`, installPath, appID, installPath, installPath+stagingMarker)
sidecarSpec := runtime.InstanceSpec{
InstanceID: fmt.Sprintf("%s-steamcmd-finalize", uc.InstanceID),
IsSidecar: true,
Image: "alpine:latest",
Command: []string{"sh", "-c", script},
Volumes: volumes,
RestartPolicy: "no",
}
contID, err := uc.Runtime.Create(ctx, sidecarSpec)
if err != nil {
return false, fmt.Errorf("create finalize sidecar: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = uc.Runtime.Remove(rmCtx, contID)
}()
if err := uc.Runtime.Start(ctx, contID); err != nil {
return false, fmt.Errorf("start finalize sidecar: %w", err)
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
var lastLine string
go func() {
defer close(logDone)
_ = uc.Runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
lastLine = strings.TrimSpace(line)
uc.Log("finalize: " + lastLine)
})
}()
exitCode, err := uc.Runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return false, fmt.Errorf("wait finalize sidecar: %w", err)
}
if exitCode != 0 {
return false, fmt.Errorf("finalize sidecar exit %d", exitCode)
}
return lastLine == "finalized", nil
}
// runSteamCMDOnce is a single run of the steamcmd sidecar; called in a loop
// by the retry wrapper. Returns the container's exit code on clean
// lifecycle (even non-zero) or an error on infrastructure failure.
func runSteamCMDOnce(ctx context.Context, uc *Context, image string, args []string, volumes []runtime.VolumeSpec, attempt int) (int, error) {
sidecarID := fmt.Sprintf("%s-steamcmd-%d", uc.InstanceID, attempt)
// Mount the panel-wide SteamCMD auth volume at /root/.local/share/Steam
// (the real data dir of the steamcmd:latest image — NOT /root/Steam).
// This persists config/config.vdf (login refresh token) + ssfn sentry,
// which lets subsequent `+login <user>` runs skip the 2FA push. Anon
// logins ignore this dir — no harm.
volumesWithAuth := append([]runtime.VolumeSpec(nil), volumes...)
volumesWithAuth = append(volumesWithAuth, runtime.VolumeSpec{
VolumeName: "panel-steamcmd-auth",
ContainerPath: "/root/.local/share/Steam",
Type: "volume",
})
sidecarSpec := runtime.InstanceSpec{
InstanceID: sidecarID,
IsSidecar: true,
Image: image,
Command: args,
Volumes: volumesWithAuth,
RestartPolicy: "no",
// Docker 29.4+ default seccomp ENOSYS's a syscall the 32-bit
// Steam runtime needs ("CreateBoundSocket error 38"). Older
// Docker (29.1.x on princess/ivy) doesn't filter it. Unconfine
// for the sidecar so install/validate works across hosts.
SecurityOpts: []string{"seccomp=unconfined"},
}
contID, err := uc.Runtime.Create(ctx, sidecarSpec)
if err != nil {
return 0, fmt.Errorf("create sidecar: %w", err)
}
defer func() {
rmCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
_ = uc.Runtime.Remove(rmCtx, contID)
}()
if err := uc.Runtime.Start(ctx, contID); err != nil {
return 0, fmt.Errorf("start sidecar: %w", err)
}
logCtx, cancelLogs := context.WithCancel(ctx)
defer cancelLogs()
logDone := make(chan struct{})
go func() {
defer close(logDone)
_ = uc.Runtime.StreamLogs(logCtx, contID, func(_ runtime.LogStream, line string, _ time.Time) {
uc.Log(redactPasswordLine(line, uc.SteamPassword))
})
}()
exitCode, err := uc.Runtime.Wait(ctx, contID)
cancelLogs()
<-logDone
if err != nil {
return 0, fmt.Errorf("wait sidecar: %w", err)
}
return exitCode, nil
}
+15
View File
@@ -0,0 +1,15 @@
version: v2
# Using remote plugins from the Buf Schema Registry because Windows Smart App
# Control blocks locally-built codegen binaries (protoc-gen-go from `go install`
# is unsigned and has no reputation score). Remote plugins run on BSR, so no
# local binary execution is required.
plugins:
- remote: buf.build/protocolbuffers/go:v1.36.11
out: proto
opt:
- paths=source_relative
- remote: buf.build/grpc/go:v1.6.0
out: proto
opt:
- paths=source_relative
- require_unimplemented_servers=true
+15
View File
@@ -0,0 +1,15 @@
version: v2
modules:
- path: proto
lint:
use:
- STANDARD
except:
- PACKAGE_VERSION_SUFFIX
- SERVICE_SUFFIX # "Agent" not "AgentService" — matches agent/daemon convention
- RPC_REQUEST_STANDARD_NAME # bidi envelopes carry many payload types; request name is intentional
- RPC_RESPONSE_STANDARD_NAME # same
- RPC_REQUEST_RESPONSE_UNIQUE # envelopes are reused across RPCs by design
breaking:
use:
- FILE
+56
View File
@@ -0,0 +1,56 @@
# controller/.test — UI regression gate
Two layers, run them in this order.
## 1. `gate-parse.mjs` — static, zero dependencies, run ALWAYS
```bash
node controller/.test/gate-parse.mjs
```
Extracts every inline `<script>` block from `controller/cmd/controller/static/{new,index,login}.html`
and parses each one (`acorn` if installed locally, else `node --check` on
extracted temp files). Exit 0 = all blocks parse; non-zero prints the file,
block index, and the parser error.
This is the codified **2026-05-08 blank-page deploy gate**: the panel HTML is
embedded via `//go:embed`, so `go build` succeeds even when a JS syntax error
would render the entire panel blank. Run this before every controller build
that touched `static/*.html`. Needs nothing but Node — no npm install, no
controller, no browser.
## 2. `ui.spec.mjs` — Playwright browser suite, needs a RUNNING controller
```bash
cd controller/.test
npm install && npx playwright install chromium # one-time
BASE_URL=http://127.0.0.1:8080 \
TEST_EMAIL=you@example.com TEST_PASSWORD=... \
npx playwright test ui.spec.mjs
```
Prereqs (see `memory/build.md`): Postgres dev container up
(`docker compose -f docker-compose.dev.yml up -d`), controller built and
running on `BASE_URL` (default `http://127.0.0.1:8080`).
| Check | Needs |
|---|---|
| `/login` renders form, 0 console errors | controller only |
| `/` without session redirects to `/login` | controller only |
| `/` serves new.html by default, 0 hard console errors | controller + `TEST_EMAIL`/`TEST_PASSWORD` |
| `panel_ui=classic` cookie serves index.html, **no redirect loop** (the FileServer 301 gotcha, see `memory/gotcha-fileserver-index-html-redirect-loop.md`) | controller + creds |
| Command palette opens (Ctrl+K and `#topbar-cmdk`) | controller + creds |
| Hero-stat IDs (`#stat-running/players/agents/cpu` + hints, `#status`, `#status-dot`) attached | controller + creds |
Without `TEST_EMAIL`/`TEST_PASSWORD` the authenticated tests **self-skip**
(Playwright annotation, not failure). Console errors that are clearly
missing-live-agent-shaped (websocket/agent connection noise) are annotated
`needs-live-agent` rather than failed — a live agent is never required.
## Status honesty
`gate-parse.mjs` has been run for real on this repo. `ui.spec.mjs` was
authored against the real DOM IDs in `static/new.html` / `static/login.html`
and the root-route logic in `httpapi.go`, but a full run requires a local
Postgres + controller — if your box doesn't have the dev Postgres container,
the browser suite is unverified there.
+124
View File
@@ -0,0 +1,124 @@
#!/usr/bin/env node
// gate-parse.mjs — static JS parse gate for the panel front-ends.
//
// This codifies the 2026-05-08 blank-page deploy gate: a single syntax error
// anywhere in the giant inline <script> block of new.html/index.html renders
// the whole panel as a blank page, and `go build` cannot catch it because the
// HTML is embedded as opaque bytes (//go:embed). This script extracts every
// <script> block from the served HTML files and parses each one.
//
// Zero dependencies: each block is written to a temp file and checked with
// `node --check` (falls back to acorn if a local install of it exists).
//
// Usage: node gate-parse.mjs
// Exit 0 = every script block in every file parses. Non-zero = at least one
// syntax error (printed with file, block index, and node's error output).
import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { tmpdir } from 'node:os';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
const staticDir = resolve(here, '../cmd/controller/static');
// new.html + index.html are the two panel UIs the root route can serve;
// login.html is the unauthenticated landing page. All three brick on a
// syntax error, so all three are gated.
const files = ['new.html', 'index.html', 'login.html'];
// Extract inline <script> blocks. Blocks with a src= attribute have no inline
// body worth parsing; blocks with a non-JS type (application/json templates
// etc.) are skipped.
function extractScripts(html) {
const out = [];
const re = /<script\b([^>]*)>([\s\S]*?)<\/script>/gi;
let m;
while ((m = re.exec(html)) !== null) {
const attrs = m[1];
const body = m[2];
// External local bundles (e.g. /rcc.js?v=hash) are part of the deploy:
// resolve them from static/ and gate their contents too.
const srcMatch = attrs.match(/\bsrc\s*=\s*["']?([^"'\s>]+)/i);
if (srcMatch) {
const src = srcMatch[1];
if (/^(https?:)?\/\//i.test(src)) continue; // never hotlink; skip if ever present
const local = src.replace(/^\//, '').split('?')[0];
try {
const extBody = readFileSync(join(staticDir, local), 'utf8');
const line = html.slice(0, m.index).split('\n').length;
out.push({ body: extBody, line, module: /\btype\s*=\s*["']?module/i.test(attrs), external: local });
} catch (e) {
out.push({ body: `throw new SyntaxError(${JSON.stringify(`missing external script ${local}: ${e.message}`)});(`, line: 0, module: false, external: local });
}
continue;
}
const typeMatch = attrs.match(/\btype\s*=\s*["']?([^"'\s>]+)/i);
const type = typeMatch ? typeMatch[1].toLowerCase() : 'text/javascript';
if (!['text/javascript', 'application/javascript', 'module'].includes(type)) continue;
// Line offset of the block start, for error reporting.
const line = html.slice(0, m.index).split('\n').length;
out.push({ body, line, module: type === 'module' });
}
return out;
}
// Prefer acorn if it happens to be installed next to this suite; otherwise
// use `node --check` on a temp file (always available, needs no npm install).
let acorn = null;
try { acorn = await import('acorn'); } catch { /* fine — node --check path */ }
const tmp = mkdtempSync(join(tmpdir(), 'panel-gate-'));
let failures = 0;
let checkedFiles = 0;
for (const name of files) {
let html;
try {
html = readFileSync(join(staticDir, name), 'utf8');
} catch (e) {
console.error(`FAIL ${name}: cannot read (${e.message})`);
failures++;
continue;
}
checkedFiles++;
const blocks = extractScripts(html);
let fileOk = true;
blocks.forEach((b, i) => {
const label = `${name} script-block ${i + 1}/${blocks.length}${b.external ? ` [external ${b.external}]` : ''} (starts html line ${b.line}, ${b.body.length} chars)`;
try {
if (acorn) {
acorn.parse(b.body, {
ecmaVersion: 'latest',
sourceType: b.module ? 'module' : 'script',
});
} else {
const tf = join(tmp, `${name.replace(/\W/g, '_')}_${i}${b.module ? '.mjs' : '.js'}`);
writeFileSync(tf, b.body);
execFileSync(process.execPath, ['--check', tf], { stdio: ['ignore', 'pipe', 'pipe'] });
}
console.log(`PASS ${label}`);
} catch (e) {
fileOk = false;
failures++;
const detail = (e.stderr ? e.stderr.toString() : e.message).trim();
console.error(`FAIL ${label}\n${detail.split('\n').map(l => ' ' + l).join('\n')}`);
}
});
if (blocks.length === 0) {
// A panel HTML with zero script blocks is itself a broken deploy.
console.error(`FAIL ${name}: 0 inline script blocks found — extraction or file is broken`);
failures++;
fileOk = false;
}
console.log(`${fileOk ? 'OK ' : 'BAD '} ${name}: ${blocks.length} inline script block(s)`);
}
rmSync(tmp, { recursive: true, force: true });
if (failures > 0) {
console.error(`\ngate-parse: ${failures} failure(s) across ${checkedFiles} file(s) — DO NOT DEPLOY`);
process.exit(1);
}
console.log(`\ngate-parse: all script blocks parse (${checkedFiles} files, parser: ${acorn ? 'acorn' : 'node --check'})`);
+12
View File
@@ -0,0 +1,12 @@
{
"name": "panel-controller-ui-gate",
"private": true,
"type": "module",
"scripts": {
"gate": "node gate-parse.mjs",
"test": "playwright test ui.spec.mjs"
},
"devDependencies": {
"@playwright/test": "^1.59.1"
}
}
+122
View File
@@ -0,0 +1,122 @@
// ui.spec.mjs — browser regression gate for the panel controller UI.
// Runs with @playwright/test: npx playwright test ui.spec.mjs
//
// Requires a LOCALLY RUNNING controller (see ../../memory/build.md).
// BASE_URL controller origin (default http://127.0.0.1:8080)
// TEST_EMAIL panel account email \ needed for the authenticated
// TEST_PASSWORD panel account password / tests; without them those
// tests self-skip (annotated).
//
// Anything that would need a live agent (server cards with real data,
// consoles, etc.) is deliberately NOT asserted — those checks self-skip.
// The point of this suite is the 2026-05-08 class of regression: the page
// serves, the inline JS boots with zero console errors, the root route's
// panel_ui cookie logic doesn't loop, and the command-center chrome
// (palette, hero stats) exists.
import { test, expect } from '@playwright/test';
const BASE = process.env.BASE_URL || 'http://127.0.0.1:8080';
const EMAIL = process.env.TEST_EMAIL || '';
const PASSWORD = process.env.TEST_PASSWORD || '';
// Collect console errors + page errors on a page. Filters nothing: the gate
// is "0 console errors" on first paint, matching the redroid-console house
// boilerplate (page.on('console'/'pageerror') capture).
function captureErrors(page) {
const errs = [];
page.on('console', m => { if (m.type() === 'error') errs.push(`[console.error] ${m.text().slice(0, 300)}`); });
page.on('pageerror', e => errs.push(`[pageerror] ${e.message.slice(0, 300)}`));
return errs;
}
async function login(page) {
await page.goto(BASE + '/login');
await page.fill('#email', EMAIL);
await page.fill('#password', PASSWORD);
await page.click('#submit');
await page.waitForURL(u => !u.pathname.startsWith('/login'), { timeout: 10_000 });
}
test.describe('unauthenticated', () => {
test('login page renders with its form and 0 console errors', async ({ page }) => {
const errs = captureErrors(page);
const resp = await page.goto(BASE + '/login');
expect(resp.ok()).toBeTruthy();
await expect(page.locator('#email')).toBeVisible();
await expect(page.locator('#password')).toBeVisible();
await expect(page.locator('#submit')).toBeVisible();
await page.waitForTimeout(500);
expect(errs, errs.join('\n')).toHaveLength(0);
});
test('root without session 302s to /login (no loop)', async ({ page }) => {
await page.goto(BASE + '/');
await expect(page).toHaveURL(/\/login/);
});
});
test.describe('authenticated', () => {
test.skip(!EMAIL || !PASSWORD,
'TEST_EMAIL / TEST_PASSWORD not set — authenticated UI checks skipped');
test('root serves new.html by default, boots with 0 console errors', async ({ page }, testInfo) => {
const errs = captureErrors(page);
await login(page);
await page.goto(BASE + '/');
// new.html markers: hero-stat tiles + command-palette hint in the topbar.
await expect(page.locator('#stat-running')).toBeAttached();
await expect(page.locator('#topbar-cmdk')).toBeAttached();
await page.waitForTimeout(1500); // let the boot JS run
// WebSocket/agent-dependent noise would need a live agent — annotate
// rather than fail on errors that are clearly missing-agent-shaped.
const agentish = errs.filter(e => /websocket|ws:|agent|ECONNREFUSED/i.test(e));
const hard = errs.filter(e => !agentish.includes(e));
if (agentish.length) {
testInfo.annotations.push({ type: 'needs-live-agent', description: agentish.join(' | ') });
}
expect(hard, hard.join('\n')).toHaveLength(0);
});
test('panel_ui=classic cookie serves index.html with no redirect loop', async ({ browser }) => {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await login(page);
await ctx.addCookies([{
name: 'panel_ui', value: 'classic',
url: BASE,
}]);
// The 2026-05-31 gotcha: FileServer canonicalization made "/" 301-loop
// for authenticated classic users (ERR_TOO_MANY_REDIRECTS). A successful
// goto with a 200 final response proves no loop.
const resp = await page.goto(BASE + '/');
expect(resp.status()).toBe(200);
// Classic UI must NOT show the new-brand hero-stat tiles.
await expect(page.locator('#stat-running')).toHaveCount(0);
await ctx.close();
});
test('command palette opens on Ctrl+K and via the topbar hint', async ({ page }) => {
await login(page);
await page.goto(BASE + '/');
const modal = page.locator('#cmdk-modal');
await expect(modal).toBeHidden();
await page.keyboard.press('Control+KeyK');
await expect(modal).toBeVisible();
await expect(page.locator('#cmdk-input')).toBeFocused();
await page.keyboard.press('Escape');
await expect(modal).toBeHidden();
await page.click('#topbar-cmdk');
await expect(modal).toBeVisible();
});
test('hero-stat element IDs present', async ({ page }) => {
await login(page);
await page.goto(BASE + '/');
for (const id of ['stat-running', 'stat-players', 'stat-agents', 'stat-cpu',
'stat-running-hint', 'stat-players-hint', 'stat-agents-hint', 'stat-cpu-hint',
'status-dot', 'status']) {
await expect(page.locator('#' + id), `#${id} missing from new.html DOM`).toBeAttached();
}
});
});
+106
View File
@@ -0,0 +1,106 @@
// WI-07 verification: diff the dashboard's old inline per-module maps
// (READY_PATTERNS / moduleAppearance / moduleBrowseableRoots /
// guessPortsForModule — kept in rcc.js as fallbacks) against the values
// the manifests now serve via GET /api/modules (as loaded by
// tools/module-meta-dump, which uses the controller's own LoadDir).
//
// ready_pattern + appearance were MOVED (must be value-identical → any
// mismatch exits 1). ports + browseable_roots were already declared in
// module.yaml; drift there is reported informationally (manifest wins).
//
// Run from repo root: node controller/.test/wi07-diff-maps.mjs
import fs from "node:fs";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { fileURLToPath } from "node:url";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
const rcc = fs.readFileSync(path.join(root, "controller/cmd/controller/static/rcc.js"), "utf8");
// Extract a brace-balanced object literal following `marker`.
function extractObject(marker, from = 0) {
const i = rcc.indexOf(marker, from);
if (i < 0) throw new Error("marker not found: " + marker);
const start = rcc.indexOf("{", i);
let depth = 0, j = start;
for (; j < rcc.length; j++) {
const c = rcc[j];
if (c === "{") depth++;
else if (c === "}") { depth--; if (depth === 0) break; }
}
return eval("(" + rcc.slice(start, j + 1) + ")");
}
const READY = extractObject("const READY_PATTERNS = ");
const APPEAR = extractObject("const moduleAppearance = ");
const ROOTS = extractObject("const moduleBrowseableRoots = ");
// ports map lives inside guessPortsForModule()
const fnIdx = rcc.indexOf("function guessPortsForModule(id)");
if (fnIdx < 0) throw new Error("guessPortsForModule not found");
const PORTS = extractObject("const m = {", fnIdx);
// Manifest side.
const goExe = process.env.GO_EXE || "C:\\Program Files\\Go\\bin\\go.exe";
const manifest = JSON.parse(execFileSync(goExe, ["run", "./tools/module-meta-dump"], { cwd: root, encoding: "utf8" }));
// Same compile rule the dashboard uses for manifest ready_pattern strings.
function compileReady(s) {
if (!s) return null;
const m = /^\/([\s\S]+)\/([a-z]*)$/.exec(s);
return m ? new RegExp(m[1], m[2]) : new RegExp(s);
}
const eq = (a, b) => JSON.stringify(a) === JSON.stringify(b);
let hardFail = 0;
const ids = [...new Set([...Object.keys(manifest), ...Object.keys(READY), ...Object.keys(APPEAR), ...Object.keys(ROOTS), ...Object.keys(PORTS)])].sort();
for (const id of ids) {
const man = manifest[id];
const lines = [];
// ready_pattern — moved: must be identical where both sides have it.
const oldRe = READY[id];
const newRe = man && compileReady(man.ready_pattern);
if (oldRe && !man) lines.push(" ready: JS-only (no manifest dir) — fallback covers it");
else if (oldRe && !newRe) { lines.push(" ready: MISSING in manifest"); hardFail++; }
else if (oldRe && newRe && (oldRe.source !== newRe.source || oldRe.flags !== newRe.flags)) {
lines.push(` ready: MISMATCH\n old ${oldRe}\n new ${newRe}`); hardFail++;
}
// appearance — moved: compare emoji/grad/art/steam.
const oa = APPEAR[id], na = man && man.appearance;
if (oa && !man) lines.push(" appearance: JS-only (no manifest dir) — fallback covers it");
else if (oa && !na) { lines.push(" appearance: MISSING in manifest"); hardFail++; }
else if (oa && na) {
for (const k of ["emoji", "grad", "art", "steam"]) {
if ((oa[k] || "") !== (na[k] || "")) { lines.push(` appearance.${k}: MISMATCH old=${JSON.stringify(oa[k] || "")} new=${JSON.stringify(na[k] || "")}`); hardFail++; }
}
}
// browseable_roots — pre-existing in manifests; report drift.
const orr = ROOTS[id], nrr = man && man.browseable_roots;
if (orr && man && !eq(orr, nrr)) lines.push(` roots drift (manifest wins):\n old ${JSON.stringify(orr)}\n new ${JSON.stringify(nrr)}`);
if (orr && !man) lines.push(" roots: JS-only (no manifest dir) — fallback covers it");
// ports — pre-existing in manifests; compare ignoring the additive env field.
const op = PORTS[id];
const np = man && man.ports && man.ports.map(p => {
const o = { name: p.name, proto: p.proto, default: p.default };
if (p.required) o.required = true;
if (p.internal) o.internal = true;
return o;
});
const opNorm = op && op.map(p => {
const o = { name: p.name, proto: p.proto, default: p.default };
if (p.required) o.required = true;
if (p.internal) o.internal = true;
return o;
});
if (op && man && !eq(opNorm, np)) lines.push(` ports drift (manifest wins):\n old ${JSON.stringify(opNorm)}\n new ${JSON.stringify(np)}`);
if (op && !man) lines.push(" ports: JS-only (no manifest dir) — fallback covers it");
if (lines.length) console.log(id + "\n" + lines.join("\n"));
else console.log(id + "\n OK (identical)");
}
console.log(hardFail ? `\nFAIL: ${hardFail} moved-field mismatch(es)` : "\nPASS: all moved fields (ready_pattern, appearance) value-identical");
process.exit(hardFail ? 1 : 0);
+506
View File
@@ -0,0 +1,506 @@
package main
// Admin HTTP surface for out-of-band operator repair.
//
// These endpoints duplicate (and intentionally bypass) the regular
// session-protected /api/* surface so an operator with shell access to
// the prod box can drive recreate-dance fixes without first having a
// dashboard session — which historically required deploying a temporary
// "localhost-bypass" build on every incident (see changelog 2026-04-28).
//
// Auth model: a request is admitted if EITHER
// 1. RemoteAddr resolves to loopback (127.0.0.1 / ::1), OR
// 2. the X-Panel-Admin-Token header (constant-time compared) matches
// the on-disk token at $data_dir/admin-token.
//
// The token is auto-generated on first start. It lives at mode 0600 so
// only the panel user (refuge in prod) can read it. There is no
// rotation API yet — to rotate, delete the file and restart the
// controller. The new token is logged once at boot.
//
// Endpoints (all under /admin/v1/, not /api/, so they sit OUTSIDE the
// session middleware):
// GET /admin/v1/health
// GET /admin/v1/instances
// POST /admin/v1/instances/{id}/ark-cluster-rebind
// POST /admin/v1/instances/{id}/config body: {"set":{...},"delete":[...]}
// POST /admin/v1/instances/{id}/recreate body: {"mount_overrides":{...}}
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/dbledeez/panel/controller/internal/db"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
"google.golang.org/protobuf/types/known/timestamppb"
)
const (
adminTokenFile = "admin-token"
adminTokenHeader = "X-Panel-Admin-Token"
adminRecreateBudget = 3 * time.Minute
)
// ensureAdminToken loads the existing token from $dataDir/admin-token, or
// generates and persists a new one. Returns the token bytes (raw hex).
// Logs a "first run" message when generating; subsequent reads are silent.
func ensureAdminToken(dataDir string) (string, bool, error) {
path := filepath.Join(dataDir, adminTokenFile)
if b, err := os.ReadFile(path); err == nil {
tok := strings.TrimSpace(string(b))
if len(tok) >= 32 {
return tok, false, nil
}
// File exists but is empty/short — treat as missing and regenerate.
} else if !errors.Is(err, os.ErrNotExist) {
return "", false, fmt.Errorf("read admin-token: %w", err)
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return "", false, fmt.Errorf("mkdir data: %w", err)
}
buf := make([]byte, 32)
if _, err := rand.Read(buf); err != nil {
return "", false, fmt.Errorf("rand: %w", err)
}
tok := hex.EncodeToString(buf)
if err := os.WriteFile(path, []byte(tok+"\n"), 0o600); err != nil {
return "", false, fmt.Errorf("write admin-token: %w", err)
}
return tok, true, nil
}
// adminAuth gates the admin mux. Loopback (127.0.0.1 / ::1) is always
// allowed without a token — this matches the on-host operator pattern.
// Otherwise the request must carry X-Panel-Admin-Token = the on-disk
// token. Constant-time compare so a timing oracle can't probe the token.
func (h *httpServer) adminAuth(token string, next http.Handler) http.Handler {
want := []byte(token)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLoopback(r.RemoteAddr) {
next.ServeHTTP(w, r)
return
}
got := r.Header.Get(adminTokenHeader)
if got == "" || subtle.ConstantTimeCompare([]byte(got), want) != 1 {
h.log.Warn("admin: token rejected", "remote", r.RemoteAddr, "path", r.URL.Path)
writeError(w, http.StatusUnauthorized, "unauthorized", "valid "+adminTokenHeader+" required for non-loopback requests")
return
}
next.ServeHTTP(w, r)
})
}
func isLoopback(remoteAddr string) bool {
host, _, err := net.SplitHostPort(remoteAddr)
if err != nil {
host = remoteAddr
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// requireSessionOrAdminToken is the gate for /api/*. A request is admitted if:
//
// 1. caller is loopback (on-host operator drives), OR
// 2. caller carries a valid X-Panel-Admin-Token header (service callers
// from a different LAN host — AMP-Monitor on toulouse, etc.), OR
// 3. caller has a valid panel_session cookie (browser session).
//
// Falls back through the cases in that order. Routes that need an
// authenticated user (handleMe, handleChangePassword) read sessionUserKey
// from the request context and 401 themselves if absent — service callers
// don't have a user identity, just admit-or-deny.
//
// adminToken empty (shouldn't happen at runtime; only in tests) → behaves
// exactly like requireSession.
func (h *httpServer) requireSessionOrAdminToken(isAPI bool, next http.Handler) http.Handler {
sessionGate := h.auth.requireSession(isAPI, next)
if h.adminToken == "" {
return sessionGate
}
want := []byte(h.adminToken)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLoopback(r.RemoteAddr) {
next.ServeHTTP(w, r)
return
}
if got := r.Header.Get(adminTokenHeader); got != "" &&
subtle.ConstantTimeCompare([]byte(got), want) == 1 {
next.ServeHTTP(w, r)
return
}
sessionGate.ServeHTTP(w, r)
})
}
// registerAdminRoutes wires the admin mux onto the outer mux. Called
// from httpServer.handler() during construction.
func (h *httpServer) registerAdminRoutes(mux *http.ServeMux, token string) {
if token == "" {
// Defensive: refuse to register if no token is available. This
// would only fire if ensureAdminToken returned empty, which
// shouldn't happen on a healthy boot.
h.log.Error("admin routes NOT registered (empty token)")
return
}
adm := http.NewServeMux()
adm.HandleFunc("GET /admin/v1/health", h.adminHealth)
adm.HandleFunc("GET /admin/v1/instances", h.adminListInstances)
adm.HandleFunc("POST /admin/v1/instances/{id}/ark-cluster-rebind", h.adminArkClusterRebind)
adm.HandleFunc("POST /admin/v1/instances/{id}/config", h.adminSetConfig)
adm.HandleFunc("POST /admin/v1/instances/{id}/recreate", h.adminRecreate)
adm.HandleFunc("POST /admin/v1/instances/{id}/install-base-mods", h.adminInstallBaseMods)
adm.HandleFunc("GET /admin/v1/empyrion/discoveries", h.adminEmpyrionDiscoveries)
adm.HandleFunc("GET /admin/v1/empyrion/player-summary", h.adminEmpyrionPlayerSummary)
mux.Handle("/admin/v1/", h.adminAuth(token, adm))
}
// ---- handlers ----
func (h *httpServer) adminHealth(w http.ResponseWriter, r *http.Request) {
host, _ := os.Hostname()
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"version": controllerVersion,
"hostname": host,
"loopback": isLoopback(r.RemoteAddr),
"time": time.Now().UTC().Format(time.RFC3339),
})
}
type adminInstanceDTO struct {
InstanceID string `json:"instance_id"`
AgentID string `json:"agent_id"`
ModuleID string `json:"module_id"`
Status string `json:"status"`
ConfigValues map[string]string `json:"config_values"`
AssignedPorts map[string]uint32 `json:"assigned_ports"`
ARKClusterID string `json:"ark_cluster_id,omitempty"`
}
func (h *httpServer) adminListInstances(w http.ResponseWriter, r *http.Request) {
rows, err := h.db.ListInstances(r.Context(), "")
if err != nil {
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
clusterMembers := map[string]string{}
if h.arkClusters != nil {
h.arkClusters.mu.Lock()
reg, lerr := h.arkClusters.load()
h.arkClusters.mu.Unlock()
if lerr == nil {
clusterMembers = reg.Members
}
}
out := make([]adminInstanceDTO, 0, len(rows))
for _, row := range rows {
out = append(out, adminInstanceDTO{
InstanceID: row.InstanceID,
AgentID: row.AgentID,
ModuleID: row.ModuleID,
Status: row.Status,
ConfigValues: row.ConfigValues,
AssignedPorts: row.AssignedPorts,
ARKClusterID: clusterMembers[row.InstanceID],
})
}
sort.Slice(out, func(i, j int) bool { return out[i].InstanceID < out[j].InstanceID })
writeJSON(w, http.StatusOK, map[string]any{"instances": out})
}
// adminArkClusterRebind re-runs recreateArkWithClusterMount against the
// instance's currently-registered cluster. Use case: container drifted
// off the bind mount (recreate path missed MountOverrides), and we want
// to re-attach without changing any other state.
func (h *httpServer) adminArkClusterRebind(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
row, err := h.loadInstanceForAdmin(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
if row.ModuleID != "ark-sa" {
writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("instance %q is module %q, not ark-sa", id, row.ModuleID))
return
}
h.arkClusters.mu.Lock()
reg, lerr := h.arkClusters.load()
h.arkClusters.mu.Unlock()
if lerr != nil {
writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
return
}
cid := reg.Members[id]
if cid == "" {
writeError(w, http.StatusBadRequest, "not_clustered", "instance is not registered as a cluster member; use the dashboard to set up/join a cluster first")
return
}
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
defer cancel()
if err := h.recreateArkWithClusterMount(ctx, row, row.AgentID, cid); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
h.log.Info("admin: ark-cluster-rebind", "instance", id, "cluster", cid, "remote", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"instance_id": id,
"cluster_id": cid,
})
}
type adminSetConfigReq struct {
Set map[string]string `json:"set"`
Delete []string `json:"delete"`
}
// adminSetConfig merges Set into config_values and removes Delete keys,
// then runs the env-config recreate dance preserving ports + cluster
// mount overrides.
func (h *httpServer) adminSetConfig(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req adminSetConfigReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if len(req.Set) == 0 && len(req.Delete) == 0 {
writeError(w, http.StatusBadRequest, "no_changes", "set and delete are both empty")
return
}
row, err := h.loadInstanceForAdmin(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
merged := map[string]string{}
for k, v := range row.ConfigValues {
merged[k] = v
}
for k, v := range req.Set {
merged[k] = v
}
for _, k := range req.Delete {
delete(merged, k)
}
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
defer cancel()
if err := h.adminRecreateInstance(ctx, row, merged, h.mountOverridesFor(id)); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
h.log.Info("admin: set-config", "instance", id, "set_keys", mapKeys(req.Set), "delete_keys", req.Delete, "remote", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"instance_id": id,
"config_values": merged,
})
}
type adminRecreateReq struct {
MountOverrides map[string]string `json:"mount_overrides"`
}
// adminRecreate re-runs the stop→delete-preserve→create→start dance with
// no config changes, optionally with custom MountOverrides. If the body
// is empty or omits mount_overrides, the cluster-derived overrides
// (mountOverridesFor) are used — same as every regular recreate path.
func (h *httpServer) adminRecreate(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
var req adminRecreateReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
row, err := h.loadInstanceForAdmin(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
mounts := req.MountOverrides
if mounts == nil {
mounts = h.mountOverridesFor(id)
}
ctx, cancel := context.WithTimeout(r.Context(), adminRecreateBudget)
defer cancel()
if err := h.adminRecreateInstance(ctx, row, row.ConfigValues, mounts); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
h.log.Info("admin: recreate", "instance", id, "mount_overrides", mounts, "remote", r.RemoteAddr)
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"instance_id": id,
"mount_overrides": mounts,
})
}
// adminInstallBaseMods triggers the agent's base-mod installer for the
// given instance — Allocs Server Fixes + PrismaCore, downloaded fresh
// from authoritative sources (illy.bz + GitHub). TFP_* and xMarkers are
// shipped by the SteamCMD install and don't need re-download. Custom
// mods (RefugeBot, AGF-VP, etc.) are not touched. Useful when an
// operator wants to ensure the standard base-mod set is present on a
// freshly-installed instance that didn't get warm-seeded from a sibling.
//
// Implementation note: routes through the existing Update RPC machinery
// using the special provider id "_basemods" — the agent dispatcher
// short-circuits that to installBaseMods() instead of running the
// module's normal updater. Saves us a new proto round-trip.
func (h *httpServer) adminInstallBaseMods(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
row, err := h.loadInstanceForAdmin(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
if row.ModuleID != "7dtd" {
writeError(w, http.StatusBadRequest, "wrong_module", fmt.Sprintf("base-mods installer is 7DTD-only; instance %q is module %q", id, row.ModuleID))
return
}
conn := h.registry.get(row.AgentID)
if conn == nil {
writeError(w, http.StatusBadGateway, "agent_offline", fmt.Sprintf("agent %q is not connected", row.AgentID))
return
}
// Fire-and-forget: Update RPC produces an UpdateResult routed via
// AgentEvent (not CommandResult via pending registry), so awaitAgentCall
// would block forever. The agent's _basemods short-circuit runs async
// and emits the same UPDATING / install-finished pulses as a real
// update — operators see progress on the instance card or via
// `journalctl -u panel-agent.service | grep install-base-mods`.
corrID := newCorrelationID("update-basemods")
env := &panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_Update{
Update: &panelv1.UpdateRequest{InstanceId: id, ProviderId: "_basemods"},
},
}
if err := conn.Send(env); err != nil {
writeError(w, http.StatusBadGateway, "agent_call", err.Error())
return
}
h.log.Info("admin: install-base-mods kicked", "instance", id, "remote", r.RemoteAddr, "corr_id", corrID)
writeJSON(w, http.StatusAccepted, map[string]any{
"ok": true,
"instance_id": id,
"correlation_id": corrID,
"note": "base-mods install kicked off async; watch agent log for 'install-base-mods' lines (~30-60s)",
})
}
// ---- shared recreate helper ----
// adminRecreateInstance is the admin-side equivalent of
// recreateWithEnvConfig + changePorts: stop (if running), delete-preserve,
// upsert with the new config, recreate with the given mount overrides
// and original ports, restart if it was running before.
func (h *httpServer) adminRecreateInstance(ctx context.Context, row *db.InstanceRow, mergedCV map[string]string, mountOverrides map[string]string) error {
conn := h.registry.get(row.AgentID)
if conn == nil {
return fmt.Errorf("agent %q is not connected", row.AgentID)
}
wasRunning := row.Status == "running"
if wasRunning {
if err := h.awaitAgentCall(ctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
}); err != nil {
return fmt.Errorf("stop: %w", err)
}
}
if err := h.awaitAgentCall(ctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
}); err != nil {
return fmt.Errorf("delete: %w", err)
}
if err := h.db.UpsertInstance(ctx, db.InstanceRow{
InstanceID: row.InstanceID,
AgentID: row.AgentID,
ModuleID: row.ModuleID,
ModuleVersion: row.ModuleVersion,
RunMode: row.RunMode,
Status: "creating",
ConfigValues: mergedCV,
AssignedPorts: row.AssignedPorts,
}); err != nil {
return fmt.Errorf("db upsert: %w", err)
}
var ports []*panelv1.PortMap
for name, hp := range row.AssignedPorts {
ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
}
// Copy so we can inject control sentinels without mutating mergedCV.
// This recreate PRESERVES the install volume — tell the agent to skip the
// create-time warm-seed + first-update, which would otherwise wipe /game
// and re-seed from a sibling, silently dropping a 7DTD server's custom Mods.
createCV := make(map[string]string, len(mergedCV)+2)
for k, v := range mergedCV {
createCV[k] = v
}
createCV["_PANEL_SKIP_REINSTALL"] = "true"
if !wasRunning {
createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
}
if err := h.awaitAgentCall(ctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
InstanceCreate: &panelv1.InstanceCreate{
InstanceId: row.InstanceID,
ModuleId: row.ModuleID,
Version: row.ModuleVersion,
RunMode: panelv1.RunMode_RUN_MODE_DOCKER,
Ports: ports,
ConfigValues: createCV,
MountOverrides: mountOverrides,
},
}); err != nil {
return fmt.Errorf("create: %w", err)
}
if wasRunning {
if err := h.awaitAgentCall(ctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
}); err != nil {
return fmt.Errorf("start: %w", err)
}
}
return nil
}
func (h *httpServer) loadInstanceForAdmin(ctx context.Context, id string) (*db.InstanceRow, error) {
if id == "" {
return nil, fmt.Errorf("instance id required")
}
rows, err := h.db.ListInstances(ctx, "")
if err != nil {
return nil, err
}
for i := range rows {
if rows[i].InstanceID == id {
return &rows[i], nil
}
}
return nil, fmt.Errorf("instance %q not found", id)
}
func mapKeys(m map[string]string) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
@@ -0,0 +1,99 @@
package main
// ARK auto-save restore endpoint. Sister to the existing files.go ops,
// but split into its own file because the semantics are ARK-specific:
// the agent handler walks the SavedArks/<subdir>/ layout, refuses if the
// container is running, and preserves the previously-active save by
// renaming it aside (NEVER deleting) so even abandoned attempts are
// recoverable.
import (
"encoding/json"
"io"
"net/http"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// arkSaveRestoreAwait covers stop-aware file ops on a multi-GB save dir
// over Docker's CopyTar pipe. 2 minutes is well past the worst case for
// a few-hundred-MB rename + cp on local SSD.
const arkSaveRestoreAwait = 2 * 60 * time.Second
func (h *httpServer) arkSaveRestore(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", "agent not connected")
return
}
var req struct {
SavedArksSubdir string `json:"saved_arks_subdir"`
TargetFilename string `json:"target_filename"`
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
writeError(w, http.StatusBadRequest, "read", err.Error())
return
}
if err := json.Unmarshal(body, &req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
subdir := strings.TrimSpace(req.SavedArksSubdir)
target := strings.TrimSpace(req.TargetFilename)
if subdir == "" || target == "" {
writeError(w, http.StatusBadRequest, "missing_fields", "saved_arks_subdir and target_filename are required")
return
}
corrID := newCorrelationID("arkrestore")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_ArkSaveRestore{
ArkSaveRestore: &panelv1.ArkSaveRestoreRequest{
InstanceId: id,
SavedArksSubdir: subdir,
TargetFilename: target,
},
},
}); err != nil {
writeError(w, http.StatusBadGateway, "forward", err.Error())
return
}
env, err := h.pending.Await(r.Context(), corrID, arkSaveRestoreAwait)
if err != nil {
writeError(w, http.StatusGatewayTimeout, "timeout", err.Error())
return
}
res := env.GetArkSaveRestoreResult()
if res == nil {
writeError(w, http.StatusInternalServerError, "bad_reply", "agent reply missing ark_save_restore_result")
return
}
if res.Error != "" {
// Agent's "refusing while running" message is operator-visible,
// surface as 409 so the UI can show "stop the server first" inline.
status := http.StatusBadRequest
if strings.Contains(strings.ToLower(res.Error), "refusing to restore while server is running") {
status = http.StatusConflict
}
writeError(w, status, "ark_restore", res.Error)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"aside_filename": res.AsideFilename,
"installed_filename": res.InstalledFilename,
})
}
+680
View File
@@ -0,0 +1,680 @@
package main
// ARK: Survival Ascended cluster manager.
//
// AMP-style one-click clustering: an ASA server can "Setup Cluster" to
// create a new shared cluster directory, then other ASA servers on the
// same agent can "Join Cluster" to mount the same directory. Under the
// hood we:
// 1. Swap the instance's default per-instance `panel-<id>-cluster`
// named volume for a shared `panel-ark-cluster-<clusterID>` volume
// (via InstanceCreate.mount_overrides).
// 2. Set the instance's CLUSTER_ID env var (via ConfigValues) so the
// acekorneya image passes `-clusterid=<id>` to the ASA binary.
//
// The instance's container has to be recreated for the volume swap to
// take effect (Docker can't add mounts to a live container), so the
// join/leave flow mirrors the existing changePorts dance: stop → delete
// (preserve volumes) → recreate with override → start.
//
// Membership state is derived authoritatively from one JSON file:
// `$data_dir/ark-clusters.json`. Keeping it outside the Postgres schema
// avoids a migration for a feature that isn't that load-bearing. If the
// JSON disappears or gets corrupted, clusters still *work* (the env var
// + shared volume are set on each member's container spec) — the UI just
// can't render the relationship until a fresh "Setup Cluster" happens.
//
// `ARK_CLUSTER_DIR_CONTAINER_PATH` is the single source of truth for
// where ARK writes cluster transfer data inside the container. If the
// module.yaml changes, this constant must match or the mount override
// will land at the wrong container path.
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sort"
"sync"
"time"
"github.com/dbledeez/panel/controller/internal/db"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
const (
// Must match the `container:` field of the cluster volume entry in
// `modules/ark-sa/module.yaml`. Kept here as the single place the
// controller cares about it.
arkClusterDirContainerPath = "/usr/games/.wine/drive_c/POK/Steam/steamapps/common/ARK Survival Ascended Dedicated Server/ShooterGame/Saved/clusters"
arkClusterRegistryFile = "ark-clusters.json"
// Max time we'll spend on a single stop/delete/create/start cycle
// when (un)joining a cluster. SteamCMD-free so this should finish
// fast; generous budget covers Docker Desktop cold-starts.
arkClusterRecreateTimeout = 3 * time.Minute
)
// arkClusterRegistry is the JSON-on-disk view. Cluster membership is also
// implied by the CLUSTER_ID env var on each instance's container — the
// registry exists purely to persist human-readable names and timestamps
// that don't have another home.
type arkClusterRegistry struct {
Clusters []arkClusterRecord `json:"clusters"`
Members map[string]string `json:"members"` // instance_id → cluster_id
}
type arkClusterRecord struct {
ID string `json:"id"`
Name string `json:"name"`
CreatedAt time.Time `json:"created_at"`
}
type arkClusterStore struct {
mu sync.Mutex
path string
}
func newArkClusterStore(dataDir string) *arkClusterStore {
return &arkClusterStore{path: filepath.Join(dataDir, arkClusterRegistryFile)}
}
func (s *arkClusterStore) load() (arkClusterRegistry, error) {
reg := arkClusterRegistry{Members: map[string]string{}}
b, err := os.ReadFile(s.path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return reg, nil
}
return reg, err
}
if err := json.Unmarshal(b, &reg); err != nil {
return reg, err
}
if reg.Members == nil {
reg.Members = map[string]string{}
}
return reg, nil
}
func (s *arkClusterStore) save(reg arkClusterRegistry) error {
sort.Slice(reg.Clusters, func(i, j int) bool { return reg.Clusters[i].CreatedAt.Before(reg.Clusters[j].CreatedAt) })
if err := os.MkdirAll(filepath.Dir(s.path), 0o755); err != nil {
return err
}
b, err := json.MarshalIndent(reg, "", " ")
if err != nil {
return err
}
// Write+rename for atomicity — a crashed half-write would otherwise
// leave the registry unreadable, and we'd lose names.
tmp := s.path + ".tmp"
if err := os.WriteFile(tmp, b, 0o644); err != nil {
return err
}
return os.Rename(tmp, s.path)
}
// ---- HTTP handlers ----
// handleArkClustersList returns every known cluster with its member list.
// Filters non-ARK instances defensively even though membership only gets
// recorded for ark-sa.
func (h *httpServer) handleArkClustersList(w http.ResponseWriter, r *http.Request) {
s := h.arkClusters
s.mu.Lock()
reg, err := s.load()
s.mu.Unlock()
if err != nil {
writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
return
}
// Invert members map for fast per-cluster lookup.
byCluster := map[string][]string{}
for inst, cid := range reg.Members {
byCluster[cid] = append(byCluster[cid], inst)
}
out := make([]map[string]any, 0, len(reg.Clusters))
for _, c := range reg.Clusters {
members := byCluster[c.ID]
sort.Strings(members)
out = append(out, map[string]any{
"id": c.ID,
"name": c.Name,
"created_at": c.CreatedAt.UTC().Format(time.RFC3339),
"members": members,
})
}
writeJSON(w, http.StatusOK, map[string]any{"clusters": out})
}
// handleArkClusterGet returns the cluster info for a specific instance
// (or null if it isn't clustered). Used by the ARK modal to decide
// whether to show "Setup Cluster" vs "Leave Cluster".
//
// Also returns a `mount_drift` flag — true when the registry says the
// instance is in a cluster but the agent has no record of the recreate
// having succeeded (== container still has the per-instance cluster
// volume instead of the shared bind mount). Lets the UI surface a
// "this cluster bond didn't take — re-join to repair" warning.
func (h *httpServer) handleArkClusterGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
s := h.arkClusters
s.mu.Lock()
reg, err := s.load()
s.mu.Unlock()
if err != nil {
writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
return
}
cid := reg.Members[id]
if cid == "" {
writeJSON(w, http.StatusOK, map[string]any{"cluster": nil})
return
}
var rec *arkClusterRecord
for i := range reg.Clusters {
if reg.Clusters[i].ID == cid {
rec = &reg.Clusters[i]
break
}
}
if rec == nil {
writeJSON(w, http.StatusOK, map[string]any{"cluster": map[string]any{"id": cid, "name": "(missing record)"}})
return
}
// Siblings = other members of the same cluster.
siblings := []string{}
for inst, mcid := range reg.Members {
if mcid == cid && inst != id {
siblings = append(siblings, inst)
}
}
sort.Strings(siblings)
// Drift detection: read the persisted DB row and check whether
// CLUSTER_ID matches what the registry expects. If the recreate
// dance was rolled back / failed, the row's ConfigValues won't
// have CLUSTER_ID set even though the registry says it should.
mountDrift := false
rows, _ := h.db.ListInstances(r.Context(), "")
for _, row := range rows {
if row.InstanceID == id {
cv := row.ConfigValues
if cv == nil || cv["CLUSTER_ID"] != cid {
mountDrift = true
}
break
}
}
writeJSON(w, http.StatusOK, map[string]any{
"cluster": map[string]any{
"id": rec.ID,
"name": rec.Name,
"created_at": rec.CreatedAt.UTC().Format(time.RFC3339),
"siblings": siblings,
"mount_drift": mountDrift,
},
})
}
type arkClusterActionReq struct {
// For "create new" flow: operator-supplied name (optional).
Name string `json:"name,omitempty"`
// For "join existing" flow: target cluster id.
ClusterID string `json:"cluster_id,omitempty"`
}
// handleArkClusterSetup creates a brand new cluster with the target
// instance as its first member. Generates a cluster id, writes the
// registry, and triggers the recreate dance with the new mount override.
func (h *httpServer) handleArkClusterSetup(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req arkClusterActionReq
_ = json.NewDecoder(r.Body).Decode(&req) // body is optional
clusterID := "cl_" + randomHex(6)
name := req.Name
if name == "" {
name = "ARK Cluster " + clusterID[3:]
}
s := h.arkClusters
s.mu.Lock()
reg, err := s.load()
if err != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_load", err.Error())
return
}
// Kick the instance out of any prior cluster first (no-op if none).
prior := reg.Members[id]
delete(reg.Members, id)
reg.Clusters = append(reg.Clusters, arkClusterRecord{ID: clusterID, Name: name, CreatedAt: time.Now()})
reg.Members[id] = clusterID
// If the prior cluster was orphaned (only-member instance just
// left) drop the record too, otherwise operators end up with a
// cloud of empty clusters.
if prior != "" {
stillUsed := false
for _, v := range reg.Members {
if v == prior {
stillUsed = true
break
}
}
if !stillUsed {
reg.Clusters = filterClusters(reg.Clusters, prior)
}
}
if err := s.save(reg); err != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
return
}
s.mu.Unlock()
if err := h.recreateArkWithClusterMount(r.Context(), row, agentID, clusterID); err != nil {
// Roll back the registry write — without this, a failed recreate
// leaves the instance "in" the cluster on disk while the actual
// container still has the old per-instance volume. Future joins
// of OTHER instances would then operate on a phantom registry.
s.mu.Lock()
if rb, lerr := s.load(); lerr == nil {
delete(rb.Members, id)
if prior != "" {
stillUsed := false
for _, v := range rb.Members {
if v == prior {
stillUsed = true
break
}
}
if stillUsed {
rb.Members[id] = prior
}
}
// Drop the new cluster we just added if no one else is using it.
used := false
for _, v := range rb.Members {
if v == clusterID {
used = true
break
}
}
if !used {
rb.Clusters = filterClusters(rb.Clusters, clusterID)
}
_ = s.save(rb)
}
s.mu.Unlock()
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"cluster_id": clusterID,
"cluster_name": name,
"instance_id": id,
})
}
// handleArkClusterJoin adds the instance to an existing cluster. Same
// recreate dance — the mount override points at the same shared volume
// that every other member of the cluster already uses.
func (h *httpServer) handleArkClusterJoin(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req arkClusterActionReq
if derr := json.NewDecoder(r.Body).Decode(&req); derr != nil || req.ClusterID == "" {
writeError(w, http.StatusBadRequest, "bad_json", "cluster_id is required")
return
}
s := h.arkClusters
s.mu.Lock()
reg, lerr := s.load()
if lerr != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
return
}
var exists bool
for _, c := range reg.Clusters {
if c.ID == req.ClusterID {
exists = true
break
}
}
if !exists {
s.mu.Unlock()
writeError(w, http.StatusNotFound, "not_found", "no such cluster")
return
}
prior := reg.Members[id]
reg.Members[id] = req.ClusterID
if prior != "" && prior != req.ClusterID {
stillUsed := false
for _, v := range reg.Members {
if v == prior {
stillUsed = true
break
}
}
if !stillUsed {
reg.Clusters = filterClusters(reg.Clusters, prior)
}
}
if err := s.save(reg); err != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
return
}
s.mu.Unlock()
if err := h.recreateArkWithClusterMount(r.Context(), row, agentID, req.ClusterID); err != nil {
// Same rollback contract as Setup — never leave a phantom membership.
s.mu.Lock()
if rb, lerr := s.load(); lerr == nil {
if prior == "" {
delete(rb.Members, id)
} else {
rb.Members[id] = prior
}
_ = s.save(rb)
}
s.mu.Unlock()
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"cluster_id": req.ClusterID,
"instance_id": id,
})
}
// handleArkClusterLeave removes the instance from its cluster and
// recreates the container with the default per-instance cluster volume
// (no mount override, no CLUSTER_ID env). The shared cluster volume is
// left intact on disk so other members can keep using it; if this was
// the last member, we drop the cluster record from the registry.
func (h *httpServer) handleArkClusterLeave(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
s := h.arkClusters
s.mu.Lock()
reg, lerr := s.load()
if lerr != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_load", lerr.Error())
return
}
prior := reg.Members[id]
if prior == "" {
s.mu.Unlock()
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "already_standalone": true})
return
}
delete(reg.Members, id)
stillUsed := false
for _, v := range reg.Members {
if v == prior {
stillUsed = true
break
}
}
if !stillUsed {
reg.Clusters = filterClusters(reg.Clusters, prior)
}
if err := s.save(reg); err != nil {
s.mu.Unlock()
writeError(w, http.StatusInternalServerError, "cluster_save", err.Error())
return
}
s.mu.Unlock()
if err := h.recreateArkWithClusterMount(r.Context(), row, agentID, ""); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "instance_id": id})
}
// ---- helpers ----
func filterClusters(list []arkClusterRecord, drop string) []arkClusterRecord {
out := make([]arkClusterRecord, 0, len(list))
for _, c := range list {
if c.ID != drop {
out = append(out, c)
}
}
return out
}
// removeInstanceFromCluster scrubs an instance's cluster membership and,
// if it was the last member of its cluster, drops the cluster record too.
// Safe to call for any instance_id (no-op if not in registry). Returns
// the cluster_id the instance was a member of (empty if it wasn't), so
// callers can log or react to the orphaning.
//
// Called on instance delete to prevent stale "already joined" state from
// leaking into a fresh instance with the same id.
func (s *arkClusterStore) removeInstanceFromCluster(instanceID string) (clusterID string, err error) {
s.mu.Lock()
defer s.mu.Unlock()
reg, err := s.load()
if err != nil {
return "", err
}
cid, ok := reg.Members[instanceID]
if !ok {
return "", nil
}
delete(reg.Members, instanceID)
stillUsed := false
for _, v := range reg.Members {
if v == cid {
stillUsed = true
break
}
}
if !stillUsed {
reg.Clusters = filterClusters(reg.Clusters, cid)
}
if err := s.save(reg); err != nil {
return cid, err
}
return cid, nil
}
func randomHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}
// loadArkInstance fetches the instance row and confirms module_id is ark-sa.
func (h *httpServer) loadArkInstance(ctx context.Context, id string) (*db.InstanceRow, error) {
rows, err := h.db.ListInstances(ctx, "")
if err != nil {
return nil, err
}
for i := range rows {
if rows[i].InstanceID == id {
if rows[i].ModuleID != "ark-sa" {
return nil, fmt.Errorf("instance %q is module %q, not ark-sa", id, rows[i].ModuleID)
}
return &rows[i], nil
}
}
return nil, fmt.Errorf("instance %q not found", id)
}
// recreateArkWithClusterMount stops, deletes-with-preserve, and recreates
// the instance with (or without) the cluster mount override + CLUSTER_ID
// env. Joining a cluster passes clusterID != ""; leaving passes "". If
// the instance was running before, it's started again at the end.
func (h *httpServer) recreateArkWithClusterMount(ctx context.Context, row *db.InstanceRow, agentID, clusterID string) error {
conn := h.registry.get(agentID)
if conn == nil {
return fmt.Errorf("agent %q not connected", agentID)
}
wasRunning := row.Status == "running"
cctx, cancel := context.WithTimeout(ctx, arkClusterRecreateTimeout)
defer cancel()
if wasRunning {
if err := h.awaitAgentCall(cctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
}); err != nil {
return fmt.Errorf("stop: %w", err)
}
}
if err := h.awaitAgentCall(cctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
}); err != nil {
return fmt.Errorf("delete: %w", err)
}
// Build the updated config_values for persistence + the create RPC.
// We preserve whatever the operator previously set (server name,
// admin password, etc.) and only touch the cluster-related keys.
cfg := map[string]string{}
for k, v := range row.ConfigValues {
cfg[k] = v
}
if clusterID != "" {
cfg["CLUSTER_ID"] = clusterID
} else {
delete(cfg, "CLUSTER_ID")
}
// CUSTOM_SERVER_ARGS used to set -ClusterDirOverride here as a defensive
// override. Removed 2026-04-28: the acekorneya image's launch_ASA.sh
// expands $CUSTOM_SERVER_ARGS unquoted, which word-splits paths
// containing spaces into multiple argv elements (the wine-virtualized
// install path always has spaces). The split made ARK silently treat
// only the first chunk as the cluster path, breaking transfers. The
// bind-mount target is the same as ARK's default cluster path
// (Saved/clusters), so the override was never actually needed —
// removing it makes ARK use its default, which IS the shared bind mount.
delete(cfg, "CUSTOM_SERVER_ARGS")
row.ConfigValues = cfg
row.Status = "creating"
if err := h.db.UpsertInstance(cctx, *row); err != nil {
return fmt.Errorf("db upsert: %w", err)
}
// Preserve panel-allocated host ports across the recreate so the agent's
// auto-bumper doesn't re-pick from manifest defaults and collide with
// sibling instances on the same agent.
var ports []*panelv1.PortMap
for name, hp := range row.AssignedPorts {
ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
}
// Suppress agent auto-start if the instance was stopped before this
// cluster recreate. Cluster join/leave shouldn't silently boot a
// stopped server.
createCV := cfg
if !wasRunning {
createCV = make(map[string]string, len(cfg)+1)
for k, v := range cfg {
createCV[k] = v
}
createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
}
create := &panelv1.InstanceCreate{
InstanceId: row.InstanceID,
ModuleId: row.ModuleID,
Version: row.ModuleVersion,
RunMode: panelv1.RunMode_RUN_MODE_DOCKER,
Ports: ports,
ConfigValues: createCV,
}
if clusterID != "" {
// Bind mount to a host-visible folder under the agent's data
// root — matches AMP's "virtual path" UX. Operators can browse
// `data/arkcluster/<cluster_id>/` on the host to inspect, back
// up, or manually seed cluster transfers. `$AGENT_DATA_ROOT`
// is expanded agent-side into whatever data dir the agent was
// started with (defaults to `./data`).
create.MountOverrides = map[string]string{
arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + clusterID,
}
}
if err := h.awaitAgentCall(cctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
InstanceCreate: create,
}); err != nil {
return fmt.Errorf("create: %w", err)
}
if wasRunning {
if err := h.awaitAgentCall(cctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
}); err != nil {
return fmt.Errorf("start: %w", err)
}
}
return nil
}
// arkClusterMountOverridesFor returns the mount_overrides map needed to
// keep instanceID attached to its shared cluster bind mount across a
// container recreate. Returns nil when the instance isn't a cluster
// member (or when the registry is unreadable — caller treats nil as
// "no override needed", same as a non-clustered instance).
//
// Every recreate-dance path (env-config Apply, port change, mod toggle,
// cluster join/leave) MUST consult this and pass the result as the
// InstanceCreate's MountOverrides; otherwise the recreate replaces the
// shared bind mount with the manifest-default per-instance volume and
// silently disconnects the server from the cluster.
func (s *arkClusterStore) mountOverridesFor(instanceID string) map[string]string {
s.mu.Lock()
reg, err := s.load()
s.mu.Unlock()
if err != nil {
return nil
}
cid := reg.Members[instanceID]
if cid == "" {
return nil
}
return map[string]string{
arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + cid,
}
}
+769
View File
@@ -0,0 +1,769 @@
package main
// ARK: Survival Ascended mod manager.
//
// ASA ships its own built-in CurseForge downloader — the dedicated
// server reads `-mods=<comma-id-list>` / `-passivemods=<comma-id-list>`
// from the launch command and pulls each mod from CurseForge on first
// start. This is fundamentally different from DayZ: no SteamCMD, no
// sidecar, no async job queue. All we do on the panel side is:
//
// 1. Maintain the instance's `MOD_IDS` (active, client-visible) and
// `PASSIVE_MODS` (server-side balance/tools) env vars via the
// persisted `config_values` column.
// 2. Swap the container via the usual stop → delete (preserve
// volumes) → recreate → start dance when the list changes, so
// the new env takes effect.
//
// CurseForge's official API requires an account + key. Their website
// itself is cloudflare-protected. The community-run proxy
// `api.curse.tools` exposes the same JSON, no key required, and returns
// everything we need (title, summary, logo, screenshots, authors, file
// sizes, popularity rank). The panel funnels all mod searches + detail
// lookups through it — the acekorneya image still downloads binaries
// directly from CurseForge CDN on server start.
//
// ARK:SA's CurseForge game id is 83374.
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"time"
"github.com/dbledeez/panel/controller/internal/db"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
const (
arkCurseGameID = "83374"
arkCurseToolsSearch = "https://api.curse.tools/v1/cf/mods/search"
arkCurseToolsDetail = "https://api.curse.tools/v1/cf/mods/"
// sortField=6 (LastUpdated) → results prioritise actively-maintained
// mods, which is what most server operators want when browsing. The
// user can refine the query text to pull up obscure mods instead.
arkCurseToolsSortField = "6"
)
type arkModsListResp struct {
Mods []arkModEntry `json:"mods"`
Active string `json:"active_csv"` // comma-joined active mod ids
Passive string `json:"passive_csv"` // comma-joined passive mod ids
}
type arkModEntry struct {
ID string `json:"id"` // CurseForge project id as a string
Passive bool `json:"passive"` // true if in -passivemods= list
Order int `json:"order"` // position in the CSV (0-based)
Title string `json:"title,omitempty"`
LogoURL string `json:"logo_url,omitempty"`
Summary string `json:"summary,omitempty"`
Author string `json:"author,omitempty"`
}
type arkInstallReq struct {
ModID string `json:"mod_id"` // numeric CF project id OR full URL
Passive bool `json:"passive,omitempty"`
}
// ---- list / install / uninstall ----
// handleArkModsList returns the instance's current mod lists with
// hydrated metadata from CurseForge. The UI renders this as the
// "Installed mods" section beneath the hero search.
func (h *httpServer) handleArkModsList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
active := splitModCSV(row.ConfigValues["MOD_IDS"])
passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
// Hydrate metadata in one batched call. curse.tools' search endpoint
// can match by ids via the `modIds` param, but the simplest cross-
// compat path is to fetch each mod's detail concurrently — ASA mod
// lists are almost always <20 entries in practice.
entries := make([]arkModEntry, 0, len(active)+len(passive))
for i, m := range active {
entries = append(entries, arkModEntry{ID: m, Order: i})
}
for i, m := range passive {
entries = append(entries, arkModEntry{ID: m, Order: i, Passive: true})
}
if len(entries) > 0 {
hydrateArkMods(r.Context(), entries)
}
writeJSON(w, http.StatusOK, arkModsListResp{
Mods: entries,
Active: strings.Join(active, ","),
Passive: strings.Join(passive, ","),
})
}
// handleArkModInstall appends a mod to the active (or passive) list
// and recreates the container. Duplicate adds are no-ops (returns
// ok=true, duplicate=true without triggering a recreate).
func (h *httpServer) handleArkModInstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req arkInstallReq
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
modID, err := parseArkModID(req.ModID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_mod_id", err.Error())
return
}
active := splitModCSV(row.ConfigValues["MOD_IDS"])
passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
if containsString(active, modID) || containsString(passive, modID) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "duplicate": true, "mod_id": modID})
return
}
if req.Passive {
passive = append(passive, modID)
} else {
active = append(active, modID)
}
if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
// Best-effort: fetch title for the success toast.
title := modID
if d, derr := fetchArkModDetail(modID); derr == nil && d.Name != "" {
title = d.Name
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"mod_id": modID,
"title": title,
"passive": req.Passive,
})
}
// handleArkModUninstall removes a mod from whichever list it lives in
// and recreates the container.
func (h *httpServer) handleArkModUninstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
modID := strings.TrimSpace(r.PathValue("modId"))
if modID == "" {
writeError(w, http.StatusBadRequest, "bad_mod_id", "mod id is required")
return
}
active := removeString(splitModCSV(row.ConfigValues["MOD_IDS"]), modID)
passive := removeString(splitModCSV(row.ConfigValues["PASSIVE_MODS"]), modID)
if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "mod_id": modID})
}
// handleArkModsReorder accepts full replacement lists (active + passive)
// and recreates once. Used by the UI's drag-to-reorder and the
// "move between active/passive" affordance.
func (h *httpServer) handleArkModsReorder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req struct {
Active []string `json:"active"`
Passive []string `json:"passive"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 32*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
active := dedupStrings(req.Active)
passive := dedupStrings(req.Passive)
if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true})
}
// handleArkModsBatch applies multiple mod changes in a single recreate.
// Body: {adds:[{mod_id,passive}...], removes:[mod_id...], active_order:[...]?, passive_order:[...]?}.
// - adds with duplicate ids are silently skipped
// - removes are applied first, then adds, then ordering (if provided)
// - exactly one stop→delete→create→start cycle on the agent
//
// This replaces N round-trips × N recreates from per-mod install/uninstall
// for cases where the operator stages a batch of changes in the UI.
func (h *httpServer) handleArkModsBatch(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
row, err := h.loadArkInstance(r.Context(), id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_module", err.Error())
return
}
var req struct {
Adds []arkInstallReq `json:"adds"`
Removes []string `json:"removes"`
ActiveOrder []string `json:"active_order"`
PassiveOrder []string `json:"passive_order"`
}
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
active := splitModCSV(row.ConfigValues["MOD_IDS"])
passive := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
// 1) Removes first.
for _, raw := range req.Removes {
mid := strings.TrimSpace(raw)
if mid == "" {
continue
}
active = removeString(active, mid)
passive = removeString(passive, mid)
}
// 2) Adds (parsed + deduped against the lists post-remove).
addedTitles := make([]string, 0, len(req.Adds))
for _, a := range req.Adds {
modID, err := parseArkModID(a.ModID)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_mod_id", fmt.Sprintf("%s: %s", a.ModID, err.Error()))
return
}
if containsString(active, modID) || containsString(passive, modID) {
continue
}
if a.Passive {
passive = append(passive, modID)
} else {
active = append(active, modID)
}
addedTitles = append(addedTitles, modID)
}
// 3) Ordering — if the client sent explicit order arrays, use them as
// the final order, but only honour ids that are actually in the
// current lists (defensive against stale state). Anything missing
// from the order falls through to the end in arrival order.
if len(req.ActiveOrder) > 0 {
active = applyExplicitOrder(active, req.ActiveOrder)
}
if len(req.PassiveOrder) > 0 {
passive = applyExplicitOrder(passive, req.PassiveOrder)
}
active = dedupStrings(active)
passive = dedupStrings(passive)
// Skip the recreate entirely if nothing actually changed.
curA := splitModCSV(row.ConfigValues["MOD_IDS"])
curP := splitModCSV(row.ConfigValues["PASSIVE_MODS"])
if stringSlicesEqual(curA, active) && stringSlicesEqual(curP, passive) {
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "no_changes": true})
return
}
if err := h.recreateArkWithMods(r.Context(), row, agentID, active, passive); err != nil {
writeError(w, http.StatusBadGateway, "recreate", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"active": active,
"passive": passive,
"added": addedTitles,
"removed_count": len(req.Removes),
})
}
// applyExplicitOrder returns `current` reordered to match the order
// implied by `desired`, with any current-but-not-in-desired entries
// appended at the end in their original position.
func applyExplicitOrder(current, desired []string) []string {
in := make(map[string]bool, len(current))
for _, s := range current {
in[s] = true
}
seen := make(map[string]bool, len(desired))
out := make([]string, 0, len(current))
for _, d := range desired {
if in[d] && !seen[d] {
out = append(out, d)
seen[d] = true
}
}
for _, s := range current {
if !seen[s] {
out = append(out, s)
seen[s] = true
}
}
return out
}
func stringSlicesEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
// recreateArkWithMods writes the updated MOD_IDS/PASSIVE_MODS env vars
// into config_values and triggers the standard recreate dance. Preserves
// the instance's cluster membership + any other config_values.
func (h *httpServer) recreateArkWithMods(ctx context.Context, row *db.InstanceRow, agentID string, active, passive []string) error {
conn := h.registry.get(agentID)
if conn == nil {
return fmt.Errorf("agent %q not connected", agentID)
}
wasRunning := row.Status == "running"
cctx, cancel := context.WithTimeout(ctx, arkClusterRecreateTimeout)
defer cancel()
if wasRunning {
if err := h.awaitAgentCall(cctx, conn, "stop", &panelv1.ControllerEnvelope_InstanceStop{
InstanceStop: &panelv1.InstanceStop{InstanceId: row.InstanceID, GraceSeconds: 30},
}); err != nil {
return fmt.Errorf("stop: %w", err)
}
}
if err := h.awaitAgentCall(cctx, conn, "delete", &panelv1.ControllerEnvelope_InstanceDelete{
InstanceDelete: &panelv1.InstanceDelete{InstanceId: row.InstanceID, PurgeVolumes: false},
}); err != nil {
return fmt.Errorf("delete: %w", err)
}
cfg := map[string]string{}
for k, v := range row.ConfigValues {
cfg[k] = v
}
cfg["MOD_IDS"] = strings.Join(active, ",")
cfg["PASSIVE_MODS"] = strings.Join(passive, ",")
row.ConfigValues = cfg
row.Status = "creating"
if err := h.db.UpsertInstance(cctx, *row); err != nil {
return fmt.Errorf("db upsert: %w", err)
}
// Preserve panel-allocated host ports across the recreate so the agent's
// auto-bumper doesn't re-pick from manifest defaults and collide with
// sibling instances on the same agent.
var ports []*panelv1.PortMap
for name, hp := range row.AssignedPorts {
ports = append(ports, &panelv1.PortMap{Name: name, HostPort: hp})
}
// Suppress agent auto-start if the instance was stopped before this
// mod-config recreate. Operator changing mods on a stopped server
// shouldn't see it auto-boot afterwards.
createCV := cfg
if !wasRunning {
createCV = make(map[string]string, len(cfg)+1)
for k, v := range cfg {
createCV[k] = v
}
createCV["_PANEL_SUPPRESS_AUTOSTART"] = "true"
}
// Preserve cluster-mount override if the instance was in a cluster.
create := &panelv1.InstanceCreate{
InstanceId: row.InstanceID,
ModuleId: row.ModuleID,
Version: row.ModuleVersion,
RunMode: panelv1.RunMode_RUN_MODE_DOCKER,
Ports: ports,
ConfigValues: createCV,
}
if cid := cfg["CLUSTER_ID"]; cid != "" {
create.MountOverrides = map[string]string{
arkClusterDirContainerPath: "$PANEL_DATA_ROOT/arkcluster/" + cid,
}
}
if err := h.awaitAgentCall(cctx, conn, "create", &panelv1.ControllerEnvelope_InstanceCreate{
InstanceCreate: create,
}); err != nil {
return fmt.Errorf("create: %w", err)
}
if wasRunning {
if err := h.awaitAgentCall(cctx, conn, "start", &panelv1.ControllerEnvelope_InstanceStart{
InstanceStart: &panelv1.InstanceStart{InstanceId: row.InstanceID},
}); err != nil {
return fmt.Errorf("start: %w", err)
}
}
return nil
}
// ---- CurseForge proxy (search + detail) ----
// handleArkModSearch proxies a CurseForge search via api.curse.tools.
// We normalise the response shape so the frontend has the exact same
// fields whether it's browsing search results or fetching a detail
// card: id, name, summary, logo_url, author, download_count, updated.
func (h *httpServer) handleArkModSearch(w http.ResponseWriter, r *http.Request) {
q := strings.TrimSpace(r.URL.Query().Get("q"))
if q == "" {
writeError(w, http.StatusBadRequest, "bad_query", "q is required")
return
}
u, _ := url.Parse(arkCurseToolsSearch)
qs := u.Query()
qs.Set("gameId", arkCurseGameID)
qs.Set("searchFilter", q)
qs.Set("pageSize", "30")
qs.Set("sortField", arkCurseToolsSortField)
qs.Set("sortOrder", "desc")
u.RawQuery = qs.Encode()
items, err := curseToolsFetchSearch(u.String())
if err != nil {
writeError(w, http.StatusBadGateway, "curse", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"items": items, "query": q})
}
// handleArkModDetail fetches a single mod's full metadata — including
// the long HTML description rendered by the mod info modal.
func (h *httpServer) handleArkModDetail(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.URL.Query().Get("id"))
if id == "" {
writeError(w, http.StatusBadRequest, "bad_id", "id is required")
return
}
modID, err := parseArkModID(id)
if err != nil {
writeError(w, http.StatusBadRequest, "bad_id", err.Error())
return
}
d, err := fetchArkModDetail(modID)
if err != nil {
writeError(w, http.StatusBadGateway, "curse", err.Error())
return
}
// Fetch the rendered description separately — the main detail doc
// carries `summary` (one paragraph) but not the full HTML body.
desc, _ := fetchArkModDescription(modID)
out := d.toUI()
out["description_html"] = desc
writeJSON(w, http.StatusOK, out)
}
// ---- helpers ----
// curseToolsMod is the subset of the CurseForge mod JSON we care about.
type curseToolsMod struct {
ID int `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
Summary string `json:"summary"`
Status int `json:"status"`
DownloadCount int64 `json:"downloadCount"`
GameRank int `json:"gamePopularityRank"`
DateModified string `json:"dateModified"`
DateReleased string `json:"dateReleased"`
Authors []struct {
Name string `json:"name"`
URL string `json:"url"`
} `json:"authors"`
Logo struct {
URL string `json:"url"`
} `json:"logo"`
Screenshots []struct {
URL string `json:"url"`
} `json:"screenshots"`
LatestFiles []struct {
FileLength int64 `json:"fileLength"`
DisplayName string `json:"displayName"`
DateReleased string `json:"fileDate"`
} `json:"latestFiles"`
Categories []struct {
Name string `json:"name"`
} `json:"categories"`
Links struct {
WebsiteURL string `json:"websiteUrl"`
} `json:"links"`
RatingDetails struct {
Rating float64 `json:"rating"`
TotalRatings int `json:"totalRatings"`
} `json:"ratingDetails"`
IsAvailable bool `json:"isAvailable"`
}
type curseToolsSearchResp struct {
Data []curseToolsMod `json:"data"`
}
type curseToolsDetailResp struct {
Data curseToolsMod `json:"data"`
}
type curseToolsDescriptionResp struct {
Data string `json:"data"`
}
func (m *curseToolsMod) toUI() map[string]any {
var latestSize int64
if len(m.LatestFiles) > 0 {
latestSize = m.LatestFiles[0].FileLength
}
author := ""
if len(m.Authors) > 0 {
author = m.Authors[0].Name
}
cats := make([]string, 0, len(m.Categories))
for _, c := range m.Categories {
cats = append(cats, c.Name)
}
return map[string]any{
"id": strconv.Itoa(m.ID),
"name": m.Name,
"slug": m.Slug,
"summary": m.Summary,
"logo_url": m.Logo.URL,
"author": author,
"download_count": m.DownloadCount,
"rank": m.GameRank,
"updated": m.DateModified,
"released": m.DateReleased,
"latest_size": latestSize,
"categories": cats,
"website": m.Links.WebsiteURL,
"rating": m.RatingDetails.Rating,
"ratings_total": m.RatingDetails.TotalRatings,
"is_available": m.IsAvailable,
}
}
func curseToolsFetchSearch(u string) ([]map[string]any, error) {
b, err := curseToolsGet(u)
if err != nil {
return nil, err
}
var r curseToolsSearchResp
if err := json.Unmarshal(b, &r); err != nil {
return nil, fmt.Errorf("decode search: %w", err)
}
out := make([]map[string]any, 0, len(r.Data))
for i := range r.Data {
out = append(out, r.Data[i].toUI())
}
return out, nil
}
func fetchArkModDetail(modID string) (*curseToolsMod, error) {
b, err := curseToolsGet(arkCurseToolsDetail + modID)
if err != nil {
return nil, err
}
var r curseToolsDetailResp
if err := json.Unmarshal(b, &r); err != nil {
return nil, fmt.Errorf("decode detail: %w", err)
}
return &r.Data, nil
}
func fetchArkModDescription(modID string) (string, error) {
b, err := curseToolsGet(arkCurseToolsDetail + modID + "/description")
if err != nil {
return "", err
}
var r curseToolsDescriptionResp
if err := json.Unmarshal(b, &r); err != nil {
return "", err
}
return r.Data, nil
}
var arkCurseClient = &http.Client{Timeout: 15 * time.Second}
func curseToolsGet(u string) ([]byte, error) {
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return nil, err
}
// Real-browser UA — curse.tools passes through, and Cloudflare on
// CurseForge CDN is noticeably less aggressive with a common UA.
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36")
req.Header.Set("Accept", "application/json")
resp, err := arkCurseClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return nil, fmt.Errorf("curse.tools HTTP %d", resp.StatusCode)
}
return io.ReadAll(io.LimitReader(resp.Body, 4*1024*1024))
}
// hydrateArkMods fills in title/logo/summary/author for each installed
// mod entry. Best-effort: if any fetch fails the entry keeps its id.
func hydrateArkMods(ctx context.Context, entries []arkModEntry) {
type result struct {
idx int
d *curseToolsMod
}
ch := make(chan result, len(entries))
for i := range entries {
i := i
go func() {
d, err := fetchArkModDetail(entries[i].ID)
if err != nil {
ch <- result{i, nil}
return
}
ch <- result{i, d}
}()
}
done := 0
deadline := time.NewTimer(8 * time.Second)
defer deadline.Stop()
for done < len(entries) {
select {
case r := <-ch:
done++
if r.d == nil {
continue
}
entries[r.idx].Title = r.d.Name
entries[r.idx].LogoURL = r.d.Logo.URL
entries[r.idx].Summary = r.d.Summary
if len(r.d.Authors) > 0 {
entries[r.idx].Author = r.d.Authors[0].Name
}
case <-ctx.Done():
return
case <-deadline.C:
return
}
}
sort.SliceStable(entries, func(i, j int) bool {
if entries[i].Passive != entries[j].Passive {
return !entries[i].Passive // active first
}
return entries[i].Order < entries[j].Order
})
}
var arkModIDRE = regexp.MustCompile(`(?:[?&/]|^)(\d{4,12})`)
// parseArkModID accepts: raw numeric id OR full CurseForge URL
// (https://www.curseforge.com/ark-survival-ascended/mods/<slug>/N).
// Returns the numeric project id as a string.
func parseArkModID(s string) (string, error) {
s = strings.TrimSpace(s)
if s == "" {
return "", errors.New("mod id is required")
}
if _, err := strconv.ParseInt(s, 10, 64); err == nil {
return s, nil
}
m := arkModIDRE.FindStringSubmatch(s)
if len(m) > 1 {
return m[1], nil
}
return "", fmt.Errorf("couldn't extract a CurseForge mod id from %q", s)
}
func splitModCSV(s string) []string {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
out := []string{}
for _, p := range strings.Split(s, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
out = append(out, p)
}
return out
}
func containsString(xs []string, s string) bool {
for _, x := range xs {
if x == s {
return true
}
}
return false
}
func removeString(xs []string, s string) []string {
out := make([]string, 0, len(xs))
for _, x := range xs {
if x != s {
out = append(out, x)
}
}
return out
}
func dedupStrings(xs []string) []string {
seen := map[string]bool{}
out := []string{}
for _, x := range xs {
x = strings.TrimSpace(x)
if x == "" || seen[x] {
continue
}
seen[x] = true
out = append(out, x)
}
return out
}
+391
View File
@@ -0,0 +1,391 @@
package main
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"regexp"
"strings"
"time"
"golang.org/x/crypto/argon2"
"github.com/dbledeez/panel/controller/internal/db"
)
// argon2id parameters — chosen to keep login latency ≈ ~50-100 ms on
// typical hardware while resisting offline cracking. Stored inline with
// each hash (PHC string format) so we can bump them later.
const (
argonTime = 3
argonMemory = 64 * 1024 // 64 MiB
argonThreads = 2
argonKeyLen = 32
argonSaltLen = 16
)
const (
sessionCookieName = "panel_session"
sessionTTL = 24 * time.Hour
)
// sessionUserKey is the context key the auth middleware uses to stash the
// authenticated user on an incoming request.
type sessionUserKey struct{}
// auth holds the bits the auth handlers need. Kept in the main package so
// it can share types with httpServer without any dependency rewiring.
type auth struct {
log *slog.Logger
db *db.DB
publicURLOverride string // set from --public-url flag; used by Steam OpenID realm/return_to
}
// ---- Password hashing (argon2id, PHC format) ----
// hashPassword produces a PHC-format string safe for DB storage:
//
// $argon2id$v=19$m=65536,t=3,p=2$<salt>$<hash>
func hashPassword(password string) (string, error) {
salt := make([]byte, argonSaltLen)
if _, err := rand.Read(salt); err != nil {
return "", fmt.Errorf("rand salt: %w", err)
}
hash := argon2.IDKey([]byte(password), salt, argonTime, argonMemory, argonThreads, argonKeyLen)
return fmt.Sprintf(
"$argon2id$v=19$m=%d,t=%d,p=%d$%s$%s",
argonMemory, argonTime, argonThreads,
base64.RawStdEncoding.EncodeToString(salt),
base64.RawStdEncoding.EncodeToString(hash),
), nil
}
// verifyPassword is constant-time. Returns (true, nil) on match,
// (false, nil) on mismatch, (false, err) on bad hash format.
func verifyPassword(password, encoded string) (bool, error) {
parts := strings.Split(encoded, "$")
// ["", "argon2id", "v=19", "m=65536,t=3,p=2", salt, hash]
if len(parts) != 6 || parts[1] != "argon2id" {
return false, fmt.Errorf("not an argon2id hash")
}
var memory, timeCost uint32
var threads uint8
if _, err := fmt.Sscanf(parts[3], "m=%d,t=%d,p=%d", &memory, &timeCost, &threads); err != nil {
return false, fmt.Errorf("params: %w", err)
}
salt, err := base64.RawStdEncoding.DecodeString(parts[4])
if err != nil {
return false, fmt.Errorf("salt: %w", err)
}
expected, err := base64.RawStdEncoding.DecodeString(parts[5])
if err != nil {
return false, fmt.Errorf("hash: %w", err)
}
computed := argon2.IDKey([]byte(password), salt, timeCost, memory, threads, uint32(len(expected)))
return subtle.ConstantTimeCompare(expected, computed) == 1, nil
}
// ---- Session tokens ----
func newSessionToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
func clientIP(r *http.Request) string {
if fwd := r.Header.Get("X-Forwarded-For"); fwd != "" {
// Take the first IP in the list.
if i := strings.IndexByte(fwd, ','); i > 0 {
return strings.TrimSpace(fwd[:i])
}
return strings.TrimSpace(fwd)
}
return r.RemoteAddr
}
// ---- Admin bootstrap ----
// bootstrapAdmin creates a default admin account if the users table is
// empty. The password is generated and printed to the controller's log
// (operator is expected to log in and change it via the UI on first run).
//
// Pass a non-empty envPassword ($PANEL_ADMIN_PASSWORD) to use a stable
// password instead of a random one — useful for dev / CI.
//
// initialSteamID (17-digit SteamID64, optional):
//
// - If the users table is empty and this is non-empty, the bootstrap
// admin gets this Steam ID linked immediately, enabling
// "Sign in with Steam" on first boot.
// - If an admin already exists AND doesn't have a steam_id linked yet,
// we retro-link the first admin to this Steam ID. Useful when the
// operator is adding Steam login to an already-running panel.
// - Ignored if the first admin already has a steam_id (don't clobber).
// bootstrapAdmin creates the first admin. envEmail (from PANEL_ADMIN_EMAIL)
// lets the operator choose their login; blank falls back to the default
// admin@panel.local so existing installs are unaffected.
func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envEmail, envPassword, initialSteamID string) error {
adminEmail := strings.TrimSpace(strings.ToLower(envEmail))
if adminEmail == "" {
adminEmail = "admin@panel.local"
}
initialSteamID = strings.TrimSpace(initialSteamID)
if initialSteamID != "" && !steamID64RE.MatchString(initialSteamID) {
log.Warn("--initial-admin-steam-id isn't a 17-digit SteamID64, ignoring", "value", initialSteamID)
initialSteamID = ""
}
n, err := database.CountUsers(ctx)
if err != nil {
return err
}
if n > 0 {
// Not our first boot — if the operator added --initial-admin-steam-id
// after the fact, retro-link it to the first admin when that admin
// doesn't yet have a steam_id. Otherwise do nothing.
if initialSteamID == "" {
return nil
}
first, err := database.GetFirstAdmin(ctx)
if err != nil {
log.Warn("could not find first admin to retro-link steam id", "err", err)
return nil
}
if first.SteamID != "" {
if first.SteamID != initialSteamID {
log.Warn("first admin already has a different steam_id; leaving it alone",
"existing", first.SteamID, "requested", initialSteamID)
}
return nil
}
if err := database.LinkSteamIDToUser(ctx, first.ID, initialSteamID); err != nil {
return fmt.Errorf("link initial steam id: %w", err)
}
log.Warn("═══════════════════════════════════════════════════════════════════════════")
log.Warn("STEAM LINK — linked existing admin account to your Steam ID",
"email", first.Email, "steam_id", initialSteamID)
log.Warn(" Sign in with Steam is now active on /login")
log.Warn("═══════════════════════════════════════════════════════════════════════════")
return nil
}
password := envPassword
generated := password == ""
if generated {
pw, err := newSessionToken()
if err != nil {
return err
}
password = pw[:16] // 16 hex chars is enough entropy for dev
}
hash, err := hashPassword(password)
if err != nil {
return err
}
userID, err := database.CreateUser(ctx, adminEmail, hash, "admin")
if err != nil {
return err
}
if initialSteamID != "" {
if err := database.LinkSteamIDToUser(ctx, userID, initialSteamID); err != nil {
log.Warn("create-user succeeded but steam link failed", "err", err)
}
}
const banner = "═══════════════════════════════════════════════════════════════════════════"
log.Warn(banner)
log.Warn("ADMIN BOOTSTRAP — no users in DB, created default account")
log.Warn(" email: " + adminEmail)
if generated {
log.Warn(" password: " + password)
log.Warn(" ^^^ log in once and change it via the UI ^^^")
} else {
log.Warn(" password: (supplied via PANEL_ADMIN_PASSWORD env)")
}
if initialSteamID != "" {
log.Warn(" steam_id: " + initialSteamID + " (Sign in with Steam is active)")
}
log.Warn(banner)
return nil
}
// steamID64RE matches a 17-digit Steam community ID.
var steamID64RE = regexp.MustCompile(`^\d{17}$`)
// ---- Middleware ----
// requireSession enforces a valid session on every wrapped request.
// Unauthenticated API hits get 401; unauthenticated page hits get 302 /login.
func (a *auth) requireSession(isAPI bool, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, err := a.userFromRequest(r)
if err != nil {
if isAPI {
writeError(w, http.StatusUnauthorized, "unauthenticated", "log in to continue")
} else {
http.Redirect(w, r, "/login", http.StatusFound)
}
return
}
ctx := context.WithValue(r.Context(), sessionUserKey{}, u)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// userFromRequest reads the session cookie and returns the user, or an
// error if the cookie is missing / expired / invalid.
func (a *auth) userFromRequest(r *http.Request) (*db.UserRow, error) {
c, err := r.Cookie(sessionCookieName)
if err != nil {
return nil, fmt.Errorf("no session cookie")
}
u, err := a.db.GetSessionUser(r.Context(), c.Value, clientIP(r))
if err != nil {
return nil, err
}
if u.Disabled {
return nil, fmt.Errorf("account disabled")
}
return u, nil
}
// ---- Handlers ----
type loginReq struct {
Email string `json:"email"`
Password string `json:"password"`
}
func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
var req loginReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
if req.Email == "" || req.Password == "" {
writeError(w, http.StatusBadRequest, "missing", "email and password required")
return
}
u, err := a.db.GetUserByEmail(r.Context(), req.Email)
if err != nil {
if errors.Is(err, db.ErrUserNotFound) {
// Still do a bogus verify so login timing doesn't leak user enumeration.
_, _ = verifyPassword(req.Password, "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
return
}
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
if u.Disabled {
writeError(w, http.StatusUnauthorized, "disabled", "account is disabled")
return
}
ok, err := verifyPassword(req.Password, u.PasswordHash)
if err != nil {
writeError(w, http.StatusInternalServerError, "verify", err.Error())
return
}
if !ok {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
return
}
token, err := newSessionToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "token", err.Error())
return
}
exp := time.Now().Add(sessionTTL)
if err := a.db.CreateSession(r.Context(), token, u.ID, exp, clientIP(r)); err != nil {
writeError(w, http.StatusInternalServerError, "session", err.Error())
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
Expires: exp,
})
writeJSON(w, http.StatusOK, map[string]any{
"email": u.Email,
"role": u.Role,
})
}
func (a *auth) handleLogout(w http.ResponseWriter, r *http.Request) {
if c, err := r.Cookie(sessionCookieName); err == nil {
_ = a.db.DeleteSession(r.Context(), c.Value)
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
MaxAge: -1,
})
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (a *auth) handleMe(w http.ResponseWriter, r *http.Request) {
u, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated", "not logged in")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": u.ID,
"email": u.Email,
"role": u.Role,
"steam_id": u.SteamID,
})
}
type changePasswordReq struct {
Current string `json:"current_password"`
New string `json:"new_password"`
}
// handleChangePassword is behind requireSession — user context is guaranteed.
func (a *auth) handleChangePassword(w http.ResponseWriter, r *http.Request) {
u, ok := r.Context().Value(sessionUserKey{}).(*db.UserRow)
if !ok {
writeError(w, http.StatusUnauthorized, "unauthenticated", "not logged in")
return
}
var req changePasswordReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
if len(req.New) < 8 {
writeError(w, http.StatusBadRequest, "weak_password", "new password must be at least 8 characters")
return
}
ok, err := verifyPassword(req.Current, u.PasswordHash)
if err != nil || !ok {
writeError(w, http.StatusUnauthorized, "invalid_credentials", "current password incorrect")
return
}
newHash, err := hashPassword(req.New)
if err != nil {
writeError(w, http.StatusInternalServerError, "hash", err.Error())
return
}
if err := a.db.UpdateUserPassword(r.Context(), u.ID, newHash); err != nil {
writeError(w, http.StatusInternalServerError, "db", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+946
View File
@@ -0,0 +1,946 @@
package main
// Conan Exiles Steam Workshop mod manager — install/uninstall/list/reorder.
//
// Mirrors the DayZ pattern (controller/cmd/controller/dayzmods.go) closely
// but with three Conan-specific differences:
//
// 1. Workshop appid is 440900 (the Conan Exiles game), NOT 443030 (the
// dedicated server, which doesn't have its own workshop).
// 2. Mods are .pak files; no bikeys to copy. Install = symlink the
// workshop content directory into /game/ConanSandbox/Mods/<id> and
// add a relative .pak path to /game/ConanSandbox/Mods/modlist.txt.
// 3. The shared-volume install dir is /conan-workshop (vs DayZ's
// /dayz-workshop). Both the panel-conan-exiles container and the
// transient SteamCMD sidecar mount it, so one download serves N
// Conan instances on the same agent (dayzmods pattern).
//
// The Steam credential cache (panel-steamcmd-auth volume) is shared with
// every other workshop downloader, so a single sign-in unlocks DayZ and
// Conan together — provided the cached account owns the relevant base
// game. SteamCMD's `+workshop_download_item 440900 …` requires an account
// that owns Conan Exiles (or has access to the workshop item via free
// browsing — but most modders ship for owners only).
//
// Job lifecycle phases: queued → downloading → linking → finalizing → done
// (or → error / login_needed). Reuses the dayzModJob type and dayzModJobManager
// runner — the struct is generic enough that conan jobs ride on it without
// a parallel file. BikeysCopied stays empty for Conan jobs (no bikeys).
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os/exec"
"path"
"sort"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
const (
// conanWorkshopAppID is the Conan Exiles game's Steam appid (the
// dedicated server (443030) does not have its own Workshop). Steam's
// workshop_download_item, the GetPublishedFileDetails API, and the
// /workshop/browse search all key off the GAME's appid for items.
conanWorkshopAppID = "440900"
// conanWorkshopVolume is the named Docker volume that holds workshop
// content. Mounted in BOTH the SteamCMD sidecar (at
// /conan-workshop/steamapps/workshop) and every panel-conan-exiles
// container (at /game/steamapps/workshop) so the game can read what
// SteamCMD wrote without copying bytes around. Mirrors the DayZ
// pattern (modules/dayz/module.yaml + dayzmods.go) verbatim.
conanWorkshopVolume = "panel-conan-workshop"
// conanSteamCMDInstallDir is what SteamCMD's +force_install_dir is
// pointed at INSIDE THE SIDECAR. With the volume mounted at
// <conanSteamCMDInstallDir>/steamapps/workshop, SteamCMD's workshop
// downloads (which always land at <install_dir>/steamapps/workshop/
// content/<appid>/<id>/) land at the volume root.
conanSteamCMDInstallDir = "/conan-workshop"
// conanWorkshopGameMountIn is where the same volume mounts inside
// the GAME container. Different mount path from the sidecar; same
// volume content. Workshop content appears at
// /game/steamapps/workshop/content/<appid>/<workshop_id>/<file>.pak
// — under /game (a declared browseable_root), so FsList/FsSymlink
// don't need a separate /conan-workshop root declaration.
conanWorkshopGameMountIn = "/game/steamapps/workshop"
// conanModsDir is where Conan reads mod content from at boot time.
// Per Funcom-aligned hosting docs (Akliz / XGamingServer / Nodecraft,
// 2026), every .pak must live FLAT under this directory — Conan does
// NOT walk subdirectories of /game/ConanSandbox/Mods/. We satisfy
// that by symlinking each individual .pak from the shared workshop
// volume into here at install time (one symlink per .pak file, not
// per workshop_id).
conanModsDir = "/game/ConanSandbox/Mods"
// conanModlistFile is the operator-readable line-per-pak load order.
// Format (verified 2026 against Akliz + XGamingServer + Funcom wiki):
// *Filename1.pak
// *Filename2.pak
// Lines MUST start with `*` — without it Conan ignores the entry as
// "subscribe-only marker" and the mod doesn't load. Path is just the
// filename (relative to conanModsDir). One line per .pak; multi-pak
// mods (Pippi ships Pippi.pak + Pippi_Editor.pak) get multiple lines.
conanModlistFile = "/game/ConanSandbox/Mods/modlist.txt"
// conanSidecarFile tracks workshop_id ↔ {title, preview, paks} on
// disk so the panel can render the installed-mods list with rich
// metadata (titles, sizes, thumbs) without re-fetching from Steam,
// and so uninstall can find which symlinks belong to which workshop
// item. Lives next to modlist.txt; ignored by Conan (the dot-prefix
// keeps it out of pak loading and the file is hidden in most pak
// scanners). Format is JSON; see conanSidecarEntry for the shape.
conanSidecarFile = "/game/ConanSandbox/Mods/.panel-conan-mods.json"
)
// conanSidecarEntry is one workshop item's recorded metadata + the .pak
// filenames that belong to it. The sidecar map is keyed by workshop_id;
// every value is the entry for that id.
type conanSidecarEntry struct {
Title string `json:"title,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
Size int64 `json:"size,omitempty"`
Updated int64 `json:"updated,omitempty"`
Description string `json:"description,omitempty"`
Paks []string `json:"paks"`
InstalledAt int64 `json:"installed_at,omitempty"` // unix seconds
}
type conanSidecar map[string]*conanSidecarEntry
// conanMod is one entry in the installed-mods list rendered by the UI.
// Same shape as dayzMod — separate type so callers don't conflate them
// even though they share most fields.
type conanMod struct {
WorkshopID string `json:"workshop_id"`
Filename string `json:"filename"` // e.g. "Savage_Wilds.pak" or "Pippi.pak"
Order int `json:"order"`
// Optional metadata hydrated from Steam's GetPublishedFileDetails:
Title string `json:"title,omitempty"`
PreviewURL string `json:"preview_url,omitempty"`
Size int64 `json:"size,omitempty"`
Updated int64 `json:"updated,omitempty"` // unix seconds
Description string `json:"description,omitempty"`
}
type conanInstallReq struct {
WorkshopID string `json:"workshop_id"` // numeric id OR full Steam URL; parsed below
}
// ---- list ----
// handleConanModsList reads modlist.txt + the sidecar JSON, joins them so
// each .pak entry carries the workshop_id it belongs to, and returns the
// list in load order. The sidecar is the source of truth for grouping +
// metadata; modlist.txt is the source of truth for load order. If they
// drift (operator hand-edited modlist.txt), the modlist wins for ordering
// and any unknown .pak filenames are returned with empty workshop_id so
// the UI can flag them as "manually added".
func (h *httpServer) handleConanModsList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
paks := h.conanReadModlist(r.Context(), conn, id)
sidecar := h.conanReadSidecar(r.Context(), conn, id)
// Build pak → workshop_id reverse index from the sidecar so we can
// tag each modlist.txt entry with its owning workshop_id.
pakToWS := map[string]string{}
for wsID, e := range sidecar {
if e == nil {
continue
}
for _, p := range e.Paks {
pakToWS[p] = wsID
}
}
out := make([]conanMod, 0, len(paks))
for i, p := range paks {
entry := conanMod{
Filename: p,
Order: i,
}
if wsID := pakToWS[p]; wsID != "" {
entry.WorkshopID = wsID
if e := sidecar[wsID]; e != nil {
entry.Title = e.Title
entry.PreviewURL = e.PreviewURL
entry.Size = e.Size
entry.Updated = e.Updated
entry.Description = e.Description
}
}
out = append(out, entry)
}
writeJSON(w, http.StatusOK, map[string]any{"mods": out})
}
// ---- install (async) ----
// handleConanModInstall kicks off an async install and returns {job_id}
// in <200 ms. UI polls /conan/jobs to track progress. Mirrors DayZ's
// async pattern so a 2 GB Pippi download doesn't tie up a browser
// request.
func (h *httpServer) handleConanModInstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
var req conanInstallReq
if err := json.NewDecoder(io.LimitReader(r.Body, 4096)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
workshopID, perr := parseWorkshopID(req.WorkshopID)
if perr != nil {
writeError(w, http.StatusBadRequest, "bad_workshop_id", perr.Error())
return
}
// Pre-fetch metadata. Lets the UI render the friendly name + size on
// the queued card before the download even starts.
var title string
var size, updated int64
if d, derr := steamWorkshopDetail(workshopID); derr == nil && d != nil {
title, size, updated = d.Title, d.Size, d.TimeUpdated
}
// Steam credential check up-front so the UI can pop the sign-in
// modal without burning a job slot on a guaranteed-401.
user, _, ok := h.getSteamLoginForUpdate(r.Context())
if !ok {
writeJSON(w, http.StatusOK, map[string]any{
"steam_login_required": true,
"reason": "Conan Workshop downloads need a Steam account that owns Conan Exiles. Sign in once — the token is cached for next time.",
})
return
}
// Reject duplicate concurrent installs of the same mod on the same
// instance — operator double-click guard.
for _, existing := range h.dayzJobs.listForInstance(id) {
existing.mu.Lock()
dup := existing.WorkshopID == workshopID &&
(existing.Phase == phaseQueued || existing.Phase == phaseDownloading ||
existing.Phase == phaseLinking || existing.Phase == phaseFinalizing)
existingID := existing.ID
existing.mu.Unlock()
if dup {
writeJSON(w, http.StatusAccepted, map[string]any{
"accepted": true,
"job_id": existingID,
"duplicate": true,
})
return
}
}
// FolderName is unused for Conan (we symlink by workshop_id directly),
// but the dayzModJob struct requires a non-empty value to render in
// the UI. Stick the workshop_id there.
job := h.dayzJobs.create(id, workshopID, workshopID, false)
job.setResult(title, size, updated)
job.appendLine(fmt.Sprintf("queued: %s (id=%s)", title, workshopID))
h.log.Info("conan mod install queued",
"job_id", job.ID, "instance_id", id, "workshop_id", workshopID, "title", title)
// Detach goroutine — context.Background, NOT r.Context — so the HTTP
// request can return immediately and the operator can close the tab.
go h.runConanModInstallJob(context.Background(), job, agentID, user)
writeJSON(w, http.StatusAccepted, map[string]any{
"accepted": true,
"job_id": job.ID,
"workshop_id": workshopID,
"title": title,
})
}
// runConanModInstallJob is the install pipeline. Three phases: download
// (SteamCMD sidecar), link (FsSymlinkRequest to agent), finalize (rewrite
// modlist.txt). Each phase updates job state for the UI poller.
func (h *httpServer) runConanModInstallJob(ctx context.Context, job *dayzModJob, agentID, user string) {
defer func() {
if rec := recover(); rec != nil {
h.log.Error("conan mod install goroutine panic", "job_id", job.ID, "recover", rec)
job.setError(fmt.Sprintf("internal error: %v", rec), false)
}
}()
// ---- phase: download ----
job.setPhase(phaseDownloading)
err := runSteamCMDConanWorkshopDownloadStreaming(ctx, h.log, user, job, jobLineSink(job))
if err != nil {
if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) {
job.setLoginNeeded("Your Steam session expired. Sign in again to refresh the cached token.")
return
}
permanent := errors.Is(err, errWorkshopNotFound) ||
strings.Contains(err.Error(), "Access Denied") ||
strings.Contains(err.Error(), "own Conan Exiles")
friendly := friendlyConanDownloadError(err)
job.appendLine("download failed: " + friendly)
job.setError(friendly, permanent)
return
}
// ---- phase: linking ----
job.setPhase(phaseLinking)
job.appendLine("requesting agent: list workshop content + create per-pak symlinks")
conn := h.registry.get(agentID)
if conn == nil {
job.setError("agent disconnected during download", false)
return
}
// The workshop content dir, as seen from inside the GAME container,
// is /game/steamapps/workshop/content/440900/<id>/ (the volume mount
// point inside the game; see module.yaml). We send FsList there.
// The SAME volume content is at /conan-workshop/steamapps/workshop/
// content/440900/<id>/ from the SteamCMD sidecar's perspective —
// that's where the download wrote to.
workshopContentDir := path.Join(conanWorkshopGameMountIn, "content", conanWorkshopAppID, job.WorkshopID)
pakFiles, lerr := h.conanListPakFiles(ctx, conn, job.InstanceID, workshopContentDir)
if lerr != nil {
job.setError("agent list workshop dir: "+lerr.Error(), false)
return
}
if len(pakFiles) == 0 {
job.setError("workshop item downloaded but contains no .pak files (is it really a Conan mod?)", true)
return
}
job.appendLine(fmt.Sprintf("found %d pak file%s: %s",
len(pakFiles), plural(len(pakFiles)), strings.Join(pakFiles, ", ")))
// Conan reads .pak files FLAT from /game/ConanSandbox/Mods/ — it
// does not walk subdirectories. So one symlink per .pak file:
// /game/ConanSandbox/Mods/<filename>.pak
// → /conan-workshop/steamapps/workshop/content/440900/<id>/<filename>.pak
// On collision (two mods ship the same .pak filename) the second
// install would overwrite the first; we surface this as a soft
// warning rather than a hard error so the operator can rename one.
for _, f := range pakFiles {
linkPath := path.Join(conanModsDir, f)
target := path.Join(workshopContentDir, f)
corrID := newCorrelationID("conan_symlink")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsSymlink{
FsSymlink: &panelv1.FsSymlinkRequest{
InstanceId: job.InstanceID,
Target: target,
LinkPath: linkPath,
},
},
}); err != nil {
job.setError(fmt.Sprintf("forward symlink %s to agent: %s", f, err.Error()), false)
return
}
awaitCtx, awaitCancel := context.WithTimeout(ctx, 30*time.Second)
env, err := h.pending.Await(awaitCtx, corrID, 30*time.Second)
awaitCancel()
if err != nil {
job.setError(fmt.Sprintf("agent didn't reply to symlink %s: %s", f, err.Error()), false)
return
}
if res := env.GetFsSymlinkResult(); res == nil {
job.setError(fmt.Sprintf("agent returned empty symlink reply for %s", f), false)
return
} else if res.Error != "" {
job.setError(fmt.Sprintf("agent symlink %s: %s", f, res.Error), false)
return
}
}
job.appendLine(fmt.Sprintf("symlinked %d pak%s into %s", len(pakFiles), plural(len(pakFiles)), conanModsDir))
job.setAgentResult(pakFiles, workshopContentDir)
// ---- phase: finalizing ----
job.setPhase(phaseFinalizing)
// Update the sidecar JSON FIRST: list endpoint reads it, modlist
// uses it for grouping. We pull current state, merge the new entry,
// write it back.
sidecar := h.conanReadSidecar(ctx, conn, job.InstanceID)
if sidecar == nil {
sidecar = conanSidecar{}
}
// Pull rich metadata. Title was set during queue but we may have
// gotten less than full detail — fetching now gives us description
// + preview for the UI without a separate Steam call later.
title, previewURL, size, updated := "", "", int64(0), int64(0)
description := ""
if d, derr := steamWorkshopDetail(job.WorkshopID); derr == nil && d != nil {
title, previewURL, size, updated = d.Title, d.PreviewURL, d.Size, d.TimeUpdated
description = d.Description
}
sidecar[job.WorkshopID] = &conanSidecarEntry{
Title: title,
PreviewURL: previewURL,
Size: size,
Updated: updated,
Description: description,
Paks: pakFiles,
InstalledAt: time.Now().Unix(),
}
if err := h.conanWriteSidecar(ctx, conn, job.InstanceID, sidecar); err != nil {
// Non-fatal: modlist.txt and symlinks still work; just the UI's
// grouping/metadata fall back to "manually added" rendering.
job.appendLine("warning: failed to update sidecar metadata: " + err.Error())
}
// Append entries to modlist.txt with the *Filename.pak format Conan
// requires. Don't disturb existing entries (operator may have
// hand-added paks); just add the new ones at the end if not already
// present.
if err := h.conanAppendModlist(ctx, conn, job.InstanceID, pakFiles); err != nil {
job.setError("update modlist.txt: "+err.Error(), false)
return
}
job.appendLine(fmt.Sprintf("added %d entr%s to modlist.txt (with required *prefix)",
len(pakFiles), map[bool]string{true: "ies", false: "y"}[len(pakFiles) != 1]))
job.setPercent(100)
job.setPhase(phaseDone)
h.log.Info("conan mod install done", "job_id", job.ID, "workshop_id", job.WorkshopID,
"pak_count", len(pakFiles), "paks", pakFiles)
}
// ---- uninstall ----
func (h *httpServer) handleConanModUninstall(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
wsID := r.PathValue("workshopId")
if wsID == "" {
writeError(w, http.StatusBadRequest, "bad_id", "workshop_id path segment is required")
return
}
// 1. Look up which .pak symlinks belong to this workshop_id (via the
// sidecar). If the sidecar lost track somehow, fall back to
// leaving the symlinks (operator can hand-clean) and just clear
// the modlist entries we know.
sidecar := h.conanReadSidecar(r.Context(), conn, id)
var paks []string
if e := sidecar[wsID]; e != nil {
paks = e.Paks
}
// 2. Remove each individual .pak symlink. Each symlink only points
// into the shared workshop volume; we never recurse into the
// workshop content itself (which other instances may still need).
removed := []string{}
for _, p := range paks {
corrID := newCorrelationID("conan_unlink")
linkPath := path.Join(conanModsDir, p)
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsDelete{
FsDelete: &panelv1.FsDeleteRequest{
InstanceId: id,
Path: linkPath,
Recursive: false,
},
},
}); err != nil {
h.log.Warn("conan uninstall: forward delete failed", "pak", p, "err", err)
continue
}
env, err := h.pending.Await(r.Context(), corrID, 15*time.Second)
if err != nil {
h.log.Warn("conan uninstall: agent reply timeout", "pak", p, "err", err)
continue
}
if res := env.GetFsDeleteResult(); res != nil && res.Error != "" && !strings.Contains(res.Error, "no such file") {
h.log.Warn("conan uninstall: agent delete error", "pak", p, "err", res.Error)
continue
}
removed = append(removed, p)
}
// 3. Drop modlist.txt lines whose filename matches one of `paks`.
// If sidecar was empty we're a no-op here, which is correct.
if len(paks) > 0 {
if err := h.conanRemoveFromModlist(r.Context(), conn, id, paks); err != nil {
h.log.Warn("conan mod uninstall: modlist update failed", "instance_id", id, "workshop_id", wsID, "err", err)
}
}
// 4. Drop the workshop_id from the sidecar.
if sidecar[wsID] != nil {
delete(sidecar, wsID)
if err := h.conanWriteSidecar(r.Context(), conn, id, sidecar); err != nil {
h.log.Warn("conan mod uninstall: sidecar update failed", "instance_id", id, "err", err)
}
}
writeJSON(w, http.StatusOK, map[string]any{
"ok": true,
"workshop_id": wsID,
"removed_paks": removed,
})
}
// ---- reorder ----
type conanReorderReq struct {
// Either WorkshopIDs (groups by mod) or full Entries (raw modlist
// lines). UI sends WorkshopIDs; entries are derived from the current
// file order so all .paks for one mod stay grouped.
WorkshopIDs []string `json:"workshop_ids"`
}
func (h *httpServer) handleConanModReorder(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
var req conanReorderReq
if err := json.NewDecoder(io.LimitReader(r.Body, 64*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
current := h.conanReadModlist(r.Context(), conn, id)
sidecar := h.conanReadSidecar(r.Context(), conn, id)
// Group current paks by workshop_id (via sidecar) so a multi-pak
// mod's internal pak order is preserved when the operator reorders
// at the workshop_id granularity. Paks not tied to any sidecar entry
// (operator-hand-added mods) are bucketed under "" and appended at
// the end.
pakToWS := map[string]string{}
for wsID, e := range sidecar {
if e == nil {
continue
}
for _, p := range e.Paks {
pakToWS[p] = wsID
}
}
byWS := map[string][]string{}
for _, p := range current {
wsID := pakToWS[p]
byWS[wsID] = append(byWS[wsID], p)
}
out := []string{}
seen := map[string]bool{}
for _, wsID := range req.WorkshopIDs {
if seen[wsID] {
continue
}
seen[wsID] = true
out = append(out, byWS[wsID]...)
delete(byWS, wsID)
}
// Append any leftovers (mods in the file but not in the reorder
// request — operator hand-added or sidecar-orphaned). Stable order
// by workshop_id so reorders are deterministic; "" bucket last.
leftoverKeys := make([]string, 0, len(byWS))
for k := range byWS {
if k != "" {
leftoverKeys = append(leftoverKeys, k)
}
}
sort.Strings(leftoverKeys)
for _, k := range leftoverKeys {
out = append(out, byWS[k]...)
}
out = append(out, byWS[""]...) // hand-added paks last
if err := h.conanWriteModlist(r.Context(), conn, id, out); err != nil {
writeError(w, http.StatusBadGateway, "write_modlist", err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "entries": out})
}
// ---- job polling endpoints ----
func (h *httpServer) handleConanModJobList(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
jobs := h.dayzJobs.listForInstance(id)
snaps := make([]map[string]any, 0, len(jobs))
for _, j := range jobs {
snaps = append(snaps, j.snapshot())
}
writeJSON(w, http.StatusOK, map[string]any{"jobs": snaps})
}
func (h *httpServer) handleConanModJobGet(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
job := h.dayzJobs.get(r.PathValue("jobId"))
if job == nil || job.InstanceID != id {
writeError(w, http.StatusNotFound, "not_found", "no such job")
return
}
writeJSON(w, http.StatusOK, job.snapshot())
}
func (h *httpServer) handleConanModJobDelete(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, err := h.lookupInstance(r, id); err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
jobID := r.PathValue("jobId")
job := h.dayzJobs.get(jobID)
if job == nil || job.InstanceID != id {
writeError(w, http.StatusNotFound, "not_found", "no such job")
return
}
h.dayzJobs.remove(jobID)
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "id": jobID})
}
// ---- modlist.txt helpers ----
// conanReadModlist returns the .pak filenames in load order. Each line
// must be of the form "*Filename.pak"; the leading * is stripped before
// returning. Blank lines and # comments are skipped. We also tolerate
// (legacy / hand-edited) entries WITHOUT the asterisk and entries with
// a path prefix (which we strip down to the basename) — the panel will
// normalise on next write.
func (h *httpServer) conanReadModlist(ctx context.Context, conn *agentConn, instanceID string) []string {
res, err := h.dayzXmlReadFile(ctx, conn, instanceID, conanModlistFile)
if err != nil || res == nil || res.Error != "" {
return nil
}
out := []string{}
scanner := bufio.NewScanner(bytes.NewReader(res.Content))
for scanner.Scan() {
raw := strings.TrimSpace(scanner.Text())
if raw == "" || strings.HasPrefix(raw, "#") {
continue
}
line := strings.TrimPrefix(raw, "*")
// Normalise path separators + strip directory prefix so a legacy
// entry "*<workshop_id>/Mod.pak" still groups by the underlying
// pak name. Conan-correct entries are bare filenames already.
line = strings.ReplaceAll(line, "\\", "/")
if i := strings.LastIndex(line, "/"); i >= 0 {
line = line[i+1:]
}
line = strings.TrimSpace(line)
if line == "" {
continue
}
out = append(out, line)
}
return out
}
// conanAppendModlist adds each new pak filename in order, deduped.
func (h *httpServer) conanAppendModlist(ctx context.Context, conn *agentConn, instanceID string, newPaks []string) error {
current := h.conanReadModlist(ctx, conn, instanceID)
have := map[string]bool{}
rendered := make([]string, 0, len(current)+len(newPaks))
for _, p := range current {
have[p] = true
rendered = append(rendered, p)
}
for _, p := range newPaks {
if have[p] {
continue
}
have[p] = true
rendered = append(rendered, p)
}
return h.conanWriteModlist(ctx, conn, instanceID, rendered)
}
// conanRemoveFromModlist drops every line whose filename appears in the
// supplied set (case-insensitive on the filename itself, since Wine on
// Linux normalises pak-file lookups case-insensitively).
func (h *httpServer) conanRemoveFromModlist(ctx context.Context, conn *agentConn, instanceID string, removePaks []string) error {
current := h.conanReadModlist(ctx, conn, instanceID)
drop := map[string]bool{}
for _, p := range removePaks {
drop[strings.ToLower(p)] = true
}
rendered := make([]string, 0, len(current))
for _, p := range current {
if drop[strings.ToLower(p)] {
continue
}
rendered = append(rendered, p)
}
return h.conanWriteModlist(ctx, conn, instanceID, rendered)
}
// conanWriteModlist rewrites modlist.txt with the supplied .pak file
// names, each prefixed with `*` (REQUIRED by Conan — without it the
// dedicated server treats the entry as a no-op marker, see Funcom +
// Akliz + XGamingServer docs 2026). A header comment helps operators
// eyeballing the file recognise it as panel-managed.
func (h *httpServer) conanWriteModlist(ctx context.Context, conn *agentConn, instanceID string, paks []string) error {
var body bytes.Buffer
body.WriteString("# panel-managed mod list — one *Filename.pak per line.\n")
body.WriteString("# The leading asterisk is REQUIRED — without it Conan ignores the entry.\n")
body.WriteString("# Blank lines and '#' comments ignored. Edits via the panel UI Mods\n")
body.WriteString("# tab will overwrite this file; manual edits survive only until then.\n")
for _, p := range paks {
body.WriteString("*")
body.WriteString(p)
body.WriteByte('\n')
}
return h.dayzXmlWriteFile(ctx, conn, instanceID, conanModlistFile, body.Bytes())
}
// conanReadSidecar loads the workshop_id ↔ {metadata, paks} map from
// /game/ConanSandbox/Mods/.panel-conan-mods.json. Returns an empty map
// on any error so callers can treat "fresh state" and "lost sidecar"
// the same way.
func (h *httpServer) conanReadSidecar(ctx context.Context, conn *agentConn, instanceID string) conanSidecar {
res, err := h.dayzXmlReadFile(ctx, conn, instanceID, conanSidecarFile)
if err != nil || res == nil || res.Error != "" || len(res.Content) == 0 {
return conanSidecar{}
}
out := conanSidecar{}
if err := json.Unmarshal(res.Content, &out); err != nil {
h.log.Warn("conan sidecar parse failed", "instance_id", instanceID, "err", err)
return conanSidecar{}
}
return out
}
// conanWriteSidecar persists the workshop_id map. Pretty-prints with
// 2-space indent so an operator inspecting the file by hand can read
// the layout.
func (h *httpServer) conanWriteSidecar(ctx context.Context, conn *agentConn, instanceID string, s conanSidecar) error {
body, err := json.MarshalIndent(s, "", " ")
if err != nil {
return err
}
body = append(body, '\n')
return h.dayzXmlWriteFile(ctx, conn, instanceID, conanSidecarFile, body)
}
// ---- pak filename discovery ----
// conanListPakFiles asks the agent to list `dir` (inside the instance
// container) and returns just the .pak filenames. Sorted for stable load
// order across operators.
func (h *httpServer) conanListPakFiles(ctx context.Context, conn *agentConn, instanceID, dir string) ([]string, error) {
corrID := newCorrelationID("conan_list_pak")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsList{
FsList: &panelv1.FsListRequest{
InstanceId: instanceID,
Path: dir,
},
},
}); err != nil {
return nil, fmt.Errorf("forward list to agent: %w", err)
}
awaitCtx, awaitCancel := context.WithTimeout(ctx, 15*time.Second)
env, err := h.pending.Await(awaitCtx, corrID, 15*time.Second)
awaitCancel()
if err != nil {
return nil, err
}
res := env.GetFsListResult()
if res == nil {
return nil, errors.New("agent returned empty fs_list reply")
}
if res.Error != "" {
return nil, errors.New(res.Error)
}
out := []string{}
for _, e := range res.Entries {
if e == nil || e.IsDir {
continue
}
if !strings.HasSuffix(strings.ToLower(e.Name), ".pak") {
continue
}
out = append(out, e.Name)
}
sort.Strings(out)
return out, nil
}
// ---- SteamCMD download (Conan-flavoured) ----
// runSteamCMDConanWorkshopDownloadStreaming streams stdout into onLine
// while running a SteamCMD sidecar to download one Conan workshop item
// into the shared panel-conan-workshop volume. Same retry-on-timeout
// pattern as the DayZ flavour, with Conan-specific error classification
// (the "doesn't own DayZ" line becomes "doesn't own Conan Exiles").
func runSteamCMDConanWorkshopDownloadStreaming(ctx context.Context, log Logger, user string, job *dayzModJob, onLine func(string)) error {
if user == "" {
return errTokenMissing
}
const maxAttempts = 4
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if attempt > 1 {
backoff := time.Duration(attempt*attempt-1) * 10 * time.Second
onLine(fmt.Sprintf("retry in %s (attempt %d/%d) — last: %v", backoff, attempt, maxAttempts, lastErr))
select {
case <-time.After(backoff):
case <-ctx.Done():
return ctx.Err()
}
}
onLine(fmt.Sprintf("__attempt__:%d", attempt))
err := runSteamCMDConanWorkshopDownloadOnceStreaming(ctx, log, user, job.WorkshopID, onLine)
if err == nil {
return nil
}
if errors.Is(err, errTokenStale) || errors.Is(err, errTokenMissing) ||
errors.Is(err, errWorkshopNotFound) ||
strings.Contains(err.Error(), "Access Denied") ||
strings.Contains(err.Error(), "own Conan Exiles") {
return err
}
lastErr = err
}
return fmt.Errorf("conan workshop download failed after %d attempts — last: %w", maxAttempts, lastErr)
}
func runSteamCMDConanWorkshopDownloadOnceStreaming(ctx context.Context, log Logger, user, workshopID string, onLine func(string)) error {
dockerBin := locateDockerBinary()
cctx, cancel := context.WithTimeout(ctx, 15*time.Minute)
defer cancel()
args := []string{
"run", "--rm",
"-v", "panel-steamcmd-auth:/root/.local/share/Steam",
// Mount the workshop volume DEEP — at
// <install_dir>/steamapps/workshop — so SteamCMD's standard
// workshop write path (<install_dir>/steamapps/workshop/content/
// <appid>/<id>/) lands at the volume root. The game container
// mounts the same volume at /game/steamapps/workshop, which
// makes the content visible there as
// /game/steamapps/workshop/content/<appid>/<id>/. Verified
// pattern — same as DayZ.
"-v", conanWorkshopVolume + ":" + conanSteamCMDInstallDir + "/steamapps/workshop",
"steamcmd/steamcmd:latest",
"+force_install_dir", conanSteamCMDInstallDir,
"+login", user,
"+workshop_download_item", conanWorkshopAppID, workshopID, "validate",
"+quit",
}
cmd := exec.CommandContext(cctx, dockerBin, args...)
var full bytes.Buffer
lw := &lineWriter{
full: &full,
cb: func(line string) {
onLine(ansiEscapeRE.ReplaceAllString(line, ""))
},
}
cmd.Stdout = lw
cmd.Stderr = lw
err := cmd.Run()
lw.flush()
plain := ansiEscapeRE.ReplaceAllString(full.String(), "")
log.Info("steamcmd conan workshop_download output", "workshop_id", workshopID,
"exit_err", err, "tail", lastFewLines(plain, 20))
switch {
case strings.Contains(plain, "Success. Downloaded item"):
return nil
case strings.Contains(plain, "failed (File Not Found)"),
strings.Contains(plain, "failed (Invalid App ID)"):
return errWorkshopNotFound
case strings.Contains(plain, "Logon failure"),
strings.Contains(plain, "Invalid Password"),
strings.Contains(plain, "No cached credentials"),
strings.Contains(plain, "cached credentials are invalid"),
strings.Contains(plain, "FAILED (Rate Limit Exceeded)"),
strings.Contains(plain, "FAILED (Access Denied)"):
return errTokenStale
case strings.Contains(plain, "failed (Access Denied)"):
return fmt.Errorf("Access Denied — this workshop item is restricted for your account: %s", lastFewLines(plain, 6))
case strings.Contains(plain, "No subscription") || strings.Contains(plain, "Missing configuration"):
return fmt.Errorf("Steam account doesn't own Conan Exiles (or item access denied): %s", lastFewLines(plain, 6))
case strings.Contains(plain, "Timeout downloading item"):
return fmt.Errorf("steamcmd timeout mid-download (will retry)")
case err != nil:
return fmt.Errorf("steamcmd exec: %w — last output: %s", err, lastFewLines(plain, 6))
}
return fmt.Errorf("steamcmd: unknown result — tail:\n%s", lastFewLines(plain, 12))
}
func friendlyConanDownloadError(err error) string {
if err == nil {
return ""
}
switch {
case errors.Is(err, errWorkshopNotFound):
return "Workshop item not found — ID is wrong or the mod was delisted."
case errors.Is(err, errTokenStale):
return "Steam session expired — sign in again to refresh the cached token."
case errors.Is(err, errTokenMissing):
return "No Steam username configured — complete the Steam login first."
case strings.Contains(err.Error(), "Access Denied"):
return "Access denied — this mod is restricted for your Steam account (region-lock or private item)."
case strings.Contains(err.Error(), "own Conan Exiles"):
return "Your Steam account doesn't own Conan Exiles — use an account that has the game."
case strings.Contains(err.Error(), "timeout"):
return "SteamCMD timed out after 4 retries. Try again in a few minutes — Steam CDN is flaky."
}
msg := err.Error()
if i := strings.Index(msg, "\n"); i > 0 {
msg = msg[:i]
}
if len(msg) > 200 {
msg = msg[:200] + "…"
}
return msg
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestSameOriginHost(t *testing.T) {
cases := []struct {
origin, host string
want bool
}{
{"https://panel.example.com", "panel.example.com", true},
{"http://panel.example.com:8080", "panel.example.com:8080", true},
{"https://PANEL.example.com", "panel.example.com", true},
{"https://evil.com", "panel.example.com", false},
{"https://panel.example.com.evil.com", "panel.example.com", false},
{"null", "panel.example.com", false},
{"", "panel.example.com", false},
}
for _, c := range cases {
if got := sameOriginHost(c.origin, c.host); got != c.want {
t.Errorf("sameOriginHost(%q, %q) = %v, want %v", c.origin, c.host, got, c.want)
}
}
}
func TestWithCORSReflection(t *testing.T) {
h := withCORS(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
// Same-origin browser request: origin reflected.
r := httptest.NewRequest("GET", "http://panel.local/api/agents", nil)
r.Host = "panel.local"
r.Header.Set("Origin", "https://panel.local")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "https://panel.local" {
t.Errorf("same-origin ACAO = %q, want reflected origin", got)
}
// Cross-origin: no ACAO header at all.
r = httptest.NewRequest("GET", "http://panel.local/api/agents", nil)
r.Host = "panel.local"
r.Header.Set("Origin", "https://evil.com")
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if got := w.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("cross-origin ACAO = %q, want empty", got)
}
// No Origin (curl/panelctl): no ACAO, request still passes through.
r = httptest.NewRequest("OPTIONS", "http://panel.local/api/agents", nil)
w = httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != http.StatusNoContent {
t.Errorf("OPTIONS status = %d, want 204", w.Code)
}
if got := w.Header().Get("Cache-Control"); got == "" {
t.Error("Cache-Control no-store stamp missing")
}
}
File diff suppressed because it is too large Load Diff
+295
View File
@@ -0,0 +1,295 @@
package main
// In-memory async-job system for DayZ Workshop mod installs.
//
// Why: a single mod can be 2+ GB. Tying the install to the POST /dayz/mods
// request means the browser (and any curl client) disconnects long before
// steamcmd finishes — and when the request context cancels we'd kill the
// download. This package-local job runner lets POST return immediately with
// a job_id, then the UI polls GET /dayz/mods/jobs while steamcmd writes
// progress lines into the job's log buffer. Jobs survive browser-close but
// not controller-restart (by design: steamcmd's on-disk chunk hashes resume
// after a restart, so the worst case is one more retry).
import (
"sort"
"sync"
"time"
)
// dayzModJobPhase is the coarse-grained status a user cares about. We avoid
// exposing internal attempt counters as "phase" — those live as side data.
type dayzModJobPhase string
const (
phaseQueued dayzModJobPhase = "queued"
phaseDownloading dayzModJobPhase = "downloading" // steamcmd is running
phaseLinking dayzModJobPhase = "linking" // agent is creating symlink + copying bikeys
phaseFinalizing dayzModJobPhase = "finalizing" // writing panel-mods.txt
phaseDone dayzModJobPhase = "done"
phaseError dayzModJobPhase = "error"
phaseLoginNeeded dayzModJobPhase = "login_needed" // cached Steam token expired
)
// dayzModJob is one mod-install's state. All reads/writes MUST go through
// the helper methods which take the mutex.
type dayzModJob struct {
ID string
InstanceID string
WorkshopID string
FolderName string
ServerMod bool
mu sync.Mutex
Phase dayzModJobPhase
Attempt int // 1-based; current or final SteamCMD attempt
// Rolling log of the most recent N lines from steamcmd + agent. UI
// renders the tail in a small mono block under the progress bar.
Lines []string
// Percent is best-effort from steamcmd's "progress: N.NN" lines; if
// we can't parse it, UI shows an indeterminate bar.
Percent float64
StartedAt time.Time
EndedAt time.Time
// Populated on terminal phases.
Error string // empty unless Phase == error/login_needed
Permanent bool // true when retrying would definitely fail again (not-found, access-denied, stale-token)
Title string // from GetPublishedFileDetails
Size int64
Updated int64
BikeysCopied []string
ResolvedModPath string
}
const dayzModJobLineBufCap = 200 // tail lines retained
func (j *dayzModJob) snapshot() map[string]any {
j.mu.Lock()
defer j.mu.Unlock()
lines := append([]string(nil), j.Lines...)
terminal := j.Phase == phaseDone || j.Phase == phaseError || j.Phase == phaseLoginNeeded
// can_retry: the UI's Retry button is only useful when trying again
// MIGHT succeed. Login-needed is a special case handled by auto-popping
// the sign-in modal, not by the Retry button.
canRetry := j.Phase == phaseError && !j.Permanent
return map[string]any{
"id": j.ID,
"instance_id": j.InstanceID,
"workshop_id": j.WorkshopID,
"folder_name": j.FolderName,
"server_mod": j.ServerMod,
"phase": string(j.Phase),
"attempt": j.Attempt,
"lines": lines,
"percent": j.Percent,
"started_at": j.StartedAt.UTC().Format(time.RFC3339),
"ended_at": jobTimeOrZero(j.EndedAt),
"error": j.Error,
"permanent": j.Permanent,
"can_retry": canRetry,
"title": j.Title,
"size": j.Size,
"updated": j.Updated,
"bikeys_copied": j.BikeysCopied,
"resolved_mod_path": j.ResolvedModPath,
"terminal": terminal,
}
}
func jobTimeOrZero(t time.Time) string {
if t.IsZero() {
return ""
}
return t.UTC().Format(time.RFC3339)
}
// setPhase is the basic state-machine step. Also updates EndedAt on terminal.
func (j *dayzModJob) setPhase(p dayzModJobPhase) {
j.mu.Lock()
defer j.mu.Unlock()
j.Phase = p
switch p {
case phaseDone, phaseError, phaseLoginNeeded:
if j.EndedAt.IsZero() {
j.EndedAt = time.Now()
}
}
}
func (j *dayzModJob) setError(msg string, permanent bool) {
j.mu.Lock()
j.Error = msg
j.Permanent = permanent
j.Phase = phaseError
if j.EndedAt.IsZero() {
j.EndedAt = time.Now()
}
j.mu.Unlock()
}
func (j *dayzModJob) setLoginNeeded(reason string) {
j.mu.Lock()
j.Error = reason
j.Phase = phaseLoginNeeded
if j.EndedAt.IsZero() {
j.EndedAt = time.Now()
}
j.mu.Unlock()
}
func (j *dayzModJob) appendLine(s string) {
j.mu.Lock()
j.Lines = append(j.Lines, s)
if len(j.Lines) > dayzModJobLineBufCap {
// Ring-buffer trim — keep newest.
j.Lines = j.Lines[len(j.Lines)-dayzModJobLineBufCap:]
}
j.mu.Unlock()
}
func (j *dayzModJob) setPercent(pct float64) {
if pct < 0 {
pct = 0
} else if pct > 100 {
pct = 100
}
j.mu.Lock()
j.Percent = pct
j.mu.Unlock()
}
func (j *dayzModJob) setAttempt(n int) {
j.mu.Lock()
j.Attempt = n
j.mu.Unlock()
}
func (j *dayzModJob) setResult(title string, size, updated int64) {
j.mu.Lock()
j.Title = title
j.Size = size
j.Updated = updated
j.mu.Unlock()
}
func (j *dayzModJob) setAgentResult(bikeys []string, resolvedPath string) {
j.mu.Lock()
j.BikeysCopied = append([]string(nil), bikeys...)
j.ResolvedModPath = resolvedPath
j.mu.Unlock()
}
// ---- manager ----
type dayzModJobManager struct {
mu sync.RWMutex
jobs map[string]*dayzModJob
}
func newDayzModJobManager() *dayzModJobManager {
m := &dayzModJobManager{jobs: make(map[string]*dayzModJob)}
go m.reaperLoop()
return m
}
func (m *dayzModJobManager) create(instanceID, workshopID, folder string, serverMod bool) *dayzModJob {
j := &dayzModJob{
ID: newCorrelationID("dzmodjob"),
InstanceID: instanceID,
WorkshopID: workshopID,
FolderName: folder,
ServerMod: serverMod,
Phase: phaseQueued,
StartedAt: time.Now(),
}
m.mu.Lock()
m.jobs[j.ID] = j
m.mu.Unlock()
return j
}
func (m *dayzModJobManager) get(id string) *dayzModJob {
m.mu.RLock()
defer m.mu.RUnlock()
return m.jobs[id]
}
// remove drops a job from the map. Safe to call on non-terminal jobs —
// the in-flight goroutine keeps running but its state updates will land
// on a detached record that no one else can observe, which is fine (the
// operator asked to dismiss it). We never actually "cancel" the download
// because steamcmd has no safe abort signal short of SIGKILL — which
// would strand partial on-disk chunks that the next retry would pick up
// anyway, so this is a controlled orphan rather than a leak.
func (m *dayzModJobManager) remove(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, ok := m.jobs[id]; !ok {
return false
}
delete(m.jobs, id)
return true
}
// listForInstance returns jobs for a given instance, newest first.
func (m *dayzModJobManager) listForInstance(instanceID string) []*dayzModJob {
m.mu.RLock()
defer m.mu.RUnlock()
out := make([]*dayzModJob, 0, len(m.jobs))
for _, j := range m.jobs {
if j.InstanceID == instanceID {
out = append(out, j)
}
}
sort.Slice(out, func(i, k int) bool { return out[i].StartedAt.After(out[k].StartedAt) })
return out
}
// reaperLoop drops completed jobs after a grace period so the in-memory
// map doesn't grow forever. Also keeps recently-failed jobs around long
// enough that the operator can read the error message (10 min).
func (m *dayzModJobManager) reaperLoop() {
t := time.NewTicker(1 * time.Minute)
defer t.Stop()
for range t.C {
m.reap()
}
}
func (m *dayzModJobManager) reap() {
const (
doneGrace = 2 * time.Minute
errorGrace = 10 * time.Minute
orphanCutoff = 2 * time.Hour // stuck non-terminal jobs
)
now := time.Now()
m.mu.Lock()
defer m.mu.Unlock()
for id, j := range m.jobs {
j.mu.Lock()
phase := j.Phase
ended := j.EndedAt
started := j.StartedAt
j.mu.Unlock()
switch phase {
case phaseDone:
if !ended.IsZero() && now.Sub(ended) > doneGrace {
delete(m.jobs, id)
}
case phaseError, phaseLoginNeeded:
if !ended.IsZero() && now.Sub(ended) > errorGrace {
delete(m.jobs, id)
}
default:
// Non-terminal. Reap if we think it's wedged (controller bug
// or upstream Steam hang past any sensible timeout).
if now.Sub(started) > orphanCutoff {
delete(m.jobs, id)
}
}
}
}
+274
View File
@@ -0,0 +1,274 @@
package main
// DayZ Central Economy XML merger — operator-facing endpoints that let the
// panel drop a mod's types.xml/events.xml/etc snippet into a running
// instance's mission files, preview the diff, and commit with an automatic
// timestamped backup.
//
// Canonical merge semantics + supported file list live in pkg/dayzxml.
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path"
"strings"
"time"
"google.golang.org/protobuf/types/known/timestamppb"
"github.com/dbledeez/panel/pkg/dayzxml"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// dayzCEFile maps a dayzxml.FileKind to the container-relative path of the
// canonical file inside a standard Chernarus mission tree. Operators using
// non-default missions can still merge via the path-override form.
type dayzCEFile struct {
Kind dayzxml.FileKind `json:"kind"`
Filename string `json:"filename"` // "types.xml"
MissionPath string `json:"mission_path"` // "db/types.xml" (relative to mission folder)
Label string `json:"label"` // "Loot items (types.xml)" — UI-friendly
}
// dayzKnownFiles enumerates the CE XML targets we support. Ordered for UI.
var dayzKnownFiles = []dayzCEFile{
{Kind: dayzxml.KindTypes, Filename: "types.xml", MissionPath: "db/types.xml",
Label: "Loot items (types.xml)"},
{Kind: dayzxml.KindEvents, Filename: "events.xml", MissionPath: "db/events.xml",
Label: "Dynamic events (events.xml)"},
{Kind: dayzxml.KindSpawnableTypes, Filename: "cfgspawnabletypes.xml", MissionPath: "cfgspawnabletypes.xml",
Label: "Spawnable attachments (cfgspawnabletypes.xml)"},
{Kind: dayzxml.KindEventSpawns, Filename: "cfgeventspawns.xml", MissionPath: "cfgeventspawns.xml",
Label: "Event spawn positions (cfgeventspawns.xml)"},
{Kind: dayzxml.KindRandomPresets, Filename: "cfgrandompresets.xml", MissionPath: "cfgrandompresets.xml",
Label: "Randomized presets (cfgrandompresets.xml)"},
{Kind: dayzxml.KindMessages, Filename: "messages.xml", MissionPath: "messages.xml",
Label: "Broadcast messages (messages.xml)"},
}
// handleDayzXmlFiles returns the list of supported CE XML files along with
// whether each one currently exists on the instance. The UI uses this to
// populate the target picker.
func (h *httpServer) handleDayzXmlFiles(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
mission := r.URL.Query().Get("mission")
if mission == "" {
mission = "dayzOffline.chernarusplus"
}
// Probe each file via a cheap FsRead to see if it exists. The panel
// usually mounts the game volume at /game; mpmissions lives there.
type fileStatus struct {
dayzCEFile
Exists bool `json:"exists"`
Size int `json:"size,omitempty"`
}
out := make([]fileStatus, 0, len(dayzKnownFiles))
for _, f := range dayzKnownFiles {
absPath := path.Join("/game/mpmissions", mission, f.MissionPath)
res, _ := h.dayzXmlReadFile(r.Context(), conn, id, absPath)
fs := fileStatus{dayzCEFile: f, Exists: res != nil && res.Error == ""}
if fs.Exists {
fs.Size = len(res.Content)
}
out = append(out, fs)
}
writeJSON(w, http.StatusOK, map[string]any{
"mission": mission,
"files": out,
})
}
// dayzXmlPreviewReq is the payload for the preview + commit endpoints.
type dayzXmlPreviewReq struct {
Mission string `json:"mission,omitempty"` // "dayzOffline.chernarusplus" (default)
Path string `json:"path,omitempty"` // full mission-relative path; overrides kind→default mapping
Kind string `json:"kind,omitempty"` // one of dayzxml.SupportedKinds() names; or infer from snippet
Snippet string `json:"snippet"` // the mod XML text
}
// handleDayzXmlPreview runs a dry-run merge and returns the Plan with
// counts + the first N entry names per bucket. No files are written.
func (h *httpServer) handleDayzXmlPreview(w http.ResponseWriter, r *http.Request) {
h.dayzXmlDoMerge(w, r, true)
}
// handleDayzXmlCommit runs the merge for real: reads base, computes merged
// bytes, writes a timestamped backup, overwrites the target file.
func (h *httpServer) handleDayzXmlCommit(w http.ResponseWriter, r *http.Request) {
h.dayzXmlDoMerge(w, r, false)
}
func (h *httpServer) dayzXmlDoMerge(w http.ResponseWriter, r *http.Request, dryRun bool) {
id := r.PathValue("id")
agentID, err := h.lookupInstance(r, id)
if err != nil {
writeError(w, http.StatusNotFound, "not_found", err.Error())
return
}
conn := h.registry.get(agentID)
if conn == nil {
writeError(w, http.StatusNotFound, "agent_offline", fmt.Sprintf("agent %q not connected", agentID))
return
}
// Accept either application/json body (with snippet as a string) or
// raw application/xml (full body is the snippet, path/kind via query).
var req dayzXmlPreviewReq
ct := strings.ToLower(r.Header.Get("Content-Type"))
if strings.HasPrefix(ct, "application/xml") || strings.HasPrefix(ct, "text/xml") {
b, rerr := io.ReadAll(io.LimitReader(r.Body, 8*1024*1024))
if rerr != nil {
writeError(w, http.StatusBadRequest, "bad_body", rerr.Error())
return
}
req.Snippet = string(b)
req.Mission = r.URL.Query().Get("mission")
req.Path = r.URL.Query().Get("path")
req.Kind = r.URL.Query().Get("kind")
} else {
if err := json.NewDecoder(io.LimitReader(r.Body, 8*1024*1024)).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "bad_json", err.Error())
return
}
}
if strings.TrimSpace(req.Snippet) == "" {
writeError(w, http.StatusBadRequest, "empty_snippet", "snippet is required")
return
}
if req.Mission == "" {
req.Mission = "dayzOffline.chernarusplus"
}
// Determine the kind: explicit > infer from snippet root.
var kind dayzxml.FileKind
if req.Kind != "" {
kind = dayzxml.FileKind(req.Kind)
if _, ok := dayzxml.SpecFor(kind); !ok {
writeError(w, http.StatusBadRequest, "bad_kind", "unknown kind "+req.Kind)
return
}
} else {
k, derr := dayzxml.DetectKind([]byte(req.Snippet))
if derr != nil {
writeError(w, http.StatusBadRequest, "detect_kind", derr.Error())
return
}
kind = k
}
// Resolve the target file path.
targetPath := req.Path
if targetPath == "" {
for _, f := range dayzKnownFiles {
if f.Kind == kind {
targetPath = path.Join("/game/mpmissions", req.Mission, f.MissionPath)
break
}
}
if targetPath == "" {
writeError(w, http.StatusBadRequest, "no_default_path", "no canonical path for kind "+string(kind)+"; supply `path`")
return
}
}
// Read the existing base.
readRes, err := h.dayzXmlReadFile(r.Context(), conn, id, targetPath)
if err != nil {
writeError(w, http.StatusBadGateway, "fs_read", err.Error())
return
}
if readRes.Error != "" {
writeError(w, http.StatusNotFound, "base_missing", readRes.Error)
return
}
// Merge.
res, err := dayzxml.Merge(kind, readRes.Content, []byte(req.Snippet), dryRun)
if err != nil {
writeError(w, http.StatusBadRequest, "merge", err.Error())
return
}
resp := map[string]any{
"kind": string(kind),
"path": targetPath,
"plan": res.Plan,
}
if dryRun {
writeJSON(w, http.StatusOK, resp)
return
}
// Commit: back up base + write merged.
ts := time.Now().UTC().Format("20060102-150405")
backupPath := targetPath + ".bak." + ts
if err := h.dayzXmlWriteFile(r.Context(), conn, id, backupPath, readRes.Content); err != nil {
writeError(w, http.StatusBadGateway, "backup_failed", err.Error())
return
}
if err := h.dayzXmlWriteFile(r.Context(), conn, id, targetPath, res.Bytes); err != nil {
writeError(w, http.StatusBadGateway, "write_failed", err.Error())
return
}
resp["committed"] = true
resp["backup_path"] = backupPath
resp["bytes_written"] = len(res.Bytes)
writeJSON(w, http.StatusOK, resp)
}
// dayzXmlReadFile round-trips an FsRead through the agent gRPC bus. Returns
// the envelope result (including res.Error for missing files).
func (h *httpServer) dayzXmlReadFile(ctx context.Context, conn *agentConn, instanceID, absPath string) (*panelv1.FsReadResult, error) {
corrID := newCorrelationID("dzxml_read")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsRead{
FsRead: &panelv1.FsReadRequest{InstanceId: instanceID, Path: absPath},
},
}); err != nil {
return nil, err
}
env, err := h.pending.Await(ctx, corrID, 15*time.Second)
if err != nil {
return nil, err
}
return env.GetFsReadResult(), nil
}
func (h *httpServer) dayzXmlWriteFile(ctx context.Context, conn *agentConn, instanceID, absPath string, content []byte) error {
corrID := newCorrelationID("dzxml_write")
if err := conn.Send(&panelv1.ControllerEnvelope{
CorrelationId: corrID,
SentAt: timestamppb.Now(),
Payload: &panelv1.ControllerEnvelope_FsWrite{
FsWrite: &panelv1.FsWriteRequest{InstanceId: instanceID, Path: absPath, Content: content},
},
}); err != nil {
return err
}
env, err := h.pending.Await(ctx, corrID, 30*time.Second)
if err != nil {
return err
}
res := env.GetFsWriteResult()
if res == nil {
return fmt.Errorf("agent returned no fs_write_result")
}
if res.Error != "" {
return fmt.Errorf("%s", res.Error)
}
return nil
}
+111
View File
@@ -0,0 +1,111 @@
package main
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"sort"
"strings"
"time"
)
// Discord integration for the panel — currently just the channel picker that
// Region Medic uses to choose where heal alerts post. The shared RefugeBot bot
// token lives on the controller host (default below, overridable via env) so the
// picker can enumerate the bot's channels. Posting the actual heal alerts happens
// agent-side (the medic sidecar), which has its own copy of the token.
func discordTokenPath() string {
if v := os.Getenv("PANEL_DISCORD_TOKEN_FILE"); v != "" {
return v
}
return "/home/refuge/panel/data/discord-bot-token"
}
func readDiscordToken() (string, error) {
b, err := os.ReadFile(discordTokenPath())
if err != nil {
return "", err
}
t := strings.TrimSpace(string(b))
if t == "" {
return "", fmt.Errorf("discord token file is empty")
}
return t, nil
}
// discordGet does an authenticated GET against the Discord API and decodes JSON.
func discordGet(ctx context.Context, token, path string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://discord.com/api/v10"+path, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bot "+token)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode/100 != 2 {
return fmt.Errorf("discord %s: HTTP %d", path, resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
type discordGuild struct {
ID string `json:"id"`
Name string `json:"name"`
}
type discordChannelRaw struct {
ID string `json:"id"`
Name string `json:"name"`
Type int `json:"type"`
Position int `json:"position"`
}
// getDiscordChannels lists the bot's text channels across every guild it's in,
// for the Region Medic alert-channel picker. Discord channel type 0 = text,
// 5 = announcement — both can receive messages.
func (h *httpServer) getDiscordChannels(w http.ResponseWriter, r *http.Request) {
token, err := readDiscordToken()
if err != nil {
writeError(w, http.StatusServiceUnavailable, "no_token", "Discord bot token not configured on the controller")
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
defer cancel()
var guilds []discordGuild
if err := discordGet(ctx, token, "/users/@me/guilds", &guilds); err != nil {
writeError(w, http.StatusBadGateway, "discord", err.Error())
return
}
type chanDTO struct {
ID string `json:"id"`
Name string `json:"name"`
GuildID string `json:"guild_id"`
GuildName string `json:"guild_name"`
Label string `json:"label"`
}
out := []chanDTO{}
for _, g := range guilds {
var chans []discordChannelRaw
if err := discordGet(ctx, token, "/guilds/"+g.ID+"/channels", &chans); err != nil {
continue // skip a guild whose channels we can't read
}
sort.Slice(chans, func(i, j int) bool { return chans[i].Position < chans[j].Position })
for _, c := range chans {
if c.Type != 0 && c.Type != 5 {
continue
}
out = append(out, chanDTO{
ID: c.ID, Name: c.Name, GuildID: g.ID, GuildName: g.Name,
Label: g.Name + " / #" + c.Name,
})
}
}
writeJSON(w, http.StatusOK, map[string]any{"channels": out})
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,754 @@
## Line format: Item_x: name , data: count[-range], xdata: probability (x incrementing, if using a count-range use double quotes)
{ +LootGroup Name: empty
}
## New Escape Pod settings: Difficulty Levels
{ +LootGroup Name: EscapePodEasy
Count: all
Item_0: EmergencyRations, param1: 1
Item_1: WaterBottle, param1: 5
Item_2: RadarSuitT1, param1: 1
Item_3: SurvivalTent, param: 1
Item_4: OreScanner, param:1
Item_5: SantaClausHat, param:1
Item_6: SnowmanHead, param:1
# Item_4: MobileAirCon, param: 1
# Item_5: OxygenGeneratorSmall, param: 1
# Item_6: OreScanner, param: 1
# Item_1: EmergencyRations, param1: 1
# Item_2: RespiratorCharge, param1: 3
# Item_0: Flashlight, param1: 1
# Item_1: Pistol, param1: 1
# Item_2: 50Caliber, param1: 250
# Item_3: Explosives, param1: 5
# Item_4: Drill, param1: 1
# Item_5: Chainsaw, param1: 1
# Item_6: BioFuel, param1: 5
# Item_7: ConstructorSurvival, param1: 1
# Item_8: PlayerBikeKit, param1: 1
# Item_9: AutoMinerCore, param1: 10
# Item_10: Medikit02, param1: 3
# Item_11: AntibioticPills, param1: 3
# Item_12: RadiationPills, param1: 2
# Item_13: EmergencyRations, param1: 4
# Item_14: WaterBottle, param1: 2
# Item_15: RespiratorCharge, param1: 5
# Item_16: SiliconIngot, param1: 200
# Item_17: CopperIngot, param1: 300
# Item_18: IronIngot, param1: 350
# Item_19: CobaltIngot, param1: 175
# Item_20: MagnesiumPowder, param1: 50
# Item_21: PromethiumPellets, param1: 350
# Item_22: EnergyCell, param1: 5
# Item_23: EVABoost, param1: 1
# Item_24: OxygenBottleLarge, param1: 5
# Item_25: OxygenGenerator, param1: 1
# Item_26: Core, param1: 1
# Item_27: GrowingPot, param1: 2
# Item_28: LightPlant01, param1: 1
# Item_29: TomatoStage1, param1: 1
# Item_30: WheatStage1, param1: 1
# Item_31: CornStage1, param1: 1
# Item_32: PumpkinStage1, param1: 1
# Item_33: PearthingStage1, param1: 1
# Item_34: DurianRoot, param1: 1
}
{ +LootGroup Name: EscapePodMedium
Count: all
Item_0: EmergencyRations, param1: 1
Item_1: WaterBottle, param1: 5
Item_2: RadarSuitT1, param1: 1
Item_3: SurvivalTent, param: 1
Item_4: OreScanner, param:1
Item_5: SantaClausHat, param:1
Item_6: SnowmanHead, param:1
# Item_1: EmergencyRations, param1: 1
# Item_2: RespiratorCharge, param1: 2
# Item_0: Flashlight, param1: 1
# Item_1: Pistol, param1: 1
# Item_2: 50Caliber, param1: 150
# Item_3: Drill, param1: 1
# Item_4: Chainsaw, param1: 1
# Item_5: BioFuel, param1: 4
# Item_6: ConstructorSurvival, param1: 1
# Item_7: OxygenGeneratorSmall, param1: 1
# Item_8: EnergyCell, param1: 3
# Item_9: RespiratorCharge, param1: 2
# Item_10: WaterBottle, param1: 2
# Item_11: Medikit02, param1: 2
# Item_12: EmergencyRations, param1: 2
# Item_13: StomachPills, param1: 1
# Item_14: SiliconIngot, param1: 50
# Item_15: CopperIngot, param1: 50
# Item_16: IronIngot, param1: 10
# Item_17: CobaltIngot, param1: 50
# Item_18: MagnesiumPowder, param1: 30
# Item_19: PromethiumPellets, param1: 50
# Item_20: Core, param1: 1
# Item_21: PlayerBikeKit, param1: 1
# Item_22: AutoMinerCore, param1: 8
# Item_23: EVABoost, param1: 1
}
{ +LootGroup Name: EscapePodHard
Count: all
Item_0: EmergencyRations, param1: 1
Item_1: WaterBottle, param1: 5
Item_2: RadarSuitT1, param1: 1
Item_3: SurvivalTent, param: 1
Item_4: OreScanner, param:1
Item_5: SantaClausHat, param:1
Item_6: SnowmanHead, param:1
# Item_1: EmergencyRations, param1: 1
# Item_2: RespiratorCharge, param1: 1
# Item_0: Flashlight, param1: 1
# Item_1: Pistol, param1: 1
# Item_2: 50Caliber, param1: 150
# Item_3: Drill, param1: 1
# Item_4: Chainsaw, param1: 1
# Item_5: BioFuel, param1: 2
# Item_6: ConstructorSurvival, param1: 1
# Item_7: OxygenGeneratorSmall, param1: 1
# Item_8: EnergyCell, param1: 3
# Item_9: EmergencyRations, param1: 1
# Item_10: PlayerBikeKit, param1: 1
# Item_11: AutoMinerCore, param1: 5
}
## Escape pod - New Start
{ +LootGroup Name: StartingEquipment_NewStart
# Count: all
# Item_0: Drill, param1: 1
# Item_1: Chainsaw, param1: 1
# Item_2: BioFuel, param1: 2
# Item_3: ConstructorSurvival, param1: 1
# Item_4: OxygenGeneratorSmall, param1: 1
}
## Random Loot
{ +LootGroup Name: BasicComponents
Count: "1,2"
Item_0: SteelPlate, param1: "1,5", param2: 0.3
Item_1: GlassPlate, param1: "1,2", param2: 0.3
Item_2: Electronics, param1: "1,2", param2: 0.2
Item_3: OpticalFiber, param1: "1,2", param2: 0.3
Item_4: EnergyMatrix, param1: "1,2", param2: 0.3
# Item_5: MetalPieces, param1: "1,5", param2: 0.3
}
{ +LootGroup Name: CompositeComponents
Count: "1,2"
Item_0: MechanicalComponents, param1: "1,5", param2: 0.4
Item_1: Motor, param1: "1,2", param2: 0.2
Item_2: Nanotubes, param1: "1,5", param2: 0.2
Item_3: Computer, param1: "1,2", param2: 0.1
Item_4: CapacitorComponent, param1: "1,2", param2: 0.01
Item_5: CobaltAlloy, param1: 1, param2: 0.01
}
# not yet dropped
{ +LootGroup Name: AdvancedComponents
Count: "1"
Item_0: FluxCoil, param1: "1,3", param2: 0.2
Item_1: Oscillator, param1: "1,2", param2: 0.2
}
{ +LootGroup Name: Ores
Count: "1,2"
Item_0: IronOre, param1: "10,20", param2: 0.4
Item_1: CobaltOre, param1: "10,20", param2: 0.4
Item_2: CopperOre, param1: "10,15", param2: 0.3
Item_3: SiliconOre, param1: "10,15", param2: 0.3
Item_4: NeodymiumOre, param1: "10,15", param2: 0.2
Item_5: TitanOre, param1: "10,15", param2: 0.2
Item_6: MagnesiumOre, param1: "10,15", param2: 0.1
Item_7: PromethiumOre, param1: "10,15", param2: 0.1
# Item_0: ErestrumOre, param1: "1,20", param2: 0.2
# Item_10: ZascosiumOre, param1: "1,20", param2: 0.2
}
{ +LootGroup Name: Ingots
Count: "1,2"
Item_0: IronIngot, param1: "10,20", param2: 0.5
Item_1: CobaltIngot, param1: "10,20", param2: 0.4
Item_2: CopperIngot, param1: "10,15", param2: 0.3
Item_3: SiliconIngot, param1: "10,15", param2: 0.2
Item_4: NeodymiumIngot, param1: "10,15", param2: 0.1
Item_5: TitanRods, param1: "10,15", param2: 0.1
Item_6: MagnesiumPowder, param1: "10,15", param2: 0.1
Item_7: PromethiumPellets, param1: "10,20", param2: 0.2
Item_8: PlasticMaterial, param1: "10,20", param2: 0.3
# Item_0: ErestrumIngot, param1: "1,10", param2: 0.2
# Item_0: ZascosiumIngot, param1: "1,10", param2: 0.2
}
{ +LootGroup Name: Sprouts
Count: "1,2"
Item_0: DurianRoot, param1: 1, param2: 0.2
Item_1: TomatoStage1, param1: 1, param2: 0.2
Item_2: WheatStage1, param1: 1, param2: 0.2
Item_3: CornStage1, param1: 1, param2: 0.2
Item_4: PumpkinStage1, param1: 1, param2: 0.2
Item_5: PearthingStage1, param1: 1, param2: 0.2
Item_6: InsanityPepperStage1, param1: 1, param2: 0.2
Item_7: DesertPlant20Stage1, param1: 1, param2: 0.2
}
# TEST - nested groups not yet working
# { +LootGroup Name: PistolAmmo
# Count: all
# Item_0: Pistol, param1: 1
# Item_1: 50Caliber, param1: "10,20"
# }
# { +LootGroup Name: ShotgunAmmo
# Count: all
# Item_0: Shotgun, param1: 1
# Item_1: ShotgunShells, param1: "5,10"
# }
# { +LootGroup Name: AssaultRifleAmmo
# Count: all
# Item_0: AssaultRifle, param1: 1
# Item_1: 5.8mmBullet, param1: "20,30"
# }
# { +LootGroup Name: SniperAmmo
# Count: all
# Item_0: Sniper, param1: 1
# Item_1: 12.7mmBullet, param1: "5,12"
# }
# { +LootGroup Name: WeaponsBasic
# Count: "1,2"
# Group_0: PistolAmmo, param1: 1, param2: 0.3
# Group_1: ShotgunAmmo, param1: 1, param2: 0.3
# Group_2: AssaultRifleAmmo, param1: 1, param2: 0.3
# Group_3: SniperAmmo, param1: 1, param2: 0.3
# }
## NEW LOOT GROUPS
## Common
{ +LootGroup Name: OresBasic
Count: "1,2"
Item_0: IronOre, param1: "5,12", param2: 0.5
Item_1: CopperOre, param1: "5,15", param2: 0.6
Item_2: SiliconOre, param1: "5,20", param2: 0.7
Item_3: PlasticMaterial, param1: "10,40", param2: 0.9
}
## Hishkal
{ +LootGroup Name: HishkalOresBasic
Count: "2,2"
Item_0: IronOre, param1: "2,3", param2: 0.5
Item_1: CopperOre, param1: "2,3", param2: 0.6
Item_2: SiliconOre, param1: "2,3", param2: 0.7
# Item_3: PlasticMaterial, param1: "10,40", param2: 0.9
}
{ +LootGroup Name: OresAdvanced
Count: "1,2"
Item_0: CobaltOre, param1: "5,10", param2: 0.6
Item_1: NeodymiumOre, param1: "5,10", param2: 0.6
Item_2: MagnesiumOre, param1: "5,8", param2: 0.4
Item_3: TitanOre, param1: "5,8", param2: 0.5
}
{ +LootGroup Name: OresRare
Count: "1"
Item_0: ErestrumOre, param1: "3,5", param2: 0.4
Item_1: ZascosiumOre, param1: "3,5", param2: 0.2
Item_2: SathiumOre, param1: "5,8", param2: 0.4
Item_3: PromethiumOre, param1: "5,8", param2: 0.4
}
{ +LootGroup Name: IngotsBasic
Count: "1,2"
Item_0: IronIngot, param1: "5,20", param2: 0.4
Item_1: CobaltIngot, param1: "5,20", param2: 0.4
Item_2: CopperIngot, param1: "5,15", param2: 0.3
Item_3: SiliconIngot, param1: "5,15", param2: 0.3
Item_4: NeodymiumIngot, param1: "5,15", param2: 0.3
Item_5: TitanRods, param1: "5,15", param2: 0.3
}
{ +LootGroup Name: ComponentsBasic
Count: "1,2"
Item_0: SteelPlate, param1: "5,15", param2: 0.3
Item_1: Electronics, param1: "2,5", param2: 0.2
Item_2: OpticalFiber, param1: "2,5", param2: 0.3
Item_3: EnergyMatrix, param1: "2,5", param2: 0.3
Item_4: GlassPlate, param1: "5,10", param2: 0.3
Item_5: MechanicalComponents, param1: "5,10", param2: 0.4
Item_6: Motor, param1: "2,5", param2: 0.2
Item_7: Nanotubes, param1: "5,10", param2: 0.2
Item_8: Computer, param1: "1,2", param2: 0.1
# Item_9: MetalPieces, param1: "5,10", param2: 0.3
}
{ +LootGroup Name: CraftingMaterial
Count: "1,2"
Item_0: Cement, param1: "5,10", param2: 0.3
Item_1: WoodPlanks, param1: "5,10", param2: 0.3
Item_2: PlasticMaterial, param1: "5,10", param2: 0.2
Item_3: XenoSubstrate, param1: "5,10", param2: 0.3
Item_4: WaterBottle, param1: "5,10", param2: 0.3
Item_5: RockDust, param1: "5,10", param2: 0.3
Item_6: PromethiumPellets, param1: "30,100", param2: 0.3
Item_7: HydrogenBottle, param1: "1,5", param2: 0.3
Item_8: MagnesiumPowder, param1: "5,10", param2: 0.3
}
{ +LootGroup Name: EnergyItems
Count: "1,2"
Item_0: RespiratorCharge, param1: "2,5", param2: 0.3
Item_1: OxygenBottleLarge, param1: "2,5", param2: 0.3
Item_2: WaterJug, param1: "1,3", param2: 0.3
Item_3: EnergyCell, param1: "5,10", param2: 0.3
Item_4: EnergyCellLarge, param1: "2,5", param2: 0.3
Item_5: FusionCell, param1: "1,2", param2: 0.3
}
{ +LootGroup Name: BuildingBlocks
Count: "1,2"
Item_0: HullSmallBlocks, param1: "10,15", param2: 0.1
Item_1: HullArmoredSmallBlocks, param1: "5,10", param2: 0.05
Item_2: HullLargeBlocks, param1: "10,15", param2: 0.05
Item_3: HullArmoredLargeBlocks, param1: "5,10", param2: 0.03
Item_4: HullCombatLargeBlocks, param1: "5,10", param2: 0.01
Item_5: ConcreteBlocks, param1: "10,15", param2: 0.2
Item_6: WoodBlocks, param1: "10,15", param2: 0.3
Item_7: TrussSmallBlocks, param1: "10,15", param2: 0.2
Item_8: TrussLargeBlocks, param1: "10,15", param2: 0.2
Item_9: StairsBlocks, param1: "5,10", param2: 0.3
Item_10: WindowSmallBlocks, param1: "5,10", param2: 0.3
Item_11: WindowLargeBlocks, param1: "5,10", param2: 0.1
Item_12: WindowArmoredSmallBlocks, param1: "5,10", param2: 0.3
Item_13: WindowArmoredLargeBlocks, param1: "5,10", param2: 0.1
Item_14: WalkwayLargeBlocks, param1: "5,10", param2: 0.3
Item_15: DecoBlocks, param1: "5,10", param2: 0.3
Item_16: ConsoleBlocks, param1: "5,10", param2: 0.3
Item_17: IndoorPlants, param1: "2,5", param2: 0.5
Item_18: GrowingPot, param1: "2,5", param2: 0.5
}
{ +LootGroup Name: Tools
Count: "1,2"
Item_0: Flashlight, param1: 1, param2: 0.1
# Item_3: Drill, param1: 1, param2: 0.1
Item_1: Chainsaw, param1: 1, param2: 0.1
Item_2: BioFuel, param1: "2,5", param2: 0.1
# Item_3: MultiTool, param1: 1, param2: 0.1
Item_3: MultiCharge, param1: "1,5", param2: 0.1
Item_4: DrillT2, param1: 1, param2: 0.02
Item_5: DrillCharge, param1: "1,5", param2: 0.1
Item_6: MultiToolT2, param1: 1, param2: 0.02
}
{ +LootGroup Name: WeaponsBasic
Count: "1,2"
Item_0: Pistol, param1: 1, param2: 0.3
Item_1: Shotgun, param1: 1, param2: 0.3
Item_2: AssaultRifle, param1: 1, param2: 0.3
Item_3: Sniper, param1: 1, param2: 0.3
# Item_1: 50Caliber, param1: "10,20", param2: 0.3
# Item_5: 5.8mmBullet, param1: "20,30", param2: 0.3
# Item_7: 12.7mmBullet, param1: "5,12", param2: 0.3
}
{ +LootGroup Name: WeaponsBasicKit
Count: "1"
Item_0: PistolKit, param1: 1, param2: 0.3
Item_1: ShotgunKit, param1: 1, param2: 0.2
Item_2: RifleKit, param1: 1, param2: 0.3
Item_3: SniperKit, param1: 1, param2: 0.1
}
{ +LootGroup Name: Seeds
Count: "2,3"
Item_0: DurianRoot, param1: "2,5", param2: 0.3
Item_1: CornStage1, param1: "2,5", param2: 0.3
Item_2: PumpkinStage1, param1: "2,5", param2: 0.3
Item_3: TomatoStage1, param1: "2,5", param2: 0.3
Item_4: WheatStage1, param1: "2,5", param2: 0.3
Item_5: PearthingStage1, param1: "2,5", param2: 0.3
Item_6: InsanityPepperStage1, param1: "2,5", param2: 0.3
Item_7: AlienPlantTube2Stage1, param1: "2,5", param2: 0.3
Item_8: DesertPlant20Stage1, param1: "2,5", param2: 0.3
Item_9: ElderberryStage1, param1: "2,5", param2: 0.3
Item_10: AlienPalmTreeStage1, param1: "2,5", param2: 0.3
Item_11: AlienplantWormStage1, param1: "2,5", param2: 0.3
Item_12: BulbShroomYoungStage1, param1: "2,5", param2: 0.3
# Item_13: GrowingPot, param1: "2,4", param2: 0.3
# Item_14: NutrientSolution, param1: "2,5", param2: 0.3
}
{ +LootGroup Name: MedicalItems
Count: "1"
Item_0: Medikit04, param1: "1,2", param2: 0.2
Item_1: AntibioticOintment, param1: "2,3", param2: 0.8
Item_2: AntibioticPills, param1: "1,2", param2: 0.6
Item_3: AntiToxicOintment, param1: "2,3", param2: 0.7
Item_4: AntiToxicPills, param1: "1,2", param2: 0.5
Item_5: AntiRadiationOintment, param1: "2,3", param2: 0.6
Item_6: AntiParasitePills, param1: "1,2", param2: 0.3
Item_7: StomachPills, param1: "2,3", param2: 0.8
Item_8: RadiationPills, param1: "1,2", param2: 0.4
Item_9: AntibioticInjection, param1: "1", param2: 0.4
Item_10: AntiToxicInjection, param1: "1", param2: 0.3
Item_11: AntiRadiationInjection, param1: "1", param2: 0.2
Item_12: AntiParasiteInjection, param1: "1", param2: 0.1
}
{ +LootGroup Name: Medikits
Count: "1,2"
Item_0: Medikit01, param1: "1", param2: 0.6
Item_1: Medikit02, param1: "1", param2: 0.4
Item_2: Medikit03, param1: "1", param2: 0.2
Item_3: Medikit04, param1: "3,4", param2: 0.8
}
## Common
{ +LootGroup Name: DevicesCommon
Count: "1,2"
Item_0: GeneratorBA, param1: 1, param2: 0.05
Item_1: FuelTankMSSmall, param1: 1, param2: 0.1
Item_2: WaterGenerator, param1: 1, param2: 0.3
Item_3: OxygenTankSmallMS, param1: 1, param2: 0.3
Item_4: OxygenStation, param1: 1, param2: 0.3
Item_5: OxygenGenerator, param1: 1, param2: 0.3
Item_6: FridgeMS, param1: 1, param2: 0.3
Item_7: FoodProcessorV2, param1: 1, param2: 0.08
Item_8: LightLargeBlocks, param1: 1, param2: 0.3
Item_9: ConstructorT1V2, param1: 1, param2: 0.03
Item_10: ContainerAmmoSmall, param1: 1, param2: 0.3
Item_11: DoorBlocks, param1: "2,4", param2: 0.3
Item_12: CargoContainerSmall, param1: 1, param2: 0.3
Item_13: ElevatorMS, param1: "2,4", param2: 0.3
Item_14: Core, param1: 1, param2: 0.01
Item_15: HangarDoorBlocks, param1: 1, param2: 0.1
Item_16: ShutterDoorLargeBlocks, param1: 1, param2: 0.3
Item_17: TurretBaseCannon, param1: 1, param2: 0.05
Item_18: CloneChamber, param1: 1, param2: 0.3
}
## Rare
{ +LootGroup Name: DevicesRare
Count: "1"
Item_0: GeneratorMS, param1: 1, param2: 0.05
Item_1: FuelTankMSLarge, param1: 1, param2: 0.1
Item_2: ConstructorT2, param1: 1, param2: 0.03
Item_3: ContainerAmmoLarge, param1: 1, param2: 0.3
Item_4: DoorArmoredBlocks, param1: "2,4", param2: 0.3
Item_5: FuelTankMSLarge, param1: 1, param2: 0.1
Item_6: FuelTankMSLargeT2, param1: 1, param2: 0.1
Item_7: RepairBayBA, param1: 1, param2: 0.01
Item_8: GeneratorMST2, param1: 1, param2: 0.01
Item_9: OfflineProtector, param1: 1, param2: 0.3
Item_10: ATM, param1: 1, param2: 0.3
}
## Rare Vessel
{ +LootGroup Name: DevicesVesselRare
Count: "1"
Item_0: GeneratorSV, param1: 1, param2: 0.01
Item_1: FuelTankSV, param1: 1, param2: 0.1
Item_2: RCSBlockSV, param1: 1, param2: 0.01
Item_3: RCSBlockGV, param1: 1, param2: 0.01
Item_4: OxygenTankSV, param1: 1, param2: 0.3
Item_5: FridgeSV, param1: 1, param2: 0.1
Item_6: DoorBlocksSV, param1: 1, param2: 0.3
Item_7: SpotlightSSCube, param1: 1, param2: 0.3
Item_8: ContainerAmmoSmall, param1: 1, param2: 0.3
Item_9: PassengerSeatSV, param1: 1, param2: 0.3
Item_10: PassengerSeat2SV, param1: 1, param2: 0.3
Item_11: ThrusterGVDirectional, param1: 1, param2: 0.02
Item_12: ThrusterSVDirectional, param1: 1, param2: 0.02
Item_13: ThrusterSVRoundBlocks, param1: 1, param2: 0.01
Item_14: ThrusterGVRoundBlocks, param1: 1, param2: 0.01
Item_15: DockingPad, param1: 1, param2: 0.3
Item_16: WeaponSV02, param1: 1, param2: 0.3
Item_17: TurretGVMinigun, param1: 1, param2: 0.01
Item_18: TurretGVRocket, param1: 1, param2: 0.005
Item_20: TurretGVPlasma, param1: 1, param2: 0.001
}
{ +LootGroup Name: ComponentsRare
Count: "1"
Item_0: CapacitorComponent, param1: "1,2", param2: 0.3
Item_1: CobaltAlloy, param1: "1,2", param2: 0.3
Item_2: Oscillator, param1: "1,2", param2: 0.3
Item_3: FluxCoil, param1: "1,2", param2: 0.3
Item_4: PowerCoil, param1: "1,2", param2: 0.3
}
{ +LootGroup Name: FoodItems
Count: "1"
Item_0: CannedVegetables, param1: "2,4", param2: 0.3
Item_1: CannedMeat, param1: "2,4", param2: 0.3
Item_3: EmergencyRations, param1: "1,2", param2: 0.1
}
{ +LootGroup Name: WeaponsRare
Count: "1"
Item_0: PistolT2, param1: 1, param2: 0.1
Item_1: Shotgun2, param1: 1, param2: 0.1
Item_2: PulseRifle, param1: 1, param2: 0.1
Item_3: Sniper2, param1: 1, param2: 0.1
Item_4: LaserPistol, param1: 1, param2: 0.05
Item_5: LaserRifle, param1: 1, param2: 0.03
Item_6: RocketLauncher, param1: 1, param2: 0.01
# Item_1: 50Caliber, param1: "10,20", param2: 0.1
# Item_3: ShotgunShells, param1: "5,10", param2: 0.1
# Item_6: 5.8mmBullet, param1: "20,30", param2: 0.1
# Item_3: PulseLaserChargePistol, param1: "10,20"
# Item_8: 12.7mmBullet, param1: "5,12", param2: 0.1
# Item_10: SlowRocket, param1: "5,10", param2: 0.1
}
{ +LootGroup Name: WeaponsRareKit
Count: "1"
Item_0: HeavyWeaponKit, param1: 1, param2: 0.01
Item_1: LaserKit, param1: 1, param2: 0.01
}
{ +LootGroup Name: IngotsRare
Count: "1"
Item_0: ErestrumIngot, param1: "5,10", param2: 0.2
Item_1: SathiumIngot, param1: "5,10", param2: 0.2
Item_2: ZascosiumIngot, param1: "5,10", param2: 0.2
}
{ +LootGroup Name: LootGoldIngot
Count: 1
Item_0: GoldIngot, param1: "1,2", param2: 0.2
}
{ +LootGroup Name: LootGoldCoins
Count: 1
Item_0: GoldIngot, param1: "10,15", param2: 0.2
}
{ +LootGroup Name: LootMoneyCard
Count: 1
Item_0: MoneyCard, param1: "100,500", param2: 0.2
}
## Very Rare
{ +LootGroup Name: DevicesVeryRare
Count: "1"
Item_0: GeneratorMS, param1: 1, param2: 0.05
Item_1: FuelTankMSLarge, param1: 1, param2: 0.3
Item_2: FridgeMS02, param1: 1, param2: 0.3
Item_3: OxygenTankMS, param1: 1, param2: 0.3
Item_4: ConstructorSurvival, param1: 1, param2: 0.3
Item_5: ConstructorT2, param1: 1, param2: 0.03
Item_6: GravityGeneratorMS, param1: 1, param2: 0.3
Item_7: ContainerAmmoLarge, param1: 1, param2: 0.3
Item_8: MedicinelabMS, param1: 1, param2: 0.3
Item_9: GeneratorMST2, param1: 1, param2: 0.01
Item_10: TurretBaseRocket, param1: 1, param2: 0.03
Item_11: TurretBaseMinigun, param1: 1, param2: 0.02
Item_12: TurretBasePlasma, param1: 1, param2: 0.01
Item_13: TurretBaseFlak, param1: 1, param2: 0.02
Item_14: TurretBaseArtillery, param1: 1, param2: 0.01
Item_15: TurretBasePulseLaser, param1: 1, param2: 0.01
}
{ +LootGroup Name: DevicesVesselVeryRare
Count: "1"
Item_0: HoverEngineSmall, param1: 1, param2: 0.3
Item_1: SawAttachment, param1: 1, param2: 0.3
Item_2: TurretGVPlasma, param1: 1, param2: 0.02
Item_3: TurretGVArtillery, param1: 1, param2: 0.03
Item_4: ThrusterSVRoundBlocks, param1: 1, param2: 0.1
Item_5: LandinggearBlocksSV, param1: 1, param2: 0.3
Item_6: LandinggearBlocksHeavySV, param1: 1, param2: 0.3
Item_7: HoverEngineLarge, param1: 1, param2: 0.01
Item_8: WeaponSV01, param1: 1, param2: 0.008
Item_9: WeaponSV03, param1: 1, param2: 0.008
Item_10: WeaponSV04, param1: 1, param2: 0.008
Item_11: WeaponSV05, param1: 1, param2: 0.008
}
{ +LootGroup Name: DevicesCVVeryRare
Count: "1"
Item_0: ThrusterMSRoundBlocks, param1: 1, param2: 0.03
Item_1: RCSBlockMS, param1: 1, param2: 0.01
Item_2: RepairBayCV, param1: 1, param2: 0.03
Item_3: ThrusterMSRound2x2Blocks, param1: 1, param2: 0.02
Item_4: ThrusterMSRound3x3Blocks, param1: 1, param2: 0.01
Item_5: LandinggearBlocksCV, param1: 1, param2: 0.3
Item_6: WeaponMS01, param1: 1, param2: 0.03
Item_7: WeaponMS02, param1: 1, param2: 0.03
Item_8: TurretMSProjectileBlocks, param1: 1, param2: 0.008
Item_9: TurretMSLaserBlocks, param1: 1, param2: 0.005
Item_10: TurretMSRocketBlocks, param1: 1, param2: 0.005
Item_11: TurretMSArtilleryBlocks, param1: 1, param2: 0.001
Item_12: TurretMSToolBlocks, param1: 1, param2: 0.3
Item_13: RCSBlockMS_T2, param1: 1, param2: 0.001
}
{ +LootGroup Name: WeaponsVeryRare
Count: "1"
Item_0: Minigun, param1: 1
Item_1: LaserPistolT2, param1: 1
Item_2: LaserRifle, param1: 1
Item_3: ScifiCannon, param1: 1
Item_4: RocketLauncherT2, param1: 1
# Item_1: 8.3mmBullet, param1: "50,150"
# Item_3: PulseLaserChargePistol, param1: "10,20"
# Item_5: PulseLaserChargeRifle, param1: "20,30"
# Item_9: SlowRocket, param1: "5,10"
}
{ +LootGroup Name: WeaponsEpic
Count: 1
Item_0: AssaultRifleEpic, param1: 1, param2: 1
Item_1: Sniper2Epic, param1: 1, param2: 0.6
Item_2: MinigunEpic, param1: 1, param2: 0.5
Item_3: Shotgun2Epic, param1: 1, param2: 0.7
Item_4: LaserRifleEpic, param1: 1, param2: 0.7
Item_5: PistolEpic, param1: 1, param2: 1
Item_6: ScifiCannonEpic, param1: 1, param2: 0.2
Item_7: RocketLauncherEpic, param1: 1, param2: 0.3
Item_8: PulseRifleEpic, param1: 1, param2: 1
Item_9: DrillEpic, param1: 1, param2: 0.1
Item_10: ArmorHeavyEpic, param1: 1, param2: 0.1
}
{ +LootGroup Name: AutoMinerCore
Count: 1
Item_0: AutoMinerCore, param1: "1,3"
}
{ +LootGroup Name: TroopersFood
Count: 1
Item_0: CannedVegetables, param1: "1,2", param2: 0.4
Item_1: CannedMeat, param1: "1", param2: 0.4
Item_2: PowerBar, param1: "2,4", param2: 0.7
Item_3: WaterBottle, param1: "1,2", param2: 0.9
Item_4: EnergyDrink, param1: "1,2", param2: 0.8
Item_5: EmergencyRations, param1: "1", param2: 0.1
}
{ +LootGroup Name: CiviliansFood
Count: 1
Item_0: WaterBottle, param1: "1,2", param2: 0.5
Item_1: FruitJuice, param1: "1,2", param2: 0.5
Item_2: BerryJuice, param1: "1,2", param2: 0.5
Item_3: VegetableJuice, param1: "1,2", param2: 0.5
Item_4: EnergyDrink, param1: "1,2", param2: 0.5
Item_5: HotBeverage, param1: "1,2", param2: 0.5
Item_6: Milk, param1: "1,2", param2: 0.5
Item_7: Beer, param1: "1", param2: 0.5
Item_8: AkuaWine, param1: "1", param2: 0.5
Item_9: Sandwich, param1: "1", param2: 0.5
Item_10: FriedVegetables, param1: "1", param2: 0.5
Item_11: VeggieBurger, param1: "1", param2: 0.5
Item_12: MeatBurger, param1: "1", param2: 0.5
Item_13: Fruit, param1: "2,4", param2: 0.5
Item_14: Berries, param1: "2,4", param2: 0.5
Item_15: Waffles, param1: "1", param2: 0.5
Item_16: FruitPie, param1: "1", param2: 0.5
Item_17: Cereals, param1: "1", param2: 0.5
Item_18: Cheese, param1: "1", param2: 0.5
Item_19: Bread, param1: "1", param2: 0.5
Item_20: AnniversaryCake, param1: "1", param2: 0.1
}
{ +LootGroup Name: Biological01
Count: "2,3"
Item_0: Spice, param1: "1,3", param2: 0.7
Item_1: AlienThorn, param1: "1,3", param2: 0.7
Item_2: Varonroot, param1: "1,3", param2: 0.7
Item_3: HerbalLeaves, param1: "1,3", param2: 0.7
Item_4: PlantProtein, param1: "1,3", param2: 0.7
Item_5: PhoenixFernFonds, param1: "1,3", param2: 0.7
Item_6: MushroomBrown, param1: "1,3", param2: 0.7
Item_7: MushroomSpiky, param1: "1,3", param2: 0.7
Item_8: NaturalSweetener, param1: "1,3", param2: 0.7
}
{ +LootGroup Name: SuitBooster
Count: "1"
Item_0: ArmorBoost, param1: 1, param2: 0.1
Item_1: JetpackBoost, param1: 1, param2: 0.1
Item_2: MultiBoost, param1: 1, param2: 0.1
Item_3: OxygenBoost, param1: 1, param2: 0.1
Item_4: InsulationBoost, param1: 1, param2: 0.1
Item_5: MobilityBoost, param1: 1, param2: 0.1
Item_6: RadiationBoost, param1: 1, param2: 0.1
Item_7: EVABoost, param1: 1, param2: 0.1
}
{ +LootGroup Name: SmallOptronics
Count: "1"
Item_0: SmallOptronicBridge, param1: 1, param2: 0.5
Item_1: SmallOptronicMatrix, param1: 1, param2: 0.1
}
{ +LootGroup Name: LargeOptronics
Count: "1"
Item_0: LargeOptronicBridge, param1: 1, param2: 0.5
Item_1: LargeOptronicMatrix, param1: 1, param2: 0.1
}
{ +LootGroup Name: FireSticks
Count: "1"
Item_0: FireStickRed, param1: 1, param2: 0.1
Item_1: FireStickBlue, param1: 1, param2: 0.5
Item_2: FireStickGreen, param1: 1, param2: 0.9
}
{ +LootGroup Name: KeyCards
Count: "1"
Item_0: KeyCardCommon, param1: 1, param2: 0.9
Item_1: KeyCardScience, param1: 1, param2: 0.7
Item_2: KeyCardExploration, param1: 1, param2: 0.5
Item_3: KeyCardSecurity, param1: 1, param2: 0.3
Item_4: KeyCardMilitary, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsCommon
Count: "1"
Item_0: ReportMaintenanceData, param1: 1, param2: 0.9
Item_1: ReportWorkShiftData, param1: 1, param2: 0.7
Item_2: ReportTransportationData, param1: 1, param2: 0.5
Item_3: ReportCommunicationData, param1: 1, param2: 0.3
Item_4: ReportEconomicData, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsScience
Count: "1"
Item_0: ReportScienceRawData, param1: 1, param2: 0.9
Item_1: ReportBiologicalData, param1: 1, param2: 0.7
Item_2: ReportMaterialData, param1: 1, param2: 0.5
Item_3: ReportArcheologicalData, param1: 1, param2: 0.3
Item_4: ReportAnomalousData, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsExploration
Count: "1"
Item_0: ReportExplorationRawData, param1: 1, param2: 0.9
Item_1: ReportGeologicalData, param1: 1, param2: 0.7
Item_2: ReportAtmosphericData, param1: 1, param2: 0.5
Item_3: ReportAstronomicalData, param1: 1, param2: 0.3
Item_4: ReportPlanetaryResourceData, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsSecurity
Count: "1"
Item_0: ReportSpaceWeather, param1: 1, param2: 0.9
Item_1: ReportNavigationData, param1: 1, param2: 0.7
Item_2: ReportSpaceAnomality, param1: 1, param2: 0.5
Item_3: ReportWarpSignatures, param1: 1, param2: 0.3
Item_4: ReportWeaponSignatures, param1: 1, param2: 0.01
}
{ +LootGroup Name: ReportsMilitary
Count: "1"
Item_0: ReportMilitaryRawData, param1: 1, param2: 0.9
Item_1: ReportMilitaryScouting, param1: 1, param2: 0.7
Item_2: ReportMilitarySurveillance, param1: 1, param2: 0.5
Item_3: ReportMilitaryTroopMovement, param1: 1, param2: 0.3
Item_4: ReportMilitaryClassified, param1: 1, param2: 0.01
}
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Some files were not shown because too many files have changed in this diff Show More