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/
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,11 @@ 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
COPY --chmod=755 scripts/fetch-pal-icons.sh /usr/local/bin/fetch-pal-icons
USER palhelm
ENV PALHELM_ADDR=:8080 PALHELM_DATA_DIR=/data
VOLUME /data
Expand Down
6 changes: 4 additions & 2 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 All @@ -75,8 +76,8 @@ The game server side needs `RCON_ENABLED=true` and an `ADMIN_PASSWORD` (which al
Optional extras, fetched once into your data volume because the art is game-derived and never shipped:

```sh
scripts/fetch-map-tiles.sh ./palhelm-data/map-tiles # live map tiles
scripts/fetch-pal-icons.sh ./palhelm-data/pal-icons # pal preview icons
docker compose exec palhelm palhelm fetch-map-tiles # live map tiles
docker compose exec palhelm palhelm fetch-pal-icons # pal preview icons
```

## Optional configuration
Expand All @@ -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
49 changes: 48 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,14 @@ 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...)
}

var fetchPalIconsCommand = func(args ...string) *exec.Cmd {
return exec.Command("/usr/local/bin/fetch-pal-icons", args...)
}

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

func TestFetchPalIcons(t *testing.T) {
original := fetchPalIconsCommand
t.Cleanup(func() { fetchPalIconsCommand = original })

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

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

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

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

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

err := fetchPalIcons(nil)
if err == nil || !strings.Contains(err.Error(), "fetch pal icons: exit status 17") {
t.Fatalf("fetchPalIcons() 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
66 changes: 66 additions & 0 deletions backend/internal/server/docker_compose.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
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 {
return installCommand(cfg, "fetch-map-tiles")
}

func palIconsInstallCommand(cfg config.Config) string {
return installCommand(cfg, "fetch-pal-icons")
}

func installCommand(cfg config.Config, command string) 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 " + command
}
}
}
return "docker compose exec " + shellArg(service) + " palhelm " + command
}

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, "'", "'\"'\"'") + "'"
}
Loading