diff --git a/.drone.jsonnet b/.drone.jsonnet index 0c7af5f..50dcd01 100644 --- a/.drone.jsonnet +++ b/.drone.jsonnet @@ -1,14 +1,17 @@ -local name = 'game-server'; -local go = '1.23'; +local name = 'games'; +local go = '1.25'; local nginx = '1.24.0'; local node = '20'; -local platform = '26.04.7'; +local platform = '26.04.10'; local python = '3.12-slim-bookworm'; local deployer = 'https://github.com/syncloud/store/releases/download/4/syncloud-release'; local distros = ['bookworm', 'buster']; local distro_default = 'bookworm'; local arch = 'amd64'; +local platform_image(distro, arch) = + 'syncloud/platform-' + distro + '-' + arch + ':' + platform; + [{ kind: 'pipeline', type: 'docker', @@ -32,13 +35,14 @@ local arch = 'amd64'; './nginx/build.sh', ], }, + ] + [ { - name: 'nginx test', - image: 'syncloud/platform-' + distro_default + '-' + arch + ':' + platform, - commands: [ - './nginx/test.sh', - ], - }, + name: 'nginx test ' + distro, + image: platform_image(distro, arch), + commands: ['./nginx/test.sh'], + } + for distro in distros + ] + [ { name: 'web', image: 'node:' + node, @@ -46,6 +50,43 @@ local arch = 'amd64'; './web/build.sh', ], }, + { + name: 'catalog', + image: 'golang:' + go, + commands: [ + './catalog/build.sh', + ], + }, + { + name: 'jre', + image: 'debian:bookworm-slim', + commands: [ + './jre/build.sh', + ], + }, + ] + [ + { + name: 'jre test ' + distro, + image: platform_image(distro, arch), + commands: ['./jre/test.sh'], + } + for distro in distros + ] + [ + { + name: 'steamcmd', + image: 'debian:bookworm-slim', + commands: [ + './steamcmd/build.sh', + ], + }, + ] + [ + { + name: 'steamcmd test ' + distro, + image: platform_image(distro, arch), + commands: ['./steamcmd/test.sh'], + } + for distro in distros + ] + [ { name: 'backend', image: 'golang:' + go, @@ -57,15 +98,17 @@ local arch = 'amd64'; name: 'cli', image: 'golang:' + go, commands: [ - 'cd cli', - 'mkdir -p ../build/snap/meta/hooks', - 'CGO_ENABLED=0 go build -buildvcs=false -o ../build/snap/meta/hooks/install ./cmd/install', - 'CGO_ENABLED=0 go build -buildvcs=false -o ../build/snap/meta/hooks/configure ./cmd/configure', - 'CGO_ENABLED=0 go build -buildvcs=false -o ../build/snap/meta/hooks/pre-refresh ./cmd/pre-refresh', - 'CGO_ENABLED=0 go build -buildvcs=false -o ../build/snap/meta/hooks/post-refresh ./cmd/post-refresh', - 'CGO_ENABLED=0 go build -buildvcs=false -o ../build/snap/bin/cli ./cmd/cli', + './cli/build.sh', ], }, + ] + [ + { + name: 'cli test ' + distro, + image: platform_image(distro, arch), + commands: ['./cli/test.sh'], + } + for distro in distros + ] + [ { name: 'package', image: 'debian:bookworm-slim', @@ -76,20 +119,32 @@ local arch = 'amd64'; }, ] + [ { - name: 'test ' + distro, + name: 'test ' + distro_default, image: 'python:' + python, commands: [ - 'DOMAIN="' + distro + '.com"', - 'APP_DOMAIN="' + name + '.' + distro + '.com"', + 'DOMAIN="' + distro_default + '.com"', + 'APP_DOMAIN="' + name + '.' + distro_default + '.com"', 'getent hosts $APP_DOMAIN | sed "s/$APP_DOMAIN/auth.$DOMAIN/g" | tee -a /etc/hosts', 'cat /etc/hosts', 'APP_ARCHIVE_PATH=$(realpath $(cat package.name))', 'cd test', './deps.sh', - 'py.test -x -s test.py --distro=' + distro + ' --app-archive-path=$APP_ARCHIVE_PATH --app=' + name + ' --arch=' + arch, + 'py.test -x -s test.py --distro=' + distro_default + ' --app-archive-path=$APP_ARCHIVE_PATH --app=' + name + ' --arch=' + arch, + ], + }, + ] + [ + { + name: 'test-ui-' + projectName, + image: 'mcr.microsoft.com/playwright:v1.59.1-jammy', + commands: [ + 'DOMAIN="' + distro_default + '.com"', + 'APP_DOMAIN="' + name + '.' + distro_default + '.com"', + 'getent hosts $APP_DOMAIN | sed "s/$APP_DOMAIN/auth.$DOMAIN/g" | tee -a /etc/hosts', + 'cat /etc/hosts', + 'PLAYWRIGHT_DOMAIN=' + distro_default + '.com ./ci/ui.sh ' + projectName, ], } - for distro in distros + for projectName in ['desktop', 'mobile'] ] + [ { name: 'upload', @@ -150,19 +205,18 @@ local arch = 'amd64'; }, ], trigger: { - event: ['push', 'pull_request'], + event: ['push'], }, services: [ { - name: name + '.' + distro + '.com', - image: 'syncloud/platform-' + distro + '-' + arch + ':' + platform, + name: name + '.' + distro_default + '.com', + image: platform_image(distro_default, arch), privileged: true, volumes: [ { name: 'dbus', path: '/var/run/dbus' }, { name: 'dev', path: '/dev' }, ], - } - for distro in distros + }, ], volumes: [ { name: 'dbus', host: { path: '/var/run/dbus' } }, diff --git a/.gitignore b/.gitignore index a60915d..74fca62 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,7 @@ node_modules/ web/dist/ *.snap .idea/ +catalog/work/ +# backend/catalog/catalog.json is committed as a stub so 'go build' works +# locally without running catalog/build.sh; CI regenerates it. +.claude/ diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..ca433ce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,135 @@ +# CI + +http://ci.syncloud.org:8080/syncloud/games (via 192.168.1.101:8080). + +Check builds via API: +``` +curl -s "http://192.168.1.101:8080/api/repos/syncloud/games/builds?limit=5" +``` + +Check which step failed: +``` +curl -s "http://192.168.1.101:8080/api/repos/syncloud/games/builds/{n}" | python3 -c "import json,sys; d=json.load(sys.stdin); [print(s['name'], s['status']) for st in d['stages'] for s in st['steps']]" +``` + +Tail a specific step (stage_number/step_number, both 1-indexed): +``` +curl -s "http://192.168.1.101:8080/api/repos/syncloud/games/builds/{n}/logs/1/{step}" | python3 -c "import json,sys; [print(l.get('out','').rstrip()) for l in json.load(sys.stdin)]" +``` + +Artifacts at `http://ci.syncloud.org:8081/files/games/{build}-amd64/`. + +# Status + +WIP on `wip` branch. Issue: syncloud/platform#35. + +Last green: build #26 (lib64 bundle landed), build #27 has steamcmd diagnostics. + +Phases shipped: +- 0: snap skeleton, stub catalog API, store-styled Vue UI +- 1: server CRUD + SQLite (modernc.org/sqlite) +- 2: process management (start/stop/restart, SIGTERM+10s+SIGKILL, process-group) +- 3: SteamCMD vendor (amd64 only — 32-bit binary, anonymous + user/pass logins) +- 3c: amd64 lib bundle for 64-bit games (CS2/Valheim/Rust/Factorio) — snap is now host-lib-independent for both archs +- 4: Pelican egg consumer (best-effort non-docker, parses parkervcp/eggs format) +- 4b: real Teeworlds install + start + UDP-bound probe — proves the integration test really plays a game in CI +- 5: in-memory ring-buffer log endpoint (500 lines / server) +- 6: A2S_INFO Source-engine UDP server query (with challenge handshake) +- 7: OIDC client registration via `platformClient.RegisterOIDCClient` +- 8: Playwright e2e (desktop + mobile) +- 9: docs +- 16: mobile bottom-bar nav +- 17: minecraft real install (Pelican egg + bundled JRE) +- 18: real OIDC code+PKCE flow + backend session middleware; nginx forward-auth dropped +- 19: full minecraft cycle (install → EULA → start → tcp-bind), SVG icons extracted to assets, dead authelia templates removed + +In progress / open: +- 3b: real HLDS (CS 1.6, appid 90, 250MB) install via SteamCMD — xfailed. + steamcmd bootstrap fails with "Steam needs to be online to update" + empty + Steam/logs/. lib32 + writable runtime dir aren't enough. Diagnostics added + in test_steamcmd_diagnostics — read CI build log to root-cause. + +# Architecture + +- `cli/` — Go cobra: install / configure / pre-refresh / post-refresh / cli (storage-change, access-change, backup-pre-stop, restore-pre-start, restore-post-start) +- `backend/` — Go HTTP server on `unix:/var/snap/games/current/backend.sock` + - `db/` — SQLite open + schema + - `server/` — Store with CRUD on servers + - `runner/` — exec.Cmd-based process management with ring-buffer log capture + - `installer/` — SteamCMD + Pelican egg install logic + - `query/` — A2S_INFO UDP query +- `web/` — Vue 3 + Vite. Plain CSS theme cribbed from `../store/web` (light/dark, no Element Plus). `web/e2e/` is a self-contained Playwright TS package. +- `config/` — `nginx.conf` (no auth_request — backend session middleware enforces) + `proxy.conf`. `{{ .AuthUrl }}` rendered by `config.Generate`. +- `nginx/` — vendored nginx (build.sh copies the entire FS from the nginx docker image) +- `steamcmd/` — `build.sh` downloads steamcmd_linux.tar.gz +- `test/` — pytest integration tests using `syncloud-lib` + +# Constraints + +- **amd64 only.** `.drone.jsonnet` lists only amd64. SteamCMD ships x86_64. +- **SteamCMD is 32-bit i386.** Snap will need 32-bit glibc bundled — first SteamCMD-based test installs may fail until that's added. Tracked as a follow-up. +- **Pelican eggs that need Docker won't work.** Backend runs install scripts directly. Best-effort for bash/native eggs (Teeworlds, Minetest work). +- **Auth = OIDC code+PKCE against Authelia + signed session cookie.** Backend's `auth.Middleware` checks the cookie on every `/api/*` request on the HTTP socket; nginx no longer does `auth_request`. Integration tests use a separate `cli.sock` (file-mode 0660, no auth middleware) reached via `/snap/bin/games.cli`. +- **Backend ↔ Authelia goes over `authelia.socket`, not HTTPS.** Discovery / token / JWKS requests dial `/var/snap/platform/current/authelia.socket` (path from `golib`'s `GetAuthLocalSocket()`, stored as `authSocket` in `oidc.json`). A RoundTripper rewrites the public `https://` URLs from the discovery doc to `http://` before dialing. The browser redirect (`AuthCodeURL`) still uses the public HTTPS URL because the browser can't dial a unix socket. No syncloud-CA trust needed in the snap as a result. + +# Integration test fixture + +Use **teeworlds** for any test that needs a real install — smallest server in parkervcp/eggs (~10MB binary, headless, A2S-queryable). Egg: `https://raw.githubusercontent.com/parkervcp/eggs/master/game_eggs/teeworlds/egg-teeworlds.json`. Anonymous-friendly Steam apps (cs2, tf2, gmod, valheim, zomboid, ark) work for Steam-path tests. + +# Storage layout (on device) + +``` +/snap/games/current/ # read-only squashfs + steamcmd/ # bundled steamcmd_linux.tar.gz contents + linux32/steamcmd # i386 bootstrap binary + steamcmd.sh, steam.sh # original wrapper (unused) + lib32/ # ~40MB. i386 base runtime: + # ld-linux.so.2 + # libc.so.6, libstdc++.so.6, libgcc_s.so.1 + # libcurl.so.4, libssl.so.3, libsdl2, libgl1, ... + lib64/ # ~25MB. amd64 base runtime: + # ld-linux-x86-64.so.2 + # same lib set, amd64 variants + web/dist/ # Vue SPA build + nginx/ # vendored nginx (etc/, opt/, usr/, lib/) + bin/ + cli # Go cobra hooks binary + backend # Go HTTP backend (static) + service.backend.sh, service.nginx.sh # systemd wrappers + steamcmd.sh # wrapper that invokes linux32/steamcmd + # via lib32/ld-linux.so.2 + lib32 + +/var/snap/games/current/ # writable, $SNAP_DATA + database.db # SQLite catalog of installed servers + backend.sock # nginx -> backend + servers// # per-server install (game files) + config/ # rendered Authelia includes + oidc.secret # OIDC client_secret + nginx/ # nginx state (temp_path etc.) + .steam-home/ # steamcmd's $HOME — Steam/logs/, .steam/ + .steam-runtime/ # steamcmd's writable copy + # (linux32 + public + package + steamcmd.sh) + +/var/snap/games/common/ # shared across revisions, $SNAP_COMMON + web.socket # platform -> nginx (PLATFORM CONTRACT) + installed # marker file +``` + +`web.socket` must stay at `$SNAP_COMMON/web.socket` (platform contract). Everything else under `$SNAP_DATA` so refresh rollback works. + +# Game launch wrappers + +`installer.steamStartCmd` builds the startCmd for each Steam game using two helpers in `backend/installer/installer.go`: + +- `wrapI386(binary, extraPaths, args)` — for HLDS, TF2, GMod (32-bit SrcDS): + ``` + LD_LIBRARY_PATH=lib32[:extra] lib32/ld-linux.so.2 --library-path lib32[:extra] $bin $args + ``` +- `wrapAmd64(binary, extraPaths, args)` — for CS2, Valheim, Rust (64-bit native): + ``` + LD_LIBRARY_PATH=lib64[:extra] lib64/ld-linux-x86-64.so.2 --library-path lib64[:extra] $bin $args + ``` + +`extraPaths` typically includes `$installDir` and `$installDir/` so game-private engine libs (`libtier0_s.so`, `libsteam_api.so`, etc.) resolve. + +This pattern means the snap never depends on host glibc/libstdc++/libGL state — same snap should run on any Linux host the platform supports. diff --git a/README.md b/README.md index 88ff6a9..56007ba 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# game-server +# games Syncloud app: dedicated game server panel. @@ -13,7 +13,7 @@ See commits on `wip` for the phased build-out (server CRUD → process managemen ## Architecture - `cli/` — Go install/configure/access-change/storage-change/backup hooks (cobra). -- `backend/` — Go HTTP backend on `unix:/var/snap/game-server/current/backend.sock`. +- `backend/` — Go HTTP backend on `unix:/var/snap/games/current/backend.sock`. - `web/` — Vue 3 + Vite SPA, store-styled (`../store/web` look). Plain CSS, no Element Plus. - `config/` — nginx + authelia templates rendered at configure time. - `nginx/` — vendored static nginx. diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..1b72935 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1 @@ +/backend diff --git a/backend/auth/auth.go b/backend/auth/auth.go new file mode 100644 index 0000000..78ce2f3 --- /dev/null +++ b/backend/auth/auth.go @@ -0,0 +1,293 @@ +package auth + +import ( + "context" + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "log" + "net" + "net/http" + "os" + "strings" + "sync" + "time" + + "github.com/coreos/go-oidc/v3/oidc" + "golang.org/x/oauth2" +) + +const ( + SessionCookie = "games_session" + StateCookie = "games_oauth_state" + VerifierCookie = "games_oauth_verifier" + SessionTTL = 24 * time.Hour +) + +type User struct { + Sub string `json:"sub"` + Name string `json:"name,omitempty"` + Email string `json:"email,omitempty"` +} + +type Service struct { + mu sync.Mutex + provider *oidc.Provider + verifier *oidc.IDTokenVerifier + cfg oauth2.Config + signKey []byte + httpClient *http.Client + authUrl string + logger *log.Logger +} + +type ctxKey struct{} + +func ContextUser(ctx context.Context) (*User, bool) { + u, ok := ctx.Value(ctxKey{}).(*User) + return u, ok +} + +type httpUnixRoundTripper struct { + base http.RoundTripper +} + +func (rt *httpUnixRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + if req.URL.Scheme == "https" { + clone := req.Clone(req.Context()) + clone.URL.Scheme = "http" + clone.Header.Set("X-Forwarded-Proto", "https") + clone.Header.Set("X-Forwarded-Host", req.URL.Host) + req = clone + } + return rt.base.RoundTrip(req) +} + +func newUnixAutheliaClient(socketPath string) *http.Client { + tr := &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return (&net.Dialer{}).DialContext(ctx, "unix", socketPath) + }, + } + return &http.Client{ + Transport: &httpUnixRoundTripper{base: tr}, + Timeout: 15 * time.Second, + } +} + +func NewService(ctx context.Context, logger *log.Logger, authUrl, authSocket, clientID, clientSecret, signSecret, redirectURL string) (*Service, error) { + hc := newUnixAutheliaClient(authSocket) + ctx = oidc.ClientContext(ctx, hc) + + provider, err := oidc.NewProvider(ctx, authUrl) + if err != nil { + return nil, fmt.Errorf("oidc discovery against %s via %s: %w", authUrl, authSocket, err) + } + cfg := oauth2.Config{ + ClientID: clientID, + ClientSecret: clientSecret, + Endpoint: provider.Endpoint(), + RedirectURL: redirectURL, + Scopes: []string{oidc.ScopeOpenID, "profile", "email"}, + } + verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) + + key := sha256.Sum256([]byte(signSecret + "|games-session-v1")) + return &Service{ + provider: provider, + verifier: verifier, + cfg: cfg, + signKey: key[:], + httpClient: hc, + authUrl: authUrl, + logger: logger, + }, nil +} + +func (s *Service) HandleLogin(w http.ResponseWriter, r *http.Request) { + state := randToken(24) + verifier := randToken(32) + challenge := pkceChallenge(verifier) + http.SetCookie(w, &http.Cookie{Name: StateCookie, Value: state, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600}) + http.SetCookie(w, &http.Cookie{Name: VerifierCookie, Value: verifier, Path: "/", HttpOnly: true, Secure: true, SameSite: http.SameSiteLaxMode, MaxAge: 600}) + + url := s.cfg.AuthCodeURL(state, + oauth2.SetAuthURLParam("code_challenge", challenge), + oauth2.SetAuthURLParam("code_challenge_method", "S256"), + ) + http.Redirect(w, r, url, http.StatusFound) +} + +func (s *Service) HandleCallback(w http.ResponseWriter, r *http.Request) { + stateCookie, err := r.Cookie(StateCookie) + if err != nil || stateCookie.Value == "" || stateCookie.Value != r.URL.Query().Get("state") { + s.logger.Printf("auth callback: state mismatch") + http.Error(w, "state mismatch", http.StatusBadRequest) + return + } + verifierCookie, err := r.Cookie(VerifierCookie) + if err != nil { + http.Error(w, "missing verifier", http.StatusBadRequest) + return + } + code := r.URL.Query().Get("code") + if code == "" { + http.Error(w, "missing code", http.StatusBadRequest) + return + } + + ctx := oidc.ClientContext(r.Context(), s.httpClient) + token, err := s.cfg.Exchange(ctx, code, oauth2.SetAuthURLParam("code_verifier", verifierCookie.Value)) + if err != nil { + s.logger.Printf("auth callback: token exchange: %v", err) + http.Error(w, "token exchange failed", http.StatusUnauthorized) + return + } + rawID, ok := token.Extra("id_token").(string) + if !ok { + http.Error(w, "no id_token in response", http.StatusUnauthorized) + return + } + idTok, err := s.verifier.Verify(ctx, rawID) + if err != nil { + s.logger.Printf("auth callback: id_token verify: %v", err) + http.Error(w, "id_token verify failed", http.StatusUnauthorized) + return + } + var claims struct { + Sub string `json:"sub"` + Name string `json:"name"` + Email string `json:"email"` + } + if err := idTok.Claims(&claims); err != nil { + http.Error(w, "claims decode failed", http.StatusInternalServerError) + return + } + + cookie, err := s.mintSession(User{Sub: claims.Sub, Name: claims.Name, Email: claims.Email}) + if err != nil { + http.Error(w, "session mint failed", http.StatusInternalServerError) + return + } + http.SetCookie(w, cookie) + http.SetCookie(w, &http.Cookie{Name: StateCookie, Value: "", Path: "/", MaxAge: -1}) + http.SetCookie(w, &http.Cookie{Name: VerifierCookie, Value: "", Path: "/", MaxAge: -1}) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Service) HandleLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{Name: SessionCookie, Value: "", Path: "/", MaxAge: -1, HttpOnly: true, Secure: true}) + http.Redirect(w, r, "/", http.StatusFound) +} + +func (s *Service) HandleMe(w http.ResponseWriter, r *http.Request) { + u, ok := ContextUser(r.Context()) + if !ok { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(u) +} + +func (s *Service) Middleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if u := s.userFromCookie(r); u != nil { + next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), ctxKey{}, u))) + return + } + if strings.HasPrefix(r.URL.Path, "/api/") { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + http.Redirect(w, r, "/auth/login", http.StatusFound) + }) +} + +func (s *Service) userFromCookie(r *http.Request) *User { + c, err := r.Cookie(SessionCookie) + if err != nil { + return nil + } + u, err := s.verifySession(c.Value) + if err != nil { + return nil + } + return u +} + +type sessionPayload struct { + User + Exp int64 `json:"exp"` +} + +func (s *Service) mintSession(u User) (*http.Cookie, error) { + p := sessionPayload{User: u, Exp: time.Now().Add(SessionTTL).Unix()} + body, err := json.Marshal(p) + if err != nil { + return nil, err + } + b64 := base64.RawURLEncoding.EncodeToString(body) + mac := hmac.New(sha256.New, s.signKey) + mac.Write([]byte(b64)) + sig := hex.EncodeToString(mac.Sum(nil)) + value := b64 + "." + sig + return &http.Cookie{ + Name: SessionCookie, + Value: value, + Path: "/", + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + MaxAge: int(SessionTTL.Seconds()), + }, nil +} + +func (s *Service) verifySession(value string) (*User, error) { + parts := strings.SplitN(value, ".", 2) + if len(parts) != 2 { + return nil, errors.New("bad session shape") + } + mac := hmac.New(sha256.New, s.signKey) + mac.Write([]byte(parts[0])) + expectSig := hex.EncodeToString(mac.Sum(nil)) + if !hmac.Equal([]byte(expectSig), []byte(parts[1])) { + return nil, errors.New("session signature mismatch") + } + body, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return nil, err + } + var p sessionPayload + if err := json.Unmarshal(body, &p); err != nil { + return nil, err + } + if time.Now().Unix() > p.Exp { + return nil, errors.New("session expired") + } + return &p.User, nil +} + +func randToken(n int) string { + b := make([]byte, n) + _, _ = rand.Read(b) + return base64.RawURLEncoding.EncodeToString(b) +} + +func pkceChallenge(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +func LoadClientSecret(path string) (string, error) { + b, err := os.ReadFile(path) + if err != nil { + return "", err + } + return strings.TrimSpace(string(b)), nil +} diff --git a/backend/catalog/catalog.go b/backend/catalog/catalog.go new file mode 100644 index 0000000..b2ebf52 --- /dev/null +++ b/backend/catalog/catalog.go @@ -0,0 +1,93 @@ +package catalog + +import ( + _ "embed" + "encoding/json" + "fmt" + "sort" +) + +//go:embed catalog.json +var raw []byte + +type Game struct { + ID string `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + UpstreamRef string `json:"upstreamRef,omitempty"` + Summary string `json:"summary"` + DefaultPort int `json:"defaultPort"` + Protocols []string `json:"protocols"` + Tier string `json:"tier"` + TierReason string `json:"tierReason,omitempty"` + SteamAppID int `json:"steamAppId,omitempty"` + InstallRecipe *InstallRecipe `json:"installRecipe,omitempty"` + Start *StartRecipe `json:"start,omitempty"` +} + +type InstallRecipe struct { + Method string `json:"method"` + URL string `json:"url,omitempty"` + SteamAppID int `json:"steamAppId,omitempty"` + SteamArgs []string `json:"steamArgs,omitempty"` +} + +type StartRecipe struct { + Binary string `json:"binary"` + Wrap string `json:"wrap,omitempty"` + ExtraLibs []string `json:"extraLibs,omitempty"` + Args string `json:"args,omitempty"` +} + +type bundle struct { + Sources map[string]string `json:"sources"` + Games []Game `json:"games"` +} + +var ( + loaded bundle + byID map[string]Game + allList []Game +) + +func Start() error { + var b bundle + if err := json.Unmarshal(raw, &b); err != nil { + return fmt.Errorf("parse embedded catalog.json: %w", err) + } + index := make(map[string]Game, len(b.Games)) + for _, g := range b.Games { + index[g.ID] = g + } + list := append([]Game(nil), b.Games...) + sort.Slice(list, func(i, j int) bool { + if list[i].Tier != list[j].Tier { + return tierRank(list[i].Tier) < tierRank(list[j].Tier) + } + return list[i].Name < list[j].Name + }) + loaded = b + byID = index + allList = list + return nil +} + +func tierRank(t string) int { + switch t { + case "verified": + return 0 + case "compatible": + return 1 + case "experimental": + return 2 + default: + return 3 + } +} + +func All() []Game { return allList } +func Sources() map[string]string { return loaded.Sources } +func Get(id string) (Game, bool) { + g, ok := byID[id] + return g, ok +} diff --git a/backend/catalog/catalog.json b/backend/catalog/catalog.json new file mode 100644 index 0000000..935abc5 --- /dev/null +++ b/backend/catalog/catalog.json @@ -0,0 +1 @@ +{"sources":{},"games":[]} diff --git a/backend/db/db.go b/backend/db/db.go new file mode 100644 index 0000000..bf058dc --- /dev/null +++ b/backend/db/db.go @@ -0,0 +1,123 @@ +package db + +import ( + "database/sql" + "errors" + "fmt" + + _ "modernc.org/sqlite" + + "github.com/syncloud/games/backend/server" +) + +const createSchema = ` +CREATE TABLE IF NOT EXISTS servers ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL UNIQUE, + game_id TEXT NOT NULL, + port INTEGER NOT NULL, + status TEXT NOT NULL DEFAULT 'stopped', + install_dir TEXT NOT NULL DEFAULT '', + start_cmd TEXT NOT NULL DEFAULT '', + steam_user TEXT NOT NULL DEFAULT '', + steam_pass TEXT NOT NULL DEFAULT '', + last_error TEXT, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP +); +` + +type DB struct { + path string + conn *sql.DB +} + +func New(path string) *DB { + return &DB{path: path} +} + +func (d *DB) Start() error { + conn, err := sql.Open("sqlite", d.path+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(1)") + if err != nil { + return fmt.Errorf("open %s: %w", d.path, err) + } + if _, err := conn.Exec(createSchema); err != nil { + return fmt.Errorf("schema: %w", err) + } + _, _ = conn.Exec(`ALTER TABLE servers ADD COLUMN last_error TEXT`) + d.conn = conn + return nil +} + +func (d *DB) Close() error { + if d.conn == nil { + return nil + } + return d.conn.Close() +} + +func (d *DB) ListServers() ([]server.Server, error) { + rows, err := d.conn.Query(`SELECT id, name, game_id, port, status, install_dir, start_cmd, COALESCE(last_error, '') FROM servers ORDER BY id`) + if err != nil { + return nil, fmt.Errorf("query: %w", err) + } + defer rows.Close() + var out []server.Server + for rows.Next() { + var x server.Server + if err := rows.Scan(&x.ID, &x.Name, &x.GameID, &x.Port, &x.Status, &x.InstallDir, &x.StartCmd, &x.LastError); err != nil { + return nil, err + } + out = append(out, x) + } + if out == nil { + out = []server.Server{} + } + return out, rows.Err() +} + +func (d *DB) GetServer(id int64) (*server.Server, error) { + row := d.conn.QueryRow(`SELECT id, name, game_id, port, status, install_dir, start_cmd, COALESCE(last_error, '') FROM servers WHERE id = ?`, id) + var x server.Server + if err := row.Scan(&x.ID, &x.Name, &x.GameID, &x.Port, &x.Status, &x.InstallDir, &x.StartCmd, &x.LastError); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return &x, nil +} + +func (d *DB) CreateServer(in server.Server) (*server.Server, error) { + res, err := d.conn.Exec( + `INSERT INTO servers (name, game_id, port, status, install_dir, start_cmd) VALUES (?, ?, ?, ?, ?, ?)`, + in.Name, in.GameID, in.Port, "stopped", in.InstallDir, in.StartCmd, + ) + if err != nil { + return nil, fmt.Errorf("insert: %w", err) + } + id, err := res.LastInsertId() + if err != nil { + return nil, err + } + return d.GetServer(id) +} + +func (d *DB) UpdateServerStatus(id int64, status string) error { + _, err := d.conn.Exec(`UPDATE servers SET status = ? WHERE id = ?`, status, id) + return err +} + +func (d *DB) UpdateServerInstall(id int64, installDir, startCmd string) error { + _, err := d.conn.Exec(`UPDATE servers SET install_dir = ?, start_cmd = ?, last_error = NULL WHERE id = ?`, installDir, startCmd, id) + return err +} + +func (d *DB) UpdateServerLastError(id int64, msg string) error { + _, err := d.conn.Exec(`UPDATE servers SET last_error = ? WHERE id = ?`, msg, id) + return err +} + +func (d *DB) DeleteServer(id int64) error { + _, err := d.conn.Exec(`DELETE FROM servers WHERE id = ?`, id) + return err +} diff --git a/backend/go.mod b/backend/go.mod index 62a825f..270acdd 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -1,3 +1,27 @@ -module github.com/syncloud/game-server/backend +module github.com/syncloud/games/backend -go 1.23 +go 1.25.0 + +require ( + github.com/coreos/go-oidc/v3 v3.18.0 + github.com/ulikunitz/xz v0.5.12 + golang.org/x/oauth2 v0.36.0 + modernc.org/sqlite v1.34.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..35e4a13 --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,57 @@ +github.com/coreos/go-oidc/v3 v3.18.0 h1:V9orjXynvu5wiC9SemFTWnG4F45v403aIcjWo0d41+A= +github.com/coreos/go-oidc/v3 v3.18.0/go.mod h1:DYCf24+ncYi+XkIH97GY1+dqoRlbaSI26KVTCI9SrY4= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +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/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/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= +github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.1 h1:u3Yi6M0N8t9yKRDwhXcyp1eS5/ErhPTBggxWFuR6Hfk= +modernc.org/sqlite v1.34.1/go.mod h1:pXV2xHxhzXZsgT/RtTFAPY6JJDEvOTcTdwADQCCWD4k= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/backend/installer/installer.go b/backend/installer/installer.go new file mode 100644 index 0000000..b72a883 --- /dev/null +++ b/backend/installer/installer.go @@ -0,0 +1,63 @@ +package installer + +import ( + "context" + "fmt" + "os" + "path/filepath" +) + +const ( + SteamCMDPath = "/snap/games/current/bin/steamcmd.sh" + SteamLib32 = "/snap/games/current/steamcmd/lib32" + SteamLib64 = "/snap/games/current/steamcmd/lib64" + ServersBaseDir = "/data/games/servers" +) + +type Game struct { + ID string + Name string + Source string + DefaultPort int + Recipe *Recipe + Start *Start +} + +type Recipe struct { + Method string + URL string + SteamAppID int + SteamArgs []string +} + +type Start struct { + Binary string + Wrap string + ExtraLibs []string + Args string +} + +type Result struct { + InstallDir string + StartCmd string +} + +type Installer struct { + serversDir string + recipe *RecipeInstaller +} + +func New(serversDir string, recipe *RecipeInstaller) *Installer { + return &Installer{serversDir: serversDir, recipe: recipe} +} + +func (i *Installer) Install(ctx context.Context, g Game, name string, port int, steamUser, steamPass string) (*Result, error) { + installDir := filepath.Join(i.serversDir, name) + if err := os.MkdirAll(installDir, 0755); err != nil { + return nil, fmt.Errorf("mkdir: %w", err) + } + if port > 0 { + g.DefaultPort = port + } + return i.recipe.Install(ctx, g, installDir, steamUser, steamPass) +} diff --git a/backend/installer/recipe.go b/backend/installer/recipe.go new file mode 100644 index 0000000..beb1b96 --- /dev/null +++ b/backend/installer/recipe.go @@ -0,0 +1,238 @@ +package installer + +import ( + "archive/tar" + "compress/gzip" + "context" + "fmt" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/ulikunitz/xz" +) + +type RecipeInstaller struct { + steamCMDPath string + steamLib32 string + steamLib64 string + httpClient *http.Client +} + +func NewRecipeInstaller(steamCMDPath, lib32, lib64 string) *RecipeInstaller { + return &RecipeInstaller{ + steamCMDPath: steamCMDPath, + steamLib32: lib32, + steamLib64: lib64, + httpClient: &http.Client{Timeout: 30 * time.Minute}, + } +} + +func (r *RecipeInstaller) Install(ctx context.Context, g Game, installDir, steamUser, steamPass string) (*Result, error) { + if g.Recipe == nil { + return nil, fmt.Errorf("no install recipe for %s", g.ID) + } + switch g.Recipe.Method { + case "steam": + return r.installSteam(ctx, g, installDir, steamUser, steamPass) + case "downloadExtract": + return r.installDownloadExtract(ctx, g, installDir) + default: + return nil, fmt.Errorf("unknown install method %q", g.Recipe.Method) + } +} + +func (r *RecipeInstaller) installSteam(ctx context.Context, g Game, installDir, user, pass string) (*Result, error) { + if _, err := os.Stat(r.steamCMDPath); err != nil { + return nil, fmt.Errorf("steamcmd not bundled: %w", err) + } + login := "anonymous" + if user != "" { + login = user + if pass != "" { + login += " " + pass + } + } + args := []string{ + "+@sSteamCmdForcePlatformType", "linux", + "+force_install_dir", installDir, + "+login", login, + } + if len(g.Recipe.SteamArgs) > 0 { + args = append(args, g.Recipe.SteamArgs...) + } else { + args = append(args, "+app_update", strconv.Itoa(g.Recipe.SteamAppID), "validate") + } + args = append(args, "+quit") + cmd := exec.CommandContext(ctx, r.steamCMDPath, args...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + cmd.Dir = installDir + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("steamcmd: %w", err) + } + return &Result{InstallDir: installDir, StartCmd: r.renderStart(g, installDir)}, nil +} + +func (r *RecipeInstaller) installDownloadExtract(ctx context.Context, g Game, installDir string) (*Result, error) { + url := g.Recipe.URL + if url == "" { + return nil, fmt.Errorf("recipe url empty") + } + tarballPath := filepath.Join(installDir, filepath.Base(url)) + if err := r.download(ctx, url, tarballPath); err != nil { + return nil, fmt.Errorf("download: %w", err) + } + defer os.Remove(tarballPath) + if err := extractArchive(tarballPath, installDir); err != nil { + return nil, fmt.Errorf("extract: %w", err) + } + if g.Start != nil && g.Start.Binary != "" { + binPath, err := findFile(installDir, filepath.Base(g.Start.Binary)) + if err == nil { + _ = os.Chmod(binPath, 0755) + if rel, relErr := filepath.Rel(installDir, binPath); relErr == nil { + g.Start.Binary = rel + } + } + } + return &Result{InstallDir: installDir, StartCmd: r.renderStart(g, installDir)}, nil +} + +func (r *RecipeInstaller) renderStart(g Game, installDir string) string { + if g.Start == nil { + return fmt.Sprintf("echo 'no start recipe for %s; configure manually'", g.ID) + } + args := strings.ReplaceAll(g.Start.Args, "{{port}}", strconv.Itoa(g.DefaultPort)) + bin := filepath.Join(installDir, g.Start.Binary) + switch g.Start.Wrap { + case "i386": + return r.wrap(r.steamLib32, "ld-linux.so.2", installDir, g.Start.ExtraLibs, bin, args) + case "amd64": + return r.wrap(r.steamLib64, "ld-linux-x86-64.so.2", installDir, g.Start.ExtraLibs, bin, args) + default: + return fmt.Sprintf("cd %s && ./%s %s", installDir, g.Start.Binary, args) + } +} + +func (r *RecipeInstaller) wrap(libBase, loader, installDir string, extras []string, bin, args string) string { + paths := []string{libBase} + for _, p := range extras { + if p == "." || p == "" { + paths = append(paths, installDir) + } else { + paths = append(paths, filepath.Join(installDir, p)) + } + } + libPath := strings.Join(paths, ":") + return fmt.Sprintf( + "LD_LIBRARY_PATH=%s %s/%s --library-path %s %s %s", + libPath, libBase, loader, libPath, bin, args) +} + +func (r *RecipeInstaller) download(ctx context.Context, url, dst string) error { + req, _ := http.NewRequestWithContext(ctx, "GET", url, nil) + resp, err := r.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + return fmt.Errorf("http %d", resp.StatusCode) + } + out, err := os.Create(dst) + if err != nil { + return err + } + defer out.Close() + _, err = io.Copy(out, resp.Body) + return err +} + +func extractArchive(archivePath, dst string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + var r io.Reader + switch { + case strings.HasSuffix(archivePath, ".tar.xz") || strings.HasSuffix(archivePath, ".txz"): + xzr, err := xz.NewReader(f) + if err != nil { + return fmt.Errorf("xz: %w", err) + } + r = xzr + case strings.HasSuffix(archivePath, ".tar.gz") || strings.HasSuffix(archivePath, ".tgz"): + gzr, err := gzip.NewReader(f) + if err != nil { + return fmt.Errorf("gzip: %w", err) + } + defer gzr.Close() + r = gzr + case strings.HasSuffix(archivePath, ".tar"): + r = f + default: + return fmt.Errorf("unsupported archive: %s", filepath.Base(archivePath)) + } + tr := tar.NewReader(r) + for { + hdr, err := tr.Next() + if err == io.EOF { + return nil + } + if err != nil { + return err + } + target := filepath.Join(dst, hdr.Name) + if !strings.HasPrefix(target, filepath.Clean(dst)+string(os.PathSeparator)) { + return fmt.Errorf("tar entry escapes destination: %s", hdr.Name) + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + out.Close() + } + } +} + +func findFile(root, name string) (string, error) { + var found string + err := filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if !info.IsDir() && info.Name() == name { + found = p + return io.EOF + } + return nil + }) + if err != nil && err != io.EOF { + return "", err + } + if found == "" { + return "", fmt.Errorf("%s not found in %s", name, root) + } + return found, nil +} + diff --git a/backend/main.go b/backend/main.go index 0fbf8c1..f81fbb3 100644 --- a/backend/main.go +++ b/backend/main.go @@ -1,49 +1,36 @@ package main import ( + "context" "encoding/json" + "fmt" "log" "net" "net/http" "os" -) + "strconv" + "strings" + "time" -const socketPath = "/var/snap/game-server/current/backend.sock" + "github.com/syncloud/games/backend/auth" + "github.com/syncloud/games/backend/catalog" + "github.com/syncloud/games/backend/db" + "github.com/syncloud/games/backend/installer" + "github.com/syncloud/games/backend/query" + "github.com/syncloud/games/backend/runner" + "github.com/syncloud/games/backend/server" + "github.com/syncloud/games/backend/steam" +) -type Game struct { - ID string `json:"id"` - Name string `json:"name"` - Source string `json:"source"` - SteamAppID int `json:"steamAppId,omitempty"` - EggURL string `json:"eggUrl,omitempty"` - Summary string `json:"summary"` - DefaultPort int `json:"defaultPort"` - Protocols []string `json:"protocols"` -} +const oidcConfigPath = "/var/snap/games/current/oidc.json" -var catalog = []Game{ - {ID: "cs2", Name: "Counter-Strike 2", Source: "steam", SteamAppID: 730, Summary: "Valve's tactical shooter dedicated server.", DefaultPort: 27015, Protocols: []string{"udp"}}, - {ID: "tf2", Name: "Team Fortress 2", Source: "steam", SteamAppID: 232250, Summary: "Class-based team shooter.", DefaultPort: 27015, Protocols: []string{"udp"}}, - {ID: "gmod", Name: "Garry's Mod", Source: "steam", SteamAppID: 4020, Summary: "Sandbox modification of Source.", DefaultPort: 27015, Protocols: []string{"udp"}}, - {ID: "valheim", Name: "Valheim", Source: "steam", SteamAppID: 896660, Summary: "Viking survival co-op.", DefaultPort: 2456, Protocols: []string{"udp"}}, - {ID: "rust", Name: "Rust", Source: "steam", SteamAppID: 258550, Summary: "Multiplayer survival.", DefaultPort: 28015, Protocols: []string{"udp"}}, - {ID: "zomboid", Name: "Project Zomboid", Source: "steam", SteamAppID: 380870, Summary: "Isometric zombie survival sandbox.", DefaultPort: 16261, Protocols: []string{"udp"}}, - {ID: "ark", Name: "ARK: Survival Evolved", Source: "steam", SteamAppID: 376030, Summary: "Dinosaur survival multiplayer.", DefaultPort: 7777, Protocols: []string{"udp"}}, - {ID: "teeworlds", Name: "Teeworlds", Source: "egg", EggURL: "https://raw.githubusercontent.com/parkervcp/eggs/master/game_eggs/teeworlds/egg-teeworlds.json", Summary: "Tiny 2D competitive shooter. Smallest server, used as our CI fixture.", DefaultPort: 8303, Protocols: []string{"udp"}}, - {ID: "minetest", Name: "Minetest", Source: "egg", EggURL: "https://raw.githubusercontent.com/parkervcp/eggs/master/game_eggs/minetest/egg-minetest.json", Summary: "Open-source voxel sandbox.", DefaultPort: 30000, Protocols: []string{"udp"}}, - {ID: "minecraft-java", Name: "Minecraft (Java)", Source: "egg", EggURL: "https://raw.githubusercontent.com/parkervcp/eggs/master/minecraft/java/vanilla/egg-vanilla-minecraft.json", Summary: "Vanilla Minecraft Java edition server.", DefaultPort: 25565, Protocols: []string{"tcp"}}, - {ID: "terraria", Name: "Terraria (TShock)", Source: "egg", EggURL: "https://raw.githubusercontent.com/parkervcp/eggs/master/game_eggs/terraria/tshock/egg-t-shock.json", Summary: "2D sandbox with TShock server.", DefaultPort: 7777, Protocols: []string{"tcp"}}, -} +type Game = catalog.Game -type Server struct { - ID string `json:"id"` - Name string `json:"name"` - GameID string `json:"gameId"` - Status string `json:"status"` - Port int `json:"port"` -} - -var servers = []Server{} +const ( + socketPath = "/var/snap/games/current/backend.sock" + cliSocketPath = "/var/snap/games/current/cli.sock" + dbPath = "/var/snap/games/current/database.db" +) func writeJSON(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") @@ -51,9 +38,36 @@ func writeJSON(w http.ResponseWriter, code int, v any) { _ = json.NewEncoder(w).Encode(v) } +func writeError(w http.ResponseWriter, code int, msg string) { + writeJSON(w, code, map[string]string{"error": msg}) +} + +func gameByID(id string) *Game { + g, ok := catalog.Get(id) + if !ok { + return nil + } + return &g +} + func main() { logger := log.New(os.Stdout, "backend: ", log.LstdFlags) + if err := catalog.Start(); err != nil { + logger.Fatalf("catalog: %v", err) + } + + store, err := openDB(logger) + if err != nil { + logger.Fatalf("db: %v", err) + } + defer store.Close() + run := runner.New(logger) + inst := installer.New( + installer.ServersBaseDir, + installer.NewRecipeInstaller(installer.SteamCMDPath, installer.SteamLib32, installer.SteamLib64), + ) + _ = os.Remove(socketPath) listener, err := net.Listen("unix", socketPath) if err != nil { @@ -63,19 +77,397 @@ func main() { logger.Fatalf("chmod: %v", err) } - mux := http.NewServeMux() - mux.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { + _ = os.Remove(cliSocketPath) + cliListener, err := net.Listen("unix", cliSocketPath) + if err != nil { + logger.Fatalf("listen cli: %v", err) + } + if err := os.Chmod(cliSocketPath, 0660); err != nil { + logger.Fatalf("chmod cli: %v", err) + } + + authSvc := loadAuth(logger) + + api := http.NewServeMux() + api.HandleFunc("/api/v1/health", func(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) }) - mux.HandleFunc("/api/v1/games", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, catalog) + api.HandleFunc("/api/v1/me", func(w http.ResponseWriter, r *http.Request) { + if authSvc != nil { + authSvc.HandleMe(w, r) + return + } + writeJSON(w, http.StatusOK, map[string]string{"sub": "unknown"}) + }) + api.HandleFunc("/api/v1/games", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, catalog.All()) + }) + api.HandleFunc("/api/v1/catalog/sources", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, catalog.Sources()) }) - mux.HandleFunc("/api/v1/servers", func(w http.ResponseWriter, r *http.Request) { - writeJSON(w, http.StatusOK, servers) + api.HandleFunc("/api/v1/steam/login", func(w http.ResponseWriter, r *http.Request) { + handleSteamLogin(w, r) }) + api.HandleFunc("/api/v1/steam/status", func(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "linked": steam.StoredUsername() != "", + "username": steam.StoredUsername(), + }) + }) + api.HandleFunc("/api/v1/servers", func(w http.ResponseWriter, r *http.Request) { + handleServers(w, r, store) + }) + api.HandleFunc("/api/v1/servers/", func(w http.ResponseWriter, r *http.Request) { + handleServerByID(w, r, store, run, inst) + }) + + mux := http.NewServeMux() + if authSvc != nil { + mux.HandleFunc("/auth/login", authSvc.HandleLogin) + mux.HandleFunc("/auth/callback", authSvc.HandleCallback) + mux.HandleFunc("/auth/logout", authSvc.HandleLogout) + mux.Handle("/api/", authSvc.Middleware(api)) + } else { + logger.Printf("auth disabled — OIDC config not loaded; /api/ unprotected (dev mode)") + mux.Handle("/api/", api) + } + + cliMux := http.NewServeMux() + cliMux.Handle("/api/", api) + + go func() { + logger.Printf("listening on %s (cli)", cliSocketPath) + if err := http.Serve(cliListener, cliMux); err != nil { + logger.Fatalf("serve cli: %v", err) + } + }() logger.Printf("listening on %s", socketPath) if err := http.Serve(listener, mux); err != nil { logger.Fatalf("serve: %v", err) } } + +type oidcFileConfig struct { + AuthUrl string `json:"authUrl"` + AuthSocket string `json:"authSocket"` + ClientID string `json:"clientId"` + ClientSecret string `json:"clientSecret"` + RedirectUrl string `json:"redirectUrl"` +} + +func loadAuth(logger *log.Logger) *auth.Service { + data, err := os.ReadFile(oidcConfigPath) + if err != nil { + logger.Printf("auth: oidc.json missing (%v); /api/ will be unprotected", err) + return nil + } + var c oidcFileConfig + if err := json.Unmarshal(data, &c); err != nil { + logger.Printf("auth: oidc.json parse: %v", err) + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + if c.AuthSocket == "" { + logger.Printf("auth: oidc.json missing authSocket; /api/ will be unprotected") + return nil + } + svc, err := auth.NewService(ctx, logger, c.AuthUrl, c.AuthSocket, c.ClientID, c.ClientSecret, c.ClientSecret, c.RedirectUrl) + if err != nil { + logger.Printf("auth: init: %v", err) + return nil + } + logger.Printf("auth: OIDC ready (provider=%s socket=%s client=%s redirect=%s)", c.AuthUrl, c.AuthSocket, c.ClientID, c.RedirectUrl) + return svc +} + +func openDB(logger *log.Logger) (*db.DB, error) { + d := db.New(dbPath) + if err := d.Start(); err != nil { + if _, statErr := os.Stat("/var/snap/games/current"); statErr != nil { + logger.Printf("data dir missing, falling back to in-memory db: %v", statErr) + d = db.New(":memory:") + if err := d.Start(); err != nil { + return nil, err + } + return d, nil + } + return nil, err + } + return d, nil +} + +type createRequest struct { + Name string `json:"name"` + GameID string `json:"gameId"` + Port int `json:"port"` + StartCmd string `json:"startCmd"` +} + +func handleServers(w http.ResponseWriter, r *http.Request, store *db.DB) { + switch r.Method { + case http.MethodGet: + list, err := store.ListServers() + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusOK, list) + case http.MethodPost: + var req createRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + writeError(w, http.StatusBadRequest, "name required") + return + } + game := gameByID(req.GameID) + if game == nil { + writeError(w, http.StatusBadRequest, "unknown gameId") + return + } + if req.Port == 0 { + req.Port = game.DefaultPort + } + s, err := store.CreateServer(server.Server{ + Name: req.Name, + GameID: req.GameID, + Port: req.Port, + StartCmd: req.StartCmd, + }) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + writeJSON(w, http.StatusCreated, s) + default: + w.Header().Set("Allow", "GET, POST") + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func handleServerByID(w http.ResponseWriter, r *http.Request, store *db.DB, run *runner.Runner, inst *installer.Installer) { + rest := strings.TrimPrefix(r.URL.Path, "/api/v1/servers/") + parts := strings.SplitN(rest, "/", 2) + id, err := strconv.ParseInt(parts[0], 10, 64) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid id") + return + } + if len(parts) == 2 && parts[1] != "" { + switch parts[1] { + case "logs": + handleLogs(w, r, run, id) + return + case "query": + handleQuery(w, r, store, id) + return + default: + handleServerAction(w, r, store, run, inst, id, parts[1]) + return + } + } + switch r.Method { + case http.MethodGet: + s, err := store.GetServer(id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if s == nil { + writeError(w, http.StatusNotFound, "not found") + return + } + s.Status = currentStatus(s, run) + writeJSON(w, http.StatusOK, s) + case http.MethodDelete: + _ = run.Stop(id) + if err := store.DeleteServer(id); err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + w.WriteHeader(http.StatusNoContent) + default: + w.Header().Set("Allow", "GET, DELETE") + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + } +} + +func handleServerAction(w http.ResponseWriter, r *http.Request, store *db.DB, run *runner.Runner, inst *installer.Installer, id int64, action string) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + s, err := store.GetServer(id) + if err != nil { + writeError(w, http.StatusInternalServerError, err.Error()) + return + } + if s == nil { + writeError(w, http.StatusNotFound, "not found") + return + } + switch action { + case "install": + game := gameByID(s.GameID) + if game == nil { + writeError(w, http.StatusBadRequest, "unknown gameId on server") + return + } + _ = store.UpdateServerStatus(id, "installing") + go runInstall(log.Default(), store, inst, id, *game) + s.Status = "installing" + case "start": + if err := run.Start(id, s.StartCmd, s.InstallDir); err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + _ = store.UpdateServerStatus(id, "running") + case "stop": + if err := run.Stop(id); err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + _ = store.UpdateServerStatus(id, "stopped") + case "restart": + if err := run.Restart(id, s.StartCmd, s.InstallDir); err != nil { + writeError(w, http.StatusConflict, err.Error()) + return + } + _ = store.UpdateServerStatus(id, "running") + default: + writeError(w, http.StatusNotFound, "unknown action") + return + } + s, _ = store.GetServer(id) + s.Status = currentStatus(s, run) + writeJSON(w, http.StatusOK, s) +} + +func handleLogs(w http.ResponseWriter, r *http.Request, run *runner.Runner, id int64) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + writeJSON(w, http.StatusOK, map[string]any{"lines": run.Logs(id)}) +} + +func handleQuery(w http.ResponseWriter, r *http.Request, store *db.DB, id int64) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", "GET") + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + s, err := store.GetServer(id) + if err != nil || s == nil { + writeError(w, http.StatusNotFound, "not found") + return + } + addr := fmt.Sprintf("127.0.0.1:%d", s.Port) + info, err := query.QueryInfo(addr, 2*time.Second) + if err != nil { + writeError(w, http.StatusBadGateway, err.Error()) + return + } + writeJSON(w, http.StatusOK, info) +} + +type steamLoginRequest struct { + Username string `json:"username"` + Password string `json:"password"` + GuardCode string `json:"guardCode"` +} + +func handleSteamLogin(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", "POST") + writeError(w, http.StatusMethodNotAllowed, "method not allowed") + return + } + var req steamLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid body") + return + } + ctx, cancel := context.WithTimeout(r.Context(), 2*time.Minute) + defer cancel() + res, err := steam.Login(ctx, req.Username, req.Password, req.GuardCode) + if res != nil && res.Needs2FA { + writeJSON(w, http.StatusOK, map[string]any{ + "needsGuard": true, + "prompt": res.Prompt, + }) + return + } + if err != nil { + writeError(w, http.StatusUnauthorized, err.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "linked": true, + "username": res.Username, + }) +} + +func currentStatus(s *server.Server, run *runner.Runner) string { + if s.Status == "installing" || s.Status == "install-error" { + return s.Status + } + if run.Running(s.ID) { + return "running" + } + return "stopped" +} + +func runInstall(logger *log.Logger, store *db.DB, inst *installer.Installer, id int64, g Game) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) + defer cancel() + s, err := store.GetServer(id) + if err != nil || s == nil { + return + } + steamUser := steam.StoredUsername() + logger.Printf("install[%d] starting: game=%s source=%s appid=%d steamUser=%q", id, g.ID, g.Source, g.SteamAppID, steamUser) + result, err := inst.Install(ctx, toInstallerGame(g), s.Name, s.Port, steamUser, "") + if err != nil { + logger.Printf("install[%d] FAILED: %v", id, err) + _ = store.UpdateServerLastError(id, err.Error()) + _ = store.UpdateServerStatus(id, "install-error") + return + } + logger.Printf("install[%d] OK: dir=%s start=%q", id, result.InstallDir, result.StartCmd) + _ = store.UpdateServerInstall(id, result.InstallDir, result.StartCmd) + _ = store.UpdateServerStatus(id, "stopped") +} + +func toInstallerGame(g Game) installer.Game { + ig := installer.Game{ + ID: g.ID, + Name: g.Name, + Source: g.Source, + DefaultPort: g.DefaultPort, + } + if g.InstallRecipe != nil { + ig.Recipe = &installer.Recipe{ + Method: g.InstallRecipe.Method, + URL: g.InstallRecipe.URL, + SteamAppID: g.InstallRecipe.SteamAppID, + SteamArgs: g.InstallRecipe.SteamArgs, + } + } + if g.Start != nil { + ig.Start = &installer.Start{ + Binary: g.Start.Binary, + Wrap: g.Start.Wrap, + ExtraLibs: g.Start.ExtraLibs, + Args: g.Start.Args, + } + } + return ig +} diff --git a/backend/query/a2s.go b/backend/query/a2s.go new file mode 100644 index 0000000..4870d68 --- /dev/null +++ b/backend/query/a2s.go @@ -0,0 +1,139 @@ +package query + +import ( + "bytes" + "encoding/binary" + "errors" + "fmt" + "net" + "time" +) + +type Info struct { + Protocol int + Name string + Map string + Folder string + Game string + AppID int + Players int + MaxPlayers int + Bots int + ServerType string + Environment string +} + +const challengePrefix = "\xff\xff\xff\xffTSource Engine Query\x00" + +func QueryInfo(addr string, timeout time.Duration) (*Info, error) { + conn, err := net.DialTimeout("udp", addr, timeout) + if err != nil { + return nil, fmt.Errorf("dial: %w", err) + } + defer conn.Close() + _ = conn.SetDeadline(time.Now().Add(timeout)) + + if _, err := conn.Write([]byte(challengePrefix)); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + buf := make([]byte, 1400) + n, err := conn.Read(buf) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + resp := buf[:n] + if n >= 9 && bytes.Equal(resp[:5], []byte("\xff\xff\xff\xffA")) { + challenge := resp[5:9] + req := append([]byte(challengePrefix), challenge...) + if _, err := conn.Write(req); err != nil { + return nil, fmt.Errorf("write2: %w", err) + } + n, err = conn.Read(buf) + if err != nil { + return nil, fmt.Errorf("read2: %w", err) + } + resp = buf[:n] + } + return parseInfo(resp) +} + +func parseInfo(data []byte) (*Info, error) { + if len(data) < 6 { + return nil, errors.New("short response") + } + if !bytes.Equal(data[:4], []byte{0xFF, 0xFF, 0xFF, 0xFF}) { + return nil, errors.New("bad header") + } + if data[4] != 'I' { + return nil, fmt.Errorf("unexpected response type %c", data[4]) + } + r := bytes.NewReader(data[5:]) + var protocol uint8 + if err := binary.Read(r, binary.LittleEndian, &protocol); err != nil { + return nil, err + } + name, err := readCString(r) + if err != nil { + return nil, err + } + mapName, err := readCString(r) + if err != nil { + return nil, err + } + folder, err := readCString(r) + if err != nil { + return nil, err + } + game, err := readCString(r) + if err != nil { + return nil, err + } + var appID uint16 + if err := binary.Read(r, binary.LittleEndian, &appID); err != nil { + return nil, err + } + var players, maxPlayers, bots uint8 + if err := binary.Read(r, binary.LittleEndian, &players); err != nil { + return nil, err + } + if err := binary.Read(r, binary.LittleEndian, &maxPlayers); err != nil { + return nil, err + } + if err := binary.Read(r, binary.LittleEndian, &bots); err != nil { + return nil, err + } + var serverType, env uint8 + if err := binary.Read(r, binary.LittleEndian, &serverType); err != nil { + return nil, err + } + if err := binary.Read(r, binary.LittleEndian, &env); err != nil { + return nil, err + } + return &Info{ + Protocol: int(protocol), + Name: name, + Map: mapName, + Folder: folder, + Game: game, + AppID: int(appID), + Players: int(players), + MaxPlayers: int(maxPlayers), + Bots: int(bots), + ServerType: string(rune(serverType)), + Environment: string(rune(env)), + }, nil +} + +func readCString(r *bytes.Reader) (string, error) { + var buf bytes.Buffer + for { + b, err := r.ReadByte() + if err != nil { + return "", err + } + if b == 0 { + return buf.String(), nil + } + buf.WriteByte(b) + } +} diff --git a/backend/runner/runner.go b/backend/runner/runner.go new file mode 100644 index 0000000..6c0b326 --- /dev/null +++ b/backend/runner/runner.go @@ -0,0 +1,195 @@ +package runner + +import ( + "fmt" + "log" + "os" + "os/exec" + "sync" + "syscall" + "time" +) + +const ringSize = 500 + +type Runner struct { + mu sync.Mutex + procs map[int64]*exec.Cmd + logs map[int64]*ring + logger *log.Logger +} + +func New(logger *log.Logger) *Runner { + return &Runner{ + procs: map[int64]*exec.Cmd{}, + logs: map[int64]*ring{}, + logger: logger, + } +} + +func (r *Runner) Running(id int64) bool { + r.mu.Lock() + defer r.mu.Unlock() + cmd, ok := r.procs[id] + if !ok || cmd.Process == nil { + return false + } + if cmd.ProcessState != nil && cmd.ProcessState.Exited() { + return false + } + return syscall.Kill(cmd.Process.Pid, 0) == nil +} + +func (r *Runner) Logs(id int64) []string { + r.mu.Lock() + defer r.mu.Unlock() + rg, ok := r.logs[id] + if !ok { + return []string{} + } + return rg.snapshot() +} + +func (r *Runner) Start(id int64, startCmd string, workDir string) error { + if startCmd == "" { + return fmt.Errorf("start command empty") + } + r.mu.Lock() + if existing, ok := r.procs[id]; ok && existing.Process != nil { + if existing.ProcessState == nil || !existing.ProcessState.Exited() { + r.mu.Unlock() + return fmt.Errorf("already running") + } + } + rg, ok := r.logs[id] + if !ok { + rg = newRing(ringSize) + r.logs[id] = rg + } + cmd := exec.Command("sh", "-c", startCmd) + if workDir != "" { + if _, err := os.Stat(workDir); err == nil { + cmd.Dir = workDir + } + } + if workDir != "" { + cmd.Env = append(os.Environ(), "HOME="+workDir) + } + cmd.Stdout = newLogWriter(r.logger, fmt.Sprintf("server[%d] stdout", id), rg) + cmd.Stderr = newLogWriter(r.logger, fmt.Sprintf("server[%d] stderr", id), rg) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + if err := cmd.Start(); err != nil { + r.mu.Unlock() + return fmt.Errorf("start: %w", err) + } + r.procs[id] = cmd + r.mu.Unlock() + + go func() { + err := cmd.Wait() + if err != nil { + r.logger.Printf("server[%d] exited: %v", id, err) + rg.add(fmt.Sprintf("[runner] exited: %v", err)) + } else { + r.logger.Printf("server[%d] exited cleanly", id) + rg.add("[runner] exited cleanly") + } + }() + return nil +} + +func (r *Runner) Stop(id int64) error { + r.mu.Lock() + cmd, ok := r.procs[id] + r.mu.Unlock() + if !ok || cmd.Process == nil { + return fmt.Errorf("not running") + } + pgid, err := syscall.Getpgid(cmd.Process.Pid) + if err == nil { + _ = syscall.Kill(-pgid, syscall.SIGTERM) + } else { + _ = cmd.Process.Signal(syscall.SIGTERM) + } + done := make(chan struct{}) + go func() { + _, _ = cmd.Process.Wait() + close(done) + }() + select { + case <-done: + case <-time.After(10 * time.Second): + if pgid > 0 { + _ = syscall.Kill(-pgid, syscall.SIGKILL) + } else { + _ = cmd.Process.Kill() + } + } + r.mu.Lock() + delete(r.procs, id) + r.mu.Unlock() + return nil +} + +func (r *Runner) Restart(id int64, startCmd string, workDir string) error { + if r.Running(id) { + if err := r.Stop(id); err != nil { + return err + } + } + return r.Start(id, startCmd, workDir) +} + +type ring struct { + mu sync.Mutex + buf []string + pos int + full bool + size int +} + +func newRing(size int) *ring { + return &ring{buf: make([]string, size), size: size} +} + +func (r *ring) add(s string) { + r.mu.Lock() + defer r.mu.Unlock() + r.buf[r.pos] = s + r.pos = (r.pos + 1) % r.size + if r.pos == 0 { + r.full = true + } +} + +func (r *ring) snapshot() []string { + r.mu.Lock() + defer r.mu.Unlock() + if !r.full { + out := make([]string, r.pos) + copy(out, r.buf[:r.pos]) + return out + } + out := make([]string, r.size) + copy(out, r.buf[r.pos:]) + copy(out[r.size-r.pos:], r.buf[:r.pos]) + return out +} + +type logWriter struct { + logger *log.Logger + prefix string + ring *ring +} + +func newLogWriter(l *log.Logger, p string, r *ring) *logWriter { + return &logWriter{logger: l, prefix: p, ring: r} +} + +func (w *logWriter) Write(p []byte) (int, error) { + line := string(p) + w.logger.Printf("%s: %s", w.prefix, line) + w.ring.add(line) + return len(p), nil +} diff --git a/backend/server/server.go b/backend/server/server.go new file mode 100644 index 0000000..8cc4752 --- /dev/null +++ b/backend/server/server.go @@ -0,0 +1,12 @@ +package server + +type Server struct { + ID int64 `json:"id"` + Name string `json:"name"` + GameID string `json:"gameId"` + Port int `json:"port"` + Status string `json:"status"` + InstallDir string `json:"installDir"` + StartCmd string `json:"startCmd"` + LastError string `json:"lastError,omitempty"` +} diff --git a/backend/steam/login.go b/backend/steam/login.go new file mode 100644 index 0000000..8e71b64 --- /dev/null +++ b/backend/steam/login.go @@ -0,0 +1,92 @@ +package steam + +import ( + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "time" +) + +const ( + SteamCMDPath = "/snap/games/current/bin/steamcmd.sh" + UsernameFile = "/var/snap/games/current/steam-username" +) + +var ( + ErrInvalidCredentials = errors.New("invalid Steam credentials") + ErrNeedsGuardCode = errors.New("Steam Guard code required") +) + +type LoginResult struct { + OK bool + Username string + Needs2FA bool + Prompt string + Raw string +} + +func Login(ctx context.Context, username, password, guardCode string) (*LoginResult, error) { + if strings.TrimSpace(username) == "" { + return nil, errors.New("username required") + } + + args := []string{"+login", username, password} + if strings.TrimSpace(guardCode) != "" { + args = append(args, guardCode) + } + args = append(args, "+quit") + + ctx2, cancel := context.WithTimeout(ctx, 90*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx2, SteamCMDPath, args...) + out, _ := cmd.CombinedOutput() + raw := string(out) + low := strings.ToLower(raw) + + res := &LoginResult{Username: username, Raw: raw} + + if strings.Contains(low, "logon success") || + strings.Contains(low, "logged in ok") || + (strings.Contains(low, "loading steam api...ok") && strings.Contains(low, "connecting anonymously") == false && cmd.ProcessState != nil && cmd.ProcessState.ExitCode() == 0) { + res.OK = true + if err := persistUsername(username); err != nil { + return res, err + } + return res, nil + } + + if strings.Contains(low, "steam guard code") || + strings.Contains(low, "two-factor code") || + strings.Contains(low, "auth code") || + strings.Contains(low, "rate limit exceeded") == false && strings.Contains(low, "guard") { + res.Needs2FA = true + res.Prompt = "Steam Guard code required — check your email or Steam mobile app" + return res, nil + } + + if strings.Contains(low, "invalid password") || + strings.Contains(low, "login failure") || + strings.Contains(low, "incorrect password") { + return res, ErrInvalidCredentials + } + + return res, errors.New("steamcmd login failed (unknown response)") +} + +func StoredUsername() string { + b, err := os.ReadFile(UsernameFile) + if err != nil { + return "" + } + return strings.TrimSpace(string(b)) +} + +func persistUsername(u string) error { + if err := os.MkdirAll(filepath.Dir(UsernameFile), 0755); err != nil { + return err + } + return os.WriteFile(UsernameFile, []byte(strings.TrimSpace(u)+"\n"), 0640) +} diff --git a/bin/service.nginx.sh b/bin/service.nginx.sh index 83de8d7..998021b 100755 --- a/bin/service.nginx.sh +++ b/bin/service.nginx.sh @@ -2,5 +2,5 @@ DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd ) -/bin/rm -f /var/snap/game-server/common/web.socket -exec ${DIR}/nginx/bin/nginx.sh -c /var/snap/game-server/current/config/nginx.conf -p ${DIR}/nginx -e stderr +/bin/rm -f /var/snap/games/common/web.socket +exec ${DIR}/nginx/bin/nginx.sh -c /var/snap/games/current/config/nginx.conf -p ${DIR}/nginx -e stderr diff --git a/bin/wait-for-configure.sh b/bin/wait-for-configure.sh index 3c7993f..82488f9 100755 --- a/bin/wait-for-configure.sh +++ b/bin/wait-for-configure.sh @@ -2,7 +2,7 @@ retry=0 retries=100 -APP=game-server +APP=games NEXT=/snap/$APP/current/version CURRENT=/var/snap/$APP/current/version while ! diff $NEXT $CURRENT; do diff --git a/catalog/build.sh b/catalog/build.sh new file mode 100755 index 0000000..cdd2ca5 --- /dev/null +++ b/catalog/build.sh @@ -0,0 +1,59 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd ${DIR} + +apt update +apt -y install wget ca-certificates + +PARKERVCP_SHA="${PARKERVCP_SHA:-fcfd5a3549769ade15127a7577d6d3c397e83b05}" +PELICAN_GAMES_SHA="${PELICAN_GAMES_SHA:-34331fce33c83df752d94e2a90b1e43ca6280f82}" +LINUXGSM_SHA="${LINUXGSM_SHA:-d05992d7d2deb88ed0a0c9df5ffc423995947d11}" # v26.1.0 + +WORK=${DIR}/work +rm -rf ${WORK} +mkdir -p ${WORK} +cd ${WORK} + +echo "fetching parkervcp/eggs @ ${PARKERVCP_SHA}" +mkdir -p parkervcp +wget -q "https://github.com/parkervcp/eggs/archive/${PARKERVCP_SHA}.tar.gz" -O parkervcp.tar.gz +tar -xzf parkervcp.tar.gz -C parkervcp --strip-components=1 + +echo "fetching pelican-eggs/games @ ${PELICAN_GAMES_SHA}" +mkdir -p pelican +wget -q "https://github.com/pelican-eggs/games/archive/${PELICAN_GAMES_SHA}.tar.gz" -O pelican.tar.gz +tar -xzf pelican.tar.gz -C pelican --strip-components=1 + +echo "fetching GameServerManagers/LinuxGSM @ ${LINUXGSM_SHA}" +mkdir -p linuxgsm +wget -q "https://github.com/GameServerManagers/LinuxGSM/archive/${LINUXGSM_SHA}.tar.gz" -O linuxgsm.tar.gz +tar -xzf linuxgsm.tar.gz -C linuxgsm --strip-components=1 + +cd ${DIR}/convert +CGO_ENABLED=0 go build -buildvcs=false -o ${WORK}/convert . + +OUT=${DIR}/../backend/catalog/catalog.json +mkdir -p $(dirname ${OUT}) +${WORK}/convert \ + --parkervcp ${WORK}/parkervcp/game_eggs \ + --pelican ${WORK}/pelican \ + --linuxgsm ${WORK}/linuxgsm \ + --overrides ${DIR}/overrides.json \ + --parkervcp-version ${PARKERVCP_SHA} \ + --pelican-version ${PELICAN_GAMES_SHA} \ + --linuxgsm-version ${LINUXGSM_SHA} \ + --out ${OUT} + +echo "catalog stats:" +wc -c ${OUT} +python3 -c " +import json +with open('${OUT}') as f: c=json.load(f) +tiers={} +for g in c['games']: + tiers[g['tier']] = tiers.get(g['tier'], 0) + 1 +print('total:', len(c['games'])) +print('by tier:', tiers) +print('versions:', c['sources']) +" diff --git a/catalog/convert/go.mod b/catalog/convert/go.mod new file mode 100644 index 0000000..007869a --- /dev/null +++ b/catalog/convert/go.mod @@ -0,0 +1,3 @@ +module convert + +go 1.23 diff --git a/catalog/convert/linuxgsm.go b/catalog/convert/linuxgsm.go new file mode 100644 index 0000000..f960d29 --- /dev/null +++ b/catalog/convert/linuxgsm.go @@ -0,0 +1,105 @@ +package main + +import ( + "encoding/csv" + "fmt" + "log" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" +) + +var cfgKVRE = regexp.MustCompile(`^(\w+)="([^"]*)"`) + +var lgsmIDRemap = map[string]string{ + "cs": "hlds-cs", + "vh": "valheim", + "pz": "zomboid", +} + +func walkLinuxGSM(root string, out map[string]CatalogGame) { + listPath := filepath.Join(root, "lgsm", "data", "serverlist.csv") + f, err := os.Open(listPath) + if err != nil { + log.Printf("linuxgsm: open %s: %v", listPath, err) + return + } + defer f.Close() + rows, err := csv.NewReader(f).ReadAll() + if err != nil { + log.Printf("linuxgsm: parse serverlist.csv: %v", err) + return + } + + imported, skippedNonSteam, skippedCollision := 0, 0, 0 + for i, row := range rows { + if i == 0 || len(row) < 3 { + continue + } + shortname := strings.TrimSpace(row[0]) + serverName := strings.TrimSpace(row[1]) + gameName := strings.TrimSpace(row[2]) + if shortname == "" || serverName == "" { + continue + } + + cfg := parseLGSMCfg(filepath.Join(root, "lgsm", "config-default", "config-lgsm", serverName, "_default.cfg")) + appid, _ := strconv.Atoi(cfg["appid"]) + if appid <= 0 { + skippedNonSteam++ + continue + } + + id := shortname + if remapped, ok := lgsmIDRemap[shortname]; ok { + id = remapped + } + if existing, ok := out[id]; ok { + log.Printf("linuxgsm: skip %s (id %q already claimed by source=%s)", shortname, id, existing.Source) + skippedCollision++ + continue + } + + port, _ := strconv.Atoi(cfg["port"]) + + g := CatalogGame{ + ID: id, + Name: gameName, + Source: "linuxgsm", + UpstreamRef: serverName, + SteamAppID: appid, + DefaultPort: port, + Protocols: []string{"udp"}, + Tier: "experimental", + Summary: fmt.Sprintf("Steam dedicated server for %s (appid %d). Imported from LinuxGSM.", gameName, appid), + InstallRecipe: &InstallRecipe{ + Method: "steam", + SteamAppID: appid, + }, + } + out[id] = g + imported++ + } + fmt.Fprintf(os.Stderr, "linuxgsm: imported %d Steam games (%d non-Steam, %d ID collisions)\n", + imported, skippedNonSteam, skippedCollision) +} + +func parseLGSMCfg(path string) map[string]string { + m := map[string]string{} + data, err := os.ReadFile(path) + if err != nil { + return m + } + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if mm := cfgKVRE.FindStringSubmatch(line); len(mm) == 3 { + m[mm[1]] = mm[2] + } + } + return m +} diff --git a/catalog/convert/main.go b/catalog/convert/main.go new file mode 100644 index 0000000..7284a79 --- /dev/null +++ b/catalog/convert/main.go @@ -0,0 +1,261 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "io/fs" + "log" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" +) + +type Egg struct { + Name string `json:"name"` + Author string `json:"author"` + Description string `json:"description"` + Image string `json:"image"` + Startup string `json:"startup"` + Scripts struct { + Installation struct { + Script string `json:"script"` + Container string `json:"container"` + Entrypoint string `json:"entrypoint"` + } `json:"installation"` + } `json:"scripts"` + Variables []struct { + Name string `json:"name"` + EnvVariable string `json:"env_variable"` + DefaultValue string `json:"default_value"` + } `json:"variables"` +} + +type CatalogGame struct { + ID string `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + UpstreamRef string `json:"upstreamRef,omitempty"` + Summary string `json:"summary"` + DefaultPort int `json:"defaultPort"` + Protocols []string `json:"protocols"` + Tier string `json:"tier"` + TierReason string `json:"tierReason,omitempty"` + SteamAppID int `json:"steamAppId,omitempty"` + InstallRecipe *InstallRecipe `json:"installRecipe,omitempty"` + Start *StartRecipe `json:"start,omitempty"` +} + +type InstallRecipe struct { + Method string `json:"method"` + URL string `json:"url,omitempty"` + SteamAppID int `json:"steamAppId,omitempty"` + SteamArgs []string `json:"steamArgs,omitempty"` +} + +type StartRecipe struct { + Binary string `json:"binary"` + Wrap string `json:"wrap,omitempty"` + ExtraLibs []string `json:"extraLibs,omitempty"` + Args string `json:"args,omitempty"` +} + +type Catalog struct { + Sources map[string]string `json:"sources"` + Games []CatalogGame `json:"games"` +} + +var ( + steamAppRE = regexp.MustCompile(`\+app_update\s+(\d+)`) + portRE = regexp.MustCompile(`\b([0-9]{4,5})\b`) + slugRE = regexp.MustCompile(`[^a-z0-9]+`) + literalURLRE = regexp.MustCompile(`https?://[^\s"'\\)]+`) + archiveExtRE = regexp.MustCompile(`\.(tar\.gz|tgz|tar\.xz|tar\.bz2|tar|zip)(?:[?#]|$)`) +) + +func main() { + parkervcp := flag.String("parkervcp", "", "path to parkervcp game_eggs/") + pelican := flag.String("pelican", "", "path to pelican-eggs games/") + linuxgsm := flag.String("linuxgsm", "", "path to LinuxGSM checkout root") + overridesPath := flag.String("overrides", "", "path to overrides.json (hand-curated)") + parkervcpVer := flag.String("parkervcp-version", "", "") + pelicanVer := flag.String("pelican-version", "", "") + linuxgsmVer := flag.String("linuxgsm-version", "", "") + out := flag.String("out", "", "output catalog.json path") + flag.Parse() + + overrides := loadOverrides(*overridesPath) + + cat := Catalog{ + Sources: map[string]string{ + "parkervcp/eggs": *parkervcpVer, + "pelican-eggs/games": *pelicanVer, + "linuxgsm": *linuxgsmVer, + }, + } + + games := map[string]CatalogGame{} + if *parkervcp != "" { + walkAndIngest(*parkervcp, "parkervcp", games) + } + if *pelican != "" { + walkAndIngest(*pelican, "pelican", games) + } + if *linuxgsm != "" { + walkLinuxGSM(*linuxgsm, games) + } + + for id, ov := range overrides { + base, ok := games[id] + if !ok { + base = CatalogGame{ID: id} + } + games[id] = applyOverride(base, ov) + } + + dropped := 0 + for _, g := range games { + if g.InstallRecipe == nil { + dropped++ + continue + } + if g.Tier == "" { + g.Tier = "compatible" + } + cat.Games = append(cat.Games, g) + } + fmt.Fprintf(os.Stderr, "dropped %d games with no install recipe\n", dropped) + sort.Slice(cat.Games, func(i, j int) bool { return cat.Games[i].ID < cat.Games[j].ID }) + + if *out == "" { + log.Fatal("--out required") + } + f, err := os.Create(*out) + if err != nil { + log.Fatal(err) + } + enc := json.NewEncoder(f) + enc.SetIndent("", " ") + if err := enc.Encode(cat); err != nil { + log.Fatal(err) + } + f.Close() + fmt.Fprintf(os.Stderr, "wrote %d games to %s\n", len(cat.Games), *out) +} + +func walkAndIngest(root, sourceLabel string, out map[string]CatalogGame) { + _ = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() { + return nil + } + base := filepath.Base(path) + if !strings.HasPrefix(base, "egg-") || !strings.HasSuffix(base, ".json") { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + var egg Egg + if json.Unmarshal(data, &egg) != nil { + return nil + } + g := convertEgg(egg, sourceLabel, path, root) + if g == nil { + return nil + } + if existing, ok := out[g.ID]; ok { + if existing.Source == "parkervcp" && g.Source == "pelican" { + out[g.ID] = *g + } + return nil + } + out[g.ID] = *g + return nil + }) +} + +func convertEgg(egg Egg, source, path, root string) *CatalogGame { + name := strings.TrimSpace(egg.Name) + if name == "" { + return nil + } + id := slugRE.ReplaceAllString(strings.ToLower(name), "-") + id = strings.Trim(id, "-") + if id == "" { + return nil + } + rel, _ := filepath.Rel(root, path) + g := CatalogGame{ + ID: id, + Name: name, + Source: source, + UpstreamRef: rel, + Summary: truncate(egg.Description, 200), + Protocols: detectProtocols(egg.Startup), + DefaultPort: detectPort(egg), + } + g.InstallRecipe = deriveRecipe(egg) + if g.InstallRecipe != nil && g.InstallRecipe.Method == "steam" { + g.SteamAppID = g.InstallRecipe.SteamAppID + } + return &g +} + +func deriveRecipe(egg Egg) *InstallRecipe { + script := egg.Scripts.Installation.Script + ep := strings.ToLower(strings.TrimSpace(egg.Scripts.Installation.Entrypoint)) + if ep != "" && ep != "bash" && ep != "sh" && ep != "ash" { + return nil + } + if m := steamAppRE.FindStringSubmatch(script); len(m) == 2 { + if appid, err := strconv.Atoi(m[1]); err == nil { + return &InstallRecipe{Method: "steam", SteamAppID: appid} + } + } + for _, u := range literalURLRE.FindAllString(script, -1) { + if archiveExtRE.MatchString(u) { + return &InstallRecipe{Method: "downloadExtract", URL: u} + } + } + return nil +} + +func detectProtocols(startup string) []string { + s := strings.ToLower(startup) + if strings.Contains(s, "udp") { + return []string{"udp"} + } + if strings.Contains(s, "tcp") { + return []string{"tcp"} + } + return []string{"udp"} +} + +func detectPort(egg Egg) int { + for _, v := range egg.Variables { + env := strings.ToUpper(v.EnvVariable) + if env == "SERVER_PORT" || env == "PORT" || strings.HasSuffix(env, "_PORT") { + if n, err := strconv.Atoi(strings.TrimSpace(v.DefaultValue)); err == nil && n > 0 { + return n + } + } + } + if m := portRE.FindStringSubmatch(egg.Startup); len(m) == 2 { + if n, err := strconv.Atoi(m[1]); err == nil && n >= 1024 && n <= 65535 { + return n + } + } + return 0 +} + +func truncate(s string, n int) string { + s = strings.TrimSpace(s) + if len(s) <= n { + return s + } + return s[:n] + "..." +} diff --git a/catalog/convert/overrides.go b/catalog/convert/overrides.go new file mode 100644 index 0000000..74f3a44 --- /dev/null +++ b/catalog/convert/overrides.go @@ -0,0 +1,69 @@ +package main + +import ( + "encoding/json" + "log" + "os" +) + +type Override struct { + Source *string `json:"source,omitempty"` + Name *string `json:"name,omitempty"` + Summary *string `json:"summary,omitempty"` + Tier *string `json:"tier,omitempty"` + TierReason *string `json:"tierReason,omitempty"` + DefaultPort *int `json:"defaultPort,omitempty"` + Protocols []string `json:"protocols,omitempty"` + SteamAppID *int `json:"steamAppId,omitempty"` + InstallRecipe *InstallRecipe `json:"installRecipe,omitempty"` + Start *StartRecipe `json:"start,omitempty"` +} + +func loadOverrides(path string) map[string]Override { + if path == "" { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + log.Fatalf("overrides: open %s: %v", path, err) + } + var m map[string]Override + if err := json.Unmarshal(data, &m); err != nil { + log.Fatalf("overrides: parse %s: %v", path, err) + } + return m +} + +func applyOverride(g CatalogGame, o Override) CatalogGame { + if o.Source != nil { + g.Source = *o.Source + } + if o.Name != nil { + g.Name = *o.Name + } + if o.Summary != nil { + g.Summary = *o.Summary + } + if o.Tier != nil { + g.Tier = *o.Tier + } + if o.TierReason != nil { + g.TierReason = *o.TierReason + } + if o.DefaultPort != nil { + g.DefaultPort = *o.DefaultPort + } + if len(o.Protocols) > 0 { + g.Protocols = o.Protocols + } + if o.SteamAppID != nil { + g.SteamAppID = *o.SteamAppID + } + if o.InstallRecipe != nil { + g.InstallRecipe = o.InstallRecipe + } + if o.Start != nil { + g.Start = o.Start + } + return g +} diff --git a/catalog/overrides.json b/catalog/overrides.json new file mode 100644 index 0000000..da730a9 --- /dev/null +++ b/catalog/overrides.json @@ -0,0 +1,121 @@ +{ + "teeworlds": { + "source": "teeworlds", + "name": "Teeworlds", + "summary": "2D arena shooter; ~10MB native server. CI install fixture.", + "tier": "compatible", + "defaultPort": 8303, + "protocols": ["udp"], + "installRecipe": { + "method": "downloadExtract", + "url": "https://github.com/teeworlds/teeworlds/releases/download/0.7.5/teeworlds-0.7.5-linux_x86_64.tar.gz" + }, + "start": { + "binary": "teeworlds_srv", + "args": "\"sv_port {{port}}\"" + } + }, + "hlds-cs": { + "source": "linuxgsm", + "tier": "verified", + "summary": "Classic Half-Life dedicated server running CS 1.6 (~250MB). CI Steam fixture.", + "defaultPort": 27015, + "protocols": ["udp"], + "installRecipe": { + "method": "steam", + "steamAppId": 90, + "steamArgs": ["+app_set_config", "90", "mod", "cstrike", "+app_update", "90", "-beta", "steam_legacy", "validate"] + }, + "start": { + "binary": "hlds_linux", + "wrap": "i386", + "extraLibs": [".", "cstrike"], + "args": "-game cstrike -insecure +sv_lan 1 +map de_dust2 +port {{port}} +maxplayers 8" + } + }, + "cs2": { + "source": "linuxgsm", + "tier": "compatible", + "summary": "Valve's tactical shooter dedicated server (~30GB).", + "defaultPort": 27015, + "protocols": ["udp"], + "installRecipe": {"method": "steam", "steamAppId": 730}, + "start": { + "binary": "game/bin/linuxsteamrt64/cs2", + "wrap": "amd64", + "extraLibs": ["game/bin/linuxsteamrt64"], + "args": "-dedicated +map de_dust2 +port {{port}}" + } + }, + "tf2": { + "source": "linuxgsm", + "tier": "compatible", + "summary": "Class-based team shooter (~10GB).", + "defaultPort": 27015, + "protocols": ["udp"], + "installRecipe": {"method": "steam", "steamAppId": 232250}, + "start": { + "binary": "srcds_linux", + "wrap": "i386", + "extraLibs": [".", "bin"], + "args": "-game tf +map ctf_2fort +port {{port}}" + } + }, + "gmod": { + "source": "linuxgsm", + "tier": "compatible", + "summary": "Sandbox modification of Source (~600MB).", + "defaultPort": 27015, + "protocols": ["udp"], + "installRecipe": {"method": "steam", "steamAppId": 4020}, + "start": { + "binary": "srcds_linux", + "wrap": "i386", + "extraLibs": [".", "bin"], + "args": "-game garrysmod +port {{port}}" + } + }, + "valheim": { + "source": "linuxgsm", + "tier": "compatible", + "summary": "Viking survival co-op (~2GB).", + "defaultPort": 2456, + "protocols": ["udp"], + "installRecipe": {"method": "steam", "steamAppId": 896660}, + "start": { + "binary": "valheim_server.x86_64", + "wrap": "amd64", + "extraLibs": ["."], + "args": "-port {{port}} -world Dedicated -password changeme" + } + }, + "zomboid": { + "source": "linuxgsm", + "tier": "compatible", + "summary": "Isometric zombie survival sandbox (~3GB).", + "defaultPort": 16261, + "protocols": ["udp"], + "installRecipe": {"method": "steam", "steamAppId": 380870}, + "start": { + "binary": "start-server.sh", + "args": "-port {{port}}" + } + }, + "ark": { + "source": "linuxgsm", + "tier": "experimental", + "summary": "Dinosaur survival multiplayer (~25GB).", + "defaultPort": 7777, + "protocols": ["udp"], + "installRecipe": {"method": "steam", "steamAppId": 376030} + }, + "rust": { + "source": "linuxgsm", + "tier": "experimental", + "summary": "Multiplayer survival. Requires paid Steam account.", + "tierReason": "Steam appid is not anonymous-loginnable", + "defaultPort": 28015, + "protocols": ["udp"], + "installRecipe": {"method": "steam", "steamAppId": 258550} + } +} diff --git a/ci/ui.sh b/ci/ui.sh new file mode 100755 index 0000000..0806d3f --- /dev/null +++ b/ci/ui.sh @@ -0,0 +1,35 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && cd .. && pwd ) +cd ${DIR}/web/e2e + +npm ci +PROJECT="${1:-desktop}" +set +e +PLAYWRIGHT_DOMAIN=${PLAYWRIGHT_DOMAIN:-bookworm.com} \ +PLAYWRIGHT_USER=${PLAYWRIGHT_USER:-user} \ +PLAYWRIGHT_PASSWORD=${PLAYWRIGHT_PASSWORD:-Password1} \ +npx playwright test --project="${PROJECT}" +EXIT=$? +set -e + +ART=${DIR}/artifact +SHOTS=${ART}/screenshots-${PROJECT} +VIDEOS=${ART}/videos-${PROJECT} +mkdir -p ${SHOTS} ${VIDEOS} + +if [ -d test-results ]; then + for d in test-results/*/; do + name=$(basename "$d") + i=0 + for img in "$d"*.png; do + [ -f "$img" ] || continue + suffix=$([ $i -eq 0 ] && echo "" || echo "-$i") + cp "$img" "${SHOTS}/${name}${suffix}.png" + i=$((i+1)) + done + [ -f "${d}video.webm" ] && cp "${d}video.webm" "${VIDEOS}/${name}.webm" + done +fi + +exit ${EXIT} diff --git a/cli/build.sh b/cli/build.sh new file mode 100755 index 0000000..85d71f0 --- /dev/null +++ b/cli/build.sh @@ -0,0 +1,14 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd ${DIR} + +BIN_OUT=${DIR}/../build/snap/bin +HOOKS_OUT=${DIR}/../build/snap/meta/hooks +mkdir -p ${BIN_OUT} ${HOOKS_OUT} + +CGO_ENABLED=0 go build -buildvcs=false -o ${HOOKS_OUT}/install ./cmd/install +CGO_ENABLED=0 go build -buildvcs=false -o ${HOOKS_OUT}/configure ./cmd/configure +CGO_ENABLED=0 go build -buildvcs=false -o ${HOOKS_OUT}/pre-refresh ./cmd/pre-refresh +CGO_ENABLED=0 go build -buildvcs=false -o ${HOOKS_OUT}/post-refresh ./cmd/post-refresh +CGO_ENABLED=0 go build -buildvcs=false -o ${BIN_OUT}/cli ./cmd/cli diff --git a/cli/cli b/cli/cli new file mode 100755 index 0000000..a3cdbdc Binary files /dev/null and b/cli/cli differ diff --git a/cli/client/client.go b/cli/client/client.go new file mode 100644 index 0000000..a9427d6 --- /dev/null +++ b/cli/client/client.go @@ -0,0 +1,67 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "time" +) + +const SocketPath = "/var/snap/games/current/cli.sock" + +type Client struct { + http *http.Client +} + +func New() *Client { + return &Client{ + http: &http.Client{ + Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", SocketPath) + }, + }, + Timeout: 30 * time.Second, + }, + } +} + +func (c *Client) Do(method, path string, body any, out any) error { + var bodyReader io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return err + } + bodyReader = bytes.NewReader(b) + } + req, err := http.NewRequest(method, "http://backend"+path, bodyReader) + if err != nil { + return err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + resp, err := c.http.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + var e struct{ Error string `json:"error"` } + _ = json.Unmarshal(raw, &e) + if e.Error != "" { + return fmt.Errorf("%s %s: %s", method, path, e.Error) + } + return fmt.Errorf("%s %s: http %d: %s", method, path, resp.StatusCode, string(raw)) + } + if out != nil && len(raw) > 0 { + return json.Unmarshal(raw, out) + } + return nil +} diff --git a/cli/cmd/cli/games_cmd.go b/cli/cmd/cli/games_cmd.go new file mode 100644 index 0000000..746c5a1 --- /dev/null +++ b/cli/cmd/cli/games_cmd.go @@ -0,0 +1,84 @@ +package main + +import ( + "fmt" + + "github.com/spf13/cobra" + "hooks/client" +) + +type catalogEntry struct { + ID string `json:"id"` + Name string `json:"name"` + Source string `json:"source"` + UpstreamRef string `json:"upstreamRef"` + Tier string `json:"tier"` + DefaultPort int `json:"defaultPort"` + Protocols []string `json:"protocols"` +} + +func gamesCmd() *cobra.Command { + var jsonOut bool + + cmd := &cobra.Command{Use: "games", Short: "Browse the game catalog"} + cmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "emit JSON to stdout") + + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List all games in the catalog", + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + var list []catalogEntry + if err := c.Do("GET", "/api/v1/games", nil, &list); err != nil { + return err + } + if jsonOut { + printJSON(list) + } else { + fmt.Printf("%-26s %-12s %-13s %-6s %s\n", "ID", "SOURCE", "TIER", "PORT", "NAME") + for _, g := range list { + fmt.Printf("%-26s %-12s %-13s %-6d %s\n", g.ID, g.Source, g.Tier, g.DefaultPort, g.Name) + } + } + return nil + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "sources", + Short: "Pinned upstream egg-catalog SHAs", + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + var r map[string]string + if err := c.Do("GET", "/api/v1/catalog/sources", nil, &r); err != nil { + return err + } + if jsonOut { + printJSON(r) + } else { + for k, v := range r { + fmt.Printf("%-24s %s\n", k, v) + } + } + return nil + }, + }) + + return cmd +} + +func healthCmd() *cobra.Command { + return &cobra.Command{ + Use: "health", + Short: "Check backend health", + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + var r map[string]string + if err := c.Do("GET", "/api/v1/health", nil, &r); err != nil { + return err + } + fmt.Println(r["status"]) + return nil + }, + } +} diff --git a/cli/cmd/cli/main.go b/cli/cmd/cli/main.go index c5f78e6..f9e6ad0 100644 --- a/cli/cmd/cli/main.go +++ b/cli/cmd/cli/main.go @@ -1,7 +1,6 @@ package main import ( - "fmt" "github.com/spf13/cobra" "github.com/syncloud/golib/log" "hooks/installer" @@ -48,8 +47,11 @@ func main() { }, }) + cmd.AddCommand(serverCmd()) + cmd.AddCommand(gamesCmd()) + cmd.AddCommand(healthCmd()) + if err := cmd.Execute(); err != nil { - fmt.Print(err) os.Exit(1) } } diff --git a/cli/cmd/cli/server_cmd.go b/cli/cmd/cli/server_cmd.go new file mode 100644 index 0000000..76f652b --- /dev/null +++ b/cli/cmd/cli/server_cmd.go @@ -0,0 +1,225 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "strconv" + "strings" + + "github.com/spf13/cobra" + "hooks/client" +) + +type server struct { + ID int64 `json:"id"` + Name string `json:"name"` + GameID string `json:"gameId"` + Port int `json:"port"` + Status string `json:"status"` + InstallDir string `json:"installDir"` + StartCmd string `json:"startCmd"` + LastError string `json:"lastError,omitempty"` +} + +func resolveServer(c *client.Client, ident string) (*server, error) { + var list []server + if err := c.Do("GET", "/api/v1/servers", nil, &list); err != nil { + return nil, err + } + if id, err := strconv.ParseInt(ident, 10, 64); err == nil { + for _, s := range list { + if s.ID == id { + return &s, nil + } + } + } + for _, s := range list { + if s.Name == ident { + return &s, nil + } + } + return nil, fmt.Errorf("no server with id/name %q", ident) +} + +func printJSON(v any) { + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} + +func printServerTable(servers []server) { + if len(servers) == 0 { + fmt.Println("(no servers)") + return + } + fmt.Printf("%-4s %-20s %-16s %-6s %s\n", "ID", "NAME", "GAME", "PORT", "STATUS") + for _, s := range servers { + fmt.Printf("%-4d %-20s %-16s %-6d %s\n", s.ID, s.Name, s.GameID, s.Port, s.Status) + } +} + +func serverCmd() *cobra.Command { + var jsonOut bool + + cmd := &cobra.Command{Use: "server", Short: "Manage game servers"} + cmd.PersistentFlags().BoolVar(&jsonOut, "json", false, "emit JSON to stdout") + + cmd.AddCommand(&cobra.Command{ + Use: "list", + Short: "List installed game servers", + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + var list []server + if err := c.Do("GET", "/api/v1/servers", nil, &list); err != nil { + return err + } + if jsonOut { + printJSON(list) + } else { + printServerTable(list) + } + return nil + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "show ", + Short: "Show a server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + s, err := resolveServer(c, args[0]) + if err != nil { + return err + } + if jsonOut { + printJSON(s) + } else { + fmt.Printf("id: %d\nname: %s\ngame: %s\nport: %d\nstatus: %s\ninstallDir: %s\nstartCmd: %s\n", + s.ID, s.Name, s.GameID, s.Port, s.Status, s.InstallDir, s.StartCmd) + if s.LastError != "" { + fmt.Printf("lastError: %s\n", s.LastError) + } + } + return nil + }, + }) + + createCmd := &cobra.Command{ + Use: "create ", + Short: "Create a server entry (does not install)", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + port, _ := cmd.Flags().GetInt("port") + startCmd, _ := cmd.Flags().GetString("start-cmd") + c := client.New() + body := map[string]any{"name": args[0], "gameId": args[1], "port": port} + if startCmd != "" { + body["startCmd"] = startCmd + } + var s server + if err := c.Do("POST", "/api/v1/servers", body, &s); err != nil { + return err + } + if jsonOut { + printJSON(s) + } else { + fmt.Printf("created server[%d] %s (%s)\n", s.ID, s.Name, s.GameID) + } + return nil + }, + } + createCmd.Flags().Int("port", 0, "listen port (0 = use game default)") + createCmd.Flags().String("start-cmd", "", "override the start command (tests / stubs)") + cmd.AddCommand(createCmd) + + action := func(name, verb string) *cobra.Command { + return &cobra.Command{ + Use: name + " ", + Short: verb + " a server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + s, err := resolveServer(c, args[0]) + if err != nil { + return err + } + var updated server + if err := c.Do("POST", fmt.Sprintf("/api/v1/servers/%d/%s", s.ID, name), nil, &updated); err != nil { + return err + } + if jsonOut { + printJSON(updated) + } else { + fmt.Printf("%s server[%d] %s → %s\n", verb, updated.ID, updated.Name, updated.Status) + } + return nil + }, + } + } + cmd.AddCommand(action("install", "installing")) + cmd.AddCommand(action("start", "started")) + cmd.AddCommand(action("stop", "stopped")) + cmd.AddCommand(action("restart", "restarted")) + + cmd.AddCommand(&cobra.Command{ + Use: "delete ", + Short: "Delete a server (does not remove install dir)", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + s, err := resolveServer(c, args[0]) + if err != nil { + return err + } + return c.Do("DELETE", fmt.Sprintf("/api/v1/servers/%d", s.ID), nil, nil) + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "logs ", + Short: "Print captured stdout/stderr", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + s, err := resolveServer(c, args[0]) + if err != nil { + return err + } + var r struct { + Lines []string `json:"lines"` + } + if err := c.Do("GET", fmt.Sprintf("/api/v1/servers/%d/logs", s.ID), nil, &r); err != nil { + return err + } + if jsonOut { + printJSON(r.Lines) + } else { + fmt.Print(strings.Join(r.Lines, "")) + } + return nil + }, + }) + + cmd.AddCommand(&cobra.Command{ + Use: "query ", + Short: "A2S_INFO query against a running Source-engine server", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + c := client.New() + s, err := resolveServer(c, args[0]) + if err != nil { + return err + } + var r map[string]any + if err := c.Do("GET", fmt.Sprintf("/api/v1/servers/%d/query", s.ID), nil, &r); err != nil { + return err + } + printJSON(r) + return nil + }, + }) + + return cmd +} diff --git a/cli/installer/executor.go b/cli/installer/executor.go deleted file mode 100644 index 826bb4e..0000000 --- a/cli/installer/executor.go +++ /dev/null @@ -1,22 +0,0 @@ -package installer - -import ( - "go.uber.org/zap" - "os/exec" -) - -type Executor struct { - logger *zap.Logger -} - -func NewExecutor(logger *zap.Logger) *Executor { - return &Executor{logger: logger} -} - -func (e *Executor) Run(name string, args ...string) error { - e.logger.Info("exec", zap.String("name", name), zap.Strings("args", args)) - cmd := exec.Command(name, args...) - out, err := cmd.CombinedOutput() - e.logger.Info("exec output", zap.String("out", string(out))) - return err -} diff --git a/cli/installer/installer.go b/cli/installer/installer.go index 4011586..a3b000e 100644 --- a/cli/installer/installer.go +++ b/cli/installer/installer.go @@ -1,6 +1,7 @@ package installer import ( + "fmt" cp "github.com/otiai10/copy" "github.com/syncloud/golib/config" "github.com/syncloud/golib/linux" @@ -8,18 +9,20 @@ import ( "go.uber.org/zap" "os" "path" + "strings" ) const ( - App = "game-server" - AppDir = "/snap/game-server/current" - DataDir = "/var/snap/game-server/current" - CommonDir = "/var/snap/game-server/common" + App = "games" + AppDir = "/snap/games/current" + DataDir = "/var/snap/games/current" + CommonDir = "/var/snap/games/common" + SteamcmdSrcDir = AppDir + "/steamcmd" + SteamRuntimeDir = DataDir + "/.steam-runtime" ) type Variables struct { - AuthUrl string - AuthLocalSocket string + AuthUrl string } type Installer struct { @@ -28,20 +31,17 @@ type Installer struct { configDir string platformClient *platform.Client installFile string - executor *Executor logger *zap.Logger } func New(logger *zap.Logger) *Installer { configDir := path.Join(DataDir, "config") - executor := NewExecutor(logger) return &Installer{ newVersionFile: path.Join(AppDir, "version"), currentVersionFile: path.Join(DataDir, "version"), configDir: configDir, platformClient: platform.New(), installFile: path.Join(CommonDir, "installed"), - executor: executor, logger: logger, } } @@ -103,8 +103,7 @@ func (i *Installer) StorageChange() error { return err } if err := i.createMissingDirs( - path.Join(DataDir, "storage"), - path.Join(DataDir, "servers"), + path.Join(storageDir, "servers"), ); err != nil { return err } @@ -130,14 +129,21 @@ func (i *Installer) UpdateConfigs() error { return err } + if err := i.SeedSteamRuntime(); err != nil { + return fmt.Errorf("steam runtime seed: %w", err) + } + authUrl, err := i.platformClient.GetAppUrl("auth") if err != nil { return err } + if err := i.registerOIDC(); err != nil { + return fmt.Errorf("oidc register: %w", err) + } + variables := Variables{ - AuthUrl: authUrl, - AuthLocalSocket: i.platformClient.GetAuthLocalSocket(), + AuthUrl: authUrl, } if err := config.Generate( @@ -151,6 +157,53 @@ func (i *Installer) UpdateConfigs() error { return i.FixPermissions() } +func (i *Installer) SeedSteamRuntime() error { + if _, err := os.Stat(path.Join(SteamRuntimeDir, "linux32", "steamcmd")); os.IsNotExist(err) { + if err := os.MkdirAll(SteamRuntimeDir, 0755); err != nil { + return err + } + entries, err := os.ReadDir(SteamcmdSrcDir) + if err != nil { + return err + } + for _, e := range entries { + if e.Name() == "lib32" || e.Name() == "lib64" { + continue + } + if err := cp.Copy(path.Join(SteamcmdSrcDir, e.Name()), path.Join(SteamRuntimeDir, e.Name())); err != nil { + return err + } + } + } + ldDst := path.Join(SteamRuntimeDir, "linux32", "ld-linux.so.2") + if err := os.MkdirAll(path.Dir(ldDst), 0755); err != nil { + return err + } + return cp.Copy(path.Join(SteamcmdSrcDir, "lib32", "ld-linux.so.2"), ldDst) +} + +func (i *Installer) registerOIDC() error { + password, err := i.platformClient.RegisterOIDCClient(App, "/auth/callback", true, "client_secret_basic") + if err != nil { + return err + } + if err := os.WriteFile(path.Join(DataDir, "oidc.secret"), []byte(password), 0640); err != nil { + return err + } + authUrl, err := i.platformClient.GetAppUrl("auth") + if err != nil { + return err + } + appUrl, err := i.platformClient.GetAppUrl(App) + if err != nil { + return err + } + authSocket := strings.TrimSuffix(strings.TrimPrefix(i.platformClient.GetAuthLocalSocket(), "http://unix:"), ":") + cfg := fmt.Sprintf(`{"authUrl":%q,"authSocket":%q,"clientId":%q,"clientSecret":%q,"redirectUrl":%q}`, + authUrl, authSocket, App, password, appUrl+"/auth/callback") + return os.WriteFile(path.Join(DataDir, "oidc.json"), []byte(cfg), 0640) +} + func (i *Installer) FixPermissions() error { if err := linux.Chown(DataDir, App); err != nil { return err diff --git a/cli/test.sh b/cli/test.sh new file mode 100755 index 0000000..b1096d3 --- /dev/null +++ b/cli/test.sh @@ -0,0 +1,14 @@ +#!/bin/sh -ex + +DIR=$( cd "$( dirname "$0" )" && pwd ) +cd ${DIR} + +BUILD_DIR=${DIR}/../build/snap + +${BUILD_DIR}/bin/cli --help +${BUILD_DIR}/meta/hooks/install --help > /dev/null 2>&1 || true +${BUILD_DIR}/meta/hooks/configure --help > /dev/null 2>&1 || true +${BUILD_DIR}/meta/hooks/pre-refresh --help > /dev/null 2>&1 || true +${BUILD_DIR}/meta/hooks/post-refresh --help > /dev/null 2>&1 || true + +${BUILD_DIR}/bin/backend 2>&1 | head -5 || true diff --git a/config/authelia-authrequest.conf b/config/authelia-authrequest.conf deleted file mode 100644 index bf4cf6f..0000000 --- a/config/authelia-authrequest.conf +++ /dev/null @@ -1,14 +0,0 @@ -auth_request /internal/authelia/authz; - -auth_request_set $user $upstream_http_remote_user; -auth_request_set $groups $upstream_http_remote_groups; -auth_request_set $name $upstream_http_remote_name; -auth_request_set $email $upstream_http_remote_email; - -proxy_set_header Remote-User $user; -proxy_set_header Remote-Groups $groups; -proxy_set_header Remote-Email $email; -proxy_set_header Remote-Name $name; - -auth_request_set $redirection_url $upstream_http_location; -error_page 401 =302 $redirection_url; diff --git a/config/authelia-location.conf b/config/authelia-location.conf deleted file mode 100644 index 81ddca2..0000000 --- a/config/authelia-location.conf +++ /dev/null @@ -1,24 +0,0 @@ -location /internal/authelia/authz { - internal; - proxy_pass {{ .AuthLocalSocket }}/api/authz/auth-request; - - proxy_set_header X-Original-Method $request_method; - proxy_set_header X-Original-URL https://$http_host$request_uri; - proxy_set_header X-Forwarded-For $remote_addr; - proxy_set_header Content-Length ""; - proxy_set_header Connection ""; - - proxy_pass_request_body off; - proxy_next_upstream error timeout invalid_header http_500 http_502 http_503; - proxy_redirect http:// $scheme://; - proxy_http_version 1.1; - proxy_cache_bypass $cookie_session; - proxy_no_cache $cookie_session; - proxy_buffers 4 32k; - client_body_buffer_size 128k; - - send_timeout 5m; - proxy_read_timeout 240; - proxy_send_timeout 240; - proxy_connect_timeout 240; -} diff --git a/config/nginx.conf b/config/nginx.conf index bb95334..b840d3b 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -1,4 +1,4 @@ -pid /var/snap/game-server/current/nginx.pid; +pid /var/snap/games/current/nginx.pid; daemon off; worker_processes auto; @@ -10,25 +10,25 @@ events { http { access_log syslog:server=unix:/dev/log; - include /snap/game-server/current/nginx/etc/nginx/mime.types; + include /snap/games/current/nginx/etc/nginx/mime.types; - client_body_temp_path /var/snap/game-server/current/nginx/client_body_temp; - proxy_temp_path /var/snap/game-server/current/nginx/proxy_temp; - fastcgi_temp_path /var/snap/game-server/current/nginx/fastcgi_temp; - uwsgi_temp_path /var/snap/game-server/current/nginx/uwsgi_temp; - scgi_temp_path /var/snap/game-server/current/nginx/scgi_temp; + client_body_temp_path /var/snap/games/current/nginx/client_body_temp; + proxy_temp_path /var/snap/games/current/nginx/proxy_temp; + fastcgi_temp_path /var/snap/games/current/nginx/fastcgi_temp; + uwsgi_temp_path /var/snap/games/current/nginx/uwsgi_temp; + scgi_temp_path /var/snap/games/current/nginx/scgi_temp; upstream backend { - server unix:/var/snap/game-server/current/backend.sock; + server unix:/var/snap/games/current/backend.sock; } server { - listen unix:/var/snap/game-server/common/web.socket; + listen unix:/var/snap/games/common/web.socket; set_real_ip_from unix:; server_name localhost; - root /snap/game-server/current/web/dist; + root /snap/games/current/web/dist; gzip on; gzip_vary on; @@ -36,17 +36,24 @@ http { gzip_comp_level 6; gzip_types text/plain text/css application/json application/javascript text/javascript; - include /var/snap/game-server/current/config/authelia-location.conf; + # SPA static assets — public. The SPA fetches /api/v1/me on load + # and redirects to /auth/login if 401. + location / { + try_files $uri $uri/ /index.html; + } - location /api/ { - include /var/snap/game-server/current/config/proxy.conf; - include /var/snap/game-server/current/config/authelia-authrequest.conf; + # OIDC handshake endpoints — proxied to backend (no auth check; + # backend's /auth/login does the redirect to Authelia). + location /auth/ { + include /var/snap/games/current/config/proxy.conf; proxy_pass http://backend; } - location / { - include /var/snap/game-server/current/config/authelia-authrequest.conf; - try_files $uri $uri/ /index.html; + # API — backend auth middleware enforces login (signed session cookie + # or HTTP Basic Auth delegated to Authelia for tests/curl users). + location /api/ { + include /var/snap/games/current/config/proxy.conf; + proxy_pass http://backend; } } } diff --git a/jre/build.sh b/jre/build.sh new file mode 100755 index 0000000..317f9a9 --- /dev/null +++ b/jre/build.sh @@ -0,0 +1,27 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd ${DIR} + +apt update +apt -y install wget ca-certificates + +JRE_VERSION="${JRE_VERSION:-17.0.13+11}" +JRE_VERSION_ENC=$(echo ${JRE_VERSION} | sed 's/+/%2B/') +JRE_VERSION_FILE=$(echo ${JRE_VERSION} | sed 's/+/_/') + +URL="https://github.com/adoptium/temurin17-binaries/releases/download/jdk-${JRE_VERSION_ENC}/OpenJDK17U-jre_x64_linux_hotspot_${JRE_VERSION_FILE}.tar.gz" + +OUT=${DIR}/../build/snap/jre +rm -rf ${OUT} +mkdir -p ${OUT} + +wget -q "${URL}" -O /tmp/jre.tar.gz +tar -xzf /tmp/jre.tar.gz -C ${OUT} --strip-components=1 +rm /tmp/jre.tar.gz + +ls -la ${OUT} +echo "java version:" +${OUT}/bin/java -version +echo "jre size:" +du -sh ${OUT} diff --git a/jre/test.sh b/jre/test.sh new file mode 100755 index 0000000..e6009cc --- /dev/null +++ b/jre/test.sh @@ -0,0 +1,9 @@ +#!/bin/sh -ex + +DIR=$( cd "$( dirname "$0" )" && pwd ) +cd ${DIR} + +BUILD_DIR=${DIR}/../build/snap/jre + +${BUILD_DIR}/bin/java -version +${BUILD_DIR}/bin/java --help > /dev/null diff --git a/snap.yaml b/snap.yaml index 4bf5b03..310776f 100644 --- a/snap.yaml +++ b/snap.yaml @@ -1,11 +1,11 @@ -name: game-server -summary: Game Server +name: games +summary: Syncloud Game Hub description: Dedicated game server panel for Syncloud grade: stable apps: backend: - user: game-server + user: games command: bin/service.backend.sh daemon: simple restart-condition: always @@ -13,7 +13,7 @@ apps: restart-delay: 10s nginx: - user: game-server + user: games command: bin/service.nginx.sh daemon: simple restart-condition: always diff --git a/steamcmd/bin/steamcmd.sh b/steamcmd/bin/steamcmd.sh new file mode 100755 index 0000000..48c02cb --- /dev/null +++ b/steamcmd/bin/steamcmd.sh @@ -0,0 +1,22 @@ +#!/bin/bash +set -e + +SCDIR=/snap/games/current/steamcmd +LIBS="${SCDIR}/lib32" +RUNTIME=/var/snap/games/current/.steam-runtime + +export HOME=/var/snap/games/current/.steam-home +mkdir -p "${HOME}" + +export LD_LIBRARY_PATH="${RUNTIME}/linux32:${LIBS}:${LD_LIBRARY_PATH:-}" + +cd "${RUNTIME}" + +STATUS=42 +while [ "${STATUS}" -eq 42 ]; do + set +e + "${RUNTIME}/linux32/ld-linux.so.2" --library-path "${RUNTIME}/linux32:${LIBS}" "${RUNTIME}/linux32/steamcmd" "$@" + STATUS=$? + set -e +done +exit "${STATUS}" diff --git a/steamcmd/build.sh b/steamcmd/build.sh new file mode 100755 index 0000000..8a4dddf --- /dev/null +++ b/steamcmd/build.sh @@ -0,0 +1,55 @@ +#!/bin/bash -ex + +DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) +cd ${DIR} + +apt update +apt -y install wget ca-certificates + +OUT=${DIR}/../build/snap/steamcmd +BIN_OUT=${DIR}/../build/snap/bin +mkdir -p ${OUT} ${BIN_OUT} + +install -m 0755 ${DIR}/bin/steamcmd.sh ${BIN_OUT}/steamcmd.sh + +wget -q https://media.steampowered.com/installer/steamcmd_linux.tar.gz -O steamcmd.tar.gz +tar xf steamcmd.tar.gz -C ${OUT} +rm steamcmd.tar.gz + +dpkg --add-architecture i386 +apt update +apt -y install \ + libc6:i386 libstdc++6:i386 libgcc-s1:i386 \ + zlib1g:i386 libcurl4:i386 libssl3:i386 \ + libtinfo6:i386 libncurses6:i386 \ + libsdl2-2.0-0:i386 libgl1:i386 \ + curl:i386 + +mkdir -p ${OUT}/lib32 +cp /usr/bin/curl ${OUT}/lib32/curl.i386 || cp /usr/bin/curl.i386 ${OUT}/lib32/curl.i386 || true +[ -f /lib/ld-linux.so.2 ] && cp /lib/ld-linux.so.2 ${OUT}/lib32/ +for src in /lib/i386-linux-gnu /usr/lib/i386-linux-gnu; do + if [ -d "$src" ]; then + find "$src" -maxdepth 1 \( -type f -o -type l \) -name '*.so*' -exec cp -P {} ${OUT}/lib32/ \; + fi +done + +apt -y install \ + libc6:amd64 libstdc++6:amd64 libgcc-s1:amd64 \ + zlib1g:amd64 libcurl4:amd64 libssl3:amd64 \ + libtinfo6:amd64 libncurses6:amd64 \ + libsdl2-2.0-0:amd64 libgl1:amd64 + +mkdir -p ${OUT}/lib64 +[ -f /lib64/ld-linux-x86-64.so.2 ] && cp /lib64/ld-linux-x86-64.so.2 ${OUT}/lib64/ +for src in /lib/x86_64-linux-gnu /usr/lib/x86_64-linux-gnu; do + if [ -d "$src" ]; then + find "$src" -maxdepth 1 \( -type f -o -type l \) -name '*.so*' -exec cp -P {} ${OUT}/lib64/ \; + fi +done + +cd ${DIR} + +ls -la ${OUT} +echo "lib32 file count: $(ls ${OUT}/lib32 | wc -l), size: $(du -sh ${OUT}/lib32 | cut -f1)" +echo "lib64 file count: $(ls ${OUT}/lib64 | wc -l), size: $(du -sh ${OUT}/lib64 | cut -f1)" diff --git a/steamcmd/test.sh b/steamcmd/test.sh new file mode 100755 index 0000000..74c9e12 --- /dev/null +++ b/steamcmd/test.sh @@ -0,0 +1,28 @@ +#!/bin/sh -ex + +DIR=$( cd "$( dirname "$0" )" && pwd ) +cd ${DIR} + +BUILD_DIR=${DIR}/../build/snap + +DATA_DIR=/tmp/games-snap-data +rm -rf ${DATA_DIR} +mkdir -p ${DATA_DIR} + +mkdir -p /snap/games /var/snap/games +ln -sfn ${BUILD_DIR} /snap/games/current +ln -sfn ${DATA_DIR} /var/snap/games/current + +SRC=/snap/games/current/steamcmd +RUNTIME=/var/snap/games/current/.steam-runtime +mkdir -p ${RUNTIME} +for f in ${SRC}/*; do + name=$(basename "$f") + case "$name" in lib32|lib64) continue ;; esac + cp -r "$f" ${RUNTIME}/ +done +cp -f ${SRC}/lib32/ld-linux.so.2 ${RUNTIME}/linux32/ld-linux.so.2 + +/snap/games/current/bin/steamcmd.sh +quit + +${BUILD_DIR}/steamcmd/lib64/ld-linux-x86-64.so.2 --version diff --git a/test/cli.py b/test/cli.py new file mode 100644 index 0000000..9e1da7b --- /dev/null +++ b/test/cli.py @@ -0,0 +1,49 @@ +import json +import shlex +import time + + +def run(device, *args, want_json=True): + """Invoke games.cli on the device. Auto-appends --json when want_json is + set and --json isn't already in args. Returns the parsed JSON (None for + empty output) or the raw stdout if want_json=False.""" + parts = ['/snap/bin/games.cli'] + list(args) + if want_json and '--json' not in args: + parts.append('--json') + out = device.run_ssh(' '.join(shlex.quote(p) for p in parts)) + if not want_json: + return out + out = (out or '').strip() + if not out: + return None + return json.loads(out) + + +def run_text(device, *args): + return run(device, *args, want_json=False) + + +def wait_status(device, name, target, timeout): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + s = run(device, 'server', 'show', name) + last = s.get('status') if s else None + if last == target: + return s + if last == 'install-error': + raise AssertionError('install errored: {!r}'.format(s)) + time.sleep(2) + raise AssertionError('timeout waiting for status={} on {}, last={}'.format(target, name, last)) + + +def wait_a2s(device, name, timeout): + deadline = time.time() + timeout + last = None + while time.time() < deadline: + try: + return run(device, 'server', 'query', name) + except Exception as e: + last = str(e) + time.sleep(3) + raise AssertionError('timeout waiting for A2S response, last={}'.format(last)) diff --git a/test/conftest.py b/test/conftest.py index 406a2f2..b913a43 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -3,7 +3,6 @@ DIR = dirname(__file__) - @pytest.fixture(scope="session") def project_dir(): return join(DIR, '..') diff --git a/test/requirements.txt b/test/requirements.txt index 74ddc47..7ea651d 100644 --- a/test/requirements.txt +++ b/test/requirements.txt @@ -1,4 +1,5 @@ pytest==8.4.1 +selenium==4.21.0 syncloud-lib==365 retry==0.9.2 pytest-retry==1.6.3 diff --git a/test/test.py b/test/test.py index 81a1035..7bbdfe7 100644 --- a/test/test.py +++ b/test/test.py @@ -1,4 +1,5 @@ import os +import time from os.path import join from subprocess import check_output @@ -9,87 +10,248 @@ from syncloudlib.integration.hosts import add_host_alias from syncloudlib.integration.installer import local_install +from test.cli import run as cli_run, run_text as cli_text, wait_status, wait_a2s + TMP_DIR = '/tmp/syncloud' requests.packages.urllib3.disable_warnings(InsecureRequestWarning) - @pytest.fixture(scope="session") def module_setup(request, device, app_dir, artifact_dir): def module_teardown(): - device.run_ssh('ls -la /var/snap/game-server/current/config > {0}/config.ls.log'.format(TMP_DIR), throw=False) + device.run_ssh('ls -la /var/snap/games/current/config > {0}/config.ls.log'.format(TMP_DIR), throw=False) device.run_ssh('top -bn 1 -w 500 -c > {0}/top.log'.format(TMP_DIR), throw=False) device.run_ssh('ps auxfw > {0}/ps.log'.format(TMP_DIR), throw=False) device.run_ssh('netstat -nlp > {0}/netstat.log'.format(TMP_DIR), throw=False) device.run_ssh('journalctl | tail -2000 > {0}/journalctl.log'.format(TMP_DIR), throw=False) device.run_ssh('ls -la /snap > {0}/snap.ls.log'.format(TMP_DIR), throw=False) - device.run_ssh('ls -la /snap/game-server > {0}/snap.ls.log'.format(TMP_DIR), throw=False) - device.run_ssh('ls -la /var/snap/game-server > {0}/var.snap.ls.log'.format(TMP_DIR), throw=False) - device.run_ssh('ls -la /var/snap/game-server/current/ > {0}/var.snap.current.ls.log'.format(TMP_DIR), throw=False) - device.run_ssh('ls -la /var/snap/game-server/common > {0}/var.snap.common.ls.log'.format(TMP_DIR), throw=False) + device.run_ssh('ls -la /snap/games > {0}/snap.ls.log'.format(TMP_DIR), throw=False) + device.run_ssh('ls -la /var/snap/games > {0}/var.snap.ls.log'.format(TMP_DIR), throw=False) + device.run_ssh('ls -la /var/snap/games/current/ > {0}/var.snap.current.ls.log'.format(TMP_DIR), throw=False) + device.run_ssh('ls -la /var/snap/games/common > {0}/var.snap.common.ls.log'.format(TMP_DIR), throw=False) device.run_ssh('cat /etc/hosts > {0}/hosts.log'.format(TMP_DIR), throw=False) + device.run_ssh('ls -la /snap/games/current/steamcmd > {0}/steamcmd.ls.log'.format(TMP_DIR), throw=False) + device.run_ssh('ls /snap/games/current/steamcmd/lib32 | head -50 > {0}/steamcmd.lib32.log'.format(TMP_DIR), throw=False) + device.run_ssh('cat /var/snap/games/current/.steam-home/Steam/logs/stderr.txt > {0}/steam.stderr.log 2>/dev/null'.format(TMP_DIR), throw=False) + device.run_ssh('cat /var/snap/games/current/.steam-home/Steam/logs/bootstrap_log.txt > {0}/steam.bootstrap.log 2>/dev/null'.format(TMP_DIR), throw=False) + device.run_ssh('ls -la /var/snap/games/current/.steam-home/Steam/logs/ > {0}/steam.logs.ls 2>/dev/null'.format(TMP_DIR), throw=False) - app_log_dir = join(artifact_dir, 'log') - os.mkdir(app_log_dir) - device.scp_from_device('{0}/*'.format(TMP_DIR), app_log_dir) + device.scp_from_device('{0}/*'.format(TMP_DIR), artifact_dir) check_output('chmod -R a+r {0}'.format(artifact_dir), shell=True) request.addfinalizer(module_teardown) - def test_start(module_setup, device, device_host, app, domain): add_host_alias(app, device_host, domain) device.run_ssh('date', retries=100) device.run_ssh('mkdir {0}'.format(TMP_DIR)) - @pytest.mark.flaky(retries=50, delay=10) def test_activate_device(device): response = device.activate_custom() assert response.status_code == 200, response.text - def test_install(app_archive_path, device_host, device_password, device): local_install(device_host, device_password, app_archive_path) - def test_index(app_domain): wait_for_rest(requests.session(), "https://{0}".format(app_domain), 200, 10) +def test_health(device): + out = cli_text(device, 'health').strip() + assert out == 'ok', out -def test_health(app_domain): - response = requests.get('https://{0}/api/v1/health'.format(app_domain), verify=False) - assert response.status_code == 200, response.text - assert response.json().get('status') == 'ok', response.text - - -def test_games_catalog(app_domain): - response = requests.get('https://{0}/api/v1/games'.format(app_domain), verify=False) - assert response.status_code == 200, response.text - games = response.json() +def test_games_catalog(device): + games = cli_run(device, 'games', 'list') ids = {g['id'] for g in games} assert 'teeworlds' in ids, 'teeworlds (smallest pelican egg, our CI fixture) must be in catalog' assert 'cs2' in ids, 'cs2 anonymous-friendly steam server must be in catalog' + assert 'hlds-cs' in ids, 'hlds-cs (CI Steam fixture) must be in catalog' + for g in games: + assert g.get('tier') in ('verified', 'compatible', 'experimental'), \ + 'game {} has no tier: {}'.format(g.get('id'), g) +def test_catalog_sources(device): + sources = cli_run(device, 'games', 'sources') + assert 'parkervcp/eggs' in sources + assert 'pelican-eggs/games' in sources -def test_servers_empty(app_domain): - response = requests.get('https://{0}/api/v1/servers'.format(app_domain), verify=False) - assert response.status_code == 200, response.text - assert response.json() == [], 'no servers installed yet at phase 0' +def test_servers_empty(device): + assert cli_run(device, 'server', 'list') == [] +def test_create_server(device): + s = cli_run(device, 'server', 'create', 'test-tw', 'teeworlds', '--port', '8303') + assert s['id'] > 0 + assert s['name'] == 'test-tw' + assert s['gameId'] == 'teeworlds' + assert s['status'] == 'stopped' -def test_storage_change_event(device): - device.run_ssh('snap run game-server.storage-change > {0}/storage-change.log'.format(TMP_DIR)) +def test_list_after_create(device): + servers = cli_run(device, 'server', 'list') + assert len(servers) == 1 + assert servers[0]['name'] == 'test-tw' +def test_delete_server(device): + cli_run(device, 'server', 'delete', 'test-tw') + assert cli_run(device, 'server', 'list') == [] -def test_access_change_event(device): - device.run_ssh('snap run game-server.access-change > {0}/access-change.log'.format(TMP_DIR)) +def test_create_unknown_game_rejected(device): + out = device.run_ssh( + '/snap/bin/games.cli server create bad not-a-game --port 1234 2>&1; echo EXIT=\$?', + throw=False) + assert 'unknown gameId' in out, out + assert 'EXIT=1' in out, out + +def test_logs_endpoint(device): + cli_run(device, 'server', 'create', 'log-stub', 'teeworlds', + '--port', '8304', '--start-cmd', 'echo hello-from-runner; sleep 5') + cli_run(device, 'server', 'start', 'log-stub') + time.sleep(2) + lines = cli_run(device, 'server', 'logs', 'log-stub') + assert any('hello-from-runner' in l for l in lines), 'log buffer should capture stdout: ' + str(lines) + cli_run(device, 'server', 'stop', 'log-stub') + cli_run(device, 'server', 'delete', 'log-stub') + +def test_teeworlds_real_install_and_play(device): + cli_run(device, 'server', 'create', 'tw-real', 'teeworlds', '--port', '8313') + cli_run(device, 'server', 'install', 'tw-real') + wait_status(device, 'tw-real', 'stopped', timeout=180) + + s = cli_run(device, 'server', 'start', 'tw-real') + assert s['status'] == 'running' + + time.sleep(4) + probe = device.run_ssh('ss -ulnp | grep 8313 || true') + assert '8313' in probe, 'teeworlds_srv should be bound on udp:8313 — ss output: {0!r}'.format(probe) + + cli_run(device, 'server', 'stop', 'tw-real') + cli_run(device, 'server', 'delete', 'tw-real') + +@pytest.mark.flaky(retries=2, delay=15) +def test_hlds_cs_real_install(device): + """Real CS 1.6 dedicated server install via SteamCMD (~822 MB download). + + Asserts the install path of phase 3b end-to-end: bundled SteamCMD, + 32-bit lib bundle (lib32/), runtime dir under $SNAP_DATA, +login + anonymous +app_set_config 90 mod cstrike +app_update 90 validate. + + Starting hlds_linux is xfail'd separately — it requires a Steam + Auth Server reachable for SteamAPI_Init / IClientUtils, which the + snap can't provide without a full Steam runtime emulator.""" + for s in cli_run(device, 'server', 'list') or []: + if s.get('name') == 'hlds-real': + cli_run(device, 'server', 'delete', 'hlds-real') + device.run_ssh('rm -rf /data/games/servers/hlds-real', throw=False) + + cli_run(device, 'server', 'create', 'hlds-real', 'hlds-cs', '--port', '27115') + cli_run(device, 'server', 'install', 'hlds-real') + wait_status(device, 'hlds-real', 'stopped', timeout=900) + out = device.run_ssh('ls /data/games/servers/hlds-real/hlds_linux 2>&1') + assert 'hlds_linux' in out and 'No such file' not in out, \ + 'hlds_linux missing post-install: ' + out + + cli_run(device, 'server', 'delete', 'hlds-real') + +@pytest.mark.xfail( + reason="Paper/Fabric/etc. Minecraft eggs need `jq` (Paper) or apt " + "(Fabric, Glowstone) at install time; neither is available " + "in the snap install context. Tracked as follow-up: vendor " + "a curated minecraft-vanilla entry that fetches server.jar " + "from Mojang launchermeta with curl alone, no jq.", + strict=False, run=True) +def test_minecraft_real_install_and_play(device): + """Full Minecraft cycle: install via Pelican egg + bundled JRE, accept + EULA, start the server, verify it binds the TCP port (proves the JVM + came up and the server is listening on the Minecraft protocol), stop + and delete. EULA is accepted explicitly here for CI — the product + itself never auto-accepts.""" + games = cli_run(device, 'games', 'list') + candidates = [ + g for g in games + if 'minecraft/java' in g.get('upstreamRef', '').lower() + and g.get('tier') in ('verified', 'compatible') + ] + print('minecraft candidates ({}): {}'.format( + len(candidates), [g['id'] for g in candidates[:10]])) + assert candidates, 'no Minecraft Java entries in catalog with tier verified|compatible' + candidates.sort(key=lambda g: (0 if g['id'] == 'paper' else 1, g['id'])) + g = candidates[0] + print('using minecraft entry:', g['id'], 'name=', g['name'], 'ref=', g.get('upstreamRef')) + + port = g.get('defaultPort') or 25565 + cli_run(device, 'server', 'create', 'mc-real', g['id'], '--port', str(port)) + cli_run(device, 'server', 'install', 'mc-real') + wait_status(device, 'mc-real', 'stopped', timeout=900) + + install_dir = '/data/games/servers/mc-real' + out = device.run_ssh('ls {0} 2>&1'.format(install_dir)) + assert '.jar' in out, 'minecraft .jar missing post-install: ' + out + + device.run_ssh( + 'echo eula=true > {0}/eula.txt && chown games:games {0}/eula.txt'.format(install_dir)) + + s = cli_run(device, 'server', 'start', 'mc-real') + assert s['status'] == 'running' + + deadline = time.time() + 240 + bound = '' + while time.time() < deadline: + bound = device.run_ssh('ss -tlnp 2>/dev/null | grep -E "java|:{0}\\b" || true'.format(port)) + if 'java' in bound or ':{0}'.format(port) in bound: + break + time.sleep(5) + assert 'java' in bound or ':{0}'.format(port) in bound, \ + 'minecraft java server should be listening on tcp — ss output: {0!r}'.format(bound) + + lines = cli_run(device, 'server', 'logs', 'mc-real') + print('mc server first 20 log lines:', lines[:20]) + + cli_run(device, 'server', 'stop', 'mc-real') + cli_run(device, 'server', 'delete', 'mc-real') + +@pytest.mark.xfail( + reason='HLDS startup needs Steam Pipe / lsteamclient shim to satisfy ' + 'SteamAPI_Init -> IClientUtils::GetConnectedUniverse. SteamCMD ' + 'install itself works (see test_hlds_cs_real_install).', + strict=False, run=True) +def test_hlds_cs_a2s_query(device): + cli_run(device, 'server', 'create', 'hlds-query', 'hlds-cs', '--port', '27116') + cli_run(device, 'server', 'install', 'hlds-query') + wait_status(device, 'hlds-query', 'stopped', timeout=900) + cli_run(device, 'server', 'start', 'hlds-query') + + info = wait_a2s(device, 'hlds-query', timeout=60) + assert 'Counter-Strike' in info.get('Game', '') or 'cstrike' in info.get('Folder', ''), info + + cli_run(device, 'server', 'stop', 'hlds-query') + cli_run(device, 'server', 'delete', 'hlds-query') + +def test_lifecycle(device): + cli_run(device, 'server', 'create', 'stub', 'teeworlds', + '--port', '8303', '--start-cmd', 'sleep 30') + + s = cli_run(device, 'server', 'start', 'stub') + assert s['status'] == 'running' + + again = device.run_ssh('/snap/bin/games.cli server start stub 2>&1; echo EXIT=\$?', throw=False) + assert 'already running' in again, again + assert 'EXIT=1' in again, again + + s = cli_run(device, 'server', 'stop', 'stub') + assert s['status'] == 'stopped' + + cli_run(device, 'server', 'delete', 'stub') + +def test_storage_change_event(device): + device.run_ssh('snap run games.storage-change > {0}/storage-change.log'.format(TMP_DIR)) + +def test_access_change_event(device): + device.run_ssh('snap run games.access-change > {0}/access-change.log'.format(TMP_DIR)) def test_remove(device, app): response = device.app_remove(app) assert response.status_code == 200, response.text - def test_reinstall(app_archive_path, device_host, device_password): local_install(device_host, device_password, app_archive_path) diff --git a/web/e2e/.gitignore b/web/e2e/.gitignore new file mode 100644 index 0000000..cdf1961 --- /dev/null +++ b/web/e2e/.gitignore @@ -0,0 +1,4 @@ +.auth/ +playwright-report/ +test-results/ +node_modules/ diff --git a/web/e2e/global-setup.ts b/web/e2e/global-setup.ts new file mode 100644 index 0000000..c4ffc54 --- /dev/null +++ b/web/e2e/global-setup.ts @@ -0,0 +1,18 @@ +import { chromium, FullConfig } from '@playwright/test' +import { loginViaAuthelia } from './helpers/auth' + +export default async function globalSetup (config: FullConfig) { + const { baseURL, storageState } = config.projects[0].use as any + const username = process.env.PLAYWRIGHT_USER || 'user' + const password = process.env.PLAYWRIGHT_PASSWORD || 'Password1' + + const browser = await chromium.launch() + const context = await browser.newContext({ ignoreHTTPSErrors: true }) + const page = await context.newPage() + try { + await loginViaAuthelia(page, baseURL, username, password) + await context.storageState({ path: storageState }) + } finally { + await browser.close() + } +} diff --git a/web/e2e/helpers/auth.ts b/web/e2e/helpers/auth.ts new file mode 100644 index 0000000..390b24f --- /dev/null +++ b/web/e2e/helpers/auth.ts @@ -0,0 +1,61 @@ +import { Page } from '@playwright/test' + +export async function loginViaAuthelia ( + page: Page, + baseURL: string, + username: string, + password: string +) { + await page.goto(baseURL) + + try { + await page.waitForURL((url) => { + const h = new URL(url.toString()).host + return h.startsWith('auth.') + }, { timeout: 15_000 }) + } catch (_) { + } + + const usernameSelectors = [ + 'input[name="username"]', + 'input#username-textfield', + 'input[autocomplete="username"]', + 'input[type="text"]' + ] + const passwordSelectors = [ + 'input[name="password"]', + 'input#password-textfield', + 'input[autocomplete="current-password"]', + 'input[type="password"]' + ] + const submitSelectors = [ + 'button#sign-in-button', + 'button[type="submit"]', + 'button:has-text("Sign in")', + 'button:has-text("Login")' + ] + + const found = async (selectors: string[]): Promise => { + for (const sel of selectors) { + const el = page.locator(sel).first() + try { + await el.waitFor({ state: 'visible', timeout: 5_000 }) + return sel + } catch (_) { } + } + const url = page.url() + const title = await page.title().catch(() => '?') + throw new Error(`no selector matched on ${url} (title="${title}"): ${selectors.join(', ')}`) + } + + const userSel = await found(usernameSelectors) + await page.fill(userSel, username) + const passSel = await found(passwordSelectors) + await page.fill(passSel, password) + const submitSel = await found(submitSelectors) + await Promise.all([ + page.waitForURL((url) => !url.toString().includes('/?rd='), { timeout: 30_000 }), + page.click(submitSel) + ]) + await page.waitForSelector('[data-testid="brand"]', { timeout: 15_000 }) +} diff --git a/web/e2e/helpers/screenshot.ts b/web/e2e/helpers/screenshot.ts new file mode 100644 index 0000000..3fc4a0d --- /dev/null +++ b/web/e2e/helpers/screenshot.ts @@ -0,0 +1,5 @@ +import { Page, TestInfo } from '@playwright/test' + +export async function shoot (page: Page, info: TestInfo, name: string) { + await page.screenshot({ path: info.outputPath(`${name}.png`) }) +} diff --git a/web/e2e/package-lock.json b/web/e2e/package-lock.json new file mode 100644 index 0000000..668447f --- /dev/null +++ b/web/e2e/package-lock.json @@ -0,0 +1,93 @@ +{ + "name": "syncloud-games-e2e", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "syncloud-games-e2e", + "version": "0.1.0", + "devDependencies": { + "@playwright/test": "1.59.1", + "typescript": "^5.4.0" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/web/e2e/package.json b/web/e2e/package.json new file mode 100644 index 0000000..d4abfde --- /dev/null +++ b/web/e2e/package.json @@ -0,0 +1,13 @@ +{ + "name": "syncloud-games-e2e", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "test": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.59.1", + "typescript": "^5.4.0" + } +} diff --git a/web/e2e/playwright.config.ts b/web/e2e/playwright.config.ts new file mode 100644 index 0000000..0cfba20 --- /dev/null +++ b/web/e2e/playwright.config.ts @@ -0,0 +1,27 @@ +import { defineConfig, devices } from '@playwright/test' + +const domain = process.env.PLAYWRIGHT_DOMAIN || 'bookworm.com' +const baseURL = `https://games.${domain}` +const storageState = '.auth/state.json' + +export default defineConfig({ + testDir: './specs', + timeout: 60_000, + expect: { timeout: 10_000 }, + workers: 1, + retries: process.env.CI ? 1 : 0, + reporter: [['list']], + globalSetup: './global-setup.ts', + use: { + baseURL, + ignoreHTTPSErrors: true, + storageState, + trace: 'off', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + projects: [ + { name: 'desktop', use: { ...devices['Desktop Chrome'], baseURL, ignoreHTTPSErrors: true, storageState } }, + { name: 'mobile', use: { ...devices['Pixel 7'], baseURL, ignoreHTTPSErrors: true, storageState } } + ] +}) diff --git a/web/e2e/specs/01-catalog.spec.ts b/web/e2e/specs/01-catalog.spec.ts new file mode 100644 index 0000000..530e18c --- /dev/null +++ b/web/e2e/specs/01-catalog.spec.ts @@ -0,0 +1,47 @@ +import { test, expect } from '@playwright/test' +import { shoot } from '../helpers/screenshot' + +test('catalog renders with brand and game grid', async ({ page }, info) => { + await page.goto('/#/catalog') + await expect(page.getByTestId('brand')).toBeVisible() + await expect(page.getByTestId('game-grid')).toBeVisible() + await expect(page.getByTestId('game-teeworlds')).toBeVisible() + await expect(page.getByTestId('game-cs2')).toBeVisible() + await shoot(page, info, 'catalog') +}) + +test('search filters games', async ({ page }, info) => { + await page.goto('/#/catalog') + await page.getByTestId('search').fill('teeworlds') + await expect(page.getByTestId('game-teeworlds')).toBeVisible() + await expect(page.getByTestId('game-cs2')).not.toBeVisible() + await shoot(page, info, 'search') +}) + +test('tier filter pills', async ({ page }, info) => { + await page.goto('/#/catalog') + await expect(page.getByTestId('tier-all')).toBeVisible() + await expect(page.getByTestId('tier-verified')).toBeVisible() + await expect(page.getByTestId('tier-compatible')).toBeVisible() + await expect(page.getByTestId('tier-experimental')).toBeVisible() + await page.getByTestId('tier-verified').click() + await expect(page.getByTestId('game-hlds-cs')).toBeVisible() + await shoot(page, info, 'tier-verified') + await page.getByTestId('tier-compatible').click() + await expect(page.getByTestId('game-teeworlds')).toBeVisible() +}) + +test('servers tab empty by default', async ({ page }, info) => { + await page.goto('/#/servers') + await expect(page.getByTestId('servers-empty')).toBeVisible() + await shoot(page, info, 'servers-empty') +}) + +test('settings page shows catalog sources and Steam form', async ({ page }, info) => { + await page.goto('/#/settings') + await expect(page.getByTestId('settings-steam')).toBeVisible() + await expect(page.getByTestId('settings-sources')).toBeVisible() + await expect(page.getByTestId('steam-username')).toBeVisible() + await expect(page.getByTestId('steam-password')).toBeVisible() + await shoot(page, info, 'settings') +}) diff --git a/web/e2e/specs/02-install.spec.ts b/web/e2e/specs/02-install.spec.ts new file mode 100644 index 0000000..8f7bff5 --- /dev/null +++ b/web/e2e/specs/02-install.spec.ts @@ -0,0 +1,31 @@ +import { test, expect } from '@playwright/test' +import { shoot } from '../helpers/screenshot' + +test.use({ video: 'on' }) + +test('install dialog opens, creates a server, lands on server detail', async ({ page }, info) => { + const name = `e2e-tw-${Date.now()}` + + await page.goto('/#/catalog') + const teeCard = page.getByTestId('game-teeworlds') + await teeCard.scrollIntoViewIfNeeded() + await teeCard.getByTestId('install-btn').click() + await expect(page.getByTestId('install-dialog')).toBeVisible() + await page.getByTestId('dialog-name').fill(name) + await page.getByTestId('dialog-port').fill('8313') + await page.getByTestId('dialog-submit').click() + await expect(page.getByTestId('detail-name')).toHaveText(name) + await expect(page.getByTestId('detail-status')).toBeVisible() + await shoot(page, info, 'server-detail') + + await page.getByTestId('subtab-logs').click() + await expect(page.getByTestId('detail-logs')).toBeVisible() + + await page.getByTestId('subtab-query').click() + await expect(page.getByTestId('detail-query')).toBeVisible() + + await page.getByTestId('action-delete').click() + await expect(page.getByTestId('confirm-dialog')).toBeVisible() + await page.getByTestId('confirm-ok').click() + await expect(page.getByTestId('servers-empty')).toBeVisible() +}) diff --git a/web/e2e/specs/03-mobile-nav.spec.ts b/web/e2e/specs/03-mobile-nav.spec.ts new file mode 100644 index 0000000..7a2860b --- /dev/null +++ b/web/e2e/specs/03-mobile-nav.spec.ts @@ -0,0 +1,23 @@ +import { test, expect } from '@playwright/test' +import { shoot } from '../helpers/screenshot' + +test('bottom-nav is mobile-only and clicks navigate', async ({ page, isMobile }, info) => { + test.skip(!isMobile, 'desktop project hides bottom-nav by design') + + await page.goto('/#/catalog') + await expect(page.getByTestId('bottom-nav')).toBeVisible() + await expect(page.getByTestId('bottom-catalog')).toHaveClass(/active/) + await shoot(page, info, 'mobile-catalog') + + await page.evaluate(() => window.scrollTo(0, 0)) + await page.getByTestId('bottom-servers').click({ force: true }) + await expect(page.getByTestId('servers-empty')).toBeVisible() + await expect(page.getByTestId('bottom-servers')).toHaveClass(/active/) + + await page.evaluate(() => window.scrollTo(0, 0)) + await page.getByTestId('bottom-settings').click({ force: true }) + await expect(page.getByTestId('settings-account')).toBeVisible() + await expect(page.getByTestId('account-name')).toBeVisible() + await expect(page.getByTestId('settings-steam')).toBeVisible() + await shoot(page, info, 'mobile-settings') +}) diff --git a/web/e2e/tsconfig.json b/web/e2e/tsconfig.json new file mode 100644 index 0000000..10141f0 --- /dev/null +++ b/web/e2e/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true + }, + "include": ["specs/**/*.ts", "helpers/**/*.ts", "playwright.config.ts"] +} diff --git a/web/index.html b/web/index.html index 626f135..b6316d8 100644 --- a/web/index.html +++ b/web/index.html @@ -4,7 +4,7 @@ - Game Server + Syncloud Game Hub
diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 0000000..ed92704 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,1331 @@ +{ + "name": "syncloud-games-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "syncloud-games-web", + "version": "0.1.0", + "dependencies": { + "vue": "^3.4.21", + "vue-router": "^4.6.4" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.4", + "miragejs": "^0.1.48", + "vite": "^5.2.6" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "license": "MIT" + }, + "node_modules/@miragejs/pretender-node-polyfill": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@miragejs/pretender-node-polyfill/-/pretender-node-polyfill-0.1.2.tgz", + "integrity": "sha512-M/BexG/p05C5lFfMunxo/QcgIJnMT2vDVCd00wNqK2ImZONIlEETZwWJu1QtLxtmYlSHlCFl3JNzp0tLe7OJ5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.3.tgz", + "integrity": "sha512-x35CNW/ANXG3hE/EZpRU8MXX1JDN86hBb2wMGAtltkz7pc6cxgjpy1OMMfDosOQ+2hWqIkag/fGok1Yady9nGw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.3.tgz", + "integrity": "sha512-xw3xtkDApIOGayehp2+Rz4zimfkaX65r4t47iy+ymQB2G4iJCBBfj0ogVg5jpvjpn8UWn/+q9tprxleYeNp3Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.3.tgz", + "integrity": "sha512-vo6Y5Qfpx7/5EaamIwi0WqW2+zfiusVihKatLvtN1VFVy3D13uERk/6gZLU1UiHRL6fDXqj/ELIeVRGnvcTE1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.3.tgz", + "integrity": "sha512-D+0QGcZhBzTN82weOnsSlY7V7+RMmPuF1CkbxyMAGE8+ZHeUjyb76ZiWmBlCu//AQQONvxcqRbwZTajZKqjuOw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.3.tgz", + "integrity": "sha512-6HnvHCT7fDyj6R0Ph7A6x8dQS/S38MClRWeDLqc0MdfWkxjiu1HSDYrdPhqSILzjTIC/pnXbbJbo+ft+gy/9hQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.3.tgz", + "integrity": "sha512-KHLgC3WKlUYW3ShFKnnosZDOJ0xjg9zp7au3sIm2bs/tGBeC2ipmvRh/N7JKi0t9Ue20C0dpEshi8WUubg+cnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.3.tgz", + "integrity": "sha512-DV6fJoxEYWJOvaZIsok7KrYl0tPvga5OZ2yvKHNNYyk/2roMLqQAbGhr78EQ5YhHpnhLKJD3S1WFusAkmUuV5g==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.3.tgz", + "integrity": "sha512-mQKoJAzvuOs6F+TZybQO4GOTSMUu7v0WdxEk24krQ/uUxXoPTtHjuaUuPmFhtBcM4K0ons8nrE3JyhTuCFtT/w==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.3.tgz", + "integrity": "sha512-Whjj2qoiJ6+OOJMGptTYazaJvjOJm+iKHpXQM1P3LzGjt7Ff++Tp7nH4N8J/BUA7R9IHfDyx4DJIflifwnbmIA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.3.tgz", + "integrity": "sha512-4YTNHKqGng5+yiZt3mg77nmyuCfmNfX4fPmyUapBcIk+BdwSwmCWGXOUxhXbBEkFHtoN5boLj/5NON+u5QC9tg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.3.tgz", + "integrity": "sha512-SU3kNlhkpI4UqlUc2VXPGK9o886ZsSeGfMAX2ba2b8DKmMXq4AL7KUrkSWVbb7koVqx41Yczx6dx5PNargIrEA==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.3.tgz", + "integrity": "sha512-6lDLl5h4TXpB1mTf2rQWnAk/LcXrx9vBfu/DT5TIPhvMhRWaZ5MxkIc8u4lJAmBo6klTe1ywXIUHFjylW505sg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.3.tgz", + "integrity": "sha512-BMo8bOw8evlup/8G+cj5xWtPyp93xPdyoSN16Zy90Q2QZ0ZYRhCt6ZJSwbrRzG9HApFabjwj2p25TUPDWrhzqQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.3.tgz", + "integrity": "sha512-E0L8X1dZN1/Rph+5VPF6Xj2G7JJvMACVXtamTJIDrVI44Y3K+G8gQaMEAavbqCGTa16InptiVrX6eM6pmJ+7qA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.3.tgz", + "integrity": "sha512-oZJ/WHaVfHUiRAtmTAeo3DcevNsVvH8mbvodjZy7D5QKvCefO371SiKRpxoDcCxB3PTRTLayWBkvmDQKTcX/sw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.3.tgz", + "integrity": "sha512-Dhbyh7j9FybM3YaTgaHmVALwA8AkUwTPccyCQ79TG9AJUsMQqgN1DDEZNr4+QUfwiWvLDumW5vdwzoeUF+TNxQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.3.tgz", + "integrity": "sha512-cJd1X5XhHHlltkaypz1UcWLA8AcoIi1aWhsvaWDskD1oz2eKCypnqvTQ8ykMNI0RSmm7NkTdSqSSD7zM0xa6Ig==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.3.tgz", + "integrity": "sha512-DAZDBHQfG2oQuhY7mc6I3/qB4LU2fQCjRvxbDwd/Jdvb9fypP4IJ4qmtu6lNjes6B531AI8cg1aKC2di97bUxA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.3.tgz", + "integrity": "sha512-cRxsE8c13mZOh3vP+wLDxpQBRrOHDIGOWyDL93Sy0Ga8y515fBcC2pjUfFwUe5T7tqvTvWbCpg1URM/AXdWIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.3.tgz", + "integrity": "sha512-QaWcIgRxqEdQdhJqW4DJctsH6HCmo5vHxY0krHSX4jMtOqfzC+dqDGuHM87bu4H8JBeibWx7jFz+h6/4C8wA5Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.3.tgz", + "integrity": "sha512-AaXwSvUi3QIPtroAUw1t5yHGIyqKEXwH54WUocFolZhpGDruJcs8c+xPNDRn4XiQsS7MEwnYsHW2l0MBLDMkWg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.3.tgz", + "integrity": "sha512-65LAKM/bAWDqKNEelHlcHvm2V+Vfb8C6INFxQXRHCvaVN1rJfwr4NvdP4FyzUaLqWfaCGaadf6UbTm8xJeYfEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.3.tgz", + "integrity": "sha512-EEM2gyhBF5MFnI6vMKdX1LAosE627RGBzIoGMdLloPZkXrUN0Ckqgr2Qi8+J3zip/8NVVro3/FjB+tjhZUgUHA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.3.tgz", + "integrity": "sha512-E5Eb5H/DpxaoXH++Qkv28RcUJboMopmdDUALBczvHMf7hNIxaDZqwY5lK12UK1BHacSmvupoEWGu+n993Z0y1A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.3.tgz", + "integrity": "sha512-hPt/bgL5cE+Qp+/TPHBqptcAgPzgj46mPcg/16zNUmbQk0j+mOEQV/+Lqu8QRtDV3Ek95Q6FeFITpuhl6OTsAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-vue": { + "version": "5.2.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", + "integrity": "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "vite": "^5.0.0 || ^6.0.0", + "vue": "^3.2.25" + } + }, + "node_modules/@vue/compiler-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz", + "integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/shared": "3.5.34", + "entities": "^7.0.1", + "estree-walker": "^2.0.2", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz", + "integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==", + "license": "MIT", + "dependencies": { + "@vue/compiler-core": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/compiler-sfc": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.34.tgz", + "integrity": "sha512-D/ihr6uZeIt6r+pVZf46RWT1fAsLFMbUP7k8G1VkiiWexriED9GrX3echHd4Abbt17zjlfiFJ8z7a3BxZOPNjg==", + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@vue/compiler-core": "3.5.34", + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34", + "estree-walker": "^2.0.2", + "magic-string": "^0.30.21", + "postcss": "^8.5.14", + "source-map-js": "^1.2.1" + } + }, + "node_modules/@vue/compiler-ssr": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.34.tgz", + "integrity": "sha512-cDtTHKibkThKGHH1SP+WdccquNRYQDFH6rRjQCqT9G2ltFAfoR5pUftpab/z+aM5mW9HLLVQW7hfKKQe/1GBeQ==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/devtools-api": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.4.tgz", + "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==", + "license": "MIT" + }, + "node_modules/@vue/reactivity": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.34.tgz", + "integrity": "sha512-y9XDjCEuBp+98k+UL5dbYkh57AHU4o6cxZedOPXw3bmrZZYLQsVHguGurq7hVrPCSrQtrnz1f9dssyFr+dMXfQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-core": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.34.tgz", + "integrity": "sha512-mKeBYvu8tcMSLhypAHBmriUFfWXKTCF/23Z4jiCoYK3UtWepkliViNLuR90V9XOyD62mUxs9p1jsrpK3CCGIzw==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/shared": "3.5.34" + } + }, + "node_modules/@vue/runtime-dom": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.34.tgz", + "integrity": "sha512-e8kZzERmCwUnBRVsgSQlAfrfU2rGoy0FFKPBXSlfEjc/O3KfA7QP0t1/2ZylrbchjmIKB4dPTd07A6WPr0eOrg==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "3.5.34", + "@vue/runtime-core": "3.5.34", + "@vue/shared": "3.5.34", + "csstype": "^3.2.3" + } + }, + "node_modules/@vue/server-renderer": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.34.tgz", + "integrity": "sha512-nHxmJoTrKsmrkbILRhkC9gY1G3moZbJTqCzDd7DOOzG5KH9oeJ0Unqrff5f9v0pW//jES05ZkJcNtfE8JjOIew==", + "license": "MIT", + "dependencies": { + "@vue/compiler-ssr": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "vue": "3.5.34" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz", + "integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "license": "MIT" + }, + "node_modules/fake-xml-http-request": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/fake-xml-http-request/-/fake-xml-http-request-2.1.2.tgz", + "integrity": "sha512-HaFMBi7r+oEC9iJNpc3bvcW7Z7iLmM26hPDmlb0mFwyANSsOQAtJxbdWsXITKOzZUyMYK0zYCv3h5yDj9TsiXg==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/inflected": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/inflected/-/inflected-2.1.0.tgz", + "integrity": "sha512-hAEKNxvHf2Iq3H60oMBHkB4wl5jn3TPF3+fXek/sRwAB5gP9xWs4r7aweSF95f99HFoz69pnZTcu8f0SIHV18w==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/miragejs": { + "version": "0.1.48", + "resolved": "https://registry.npmjs.org/miragejs/-/miragejs-0.1.48.tgz", + "integrity": "sha512-MGZAq0Q3OuRYgZKvlB69z4gLN4G3PvgC4A2zhkCXCXrLD5wm2cCnwNB59xOBVA+srZ0zEes6u+VylcPIkB4SqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@miragejs/pretender-node-polyfill": "^0.1.0", + "inflected": "^2.0.4", + "lodash": "^4.0.0", + "pretender": "^3.4.7" + }, + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretender": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/pretender/-/pretender-3.4.7.tgz", + "integrity": "sha512-jkPAvt1BfRi0RKamweJdEcnjkeu7Es8yix3bJ+KgBC5VpG/Ln4JE3hYN6vJym4qprm8Xo5adhWpm3HCoft1dOw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fake-xml-http-request": "^2.1.2", + "route-recognizer": "^0.3.3" + } + }, + "node_modules/rollup": { + "version": "4.60.3", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.3.tgz", + "integrity": "sha512-pAQK9HalE84QSm4Po3EmWIZPd3FnjkShVkiMlz1iligWYkWQ7wHYd1PF/T7QZ5TVSD6uSTon5gBVMSM4JfBV+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.3", + "@rollup/rollup-android-arm64": "4.60.3", + "@rollup/rollup-darwin-arm64": "4.60.3", + "@rollup/rollup-darwin-x64": "4.60.3", + "@rollup/rollup-freebsd-arm64": "4.60.3", + "@rollup/rollup-freebsd-x64": "4.60.3", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.3", + "@rollup/rollup-linux-arm-musleabihf": "4.60.3", + "@rollup/rollup-linux-arm64-gnu": "4.60.3", + "@rollup/rollup-linux-arm64-musl": "4.60.3", + "@rollup/rollup-linux-loong64-gnu": "4.60.3", + "@rollup/rollup-linux-loong64-musl": "4.60.3", + "@rollup/rollup-linux-ppc64-gnu": "4.60.3", + "@rollup/rollup-linux-ppc64-musl": "4.60.3", + "@rollup/rollup-linux-riscv64-gnu": "4.60.3", + "@rollup/rollup-linux-riscv64-musl": "4.60.3", + "@rollup/rollup-linux-s390x-gnu": "4.60.3", + "@rollup/rollup-linux-x64-gnu": "4.60.3", + "@rollup/rollup-linux-x64-musl": "4.60.3", + "@rollup/rollup-openbsd-x64": "4.60.3", + "@rollup/rollup-openharmony-arm64": "4.60.3", + "@rollup/rollup-win32-arm64-msvc": "4.60.3", + "@rollup/rollup-win32-ia32-msvc": "4.60.3", + "@rollup/rollup-win32-x64-gnu": "4.60.3", + "@rollup/rollup-win32-x64-msvc": "4.60.3", + "fsevents": "~2.3.2" + } + }, + "node_modules/route-recognizer": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/route-recognizer/-/route-recognizer-0.3.4.tgz", + "integrity": "sha512-2+MhsfPhvauN1O8KaXpXAOfR/fwe8dnUXVM+xw7yt40lJRfPVQxV6yryZm0cgRvAj5fMF/mdRZbL2ptwbs5i2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vue": { + "version": "3.5.34", + "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", + "integrity": "sha512-WdLBG9gm02OgJIG9axd5Hpx0TFLdzVgfG2evFFu8Rur5O/IoGc5cMjnjh3tPL6GnRGsYvUhBSKVPYVcxRKpMCA==", + "license": "MIT", + "dependencies": { + "@vue/compiler-dom": "3.5.34", + "@vue/compiler-sfc": "3.5.34", + "@vue/runtime-dom": "3.5.34", + "@vue/server-renderer": "3.5.34", + "@vue/shared": "3.5.34" + }, + "peerDependencies": { + "typescript": "*" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/vue-router": { + "version": "4.6.4", + "resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.6.4.tgz", + "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==", + "license": "MIT", + "dependencies": { + "@vue/devtools-api": "^6.6.4" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "vue": "^3.5.0" + } + } + } +} diff --git a/web/package.json b/web/package.json index fbcd78e..48f264a 100644 --- a/web/package.json +++ b/web/package.json @@ -1,5 +1,5 @@ { - "name": "syncloud-game-server-web", + "name": "syncloud-games-web", "version": "0.1.0", "private": true, "type": "module", @@ -10,10 +10,12 @@ "preview": "vite preview --host 127.0.0.1 --port 4173" }, "dependencies": { - "vue": "^3.4.21" + "vue": "^3.4.21", + "vue-router": "^4.6.4" }, "devDependencies": { "@vitejs/plugin-vue": "^5.0.4", + "miragejs": "^0.1.48", "vite": "^5.2.6" } } diff --git a/web/src/App.vue b/web/src/App.vue index 643d852..68647af 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,104 +1,67 @@ diff --git a/web/src/api.js b/web/src/api.js new file mode 100644 index 0000000..819dc64 --- /dev/null +++ b/web/src/api.js @@ -0,0 +1,26 @@ +async function jsonOrThrow (res) { + if (!res.ok) { + let msg = `http ${res.status}` + try { msg = (await res.json()).error || msg } catch (_) { } + throw new Error(msg) + } + if (res.status === 204) return null + return res.json() +} + +export const api = { + health: () => fetch('/api/v1/health').then(jsonOrThrow), + games: () => fetch('/api/v1/games').then(jsonOrThrow), + catalogSources: () => fetch('/api/v1/catalog/sources').then(jsonOrThrow), + servers: () => fetch('/api/v1/servers').then(jsonOrThrow), + server: (id) => fetch(`/api/v1/servers/${id}`).then(jsonOrThrow), + createServer: (body) => fetch('/api/v1/servers', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }).then(jsonOrThrow), + deleteServer: (id) => fetch(`/api/v1/servers/${id}`, { method: 'DELETE' }).then(jsonOrThrow), + action: (id, name) => fetch(`/api/v1/servers/${id}/${name}`, { method: 'POST' }).then(jsonOrThrow), + logs: (id) => fetch(`/api/v1/servers/${id}/logs`).then(jsonOrThrow), + query: (id) => fetch(`/api/v1/servers/${id}/query`).then(jsonOrThrow) +} diff --git a/web/src/assets/icons/catalog.svg b/web/src/assets/icons/catalog.svg new file mode 100644 index 0000000..9319c67 --- /dev/null +++ b/web/src/assets/icons/catalog.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/assets/icons/servers.svg b/web/src/assets/icons/servers.svg new file mode 100644 index 0000000..f160a0a --- /dev/null +++ b/web/src/assets/icons/servers.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/web/src/assets/icons/settings.svg b/web/src/assets/icons/settings.svg new file mode 100644 index 0000000..180c136 --- /dev/null +++ b/web/src/assets/icons/settings.svg @@ -0,0 +1,4 @@ + + + + diff --git a/web/src/assets/syncloud-logo.svg b/web/src/assets/syncloud-logo.svg new file mode 100644 index 0000000..9bc3436 --- /dev/null +++ b/web/src/assets/syncloud-logo.svg @@ -0,0 +1,20 @@ + + + + + logo + + + + + + + + + + + diff --git a/web/src/components/ConfirmDialog.vue b/web/src/components/ConfirmDialog.vue new file mode 100644 index 0000000..e3a2f54 --- /dev/null +++ b/web/src/components/ConfirmDialog.vue @@ -0,0 +1,85 @@ + + + + + diff --git a/web/src/components/GameCard.vue b/web/src/components/GameCard.vue index 126906d..879317e 100644 --- a/web/src/components/GameCard.vue +++ b/web/src/components/GameCard.vue @@ -1,5 +1,6 @@ + + diff --git a/web/src/components/InstallDialog.vue b/web/src/components/InstallDialog.vue new file mode 100644 index 0000000..c22860d --- /dev/null +++ b/web/src/components/InstallDialog.vue @@ -0,0 +1,149 @@ + + + + + diff --git a/web/src/components/ServerRow.vue b/web/src/components/ServerRow.vue index cfd3c73..c5dba44 100644 --- a/web/src/components/ServerRow.vue +++ b/web/src/components/ServerRow.vue @@ -1,19 +1,50 @@ + + diff --git a/web/src/components/ThemeToggle.vue b/web/src/components/ThemeToggle.vue index c64485a..d75802d 100644 --- a/web/src/components/ThemeToggle.vue +++ b/web/src/components/ThemeToggle.vue @@ -1,7 +1,7 @@ + + + + diff --git a/web/src/pages/ServerDetailPage.vue b/web/src/pages/ServerDetailPage.vue new file mode 100644 index 0000000..3441a67 --- /dev/null +++ b/web/src/pages/ServerDetailPage.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/web/src/pages/ServersPage.vue b/web/src/pages/ServersPage.vue new file mode 100644 index 0000000..08df8c4 --- /dev/null +++ b/web/src/pages/ServersPage.vue @@ -0,0 +1,71 @@ + + + diff --git a/web/src/pages/SettingsPage.vue b/web/src/pages/SettingsPage.vue new file mode 100644 index 0000000..7d27d9b --- /dev/null +++ b/web/src/pages/SettingsPage.vue @@ -0,0 +1,173 @@ + + + + + diff --git a/web/src/router.js b/web/src/router.js new file mode 100644 index 0000000..87f2eec --- /dev/null +++ b/web/src/router.js @@ -0,0 +1,16 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import CatalogPage from './pages/CatalogPage.vue' +import ServersPage from './pages/ServersPage.vue' +import ServerDetailPage from './pages/ServerDetailPage.vue' +import SettingsPage from './pages/SettingsPage.vue' + +export const router = createRouter({ + history: createWebHashHistory(), + routes: [ + { path: '/', redirect: '/catalog' }, + { path: '/catalog', name: 'catalog', component: CatalogPage }, + { path: '/servers', name: 'servers', component: ServersPage }, + { path: '/servers/:id', name: 'server-detail', component: ServerDetailPage, props: true }, + { path: '/settings', name: 'settings', component: SettingsPage } + ] +}) diff --git a/web/src/stub/api.js b/web/src/stub/api.js new file mode 100644 index 0000000..749c486 --- /dev/null +++ b/web/src/stub/api.js @@ -0,0 +1,213 @@ +import { createServer, Response } from 'miragejs' + +const games = [ + { + id: 'teeworlds', + name: 'Teeworlds', + source: 'pelican', + upstreamRef: 'game_eggs/teeworlds/egg-teeworlds.json', + summary: 'Fast-paced 2D online shooter. Smallest dedicated server in the catalog (~10 MB).', + defaultPort: 8303, + protocols: ['udp'], + tier: 'compatible' + }, + { + id: 'hlds-cs', + name: 'Counter-Strike 1.6', + source: 'steam', + upstreamRef: 'curated/hlds-cs', + summary: 'Classic GoldSrc CS 1.6 dedicated server (HLDS). Bundled mod = cstrike.', + defaultPort: 27015, + protocols: ['udp'], + tier: 'verified', + steamAppId: 90 + }, + { + id: 'cs2', + name: 'Counter-Strike 2', + source: 'steam', + upstreamRef: 'curated/cs2', + summary: 'Source 2 successor to CS:GO. Anonymous-friendly install, ~30 GB.', + defaultPort: 27015, + protocols: ['udp'], + tier: 'verified', + steamAppId: 730 + }, + { + id: 'valheim', + name: 'Valheim', + source: 'steam', + upstreamRef: 'curated/valheim', + summary: 'Co-op Viking survival. Dedicated server bundled by the publisher.', + defaultPort: 2456, + protocols: ['udp'], + tier: 'verified', + steamAppId: 896660 + }, + { + id: 'paper', + name: 'Paper', + source: 'pelican', + upstreamRef: 'game_eggs/minecraft/java/paper/egg-paper.json', + summary: 'High-performance Minecraft Java server (Paper). Plugins compatible with Spigot.', + defaultPort: 25565, + protocols: ['tcp'], + tier: 'compatible' + }, + { + id: 'factorio', + name: 'Factorio', + source: 'steam', + upstreamRef: 'curated/factorio', + summary: 'Build automated factories. Multiplayer dedicated server.', + defaultPort: 34197, + protocols: ['udp'], + tier: 'compatible', + steamAppId: 427520 + }, + { + id: 'mindustry', + name: 'Mindustry', + source: 'pelican', + upstreamRef: 'mindustry/egg-mindustry.json', + summary: 'Tower-defense factory game. Headless server bundles a single JAR.', + defaultPort: 6567, + protocols: ['tcp'], + tier: 'compatible' + }, + { + id: 'arma3', + name: 'Arma 3', + source: 'steam', + upstreamRef: 'curated/arma3', + summary: 'Tactical military sandbox. Needs a paid Steam account on the host.', + defaultPort: 2302, + protocols: ['udp'], + tier: 'experimental', + steamAppId: 233780 + } +] + +const sources = { + 'parkervcp/eggs': 'fcfd5a3549769ade15127a7577d6d3c397e83b05', + 'pelican-eggs/games': '34331fce33c83df752d94e2a90b1e43ca6280f82' +} + +const initialServers = [ + { + id: 1, + name: 'my-tw', + gameId: 'teeworlds', + port: 8303, + status: 'running', + installDir: '/data/games/servers/my-tw', + startCmd: 'cd /data/games/servers/my-tw && ./teeworlds_srv "sv_port 8303"' + }, + { + id: 2, + name: 'mc-paper', + gameId: 'paper', + port: 25565, + status: 'stopped', + installDir: '/data/games/servers/mc-paper', + startCmd: 'cd /data/games/servers/mc-paper && java -jar paper.jar nogui' + } +] + +function buildLog (server) { + return [ + `[stub] runner: starting server[${server.id}] ${server.name}`, + `[stub] runner: workDir=${server.installDir}`, + `[stub] server[${server.id}] stdout: bound on port ${server.port}`, + `[stub] server[${server.id}] stdout: ready, waiting for clients...` + ] +} + +export function mock () { + let nextId = initialServers.length + 1 + const servers = [...initialServers] + + createServer({ + routes () { + this.get('/api/v1/health', () => ({ status: 'ok' })) + this.get('/api/v1/me', () => ({ sub: 'devstub', name: 'Dev Stub', email: 'dev@stub.local' })) + this.get('/api/v1/games', () => games) + this.get('/api/v1/catalog/sources', () => sources) + const steamState = { linked: false, username: '' } + this.get('/api/v1/steam/status', () => ({ ...steamState })) + this.post('/api/v1/steam/login', (_, request) => { + const body = JSON.parse(request.requestBody || '{}') + if (!body.guardCode) { + return { needsGuard: true, prompt: 'Steam Guard code (stub: any 5 chars accepted)' } + } + steamState.linked = true + steamState.username = body.username + return { linked: true, username: body.username } + }) + + this.get('/api/v1/servers', () => servers) + + this.get('/api/v1/servers/:id', (_, request) => { + const s = servers.find(x => x.id === Number(request.params.id)) + return s || new Response(404, {}, { error: 'not found' }) + }) + + this.post('/api/v1/servers', (_, request) => { + const body = JSON.parse(request.requestBody || '{}') + const g = games.find(x => x.id === body.gameId) + if (!g) return new Response(400, {}, { error: 'unknown game' }) + const s = { + id: nextId++, + name: body.name, + gameId: body.gameId, + port: body.port || g.defaultPort, + status: 'stopped', + installDir: `/data/games/servers/${body.name}`, + startCmd: '(stub) not started yet' + } + servers.push(s) + return new Response(201, {}, s) + }) + + this.delete('/api/v1/servers/:id', (_, request) => { + const idx = servers.findIndex(x => x.id === Number(request.params.id)) + if (idx === -1) return new Response(404, {}, { error: 'not found' }) + servers.splice(idx, 1) + return new Response(204) + }) + + const action = (next) => (_, request) => { + const s = servers.find(x => x.id === Number(request.params.id)) + if (!s) return new Response(404, {}, { error: 'not found' }) + s.status = next + return s + } + this.post('/api/v1/servers/:id/install', action('stopped')) + this.post('/api/v1/servers/:id/start', action('running')) + this.post('/api/v1/servers/:id/stop', action('stopped')) + this.post('/api/v1/servers/:id/restart', action('running')) + + this.get('/api/v1/servers/:id/logs', (_, request) => { + const s = servers.find(x => x.id === Number(request.params.id)) + if (!s) return new Response(404, {}, { error: 'not found' }) + return { lines: buildLog(s) } + }) + + this.get('/api/v1/servers/:id/query', (_, request) => { + const s = servers.find(x => x.id === Number(request.params.id)) + if (!s) return new Response(404, {}, { error: 'not found' }) + if (s.status !== 'running') return new Response(502, {}, { error: 'server not running' }) + return { + name: `${s.name} (stub)`, + map: 'dm1', + players: 3, + maxPlayers: 16, + gameId: s.gameId, + ping: 8 + } + }) + + this.passthrough() + } + }) +} diff --git a/web/src/style/global.css b/web/src/style/global.css index d8dba61..58855ab 100644 --- a/web/src/style/global.css +++ b/web/src/style/global.css @@ -55,6 +55,7 @@ button { font: inherit; cursor: pointer; } .page { min-height: 100vh; + min-height: 100dvh; display: flex; flex-direction: column; } @@ -84,6 +85,11 @@ button { font: inherit; cursor: pointer; } font-size: 18px; } +.brand-logo { + width: 32px; + height: 32px; + display: block; +} .brand-icon { width: 32px; height: 32px; @@ -112,24 +118,131 @@ button { font: inherit; cursor: pointer; } padding: 6px 14px; border-radius: 6px; font-weight: 500; + font-size: 14px; + text-decoration: none; + display: inline-block; transition: all 0.15s ease; } -.tab:hover { color: var(--text); } +.tab:hover { color: var(--text); text-decoration: none; } .tab.active { background: var(--bg-elevated); color: var(--text); box-shadow: var(--shadow); } -.theme-toggle { - border: 1px solid var(--border); +.brand { + display: flex; + align-items: center; + gap: 12px; + font-weight: 600; + font-size: 18px; + color: var(--text); + text-decoration: none; +} +.brand:hover { text-decoration: none; } + +.desktop-only { display: inherit; } +.mobile-only { display: none; } + +.bottom-bar { + display: none; + position: sticky; + bottom: 0; + background: var(--bg-elevated); + border-top: 1px solid var(--border); + padding: 6px 0 max(6px, env(safe-area-inset-bottom)); + justify-content: space-around; + z-index: 100; + box-shadow: 0 -2px 12px rgba(15, 23, 42, 0.06); +} +.bottom-tab { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + color: var(--text-muted); + text-decoration: none; + padding: 6px 18px 4px; + border-radius: 12px; + min-width: 72px; + transition: color 0.15s ease; +} +.bottom-tab::before { + content: ''; + position: absolute; + top: 0; + left: 50%; + transform: translateX(-50%) scaleX(0); + width: 28px; + height: 3px; + background: var(--accent); + border-radius: 0 0 3px 3px; + transition: transform 0.18s ease; +} +.bottom-tab:hover { text-decoration: none; color: var(--text); } +.bottom-tab.active { color: var(--accent); } +.bottom-tab.active::before { transform: translateX(-50%) scaleX(1); } +.bottom-icon { + display: inline-block; + width: 22px; + height: 22px; + background-color: currentColor; + -webkit-mask: var(--icon-url) no-repeat center / contain; + mask: var(--icon-url) no-repeat center / contain; + pointer-events: none; +} +.bottom-label { font-size: 11px; font-weight: 600; letter-spacing: 0.01em; pointer-events: none; } + +.user-chip { + display: flex; + align-items: center; + gap: 8px; background: var(--bg); + padding: 4px 4px 4px 12px; + border-radius: 999px; + font-size: 13px; color: var(--text); - border-radius: 8px; - padding: 6px 10px; + font-weight: 500; +} +.logout-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--text-muted); + padding: 4px 10px; + border-radius: 999px; + font-size: 12px; + font-weight: 500; +} +.logout-btn:hover { color: var(--text); background: var(--bg-elevated); } + +@media (max-width: 640px) { + .desktop-only { display: none !important; } + .mobile-only { display: block; } + .bottom-bar.mobile-only { display: flex; } + .header-inner { padding: 12px 16px; } + .brand-name { font-size: 16px; } + main { padding: 20px 16px; } + .toolbar { flex-wrap: wrap; gap: 8px; } + .search { max-width: none; flex: 1 1 100%; } + .tier-pills { flex-wrap: wrap; } + .grid { grid-template-columns: 1fr; gap: 12px; } + html { scroll-padding-bottom: 96px; } + .card { scroll-margin-bottom: 96px; } +} + +.theme-toggle { + border: 1px solid var(--border); + background: transparent; + color: var(--text-muted); + border-radius: 999px; + padding: 4px 12px; font-size: 14px; + line-height: 1; + min-width: 36px; } +.theme-toggle:hover { color: var(--text); background: var(--bg-elevated); } main { flex: 1;