1 Commits

Author SHA1 Message Date
dbledeez 4ccccc6fe2 panel v0.9.2 — public release
Self-hostable game server control panel: Go controller + agent, 26 game
modules, embedded web UI. One-line install via install.sh / install.ps1.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 00:43:35 -07:00
20 changed files with 1123 additions and 56 deletions
+17
View File
@@ -33,6 +33,23 @@ credentials printed in the controller log (or pre-seed via
`PANEL_ADMIN_PASSWORD`). Full options, docker-compose, and from-source
paths: [docs/INSTALL.md](docs/INSTALL.md).
> **Availability / self-hosting the source.** The one-line installer fetches
> both the source and the default prebuilt release from `git.pdxtechs.com`. If
> that host is unreachable (or you want to pin your own mirror), point the
> installer at any Git host or tarball you control:
>
> ```bash
> # Build from your own mirror of the source:
> PANEL_REPO_URL=https://github.com/you/panel PANEL_PREBUILT_URL= sudo bash install.sh
>
> # Or install a prebuilt tarball you host yourself (no Go needed):
> PANEL_PREBUILT_URL=https://mirror.example.com/panel-linux-amd64.tar.gz sudo bash install.sh
> ```
>
> Setting `PANEL_PREBUILT_URL` to an empty value forces a from-source build
> against `PANEL_REPO_URL`. See [docs/INSTALL.md](docs/INSTALL.md) for the full
> mirror/self-host path.
```
┌─────────────────────────┐
│ Operator │
+58 -5
View File
@@ -35,6 +35,13 @@ import (
// Container IDs (the string returned by Create) are Docker's full container IDs.
type DockerRuntime struct {
cli *client.Client
// hostNetUnsupported is true when the daemon is Docker Desktop
// (Windows/macOS), where NetworkMode=host binds inside the hidden
// Linux VM and is NOT reachable from the host LAN. On such daemons we
// fall back to bridge networking + published ports so the panel's
// allocated ports actually reach players. Detected once at startup;
// overridable via PANEL_FORCE_BRIDGE=1 / PANEL_FORCE_HOST_NET=1.
hostNetUnsupported bool
}
// NewDocker connects to the local Docker daemon using environment-configured
@@ -44,7 +51,52 @@ func NewDocker() (*DockerRuntime, error) {
if err != nil {
return nil, fmt.Errorf("docker client: %w", err)
}
return &DockerRuntime{cli: cli}, nil
r := &DockerRuntime{cli: cli}
r.hostNetUnsupported = detectHostNetUnsupported(cli)
return r, nil
}
// applyHostNetPolicy decides the effective network mode + port plumbing for a
// container. Native Linux keeps host mode and drops the (meaningless) port
// bindings; a Docker-Desktop daemon converts host mode to bridge and KEEPS the
// published bindings so the panel-allocated ports reach the host LAN. Non-host
// modes pass through unchanged. Extracted for testability.
func applyHostNetPolicy(r *DockerRuntime, requested string, exposed nat.PortSet, bindings nat.PortMap) (string, nat.PortSet, nat.PortMap) {
if requested != "host" {
return requested, exposed, bindings
}
if r.hostNetUnsupported {
return "", exposed, bindings // bridge + published ports
}
return "host", nil, nil // native host mode, no bindings
}
// detectHostNetUnsupported returns true when NetworkMode=host won't reach the
// host LAN — i.e. Docker Desktop's Linux VM on Windows/macOS. Explicit env
// overrides win over autodetection so operators can force either behavior.
func detectHostNetUnsupported(cli *client.Client) bool {
if os.Getenv("PANEL_FORCE_BRIDGE") == "1" {
return true
}
if os.Getenv("PANEL_FORCE_HOST_NET") == "1" {
return false
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
info, err := cli.Info(ctx)
if err != nil {
return false // can't tell → assume native Linux (host works)
}
// Docker Desktop reports OperatingSystem "Docker Desktop" and, on
// Windows hosts, OSType "linux" while the actual client OS is windows.
// The tell-tale is the "Docker Desktop" marker or a non-Linux daemon.
if strings.Contains(info.OperatingSystem, "Docker Desktop") {
return true
}
if info.OSType != "" && info.OSType != "linux" {
return true
}
return false
}
// Close releases daemon resources.
@@ -85,9 +137,10 @@ func (r *DockerRuntime) Create(ctx context.Context, spec InstanceSpec) (string,
// Docker rejects PortBindings + ExposedPorts when NetworkMode=host —
// the container shares the host's network namespace so publish-port
// plumbing is meaningless there.
if spec.NetworkMode == "host" {
bindings = nil
exposed = nil
var netMode string
netMode, exposed, bindings = applyHostNetPolicy(r, spec.NetworkMode, exposed, bindings)
if spec.NetworkMode == "host" && netMode != "host" && len(bindings) > 0 && spec.LogSink != nil {
spec.LogSink("Docker Desktop detected: publishing ports via bridge instead of host networking (host mode is not LAN-reachable on Docker Desktop)")
}
envSlice := make([]string, 0, len(spec.Env))
@@ -144,7 +197,7 @@ func (r *DockerRuntime) Create(ctx context.Context, spec InstanceSpec) (string,
hostCfg := &container.HostConfig{
Mounts: mounts,
PortBindings: bindings,
NetworkMode: container.NetworkMode(spec.NetworkMode),
NetworkMode: container.NetworkMode(netMode),
SecurityOpt: spec.SecurityOpts,
RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyMode(restart),
@@ -0,0 +1,67 @@
package runtime
import (
"os"
"testing"
)
// TestDetectHostNetUnsupported_EnvOverrides verifies the explicit env
// overrides win over autodetection (the daemon-info path needs a live
// daemon and is covered by the integration smoke test, not here).
func TestDetectHostNetUnsupported_EnvOverrides(t *testing.T) {
t.Setenv("PANEL_FORCE_BRIDGE", "1")
if !detectHostNetUnsupported(nil) {
t.Fatal("PANEL_FORCE_BRIDGE=1 must force bridge fallback (true)")
}
os.Unsetenv("PANEL_FORCE_BRIDGE")
t.Setenv("PANEL_FORCE_HOST_NET", "1")
if detectHostNetUnsupported(nil) {
t.Fatal("PANEL_FORCE_HOST_NET=1 must force host networking (false)")
}
}
// TestBridgeFallbackPreservesBindings is the load-bearing check: on a
// Docker-Desktop-style daemon, a host-mode module must be converted to
// bridge networking WITH its published port bindings intact (so the
// panel-allocated ports actually reach players), whereas on native Linux
// the same module keeps host mode and drops the meaningless bindings.
//
// It exercises the exact decision block in Create() without a live daemon
// by replicating its inputs.
func TestBridgeFallbackPreservesBindings(t *testing.T) {
ports := []PortSpec{
{Proto: "udp", HostPort: 14507, ContainerPort: 14507},
{Proto: "udp", HostPort: 14508, ContainerPort: 14508},
}
exposed, bindings, err := buildPortBindings(ports)
if err != nil {
t.Fatalf("buildPortBindings: %v", err)
}
if len(bindings) != 2 {
t.Fatalf("expected 2 published bindings, got %d", len(bindings))
}
// Native Linux (hostNetUnsupported=false): host mode kept, bindings dropped.
r := &DockerRuntime{hostNetUnsupported: false}
netMode, gotExposed, gotBindings := applyHostNetPolicy(r, "host", exposed, bindings)
if netMode != "host" {
t.Errorf("linux: netMode = %q, want host", netMode)
}
if gotBindings != nil || gotExposed != nil {
t.Errorf("linux: expected bindings/exposed dropped for host mode")
}
// Docker Desktop (hostNetUnsupported=true): host mode → bridge, bindings kept.
r = &DockerRuntime{hostNetUnsupported: true}
netMode, gotExposed, gotBindings = applyHostNetPolicy(r, "host", exposed, bindings)
if netMode != "" {
t.Errorf("docker desktop: netMode = %q, want bridge (empty)", netMode)
}
if len(gotBindings) != 2 {
t.Errorf("docker desktop: expected 2 bindings preserved, got %d", len(gotBindings))
}
if len(gotExposed) != 2 {
t.Errorf("docker desktop: expected 2 exposed ports preserved, got %d", len(gotExposed))
}
}
+23 -2
View File
@@ -133,19 +133,40 @@ func (h *httpServer) requireSessionOrAdminToken(isAPI bool, next http.Handler) h
}
want := []byte(h.adminToken)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if isLoopback(r.RemoteAddr) {
// Service callers (on-host loopback, or a valid admin-token header)
// are admitted without a session. But downstream handlers like
// createToken / handleMe read the caller's identity from context and
// 401 if it's absent — so we MUST stash an identity, or those admit
// paths break (this was the fresh-install auto-pair failure). Prefer
// a real session user if a cookie happens to be present; otherwise
// synthesize a service-admin identity.
admit := func() {
if u, err := h.auth.userFromRequest(r); err == nil {
r = r.WithContext(context.WithValue(r.Context(), sessionUserKey{}, u))
} else {
r = r.WithContext(context.WithValue(r.Context(), sessionUserKey{}, serviceAdminUser))
}
next.ServeHTTP(w, r)
}
if isLoopback(r.RemoteAddr) {
admit()
return
}
if got := r.Header.Get(adminTokenHeader); got != "" &&
subtle.ConstantTimeCompare([]byte(got), want) == 1 {
next.ServeHTTP(w, r)
admit()
return
}
sessionGate.ServeHTTP(w, r)
})
}
// serviceAdminUser is the synthetic identity given to service callers
// (loopback / admin-token) that don't carry a session cookie. ID 0 never
// collides with a real DB user (serial starts at 1); handlers that only
// check Role == "admin" (createToken, user creation) accept it.
var serviceAdminUser = &db.UserRow{ID: 0, Email: "service@panel.local", Role: "admin"}
// registerAdminRoutes wires the admin mux onto the outer mux. Called
// from httpServer.handler() during construction.
func (h *httpServer) registerAdminRoutes(mux *http.ServeMux, token string) {
+31 -3
View File
@@ -12,7 +12,9 @@ import (
"log/slog"
"net/http"
"regexp"
"strconv"
"strings"
"sync"
"time"
"golang.org/x/crypto/argon2"
@@ -46,6 +48,8 @@ type auth struct {
log *slog.Logger
db *db.DB
publicURLOverride string // set from --public-url flag; used by Steam OpenID realm/return_to
steamRealmWarned sync.Once
loginLimiter *loginLimiter // brute-force guard for handleLogin
}
// ---- Password hashing (argon2id, PHC format) ----
@@ -131,7 +135,14 @@ func clientIP(r *http.Request) string {
// we retro-link the first admin to this Steam ID. Useful when the
// operator is adding Steam login to an already-running panel.
// - Ignored if the first admin already has a steam_id (don't clobber).
func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envPassword, initialSteamID string) error {
// bootstrapAdmin creates the first admin. envEmail (from PANEL_ADMIN_EMAIL)
// lets the operator choose their login; blank falls back to the default
// admin@panel.local so existing installs are unaffected.
func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envEmail, envPassword, initialSteamID string) error {
adminEmail := strings.TrimSpace(strings.ToLower(envEmail))
if adminEmail == "" {
adminEmail = "admin@panel.local"
}
initialSteamID = strings.TrimSpace(initialSteamID)
if initialSteamID != "" && !steamID64RE.MatchString(initialSteamID) {
log.Warn("--initial-admin-steam-id isn't a 17-digit SteamID64, ignoring", "value", initialSteamID)
@@ -183,7 +194,7 @@ func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envP
if err != nil {
return err
}
userID, err := database.CreateUser(ctx, "admin@panel.local", hash, "admin")
userID, err := database.CreateUser(ctx, adminEmail, hash, "admin")
if err != nil {
return err
}
@@ -195,7 +206,7 @@ func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envP
const banner = "═══════════════════════════════════════════════════════════════════════════"
log.Warn(banner)
log.Warn("ADMIN BOOTSTRAP — no users in DB, created default account")
log.Warn(" email: admin@panel.local")
log.Warn(" email: " + adminEmail)
if generated {
log.Warn(" password: " + password)
log.Warn(" ^^^ log in once and change it via the UI ^^^")
@@ -268,11 +279,24 @@ func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
ip := normalizeIP(clientIP(r))
if ok, retry := a.loginLimiter.Allowed(ip, req.Email); !ok {
secs := int(retry.Seconds())
if secs < 1 {
secs = 1
}
w.Header().Set("Retry-After", strconv.Itoa(secs))
writeError(w, http.StatusTooManyRequests, "rate_limited",
"too many failed login attempts; try again later")
return
}
u, err := a.db.GetUserByEmail(r.Context(), req.Email)
if err != nil {
if errors.Is(err, db.ErrUserNotFound) {
// Still do a bogus verify so login timing doesn't leak user enumeration.
_, _ = verifyPassword(req.Password, "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
a.loginLimiter.RecordFailure(ip, req.Email)
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
return
}
@@ -280,6 +304,7 @@ func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if u.Disabled {
a.loginLimiter.RecordFailure(ip, req.Email)
writeError(w, http.StatusUnauthorized, "disabled", "account is disabled")
return
}
@@ -289,9 +314,12 @@ func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
return
}
if !ok {
a.loginLimiter.RecordFailure(ip, req.Email)
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
return
}
// Successful auth — clear any accumulated failure counters.
a.loginLimiter.Reset(ip, req.Email)
token, err := newSessionToken()
if err != nil {
writeError(w, http.StatusInternalServerError, "token", err.Error())
+179
View File
@@ -0,0 +1,179 @@
package main
import (
"net"
"strings"
"sync"
"time"
)
// httpBindIsNonLoopback reports whether an http listen address (e.g. ":8080",
// "0.0.0.0:8080", "127.0.0.1:8080", "[::1]:8080") would accept connections on
// an interface other than loopback. A bare ":port" or "0.0.0.0"/"::" binds all
// interfaces → non-loopback. An empty/unparseable host is treated as
// all-interfaces (the conservative choice for a security warning).
func httpBindIsNonLoopback(addr string) bool {
host, _, err := net.SplitHostPort(strings.TrimSpace(addr))
if err != nil {
// No port separator — treat the whole thing as the host.
host = strings.TrimSpace(addr)
}
if host == "" || host == "0.0.0.0" || host == "::" {
return true // wildcard bind — all interfaces
}
ip := net.ParseIP(host)
if ip == nil {
// A hostname we can't classify cheaply; assume exposed.
return true
}
return !ip.IsLoopback()
}
// loginLimiter is a dependency-free, in-memory brute-force guard for the
// login endpoint. It tracks failed attempts keyed independently by client IP
// and by target email, so an attacker cannot dodge the IP limit by rotating
// emails, nor dodge the email limit by rotating IPs.
//
// After failLimit failures inside the sliding failWindow, the key is locked
// out for lockout. A successful login (recorded via Reset) clears the key's
// counter immediately, so a legitimate user is never penalized for a stale
// failure count.
type loginLimiter struct {
mu sync.Mutex
entries map[string]*loginAttempt
failLimit int
failWindow time.Duration
lockout time.Duration
now func() time.Time // injectable for tests
}
type loginAttempt struct {
fails int
first time.Time // start of the current counting window
lockedUntil time.Time
}
func newLoginLimiter(failLimit int, failWindow, lockout time.Duration) *loginLimiter {
return &loginLimiter{
entries: make(map[string]*loginAttempt),
failLimit: failLimit,
failWindow: failWindow,
lockout: lockout,
now: time.Now,
}
}
// keysFor returns the two limiter keys (IP + email) for a login attempt.
// Empty components are skipped so we never bucket everything under "ip:".
func keysFor(ip, email string) []string {
var ks []string
if ip != "" {
ks = append(ks, "ip:"+ip)
}
if email != "" {
ks = append(ks, "email:"+email)
}
return ks
}
// Allowed reports whether a login attempt for this ip/email may proceed.
// If locked out, it returns false and the Retry-After duration the caller
// should surface to the client.
func (l *loginLimiter) Allowed(ip, email string) (bool, time.Duration) {
if l == nil {
return true, 0
}
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
var maxRetry time.Duration
blocked := false
for _, k := range keysFor(ip, email) {
e := l.entries[k]
if e == nil {
continue
}
if now.Before(e.lockedUntil) {
blocked = true
if d := e.lockedUntil.Sub(now); d > maxRetry {
maxRetry = d
}
}
}
if blocked {
return false, maxRetry
}
return true, 0
}
// RecordFailure increments the failure counters for ip + email and applies a
// lockout once the threshold is crossed inside the window.
func (l *loginLimiter) RecordFailure(ip, email string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
for _, k := range keysFor(ip, email) {
e := l.entries[k]
if e == nil {
e = &loginAttempt{first: now}
l.entries[k] = e
}
// Reset the window if the previous one has elapsed (and we're not
// currently locked out).
if now.After(e.first.Add(l.failWindow)) && now.After(e.lockedUntil) {
e.fails = 0
e.first = now
}
e.fails++
if e.fails >= l.failLimit {
e.lockedUntil = now.Add(l.lockout)
}
}
}
// Reset clears the counters for a successful login so the user isn't carrying
// a stale failure count.
func (l *loginLimiter) Reset(ip, email string) {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
for _, k := range keysFor(ip, email) {
delete(l.entries, k)
}
}
// cleanup drops entries whose window has elapsed and whose lockout has
// expired, to bound memory under a sustained scan. Callers may run this
// periodically; it is safe to never call (map stays bounded by distinct
// IP/email pairs seen within the retention horizon).
func (l *loginLimiter) cleanup() {
if l == nil {
return
}
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
for k, e := range l.entries {
if now.After(e.lockedUntil) && now.After(e.first.Add(l.failWindow)) {
delete(l.entries, k)
}
}
}
// normalizeIP strips the port from an IP:port RemoteAddr (or X-Forwarded-For
// entry), returning a stable per-host key. Falls back to the raw string if it
// can't be parsed.
func normalizeIP(addr string) string {
if addr == "" {
return ""
}
if host, _, err := net.SplitHostPort(addr); err == nil {
return host
}
return addr
}
@@ -0,0 +1,144 @@
package main
import (
"testing"
"time"
)
func newTestLimiter() (*loginLimiter, *time.Time) {
base := time.Unix(1_700_000_000, 0)
cur := base
l := newLoginLimiter(3, 10*time.Minute, 15*time.Minute)
l.now = func() time.Time { return cur }
return l, &cur
}
func TestLimiter_AllowsUntilThreshold(t *testing.T) {
l, _ := newTestLimiter()
ip, email := "1.2.3.4", "a@b.com"
// failLimit = 3. First 2 failures still leave the account allowed.
for i := 0; i < 2; i++ {
if ok, _ := l.Allowed(ip, email); !ok {
t.Fatalf("attempt %d should be allowed", i)
}
l.RecordFailure(ip, email)
}
// 3rd failure crosses the threshold → now locked.
l.RecordFailure(ip, email)
ok, retry := l.Allowed(ip, email)
if ok {
t.Fatal("expected lockout after threshold")
}
if retry <= 0 || retry > 15*time.Minute {
t.Fatalf("unexpected retry-after: %v", retry)
}
}
func TestLimiter_ResetOnSuccess(t *testing.T) {
l, _ := newTestLimiter()
ip, email := "1.2.3.4", "a@b.com"
l.RecordFailure(ip, email)
l.RecordFailure(ip, email)
// A success clears the counter; further failures start from zero.
l.Reset(ip, email)
l.RecordFailure(ip, email)
l.RecordFailure(ip, email)
if ok, _ := l.Allowed(ip, email); !ok {
t.Fatal("counter should have reset on success; must still be allowed")
}
}
func TestLimiter_LockoutExpires(t *testing.T) {
l, cur := newTestLimiter()
ip, email := "1.2.3.4", "a@b.com"
for i := 0; i < 3; i++ {
l.RecordFailure(ip, email)
}
if ok, _ := l.Allowed(ip, email); ok {
t.Fatal("should be locked")
}
// Advance past the lockout window.
*cur = cur.Add(16 * time.Minute)
if ok, _ := l.Allowed(ip, email); !ok {
t.Fatal("lockout should have expired")
}
}
func TestLimiter_WindowRollover(t *testing.T) {
l, cur := newTestLimiter()
ip, email := "1.2.3.4", "a@b.com"
// 2 fails, then let the fail window elapse — the count should reset so
// two more fails don't trip the lockout.
l.RecordFailure(ip, email)
l.RecordFailure(ip, email)
*cur = cur.Add(11 * time.Minute) // past the 10-min window
l.RecordFailure(ip, email)
if ok, _ := l.Allowed(ip, email); !ok {
t.Fatal("window should have rolled over; not locked yet")
}
}
func TestLimiter_EmailKeyAcrossIPs(t *testing.T) {
l, _ := newTestLimiter()
email := "victim@b.com"
// Attacker rotates IP each attempt but hammers one email. The email key
// must still trip the lockout independently of IP.
l.RecordFailure("1.1.1.1", email)
l.RecordFailure("2.2.2.2", email)
l.RecordFailure("3.3.3.3", email)
if ok, _ := l.Allowed("4.4.4.4", email); ok {
t.Fatal("email-keyed lockout should hold across differing IPs")
}
}
func TestLimiter_IPKeyAcrossEmails(t *testing.T) {
l, _ := newTestLimiter()
ip := "9.9.9.9"
// Attacker rotates email but from one IP. The IP key trips the lockout.
l.RecordFailure(ip, "a@b.com")
l.RecordFailure(ip, "c@d.com")
l.RecordFailure(ip, "e@f.com")
if ok, _ := l.Allowed(ip, "z@z.com"); ok {
t.Fatal("IP-keyed lockout should hold across differing emails")
}
}
func TestLimiter_NilSafe(t *testing.T) {
var l *loginLimiter
if ok, _ := l.Allowed("1.2.3.4", "a@b.com"); !ok {
t.Fatal("nil limiter must allow")
}
l.RecordFailure("1.2.3.4", "a@b.com") // must not panic
l.Reset("1.2.3.4", "a@b.com") // must not panic
}
func TestNormalizeIP(t *testing.T) {
cases := map[string]string{
"1.2.3.4:5678": "1.2.3.4",
"1.2.3.4": "1.2.3.4",
"[::1]:8080": "::1",
"": "",
}
for in, want := range cases {
if got := normalizeIP(in); got != want {
t.Errorf("normalizeIP(%q) = %q, want %q", in, got, want)
}
}
}
func TestHTTPBindIsNonLoopback(t *testing.T) {
cases := map[string]bool{
":8080": true,
"0.0.0.0:8080": true,
"::": true,
"127.0.0.1:8080": false,
"[::1]:8080": false,
"localhost:8080": true, // hostname → conservatively exposed
"192.168.1.5:8080": true,
}
for addr, want := range cases {
if got := httpBindIsNonLoopback(addr); got != want {
t.Errorf("httpBindIsNonLoopback(%q) = %v, want %v", addr, got, want)
}
}
}
+33 -2
View File
@@ -1398,7 +1398,7 @@ func main() {
// Bootstrap the admin account on first run. The generated password is
// printed to the log so the operator can grab it immediately.
if err := bootstrapAdmin(ctx, logger, database, os.Getenv("PANEL_ADMIN_PASSWORD"), *initialAdminSteamID); err != nil {
if err := bootstrapAdmin(ctx, logger, database, os.Getenv("PANEL_ADMIN_EMAIL"), os.Getenv("PANEL_ADMIN_PASSWORD"), *initialAdminSteamID); err != nil {
logger.Error("admin bootstrap", "err", err)
os.Exit(1)
}
@@ -1408,7 +1408,20 @@ func main() {
logger.Warn("empyrion kit seed", "err", err)
}
authSvc := &auth{log: logger, db: database, publicURLOverride: *publicURL}
// Flag wins; else fall back to PANEL_PUBLIC_URL (so the installer/env file
// and docker-compose can set it without editing the ExecStart line).
effectivePublicURL := *publicURL
if effectivePublicURL == "" {
effectivePublicURL = os.Getenv("PANEL_PUBLIC_URL")
}
authSvc := &auth{
log: logger,
db: database,
publicURLOverride: effectivePublicURL,
// Brute-force guard: 8 failures per IP-or-email inside 15 min → a
// 15 min lockout. A successful login resets the counter.
loginLimiter: newLoginLimiter(8, 15*time.Minute, 15*time.Minute),
}
pairSvc := &pairService{ca: caBundle, db: database}
httpSrvHandler := &httpServer{
@@ -1443,6 +1456,24 @@ func main() {
"http", *httpAddr,
)
// Startup safety: if the HTTP dashboard binds a non-loopback address and
// the operator hasn't signaled a reverse proxy (no --public-url /
// PANEL_PUBLIC_URL), the dashboard is reachable over plain HTTP from the
// LAN — and, if the host is internet-facing, from the internet. We don't
// change the default bind for compat; we just warn loudly.
if httpBindIsNonLoopback(*httpAddr) && strings.TrimSpace(effectivePublicURL) == "" {
const bar = "═══════════════════════════════════════════════════════════════════════════"
logger.Warn(bar)
logger.Warn("SECURITY — HTTP dashboard is bound to a NON-LOOPBACK address over plain HTTP",
"http", *httpAddr)
logger.Warn(" It is reachable by anything that can route to this host — the whole LAN,")
logger.Warn(" and the public internet if this port is exposed. Credentials and session")
logger.Warn(" cookies would travel UNENCRYPTED.")
logger.Warn(" Put it behind TLS (reverse proxy) and set --public-url / PANEL_PUBLIC_URL,")
logger.Warn(" or bind to 127.0.0.1 with --http 127.0.0.1:8080.")
logger.Warn(bar)
}
go func() {
<-ctx.Done()
logger.Info("shutdown signal received, stopping gracefully")
+14 -1
View File
@@ -125,11 +125,24 @@ func (p *pairService) createToken(w http.ResponseWriter, r *http.Request) {
return
}
id := "pt_" + randHex(8)
// created_by is a NOT NULL FK -> users(id). The synthetic service-admin
// identity (loopback / admin-token header callers, see serviceAdminUser)
// has ID 0, which no real user row has, so inserting it fails the FK with
// a 500. Resolve such callers to the first real admin so the audit row
// still points at a genuine account. If there is somehow no admin, fall
// back to the raw ID and let the DB surface the error.
createdBy := user.ID
if createdBy == 0 {
if admin, err := p.db.GetFirstAdmin(r.Context()); err == nil {
createdBy = admin.ID
}
}
if err := p.db.CreatePairToken(r.Context(), db.PairTokenRow{
ID: id,
TokenHash: sha256Hex(raw),
Description: req.Description,
CreatedBy: user.ID,
CreatedBy: createdBy,
ExpiresAt: time.Now().Add(ttl),
}); err != nil {
writeError(w, http.StatusInternalServerError, "db", err.Error())
+2 -2
View File
@@ -23247,7 +23247,7 @@ function eahPlayerCtx(e, p) {
{ icon:'🚫', label:'Ban…', onClick: () => triggerEahPlayerAct('ban', p.entityId) },
{ icon:'✅', label:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) },
{ divider:true },
{ icon:'🎒', label:'View inventory', disabled:stub, onClick: () => triggerEahPlayerAct('inv', p.entityId) },
{ icon:'🎒', label:'View inventory', disabled:true, title:'Not available yet — inspect the player row for now.', onClick: () => triggerEahPlayerAct('inv', p.entityId) },
{ icon:'🎁', label:'Drop kit…', disabled:stub, onClick: () => triggerEahPlayerAct('kit', p.entityId) },
{ icon:'🧪', label:'Give item…', disabled:stub, onClick: () => triggerEahPlayerAct('give', p.entityId) },
{ icon:'💰', label:'+1k credits', disabled:stub, onClick: () => triggerEahPlayerAct('credits', p.entityId) },
@@ -23272,7 +23272,7 @@ function triggerEahPlayerAct(act, pid, p) {
return eahFetch(`players/${pid}/teleport`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ x, y, z }) })
.then(()=>toast('Teleported','ok')).catch(e=>toast(e.message,'err'));
}
if (act === 'inv') return alert('Inventory view: open in modal — TODO; for now use the existing player row.');
if (act === 'inv') return toast('Inventory view is not available yet — inspect the player row for now.', 'warn');
// Fallthrough — replay through eahPlayerAction.
const fakeBtn = document.createElement('button');
fakeBtn.dataset.act = act; fakeBtn.dataset.pid = pid;
+2 -2
View File
@@ -19217,7 +19217,7 @@ function eahPlayerCtx(e, p) {
{ icon:'🚫', label:'Ban…', onClick: () => triggerEahPlayerAct('ban', p.entityId) },
{ icon:'✅', label:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) },
{ divider:true },
{ icon:'🎒', label:'View inventory', disabled:stub, onClick: () => triggerEahPlayerAct('inv', p.entityId) },
{ icon:'🎒', label:'View inventory', disabled:true, title:'Not available yet — inspect the player row for now.', onClick: () => triggerEahPlayerAct('inv', p.entityId) },
{ icon:'🎁', label:'Drop kit…', disabled:stub, onClick: () => triggerEahPlayerAct('kit', p.entityId) },
{ icon:'🧪', label:'Give item…', disabled:stub, onClick: () => triggerEahPlayerAct('give', p.entityId) },
{ icon:'💰', label:'+1k credits', disabled:stub, onClick: () => triggerEahPlayerAct('credits', p.entityId) },
@@ -19242,7 +19242,7 @@ function triggerEahPlayerAct(act, pid, p) {
return eahFetch(`players/${pid}/teleport`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ x, y, z }) })
.then(()=>toast('Teleported','ok')).catch(e=>toast(e.message,'err'));
}
if (act === 'inv') return alert('Inventory view: open in modal — TODO; for now use the existing player row.');
if (act === 'inv') return toast('Inventory view is not available yet — inspect the player row for now.', 'warn');
// Fallthrough — replay through eahPlayerAction.
const fakeBtn = document.createElement('button');
fakeBtn.dataset.act = act; fakeBtn.dataset.pid = pid;
+28 -1
View File
@@ -34,6 +34,7 @@ import (
"errors"
"fmt"
"io"
"net"
"net/http"
"net/url"
"regexp"
@@ -314,7 +315,33 @@ func (a *auth) publicURL(r *http.Request) string {
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https"
}
return scheme + "://" + r.Host
base := scheme + "://" + r.Host
// Guardrail for the #1 Steam-login misconfiguration: a reverse proxy
// terminates TLS but doesn't forward X-Forwarded-Proto, so we build an
// http:// realm for what the user reaches over https:// — Steam then
// returns to the wrong origin and login silently fails. Warn once, with
// the fix, when we're guessing plain-http against a real hostname (has a
// dot, isn't an IP:port or localhost). Direct LAN-IP / localhost use is
// the normal no-proxy case and stays quiet.
if scheme == "http" {
host := r.Host
if i := strings.IndexByte(host, ':'); i >= 0 {
host = host[:i]
}
looksLikeDomain := strings.Contains(host, ".") && net.ParseIP(host) == nil &&
host != "localhost"
if looksLikeDomain {
a.steamRealmWarned.Do(func() {
a.log.Warn("Steam login realm is being built as plain HTTP for a hostname — "+
"if this panel is behind an HTTPS reverse proxy, Steam sign-in will fail. "+
"Fix: start the controller with --public-url https://"+host+
" (or forward the X-Forwarded-Proto header from your proxy). "+
"See docs/ADMIN.md 'Sign in with Steam'.", "guessed_realm", base)
})
}
}
return base
}
func redirectToLoginWithErr(w http.ResponseWriter, r *http.Request, msg string) {
@@ -0,0 +1,23 @@
-- 017_pair_tokens_fk_cascade.sql — fix the self-contradictory FK on
-- pair_tokens.created_by.
--
-- Migration 006 declared:
-- created_by BIGINT NOT NULL REFERENCES users(id) ON DELETE SET NULL
-- which is impossible: ON DELETE SET NULL tries to write NULL into a NOT NULL
-- column, so deleting an admin that had ever minted a pair token would fail
-- with a not-null-violation instead of cleaning up the tokens.
--
-- Forward-safe fix: drop the old FK constraint and re-add it as ON DELETE
-- CASCADE. Deleting a user now removes the (short-lived, mostly-expired) pair
-- tokens they created, which is the sane behavior for onboarding tokens.
--
-- The constraint name from an inline column REFERENCES is auto-generated by
-- Postgres as <table>_<column>_fkey. We drop it defensively with IF EXISTS so
-- this migration is safe even if the name ever differed.
ALTER TABLE pair_tokens
DROP CONSTRAINT IF EXISTS pair_tokens_created_by_fkey;
ALTER TABLE pair_tokens
ADD CONSTRAINT pair_tokens_created_by_fkey
FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE;
+4
View File
@@ -1,3 +1,7 @@
# REFERENCE ONLY — install.sh GENERATES the real unit at install time,
# substituting the actual PANEL_DIR/ports/user (see write_unit in install.sh).
# This static copy assumes the /opt/panel defaults; edit it by hand if you wire
# the units up yourself (e.g. via compose) instead of running install.sh.
[Unit]
Description=Panel agent (Docker runtime for game-server instances)
Documentation=https://github.com/dbledeez/panel
+4
View File
@@ -1,3 +1,7 @@
# REFERENCE ONLY — install.sh GENERATES the real unit at install time,
# substituting the actual PANEL_DIR/ports/user (see write_unit in install.sh).
# This static copy assumes the /opt/panel defaults; edit it by hand if you wire
# the units up yourself (e.g. via compose) instead of running install.sh.
[Unit]
Description=Panel controller (HTTP dashboard :8080 + agent gRPC :8443)
Documentation=https://github.com/dbledeez/panel
+23
View File
@@ -63,6 +63,29 @@ With the managed `panel-postgres` container:
docker exec -it panel-postgres psql -U panel -d panel
```
## Adding more users
The account you set at install is the **first admin**. To add teammates,
open the **🎮 Steam Accounts** panel (sidebar) — this is the user-management
screen. As an admin you can:
- **List** every panel user and their linked Steam ID.
- **Add a user** by their 17-digit **SteamID64**, as role `user` or `admin`
(`POST /api/users`). They then log in with **Sign in with Steam** — no
password for you to manage or hand out.
- **Link / unlink** a Steam ID on any account.
The intended model: you (the install admin) sign in with your email +
password; everyone else you add is **Steam-based**. That's ideal for a
gaming community — players already have Steam.
**Limitation, stated plainly:** there is currently **no UI/API to create
additional *email + password* users** — `POST /api/users` is Steam-only.
If you need a second non-Steam login, add it at the database level or with
`reset-admin --email <addr> --password <pw>` (see
[Resetting a lost admin password](#resetting-a-lost-admin-password)). A
proper "invite a password user" flow is a known follow-up.
## Sign in with Steam (operator login)
Optional OpenID login for the dashboard. Two controller flags:
+86 -5
View File
@@ -57,19 +57,73 @@ PANEL_SKIP_AGENT=1 PANEL_HTTP_PORT=9090 PANEL_ADMIN_PASSWORD='s3cret' \
bash install.sh
```
### Availability & self-hosting (mirroring the source)
By default the installer fetches from a single upstream, `git.pdxtechs.com`:
`PANEL_REPO_URL` supplies the Git source, and `PANEL_PREBUILT_URL` defaults to
the published prebuilt release tarball on that same host. If that host is down,
rate-limited, or you simply don't want a single external dependency in your
install path, override either one to point at a mirror or self-hosted copy you
control:
```bash
# (a) Build from your own Git mirror of the source (empty PANEL_PREBUILT_URL
# forces a from-source build; needs Go >= 1.26):
PANEL_REPO_URL=https://github.com/you/panel PANEL_PREBUILT_URL= sudo bash install.sh
# (b) Install a prebuilt tarball you host yourself (no Go toolchain needed).
# The tarball must contain bin/{controller,agent,panelctl} + modules/:
PANEL_PREBUILT_URL=https://mirror.example.com/panel-linux-amd64.tar.gz sudo bash install.sh
# (c) Air-gapped: run install.sh from a local checkout of the repo. A local
# checkout (go.work + controller/ present next to install.sh) always wins
# over the git.pdxtechs.com default and builds the tree in place.
sudo bash /path/to/panel/install.sh
```
To produce your own prebuilt tarball for (b), cross-build the three binaries
and `tar czf` them alongside `modules/` — see [Build](#from-source) below and
the release layout `bin/{controller,agent,panelctl}` + `modules/`. On Windows
the same overrides apply to `install.ps1`.
## Windows one-liner
PowerShell 5+. Docker Desktop must be installed and **running** first
(the script checks and refuses otherwise).
PowerShell 5+, run **as Administrator**:
```powershell
powershell -ExecutionPolicy Bypass -File install.ps1 -AdminPassword secret123
irm https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.ps1 | iex
```
**Docker Desktop** is required (it runs Postgres + your game servers). If it
isn't installed, the installer offers to install it for you via `winget`.
Because Docker Desktop needs WSL2/virtualization and usually a **reboot**, the
Windows install is effectively two steps the first time:
1. Run the command above. If Docker is missing it installs Docker Desktop and
then asks you to reboot + start Docker Desktop (wait for the whale icon to
settle).
2. Re-run the exact same command — it picks up from there and finishes.
To skip the prompt: `-InstallDocker` (or `PANEL_INSTALL_DOCKER=1`) auto-installs
Docker without asking; `-SkipDockerInstall` keeps the old fail-if-missing
behavior. On a box that already has Docker Desktop running, it's a genuine
one-liner.
> **Windows note:** the controller runs great on Windows. Running game
> *servers* on Windows uses Docker Desktop, where the agent automatically
> switches modules from host networking to published bridge ports so they're
> reachable — but the smoothest, fully-proven game-hosting path is a Linux
> agent. See [NETWORKING.md](NETWORKING.md).
Parameters (mirror the Linux env vars): `-RepoUrl`, `-PrebuiltUrl`,
`-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080),
`-GrpcPort` (8443), `-AdminPassword`, `-DbUrl`, `-DbPassword`,
`-SkipAgent`. The same `PANEL_*` env vars are honored as defaults.
`-GrpcPort` (8443), `-AdminEmail`, `-AdminPassword`, `-DbUrl`, `-DbPassword`,
`-DbContainer`, `-DbPort`, `-DbVolume`, `-SkipAgent`, `-InstallDocker`,
`-SkipDockerInstall`. The same `PANEL_*` env vars are honored as defaults.
When run in a real console it prompts you to choose the admin email + password
up front (no log-reading); when piped (`irm | iex`) it uses env/param values or
generates a password.
The controller and agent are registered as **logon-time Scheduled
Tasks**. For a hardened always-on Windows service, wrap the same command
@@ -210,6 +264,21 @@ sudo systemctl enable --now panel-controller panel-agent
journalctl -u panel-controller -f # ADMIN BOOTSTRAP banner lives here
```
## Do you even need a reverse proxy?
**Not necessarily.** The panel serves its own dashboard on `:8080` and works
fine with **no proxy at all**:
- **Home / LAN use** — just browse to `http://<your-box-ip>:8080`. Steam
sign-in works too (Steam returns you to that same address). Nothing else
to set up.
- **You just want HTTPS and a domain** — a reverse proxy (Caddy is the
one-file option below) gives you a real cert. Optional, but nice.
You **do** want a proxy the moment the panel is internet-facing (real TLS,
not plain HTTP over the internet). If you add one, read the Steam note below
— a misconfigured proxy is the one thing that quietly breaks Steam login.
## Reverse proxy + real TLS
The dashboard speaks plain HTTP on `:8080`; put a reverse proxy with a
@@ -217,6 +286,18 @@ real certificate in front of it for anything internet-facing. **Only
proxy the HTTP port** — agents talk to gRPC `:8443` directly (mTLS,
LAN/VPN is fine; see [PAIRING.md](PAIRING.md)).
> **Steam login behind a proxy — read this.** When a proxy terminates TLS,
> the controller must know its real public HTTPS URL or Steam's return trip
> lands on the wrong origin and login fails. Do **one** of:
> - start the controller with `--public-url https://panel.example.com`
> (most reliable — set it and forget it), **or**
> - make sure your proxy forwards the `X-Forwarded-Proto` header (the Caddy
> and nginx examples below already do the right thing).
>
> If you skip both, the controller logs a warning at first Steam-login
> attempt telling you exactly this. With **no proxy** (direct IP/LAN), none
> of this applies — Steam login just works.
The dashboard uses SSE (`/api/events`) — disable proxy buffering on
that path or the live console will lag.
+2 -3
View File
@@ -11,8 +11,6 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs=
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
@@ -21,7 +19,8 @@ golang.org/x/exp v0.0.0-20240823005443-9b4947da3948/go.mod h1:akd2r19cwCdwSwWeId
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
+119 -10
View File
@@ -18,30 +18,127 @@
[CmdletBinding()]
param(
[string]$RepoUrl = $(if ($env:PANEL_REPO_URL) { $env:PANEL_REPO_URL } else { 'https://git.pdxtechs.com/dbledeez/panel' }),
[string]$PrebuiltUrl = $env:PANEL_PREBUILT_URL,
# Defaults to the published v0.9.2 windows/amd64 prebuilt zip so the installer
# needs no Go toolchain. Pass -PrebuiltUrl '' (empty) to force a source build.
# A local checkout (running install.ps1 from inside the repo) wins over this.
[string]$PrebuiltUrl = $(if ($null -ne $env:PANEL_PREBUILT_URL) { $env:PANEL_PREBUILT_URL } else { 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.2/panel-v0.9.2-windows-amd64.zip' }),
[string]$Version = $env:PANEL_VERSION,
[string]$InstallDir = $(if ($env:PANEL_DIR) { $env:PANEL_DIR } else { 'C:\Panel' }),
[int] $HttpPort = $(if ($env:PANEL_HTTP_PORT) { [int]$env:PANEL_HTTP_PORT } else { 8080 }),
[int] $GrpcPort = $(if ($env:PANEL_GRPC_PORT) { [int]$env:PANEL_GRPC_PORT } else { 8443 }),
[string]$AdminEmail = $env:PANEL_ADMIN_EMAIL,
[string]$PublicUrl = $env:PANEL_PUBLIC_URL,
[string]$AdminPassword = $env:PANEL_ADMIN_PASSWORD,
[string]$DbUrl = $env:PANEL_DB_URL,
[string]$DbPassword = $env:PANEL_DB_PASSWORD,
[switch]$SkipAgent
[string]$DbContainer = $(if ($env:PANEL_DB_CONTAINER) { $env:PANEL_DB_CONTAINER } else { 'panel-postgres' }),
[int] $DbPort = $(if ($env:PANEL_DB_PORT) { [int]$env:PANEL_DB_PORT } else { 5432 }),
[string]$DbVolume = $(if ($env:PANEL_DB_VOLUME) { $env:PANEL_DB_VOLUME } else { 'panel_pgdata' }),
[switch]$SkipAgent,
# -InstallDocker (or PANEL_INSTALL_DOCKER=1) auto-installs Docker Desktop via
# winget without prompting; -SkipDockerInstall keeps the old fail-if-missing.
[switch]$InstallDocker,
[switch]$SkipDockerInstall
)
$ErrorActionPreference = 'Stop'
$PgContainer = 'panel-postgres'
# Run two independent panels on one host by giving each install distinct
# -InstallDir, -HttpPort, -GrpcPort, -DbContainer, -DbPort and -DbVolume.
$PgContainer = $DbContainer
function Log($msg) { Write-Host "[panel] $msg" -ForegroundColor Green }
function Warn($msg) { Write-Host "[panel] $msg" -ForegroundColor Yellow }
function Fail($msg) { Write-Host "[panel] ERROR: $msg" -ForegroundColor Red; exit 1 }
# The literal one-liner to paste to run this installer again. Used by the
# "you must reboot / start Docker, then run this again" messages so the user
# is never left guessing what "re-run" means.
$RerunCommand = 'irm https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.ps1 | iex'
# Show-RerunAndExit prints a loud, unmissable "not done yet — run this again"
# banner with the exact command, then exits. Windows Docker Desktop needs a
# reboot on first install, so a second run of the installer is expected and
# normal — this makes that crystal clear instead of a terse error.
function Show-RerunAndExit($why) {
Write-Host ''
Write-Host '===============================================================' -ForegroundColor Yellow
Write-Host ' ACTION NEEDED — the panel is not installed yet' -ForegroundColor Yellow
Write-Host '===============================================================' -ForegroundColor Yellow
Write-Host " $why" -ForegroundColor Yellow
Write-Host ''
Write-Host ' Do this, in order:' -ForegroundColor Yellow
Write-Host ' 1. REBOOT if Windows/winget asked you to (Docker Desktop needs' -ForegroundColor Yellow
Write-Host ' WSL2 + virtualization, which require a restart).' -ForegroundColor Yellow
Write-Host ' 2. Start Docker Desktop and wait until the whale icon in the' -ForegroundColor Yellow
Write-Host ' system tray stops animating (daemon ready).' -ForegroundColor Yellow
Write-Host ' 3. >>> RUN THIS INSTALLER AGAIN <<< in an Administrator' -ForegroundColor Green
Write-Host ' PowerShell window. It will pick up from here and finish:' -ForegroundColor Green
Write-Host ''
Write-Host " $RerunCommand" -ForegroundColor Cyan
Write-Host ''
Write-Host ' (Running it again is safe and expected — it is idempotent.)' -ForegroundColor Yellow
Write-Host '===============================================================' -ForegroundColor Yellow
exit 1
}
# ---- 0. Admin account: prompt when interactive -----------------------------
# A real person (interactive console) is asked to choose the admin login +
# password up front. When run non-interactively (irm | iex with no console,
# CI) the prompt is skipped: -AdminPassword / $env:PANEL_ADMIN_PASSWORD is used
# and a missing password is generated by the controller as before.
if (-not $AdminPassword -and [Environment]::UserInteractive -and $Host.UI.RawUI) {
Write-Host ''
Write-Host '-- Set up your panel admin login --' -ForegroundColor Cyan
if (-not $AdminEmail) {
$e = Read-Host 'Admin email [admin@panel.local]'
if ($e) { $AdminEmail = $e }
}
while (-not $AdminPassword) {
$s1 = Read-Host 'Admin password (min 8 chars, blank = auto-generate)' -AsSecureString
$p1 = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s1))
if (-not $p1) { Warn 'no password entered - the controller will generate one and print it.'; break }
if ($p1.Length -lt 8) { Write-Host ' password too short (need 8+); try again.'; continue }
$s2 = Read-Host 'Confirm password' -AsSecureString
$p2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s2))
if ($p1 -ne $p2) { Write-Host ' passwords did not match; try again.'; continue }
$AdminPassword = $p1
}
}
# ---- 1. Docker Desktop -----------------------------------------------------
# The panel needs Docker for Postgres + every game server. Unlike Linux we
# can't fully script Docker Desktop (it's a GUI install + WSL2 + reboot), but
# we CAN kick it off via winget and guide the rest. -InstallDocker / env
# PANEL_INSTALL_DOCKER=1 answers "yes" non-interactively; -SkipDockerInstall
# forces the old fail-fast behavior.
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
$wantDocker = $InstallDocker -or ($env:PANEL_INSTALL_DOCKER -eq '1')
if (-not $wantDocker -and -not $SkipDockerInstall -and [Environment]::UserInteractive -and $Host.UI.RawUI) {
Write-Host ''
Write-Host 'Docker Desktop is required (runs Postgres + your game servers) and is not installed.' -ForegroundColor Yellow
$ans = Read-Host 'Install Docker Desktop now via winget? [Y/n]'
$wantDocker = ($ans -eq '' -or $ans -match '^[Yy]')
}
if ($wantDocker) {
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
Fail @"
winget isn't available to auto-install Docker. Install Docker Desktop manually:
https://www.docker.com/products/docker-desktop/
Then start it (wait for the whale icon to settle) and re-run this installer.
"@
}
Log "installing Docker Desktop via winget (this is large; a reboot is usually required)"
winget install --id Docker.DockerDesktop -e --accept-source-agreements --accept-package-agreements
if ($LASTEXITCODE -ne 0) { Fail "winget install of Docker Desktop failed (exit $LASTEXITCODE). Install it manually, then re-run." }
Show-RerunAndExit "Docker Desktop was just installed — the panel is NOT installed yet."
} else {
Fail "Docker not found. Install Docker Desktop (https://www.docker.com/products/docker-desktop/), start it, then re-run."
}
}
docker info *> $null
if ($LASTEXITCODE -ne 0) {
Fail "Docker daemon is not running. Start Docker Desktop and re-run."
Show-RerunAndExit "Docker is installed but its daemon isn't running yet — the panel is NOT installed yet."
}
Log "docker present: $(docker --version)"
@@ -65,15 +162,15 @@ if ($DbUrl) {
if (-not $DbPassword) {
$DbPassword = -join ((1..32) | ForEach-Object { '{0:x}' -f (Get-Random -Maximum 16) })
}
Log "starting postgres container '$PgContainer'"
Log "starting postgres container '$PgContainer' on 127.0.0.1:$DbPort (volume $DbVolume)"
docker run -d --name $PgContainer --restart unless-stopped `
-e POSTGRES_USER=panel -e "POSTGRES_PASSWORD=$DbPassword" -e POSTGRES_DB=panel `
-p 127.0.0.1:5432:5432 `
-v panel_pgdata:/var/lib/postgresql/data `
-p "127.0.0.1:${DbPort}:5432" `
-v "${DbVolume}:/var/lib/postgresql/data" `
postgres:17-alpine | Out-Null
if ($LASTEXITCODE -ne 0) { Fail "docker run for postgres failed" }
}
$DbUrl = "postgres://panel:$DbPassword@127.0.0.1:5432/panel?sslmode=disable"
$DbUrl = "postgres://panel:$DbPassword@127.0.0.1:$DbPort/panel?sslmode=disable"
Log "waiting for postgres"
$ready = $false
foreach ($i in 1..30) {
@@ -90,6 +187,13 @@ $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
if ($ScriptDir -and (Test-Path (Join-Path $ScriptDir 'go.work')) -and (Test-Path (Join-Path $ScriptDir 'controller'))) {
$Src = $ScriptDir
Log "using local checkout at $Src"
# Running from inside the repo means build THIS tree. Don't let the prebuilt
# default override it. An explicit -PrebuiltUrl or $env:PANEL_PREBUILT_URL still
# forces prebuilt (it won't equal the untouched default / the env is non-null).
$prebuiltDefault = 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.2/panel-v0.9.2-windows-amd64.zip'
if (($null -eq $env:PANEL_PREBUILT_URL) -and ($PrebuiltUrl -eq $prebuiltDefault)) {
$PrebuiltUrl = ''
}
}
if ($PrebuiltUrl) {
@@ -136,6 +240,8 @@ if ($PrebuiltUrl) {
# ---- 4. Env file ------------------------------------------------------------
$envLines = @("PANEL_DB_URL=$DbUrl")
if ($DbPassword) { $envLines += "PANEL_DB_PASSWORD=$DbPassword" }
if ($AdminEmail) { $envLines += "PANEL_ADMIN_EMAIL=$AdminEmail" }
if ($PublicUrl) { $envLines += "PANEL_PUBLIC_URL=$PublicUrl" }
if ($AdminPassword) { $envLines += "PANEL_ADMIN_PASSWORD=$AdminPassword" }
$envLines += "PANEL_HTTP_PORT=$HttpPort"
$envLines += "PANEL_GRPC_PORT=$GrpcPort"
@@ -150,6 +256,8 @@ Get-Process -Name controller, agent -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like "$InstallDir*" } | Stop-Process -Force -ErrorAction SilentlyContinue
$env:PANEL_DB_URL = $DbUrl
if ($AdminEmail) { $env:PANEL_ADMIN_EMAIL = $AdminEmail }
if ($PublicUrl) { $env:PANEL_PUBLIC_URL = $PublicUrl }
if ($AdminPassword) { $env:PANEL_ADMIN_PASSWORD = $AdminPassword }
Log "starting controller"
Start-Process -FilePath "$InstallDir\bin\controller.exe" `
@@ -166,8 +274,9 @@ foreach ($i in 1..60) {
}
if (-not $up) { Fail "controller did not come up - see $ctrlLog" }
$adminEmail = 'admin@panel.local'
$adminShown = '(set via -AdminPassword)'
$adminEmail = if ($AdminEmail) { $AdminEmail } else { 'admin@panel.local' }
# BUG-5b: always give the operator a path to the password.
$adminShown = "(the one you set via -AdminPassword; also in $EnvFile)"
if (-not $AdminPassword) {
if ($firstBoot) {
$m = Select-String -Path $ctrlLog -Pattern 'password: ([a-f0-9]{16})' | Select-Object -First 1
+263 -19
View File
@@ -6,6 +6,8 @@
# Non-interactive knobs (env vars, all optional):
# PANEL_REPO_URL git/https base of the panel source (default: placeholder, see below)
# PANEL_PREBUILT_URL tarball with prebuilt bin/{controller,agent,panelctl} + modules/ (skips Go build)
# (default: the published v0.9.2 linux/amd64 release — so
# curl|bash needs no Go. Set it EMPTY to force a source build.)
# PANEL_VERSION version string baked into the binaries (default: git describe || dev)
# PANEL_DIR install prefix (default: /opt/panel)
# PANEL_DATA_DIR data directory (default: $PANEL_DIR/data)
@@ -13,25 +15,71 @@
# PANEL_GRPC_PORT agent gRPC port (default: 8443)
# PANEL_ADMIN_PASSWORD stable first-boot admin password (default: generated by the controller)
# PANEL_DB_URL use an EXISTING Postgres instead of the managed container
# PANEL_DB_PASSWORD password for the managed panel-postgres container (default: generated)
# PANEL_DB_PASSWORD password for the managed Postgres container (default: generated)
# PANEL_DB_CONTAINER name of the managed Postgres container (default: panel-postgres)
# PANEL_DB_PORT host port to bind Postgres on (127.0.0.1) (default: 5432)
# PANEL_DB_VOLUME docker volume for Postgres data (default: panel_pgdata)
# PANEL_SKIP_AGENT=1 install controller only (add agents on other boxes later)
#
# Run TWO independent panels on one host by giving each install distinct
# PANEL_DIR, PANEL_HTTP_PORT, PANEL_GRPC_PORT, PANEL_DB_CONTAINER,
# PANEL_DB_PORT and PANEL_DB_VOLUME values so nothing collides.
#
# Idempotent: safe to re-run — existing containers, units, CA, DB and
# passwords are kept; binaries and modules are refreshed.
set -euo pipefail
PANEL_REPO_URL="${PANEL_REPO_URL:-https://git.pdxtechs.com/dbledeez/panel}"
PANEL_PREBUILT_URL="${PANEL_PREBUILT_URL:-}"
# Default to the published prebuilt release so `curl … | sudo bash` works with no
# Go toolchain and no env. Explicitly set PANEL_PREBUILT_URL= (empty) to force the
# git-clone + Go source-build fallback instead. A local checkout (running
# ./install.sh from inside the repo) always wins over this default — see §4.
# Multi-arch: map the running machine's arch to the release naming (…-linux-<arch>).
# Only linux/amd64 has a published prebuilt release asset today — so on any other
# arch we DON'T point at a nonexistent tarball. We leave PANEL_PREBUILT_DEFAULT
# empty for non-amd64, which makes the default resolve to a source build (needs Go).
PANEL_ARCH_RAW="$(uname -m 2>/dev/null || echo unknown)"
case "$PANEL_ARCH_RAW" in
x86_64|amd64) PANEL_ARCH=amd64 ;;
aarch64|arm64) PANEL_ARCH=arm64 ;;
*) PANEL_ARCH="$PANEL_ARCH_RAW" ;;
esac
if [ "$PANEL_ARCH" = amd64 ]; then
PANEL_PREBUILT_DEFAULT="https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.2/panel-v0.9.2-linux-amd64.tar.gz"
else
# No prebuilt release asset exists for $PANEL_ARCH yet. Falling back to a
# source build (requires Go) rather than fetching the wrong-arch amd64 binary.
PANEL_PREBUILT_DEFAULT=""
fi
# Was the var set in the environment at all (even to empty)? Distinguishes an
# operator who explicitly wants prebuilt from the plain default.
if [ "${PANEL_PREBUILT_URL+set}" = set ]; then PANEL_PREBUILT_URL_EXPLICIT=1; else PANEL_PREBUILT_URL_EXPLICIT=""; fi
PANEL_PREBUILT_URL="${PANEL_PREBUILT_URL-$PANEL_PREBUILT_DEFAULT}"
PANEL_DIR="${PANEL_DIR:-/opt/panel}"
PANEL_DATA_DIR="${PANEL_DATA_DIR:-$PANEL_DIR/data}"
PANEL_HTTP_PORT="${PANEL_HTTP_PORT:-8080}"
PANEL_GRPC_PORT="${PANEL_GRPC_PORT:-8443}"
PANEL_ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-}"
PANEL_ADMIN_PASSWORD="${PANEL_ADMIN_PASSWORD:-}"
# Public HTTPS URL when behind a reverse proxy — required for Steam login to
# work through the proxy (e.g. https://panel.example.com). Leave empty for
# direct LAN/IP access.
PANEL_PUBLIC_URL="${PANEL_PUBLIC_URL:-}"
PANEL_DB_URL="${PANEL_DB_URL:-}"
PANEL_DB_PASSWORD="${PANEL_DB_PASSWORD:-}"
PANEL_DB_CONTAINER="${PANEL_DB_CONTAINER:-panel-postgres}"
PANEL_DB_PORT="${PANEL_DB_PORT:-5432}"
PANEL_DB_VOLUME="${PANEL_DB_VOLUME:-panel_pgdata}"
PANEL_SKIP_AGENT="${PANEL_SKIP_AGENT:-0}"
PANEL_USER=panel
PG_CONTAINER=panel-postgres
PG_CONTAINER="$PANEL_DB_CONTAINER"
# Legacy escape hatch (kept for compatibility): a release asset tried only when
# no prebuilt URL, no local checkout and no Go are available. Now that
# PANEL_PREBUILT_URL defaults to the published release above, this is rarely hit —
# it only matters if someone explicitly emptied PANEL_PREBUILT_URL but still has
# no Go and no checkout.
PANEL_RELEASE_TARBALL_URL="${PANEL_RELEASE_TARBALL_URL:-$PANEL_PREBUILT_DEFAULT}"
log() { printf '\033[1;32m[panel]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[panel]\033[0m %s\n' "$*" >&2; }
@@ -40,6 +88,89 @@ die() { printf '\033[1;31m[panel]\033[0m ERROR: %s\n' "$*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root (sudo bash install.sh)"
command -v curl >/dev/null || die "curl is required"
# ---- Arch note -----------------------------------------------------------
if [ "$PANEL_ARCH" = amd64 ]; then
log "arch: $PANEL_ARCH_RAW ($PANEL_ARCH) — using prebuilt release by default"
elif [ -n "$PANEL_PREBUILT_URL_EXPLICIT" ] && [ -n "$PANEL_PREBUILT_URL" ]; then
log "arch: $PANEL_ARCH_RAW ($PANEL_ARCH) — using operator-supplied PANEL_PREBUILT_URL"
else
log "arch: $PANEL_ARCH_RAW ($PANEL_ARCH) — no prebuilt for $PANEL_ARCH, building from source (needs Go)"
fi
# ---- Port pre-flight -----------------------------------------------------
# Fail early and loudly if any port we're about to bind is already taken,
# instead of letting the controller or the Postgres container crash mid-install
# with an opaque "address already in use". Uses whichever probe tool exists.
port_in_use() {
# $1 = port. Returns 0 (true) if something is LISTENing on it.
_p="$1"
if command -v ss >/dev/null 2>&1; then
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${_p}\$"
elif command -v netstat >/dev/null 2>&1; then
netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${_p}\$"
elif command -v lsof >/dev/null 2>&1; then
lsof -iTCP:"${_p}" -sTCP:LISTEN -Pn >/dev/null 2>&1
else
warn "no ss/netstat/lsof available — skipping port pre-flight for :${_p}"
return 1
fi
}
_preflight_port() {
# $1 = port, $2 = human label, $3 = env var name to override
if port_in_use "$1"; then
die "port $1 ($2) is already in use.
Free it, or choose another with: $3=<port> (re-run the installer).
e.g. $3=$(( $1 + 1 )) sudo bash install.sh"
fi
}
_preflight_port "$PANEL_HTTP_PORT" "dashboard HTTP" "PANEL_HTTP_PORT"
_preflight_port "$PANEL_GRPC_PORT" "agent gRPC" "PANEL_GRPC_PORT"
# The managed Postgres binds 127.0.0.1:$PANEL_DB_PORT. Skip this check when an
# external DB is supplied (we don't start a container then) OR when the managed
# container already exists (a re-run legitimately re-binds its own port).
if [ -z "$PANEL_DB_URL" ] && ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
_preflight_port "$PANEL_DB_PORT" "Postgres" "PANEL_DB_PORT"
fi
# ---- 0. Admin account: prompt when interactive ---------------------------
# When a real person runs this (a TTY is available), ask them to choose the
# admin login + password up front — no digging a generated password out of
# the log afterwards. When piped non-interactively (curl | bash with no TTY,
# CI, cloud-init) we skip the prompt: any PANEL_ADMIN_* env values are used,
# and a missing password is generated by the controller as before.
if [ -z "$PANEL_ADMIN_PASSWORD" ] && [ -r /dev/tty ] && { [ -t 0 ] || [ -t 1 ]; }; then
printf '\n\033[1;36m── Set up your panel admin login ──\033[0m\n' > /dev/tty
if [ -z "$PANEL_ADMIN_EMAIL" ]; then
printf 'Admin email [admin@panel.local]: ' > /dev/tty
read -r _in_email < /dev/tty || _in_email=""
PANEL_ADMIN_EMAIL="${_in_email:-}"
fi
while [ -z "$PANEL_ADMIN_PASSWORD" ]; do
printf 'Admin password (min 8 chars, blank = auto-generate): ' > /dev/tty
stty -echo 2>/dev/null < /dev/tty
read -r _pw1 < /dev/tty || _pw1=""
stty echo 2>/dev/null < /dev/tty; printf '\n' > /dev/tty
if [ -z "$_pw1" ]; then
warn "no password entered — the controller will generate one and print it."
break
fi
if [ "${#_pw1}" -lt 8 ]; then
printf ' password too short (need 8+); try again.\n' > /dev/tty
continue
fi
printf 'Confirm password: ' > /dev/tty
stty -echo 2>/dev/null < /dev/tty
read -r _pw2 < /dev/tty || _pw2=""
stty echo 2>/dev/null < /dev/tty; printf '\n' > /dev/tty
if [ "$_pw1" != "$_pw2" ]; then
printf ' passwords did not match; try again.\n' > /dev/tty
continue
fi
PANEL_ADMIN_PASSWORD="$_pw1"
done
unset _in_email _pw1 _pw2
fi
# ---- 1. Distro detection -------------------------------------------------
DISTRO=unknown
if [ -r /etc/os-release ]; then
@@ -86,14 +217,14 @@ else
if [ -z "$PANEL_DB_PASSWORD" ]; then
PANEL_DB_PASSWORD="$(head -c 24 /dev/urandom | od -An -tx1 | tr -d ' \n')"
fi
log "starting postgres container '$PG_CONTAINER'"
log "starting postgres container '$PG_CONTAINER' on 127.0.0.1:$PANEL_DB_PORT (volume $PANEL_DB_VOLUME)"
docker run -d --name "$PG_CONTAINER" --restart unless-stopped \
-e POSTGRES_USER=panel -e POSTGRES_PASSWORD="$PANEL_DB_PASSWORD" -e POSTGRES_DB=panel \
-p 127.0.0.1:5432:5432 \
-v panel_pgdata:/var/lib/postgresql/data \
-p "127.0.0.1:$PANEL_DB_PORT:5432" \
-v "$PANEL_DB_VOLUME:/var/lib/postgresql/data" \
postgres:17-alpine >/dev/null
fi
PANEL_DB_URL="postgres://panel:${PANEL_DB_PASSWORD}@127.0.0.1:5432/panel?sslmode=disable"
PANEL_DB_URL="postgres://panel:${PANEL_DB_PASSWORD}@127.0.0.1:$PANEL_DB_PORT/panel?sslmode=disable"
log "waiting for postgres to accept connections"
for _ in $(seq 1 30); do
if docker exec "$PG_CONTAINER" pg_isready -U panel -d panel >/dev/null 2>&1; then break; fi
@@ -108,6 +239,20 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" >/dev/null 2>&1 && pwd ||
if [ -n "$SCRIPT_DIR" ] && [ -f "$SCRIPT_DIR/go.work" ] && [ -d "$SCRIPT_DIR/controller" ]; then
SRC="$SCRIPT_DIR"
log "using local checkout at $SRC"
# A local checkout means the operator wants to build THIS tree. Don't silently
# override it with the prebuilt release default. (An explicit PANEL_PREBUILT_URL
# env still forces prebuilt — it survives because it was exported, not defaulted.)
if [ -z "${PANEL_PREBUILT_URL_EXPLICIT:-}" ] && [ "$PANEL_PREBUILT_URL" = "$PANEL_PREBUILT_DEFAULT" ]; then
PANEL_PREBUILT_URL=""
fi
fi
# If no prebuilt URL, no local checkout and no Go, but a release tarball URL is
# known, auto-fall-back to the published release so the one-liner still works.
if [ -z "$PANEL_PREBUILT_URL" ] && [ -z "$SRC" ] \
&& ! command -v go >/dev/null 2>&1 && [ -n "$PANEL_RELEASE_TARBALL_URL" ]; then
log "no Go toolchain — falling back to the published release tarball"
PANEL_PREBUILT_URL="$PANEL_RELEASE_TARBALL_URL"
fi
if [ -n "$PANEL_PREBUILT_URL" ]; then
@@ -121,6 +266,10 @@ else
if [ -z "$SRC" ]; then
command -v git >/dev/null || die "git is required to fetch sources (or set PANEL_PREBUILT_URL)"
SRC="$PANEL_DIR/src"
# BUG-4: the src tree is cloned as root then chowned to $PANEL_USER; on a
# re-run git would refuse with "detected dubious ownership". Mark it safe
# for whatever user runs git (root here) so re-runs are idempotent.
git config --global --add safe.directory "$SRC" 2>/dev/null || true
if [ -d "$SRC/.git" ]; then
log "updating source checkout at $SRC"
git -C "$SRC" pull --ff-only
@@ -129,7 +278,12 @@ else
git clone --depth 1 "$PANEL_REPO_URL" "$SRC"
fi
fi
command -v go >/dev/null || die "Go toolchain not found. Install Go >= 1.26 (https://go.dev/dl/) or set PANEL_PREBUILT_URL."
if ! command -v go >/dev/null 2>&1; then
die "Go toolchain not found. The one-line installer normally uses prebuilt
binaries; falling back to a source build requires Go >= 1.26 (https://go.dev/dl/).
Alternatively set PANEL_PREBUILT_URL to a prebuilt tarball, or
PANEL_RELEASE_TARBALL_URL to a published release asset."
fi
VERSION="${PANEL_VERSION:-$(git -C "$SRC" describe --tags --always --dirty 2>/dev/null || echo dev)}"
LDFLAGS="-s -w -X github.com/dbledeez/panel/pkg/version.Version=$VERSION"
log "building panel $VERSION"
@@ -154,7 +308,9 @@ umask 077
{
echo "PANEL_DB_URL=$PANEL_DB_URL"
[ -n "$PANEL_DB_PASSWORD" ] && echo "PANEL_DB_PASSWORD=$PANEL_DB_PASSWORD"
[ -n "$PANEL_ADMIN_EMAIL" ] && echo "PANEL_ADMIN_EMAIL=$PANEL_ADMIN_EMAIL"
[ -n "$PANEL_ADMIN_PASSWORD" ] && echo "PANEL_ADMIN_PASSWORD=$PANEL_ADMIN_PASSWORD"
[ -n "$PANEL_PUBLIC_URL" ] && echo "PANEL_PUBLIC_URL=$PANEL_PUBLIC_URL"
echo "PANEL_HTTP_PORT=$PANEL_HTTP_PORT"
echo "PANEL_GRPC_PORT=$PANEL_GRPC_PORT"
} > "$ENV_FILE"
@@ -162,15 +318,101 @@ umask 022
chown -R "$PANEL_USER:$PANEL_USER" "$PANEL_DIR"
chmod 600 "$ENV_FILE"
if [ -n "$SRC" ] && [ -d "$SRC/deploy/systemd" ]; then
install -m 0644 "$SRC/deploy/systemd/panel-controller.service" \
"$SRC/deploy/systemd/panel-agent.service" /etc/systemd/system/
else
# Prebuilt path: units are expected inside the tarball at deploy/systemd.
[ -f "$PANEL_DIR/deploy/systemd/panel-controller.service" ] || die "no systemd units found (deploy/systemd missing)"
install -m 0644 "$PANEL_DIR/deploy/systemd/panel-controller.service" \
"$PANEL_DIR/deploy/systemd/panel-agent.service" /etc/systemd/system/
fi
# BUG-2/3/5: do NOT install deploy/systemd/*.service verbatim — those hardcode
# /opt/panel, ProtectHome=true and stale :8080/:8443 in the Description. Instead
# GENERATE both units here, substituting the REAL install parameters.
#
# BUG-3: systemd ProtectHome=true makes any binary under /home unreadable to the
# service (203/EXEC). When PANEL_DIR is under /home we emit the unit WITHOUT
# ProtectHome so it actually runs (reduced hardening, noted inline). The default
# /opt/panel keeps ProtectHome=true.
case "$PANEL_DIR/" in
/home/*) PROTECT_HOME=""; log "PANEL_DIR is under /home — generating units without ProtectHome (reduced hardening)";;
*) PROTECT_HOME="ProtectHome=true";;
esac
write_unit() {
# $1 = destination path
cat > "$1"
chmod 0644 "$1"
}
write_unit /etc/systemd/system/panel-controller.service <<EOF
[Unit]
Description=Panel controller (HTTP dashboard :${PANEL_HTTP_PORT} + agent gRPC :${PANEL_GRPC_PORT})
Documentation=https://github.com/dbledeez/panel
After=network-online.target docker.service
Wants=network-online.target
[Service]
Type=simple
User=${PANEL_USER}
Group=${PANEL_USER}
WorkingDirectory=${PANEL_DIR}
Environment=PANEL_HTTP_PORT=${PANEL_HTTP_PORT}
Environment=PANEL_GRPC_PORT=${PANEL_GRPC_PORT}
EnvironmentFile=-${ENV_FILE}
ExecStart=${PANEL_DIR}/bin/controller \\
-http :\${PANEL_HTTP_PORT} \\
-grpc :\${PANEL_GRPC_PORT} \\
-ca-dir ./data/ca
Restart=on-failure
RestartSec=3
# Hardening — the controller only needs its own tree + the network.
NoNewPrivileges=true
ProtectSystem=strict
${PROTECT_HOME}
ReadWritePaths=${PANEL_DIR}
PrivateTmp=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
EOF
write_unit /etc/systemd/system/panel-agent.service <<EOF
[Unit]
Description=Panel agent (Docker runtime for game-server instances)
Documentation=https://github.com/dbledeez/panel
After=network-online.target docker.service panel-controller.service
Wants=network-online.target
Requires=docker.service
[Service]
Type=simple
User=${PANEL_USER}
Group=${PANEL_USER}
SupplementaryGroups=docker
WorkingDirectory=${PANEL_DIR}
Environment=PANEL_GRPC_PORT=${PANEL_GRPC_PORT}
EnvironmentFile=-${ENV_FILE}
ExecStart=${PANEL_DIR}/bin/agent \\
--controller localhost:\${PANEL_GRPC_PORT} \\
--modules-dir ./modules \\
--data-root ./data/instances \\
--meta-dir ./data/instance-meta \\
--backup-dir ./data/backups \\
--cert-dir ./data/certs
Restart=on-failure
RestartSec=3
# Hardening is intentionally lighter than the controller's: the agent manages
# Docker containers and bind-mounts instance data.
NoNewPrivileges=true
${PROTECT_HOME}
ReadWritePaths=${PANEL_DIR}
PrivateTmp=true
LockPersonality=true
[Install]
WantedBy=multi-user.target
EOF
systemctl daemon-reload
# ---- 6. Controller first boot (CA + admin bootstrap) ----------------------
@@ -191,8 +433,10 @@ for _ in $(seq 1 60); do
done
curl -fsS "http://127.0.0.1:$PANEL_HTTP_PORT/login" >/dev/null 2>&1 || die "controller did not come up — check: journalctl -u panel-controller"
ADMIN_EMAIL="admin@panel.local"
ADMIN_PW_SHOWN="(set via PANEL_ADMIN_PASSWORD)"
ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-admin@panel.local}"
# BUG-5b: always give the operator a path to the password. If they supplied/chose
# one, say so and point at the env file; otherwise scrape the generated one below.
ADMIN_PW_SHOWN="(the password you set at install; also in $ENV_FILE)"
if [ -z "$PANEL_ADMIN_PASSWORD" ]; then
if [ "$FIRST_BOOT" = 1 ]; then
# The controller prints the generated password once in its log.