Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,7 @@ oo2core*

# wrangler local cache
.wrangler/

AGENTS.md
CLAUDE.md
.claude/
3 changes: 2 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,10 @@ RUN CGO_ENABLED=0 go build -trimpath -ldflags "-s -w -X main.version=${VERSION}"
FROM alpine:3.22
# gcompat + libstdc++: the runtime-downloaded Oodle decompressor is a glibc
# binary; gcompat lets musl-based Alpine dlopen it.
RUN apk add --no-cache ca-certificates tzdata gcompat libstdc++ \
RUN apk add --no-cache ca-certificates tzdata gcompat libstdc++ curl \
&& addgroup -S palhelm && adduser -S -G palhelm palhelm
COPY --from=backend /out/palhelm /usr/local/bin/palhelm
COPY --chmod=755 scripts/fetch-map-tiles.sh /usr/local/bin/fetch-map-tiles
USER palhelm
ENV PALHELM_ADDR=:8080 PALHELM_DATA_DIR=/data
VOLUME /data
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ Palhelm slots into the Compose project you already run your server from. Minimal
# config editor (optional): let Palhelm edit this compose file's env block
PALHELM_COMPOSE_FILE: "/compose/docker-compose.yml"
PALHELM_GAME_SERVICE: "palworld"
PALHELM_PANEL_SERVICE: "palhelm"
volumes:
- ../data/Pal/Saved:/game/Saved # rw: restore writes here
- ../palhelm-data:/data # panel DB, backups, map tiles, Oodle lib
Expand Down Expand Up @@ -94,6 +95,7 @@ scripts/fetch-pal-icons.sh ./palhelm-data/pal-icons # pal preview icons
| `PALWORLD_RCON_ADDR` | — | e.g. `palworld:25575` |
| `PALWORLD_SAVE_DIR` | — | the mounted `Saved/` directory |
| `PALHELM_COMPOSE_FILE` / `PALHELM_GAME_SERVICE` | unset / `palworld` | enable Config when the containing directory supports safe atomic writes |
| `PALHELM_PANEL_SERVICE` | `palhelm` | panel service in the Compose file; resolves its `container_name` for host commands shown in the UI |
| `PALHELM_DOCKER_CONTROL` | ignored | retained for v0.2 compatibility; one-click apply is disabled in v0.3.0 |
| `PALHELM_METRICS_INTERVAL` | `5s` | metrics sampling |
| `PALHELM_SAVE_SYNC_INTERVAL` | `10m` | save parsing cadence |
Expand Down
26 changes: 25 additions & 1 deletion backend/cmd/palhelm/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"log/slog"
"net/http"
"os"
"os/exec"
"os/signal"
"path/filepath"
"strings"
Expand All @@ -24,6 +25,10 @@ import (
// version is stamped at build time via -ldflags "-X main.version=...".
var version = "dev"

var fetchMapTilesCommand = func(args ...string) *exec.Cmd {
return exec.Command("/usr/local/bin/fetch-map-tiles", args...)
}

func main() {
server.PanelVersion = version
if err := run(); err != nil {
Expand All @@ -49,10 +54,29 @@ func run() error {
return errors.New("usage: palhelm parse <file.sav>")
}
return parse(args[0])
case "fetch-map-tiles":
return fetchMapTiles(args)
default:
return fmt.Errorf("unknown subcommand %q (expected serve or parse)", command)
return fmt.Errorf("unknown subcommand %q (expected serve, parse, or fetch-map-tiles)", command)
}
}
func fetchMapTiles(args []string) error {
if len(args) == 0 {
dataDir := os.Getenv("PALHELM_DATA_DIR")
if dataDir == "" {
dataDir = "/data"
}
args = []string{filepath.Join(dataDir, "map-tiles")}
}
cmd := fetchMapTilesCommand(args...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("fetch map tiles: %w", err)
}
return nil
}
func parse(path string) error {
var v any
var err error
Expand Down
57 changes: 57 additions & 0 deletions backend/cmd/palhelm/main_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package main

import (
"os/exec"
"reflect"
"strings"
"testing"
)

func TestFetchMapTiles(t *testing.T) {
original := fetchMapTilesCommand
t.Cleanup(func() { fetchMapTilesCommand = original })

t.Run("uses data directory as default destination", func(t *testing.T) {
t.Setenv("PALHELM_DATA_DIR", "/custom/data")
var got []string
fetchMapTilesCommand = func(args ...string) *exec.Cmd {
got = append([]string(nil), args...)
return exec.Command("sh", "-c", "exit 0")
}

if err := fetchMapTiles(nil); err != nil {
t.Fatalf("fetchMapTiles() error = %v", err)
}
want := []string{"/custom/data/map-tiles"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("fetchMapTiles() args = %q, want %q", got, want)
}
})

t.Run("forwards explicit arguments", func(t *testing.T) {
var got []string
fetchMapTilesCommand = func(args ...string) *exec.Cmd {
got = append([]string(nil), args...)
return exec.Command("sh", "-c", "exit 0")
}
want := []string{"--dest", "/tiles", "--force"}

if err := fetchMapTiles(want); err != nil {
t.Fatalf("fetchMapTiles() error = %v", err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("fetchMapTiles() args = %q, want %q", got, want)
}
})

t.Run("returns downloader failure", func(t *testing.T) {
fetchMapTilesCommand = func(args ...string) *exec.Cmd {
return exec.Command("sh", "-c", "exit 17")
}

err := fetchMapTiles(nil)
if err == nil || !strings.Contains(err.Error(), "fetch map tiles: exit status 17") {
t.Fatalf("fetchMapTiles() error = %v", err)
}
})
}
3 changes: 2 additions & 1 deletion backend/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
type Config struct {
Addr, DataDir, AdminPassword, ViewerPassword, SessionSecret string
RESTURL, RESTUser, PalworldPassword, RCONAddr, SaveDir string
ComposeFile, GameService string
ComposeFile, GameService, PanelService string
// SteamWebAPIKey is optional; when empty, player avatars resolve via Steam's
// keyless public community endpoint instead of the Web API.
SteamWebAPIKey string
Expand All @@ -40,6 +40,7 @@ func Load() (Config, error) {
PalworldPassword: os.Getenv("PALWORLD_ADMIN_PASSWORD"), RCONAddr: os.Getenv("PALWORLD_RCON_ADDR"),
SaveDir: os.Getenv("PALWORLD_SAVE_DIR"),
ComposeFile: os.Getenv("PALHELM_COMPOSE_FILE"), GameService: env("PALHELM_GAME_SERVICE", "palworld"),
PanelService: env("PALHELM_PANEL_SERVICE", "palhelm"),
SteamWebAPIKey: strings.TrimSpace(os.Getenv("STEAM_WEB_API_KEY")),
}
var err error
Expand Down
20 changes: 20 additions & 0 deletions backend/internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,26 @@ func TestLoadTrustedProxyAndSecureCookieSettings(t *testing.T) {
}
}

func TestLoadPanelService(t *testing.T) {
t.Setenv("PALHELM_PANEL_SERVICE", "")
cfg, err := Load()
if err != nil {
t.Fatal(err)
}
if cfg.PanelService != "palhelm" {
t.Fatalf("PanelService = %q, want palhelm", cfg.PanelService)
}

t.Setenv("PALHELM_PANEL_SERVICE", "dashboard")
cfg, err = Load()
if err != nil {
t.Fatal(err)
}
if cfg.PanelService != "dashboard" {
t.Fatalf("PanelService = %q, want dashboard", cfg.PanelService)
}
}

func TestLoadRejectsInvalidTrustedProxy(t *testing.T) {
t.Setenv("PALHELM_TRUSTED_PROXIES", "not-a-cidr")
t.Setenv("PALHELM_SECURE_COOKIES", "")
Expand Down
58 changes: 58 additions & 0 deletions backend/internal/server/docker_compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package server

import (
"os"
"strings"

"github.com/8tp/palhelm/internal/config"
"gopkg.in/yaml.v3"
)

type composeContainerNames struct {
Services map[string]struct {
ContainerName string `yaml:"container_name"`
} `yaml:"services"`
}

func mapTilesInstallCommand(cfg config.Config) string {
service := strings.TrimSpace(cfg.PanelService)
if service == "" {
service = "palhelm"
}
if b, err := os.ReadFile(cfg.ComposeFile); err == nil {
var compose composeContainerNames
if yaml.Unmarshal(b, &compose) == nil {
name := strings.TrimSpace(compose.Services[service].ContainerName)
if validDockerName(name) {
return "docker exec " + name + " palhelm fetch-map-tiles"
}
}
}
return "docker compose exec " + shellArg(service) + " palhelm fetch-map-tiles"
}

func validDockerName(name string) bool {
if name == "" {
return false
}
for i, r := range name {
if i == 0 && !asciiAlphaNumeric(r) {
return false
}
if !asciiAlphaNumeric(r) && r != '_' && r != '.' && r != '-' {
return false
}
}
return true
}

func asciiAlphaNumeric(r rune) bool {
return r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9'
}

func shellArg(value string) string {
if validDockerName(value) {
return value
}
return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'"
}
47 changes: 47 additions & 0 deletions backend/internal/server/docker_compose_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package server

import (
"os"
"path/filepath"
"testing"

"github.com/8tp/palhelm/internal/config"
)

func TestMapTilesInstallCommand(t *testing.T) {
t.Run("uses explicit container name", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "docker-compose.yml")
compose := "services:\n dashboard:\n container_name: palworld-dashboard\n"
if err := os.WriteFile(path, []byte(compose), 0o600); err != nil {
t.Fatal(err)
}
cfg := config.Config{ComposeFile: path, PanelService: "dashboard"}
if got, want := mapTilesInstallCommand(cfg), "docker exec palworld-dashboard palhelm fetch-map-tiles"; got != want {
t.Fatalf("mapTilesInstallCommand() = %q, want %q", got, want)
}
})

t.Run("falls back to compose service", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "docker-compose.yml")
compose := "services:\n dashboard:\n image: palhelm\n"
if err := os.WriteFile(path, []byte(compose), 0o600); err != nil {
t.Fatal(err)
}
cfg := config.Config{ComposeFile: path, PanelService: "dashboard"}
if got, want := mapTilesInstallCommand(cfg), "docker compose exec dashboard palhelm fetch-map-tiles"; got != want {
t.Fatalf("mapTilesInstallCommand() = %q, want %q", got, want)
}
})

t.Run("does not render compose interpolation as shell", func(t *testing.T) {
path := filepath.Join(t.TempDir(), "docker-compose.yml")
compose := "services:\n palhelm:\n container_name: ${PALHELM_CONTAINER_NAME:-palhelm}\n"
if err := os.WriteFile(path, []byte(compose), 0o600); err != nil {
t.Fatal(err)
}
cfg := config.Config{ComposeFile: path}
if got, want := mapTilesInstallCommand(cfg), "docker compose exec palhelm palhelm fetch-map-tiles"; got != want {
t.Fatalf("mapTilesInstallCommand() = %q, want %q", got, want)
}
})
}
2 changes: 1 addition & 1 deletion backend/internal/server/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,7 @@
"PlayerPaldeckSpecies": {"type":"object","required":["characterId","displayName","known","captureCount","unlocked"],"properties":{"characterId":{"type":"string"},"displayName":{"type":"string"},"known":{"type":"boolean"},"captureCount":{"type":["integer","null"],"format":"int64","minimum":0},"unlocked":{"type":["boolean","null"]}}},
"PlayerPaldeck": {"type":"object","required":["player","coverage","catalog","captureTotal","uniquePalsCaptured","paldeckUnlocked","species"],"properties":{"player":{"type":"object","required":["uid","name"],"properties":{"uid":{"type":"string"},"name":{"type":"string"}}},"coverage":{"$ref":"#/components/schemas/PlayerPaldeckCoverage"},"catalog":{"$ref":"#/components/schemas/PaldeckCatalog"},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"species":{"type":"array","maxItems":4096,"items":{"$ref":"#/components/schemas/PlayerPaldeckSpecies"}}}},
"GuildDetail": {"type":"object","required":["id","name","adminUid","memberCount","members","bases","palCount","palsTruncated","pals","activity"],"properties":{"id":{"type":"string"},"name":{"type":"string"},"adminUid":{"type":"string"},"memberCount":{"type":"integer","minimum":0},"members":{"type":"array","items":{"type":"object","required":["uid","name","level","online","lastSeenAt","playtimeSec","captureTotal","uniquePalsCaptured","paldeckUnlocked","observedDurationSec","observedSessionCount","currentSession"],"properties":{"uid":{"type":"string"},"name":{"type":"string"},"level":{"type":"integer"},"online":{"type":"boolean"},"lastSeenAt":{"type":["string","null"],"format":"date-time"},"playtimeSec":{"type":"integer","format":"int64","minimum":0},"captureTotal":{"type":["integer","null"],"format":"int64","minimum":0},"uniquePalsCaptured":{"type":["integer","null"],"minimum":0},"paldeckUnlocked":{"type":["integer","null"],"minimum":0},"observedDurationSec":{"type":"integer","format":"int64","minimum":0},"observedSessionCount":{"type":"integer","minimum":0},"currentSession":{"type":"boolean"}}}},"bases":{"type":"array","items":{"type":"object","required":["id","name","location","level","palCount"],"properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"location":{"oneOf":[{"$ref":"#/components/schemas/IntegrationLocation"},{"type":"null"}]},"level":{"type":"integer"},"palCount":{"type":"integer","minimum":0}}}},"palCount":{"type":"integer","minimum":0},"palsTruncated":{"type":"boolean"},"pals":{"type":"array","maxItems":500,"items":{"type":"object","required":["instanceId","characterId","displayName","level","rank","isAlpha","isLucky","isBoss","placement","baseId","ownerUid","ownerName","ownerSource","ownerResolved","association"],"properties":{"instanceId":{"type":"string"},"characterId":{"type":"string"},"displayName":{"type":"string"},"level":{"type":"integer"},"rank":{"type":["integer","null"],"minimum":1,"maximum":5,"description":"Pal Condenser rank: 1 (never condensed) through 5 (four stars). null when the save carried no Rank property; never inferred as 0."},"isAlpha":{"type":"boolean"},"isLucky":{"type":"boolean"},"isBoss":{"type":"boolean"},"placement":{"type":"string","enum":["party","box","base","unknown"]},"baseId":{"type":["string","null"]},"ownerUid":{"type":"string"},"ownerName":{"type":"string"},"ownerSource":{"type":"string","enum":["save","personal_container","last_observed","unresolved"]},"ownerResolved":{"type":"boolean"},"association":{"type":"string","enum":["guild_base","current_member_owner"]}}}},"activity":{"type":"object","required":["coverage","attribution","window","since","through","trackingSince","analysisTruncated","durationSec","sessionCount","activePlayers"],"properties":{"coverage":{"type":"string","const":"panel_observed_sessions"},"attribution":{"type":"string","const":"current_guild_membership"},"window":{"type":"string","const":"30d"},"since":{"type":"string","format":"date-time"},"through":{"type":"string","format":"date-time"},"trackingSince":{"type":["string","null"],"format":"date-time"},"analysisTruncated":{"type":"boolean"},"durationSec":{"type":"integer","format":"int64","minimum":0},"sessionCount":{"type":"integer","minimum":0},"activePlayers":{"type":"integer","minimum":0}}}}},
"ServerInfo": {"type": "object", "required": ["name", "description", "version", "worldGuid", "state", "uptimeSec", "panelVersion", "sessionDays", "saveSyncMinutes"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "version": {"type": "string"}, "worldGuid": {"type": "string"}, "state": {"type": "string"}, "uptimeSec": {"type": "integer", "format": "int64"}, "panelVersion": {"type": "string"}, "sessionDays": {"type": "integer", "minimum": 1, "description": "Login session lifetime in whole days (PALHELM_SESSION_DAYS)."}, "saveSyncMinutes": {"type": "integer", "minimum": 0, "description": "Save-sync poll interval in whole minutes (PALHELM_SAVE_SYNC_INTERVAL)."}}},
"ServerInfo": {"type": "object", "required": ["name", "description", "version", "worldGuid", "state", "uptimeSec", "panelVersion", "sessionDays", "saveSyncMinutes", "mapTilesCommand"], "properties": {"name": {"type": "string"}, "description": {"type": "string"}, "version": {"type": "string"}, "worldGuid": {"type": "string"}, "state": {"type": "string"}, "uptimeSec": {"type": "integer", "format": "int64"}, "panelVersion": {"type": "string"}, "sessionDays": {"type": "integer", "minimum": 1, "description": "Login session lifetime in whole days (PALHELM_SESSION_DAYS)."}, "saveSyncMinutes": {"type": "integer", "minimum": 0, "description": "Save-sync poll interval in whole minutes (PALHELM_SAVE_SYNC_INTERVAL)."}, "mapTilesCommand": {"type": "string", "description": "Host command for installing map tiles, resolved from the panel service's Compose container_name when available."}}},
"BackupStorage": {"type": "object", "required": ["totalBytes", "freeBytes"], "properties": {"totalBytes": {"type": ["integer", "null"], "format": "int64", "minimum": 0, "description": "Total capacity of the backup filesystem, or null when unavailable."}, "freeBytes": {"type": ["integer", "null"], "format": "int64", "minimum": 0, "description": "Free space on the backup filesystem, or null when unavailable."}}},
"Backup": {"type": "object", "required": ["id", "file", "createdAt", "sizeBytes", "trigger"], "properties": {"id": {"type": "integer", "format": "int64"}, "file": {"type": "string"}, "createdAt": {"type": "string", "format": "date-time"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "trigger": {"type": "string", "enum": ["scheduled", "manual", "pre-restore", "imported"]}, "worldDay": {"type": "integer", "format": "int64"}}},
"BackupEntry": {"type": "object", "required": ["path", "sizeBytes", "modifiedAt"], "properties": {"path": {"type": "string"}, "sizeBytes": {"type": "integer", "format": "int64", "minimum": 0}, "modifiedAt": {"type": "string", "format": "date-time"}}},
Expand Down
2 changes: 1 addition & 1 deletion backend/internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ func (s *Server) serverInfo(w http.ResponseWriter, r *http.Request) {
if days < 1 {
days = 7
}
writeJSON(w, 200, map[string]any{"name": i.ServerName, "description": i.Description, "version": i.Version, "worldGuid": i.WorldGUID, "state": state, "uptimeSec": i.Uptime, "panelVersion": PanelVersion, "sessionDays": days, "saveSyncMinutes": int(s.cfg.SaveSyncInterval.Minutes())})
writeJSON(w, 200, map[string]any{"name": i.ServerName, "description": i.Description, "version": i.Version, "worldGuid": i.WorldGUID, "state": state, "uptimeSec": i.Uptime, "panelVersion": PanelVersion, "sessionDays": days, "saveSyncMinutes": int(s.cfg.SaveSyncInterval.Minutes()), "mapTilesCommand": mapTilesInstallCommand(s.cfg)})
}
func (s *Server) serverHealth(w http.ResponseWriter, r *http.Request) {
rest, rcon, save, at := s.health.Snapshot()
Expand Down
1 change: 1 addition & 0 deletions backend/internal/webdist/dist/assets/Activity-CN5lRrug.css

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading