1 Commits

Author SHA1 Message Date
dbledeez a00bd620a1 Panel — public source drop (v0.9.0)
Self-hostable game-server control panel: controller + agent + 26 game
modules. One-line install (prebuilt release, no Go required):

  curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 21:17:39 -07:00
17 changed files with 36 additions and 942 deletions
-17
View File
@@ -33,23 +33,6 @@ credentials printed in the controller log (or pre-seed via
`PANEL_ADMIN_PASSWORD`). Full options, docker-compose, and from-source `PANEL_ADMIN_PASSWORD`). Full options, docker-compose, and from-source
paths: [docs/INSTALL.md](docs/INSTALL.md). 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 │ │ Operator │
+5 -58
View File
@@ -35,13 +35,6 @@ import (
// Container IDs (the string returned by Create) are Docker's full container IDs. // Container IDs (the string returned by Create) are Docker's full container IDs.
type DockerRuntime struct { type DockerRuntime struct {
cli *client.Client 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 // NewDocker connects to the local Docker daemon using environment-configured
@@ -51,52 +44,7 @@ func NewDocker() (*DockerRuntime, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("docker client: %w", err) return nil, fmt.Errorf("docker client: %w", err)
} }
r := &DockerRuntime{cli: cli} return &DockerRuntime{cli: cli}, nil
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. // Close releases daemon resources.
@@ -137,10 +85,9 @@ func (r *DockerRuntime) Create(ctx context.Context, spec InstanceSpec) (string,
// Docker rejects PortBindings + ExposedPorts when NetworkMode=host — // Docker rejects PortBindings + ExposedPorts when NetworkMode=host —
// the container shares the host's network namespace so publish-port // the container shares the host's network namespace so publish-port
// plumbing is meaningless there. // plumbing is meaningless there.
var netMode string if spec.NetworkMode == "host" {
netMode, exposed, bindings = applyHostNetPolicy(r, spec.NetworkMode, exposed, bindings) bindings = nil
if spec.NetworkMode == "host" && netMode != "host" && len(bindings) > 0 && spec.LogSink != nil { exposed = 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)) envSlice := make([]string, 0, len(spec.Env))
@@ -197,7 +144,7 @@ func (r *DockerRuntime) Create(ctx context.Context, spec InstanceSpec) (string,
hostCfg := &container.HostConfig{ hostCfg := &container.HostConfig{
Mounts: mounts, Mounts: mounts,
PortBindings: bindings, PortBindings: bindings,
NetworkMode: container.NetworkMode(netMode), NetworkMode: container.NetworkMode(spec.NetworkMode),
SecurityOpt: spec.SecurityOpts, SecurityOpt: spec.SecurityOpts,
RestartPolicy: container.RestartPolicy{ RestartPolicy: container.RestartPolicy{
Name: container.RestartPolicyMode(restart), Name: container.RestartPolicyMode(restart),
@@ -1,67 +0,0 @@
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))
}
}
+2 -23
View File
@@ -133,40 +133,19 @@ func (h *httpServer) requireSessionOrAdminToken(isAPI bool, next http.Handler) h
} }
want := []byte(h.adminToken) want := []byte(h.adminToken)
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// 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) { if isLoopback(r.RemoteAddr) {
admit() next.ServeHTTP(w, r)
return return
} }
if got := r.Header.Get(adminTokenHeader); got != "" && if got := r.Header.Get(adminTokenHeader); got != "" &&
subtle.ConstantTimeCompare([]byte(got), want) == 1 { subtle.ConstantTimeCompare([]byte(got), want) == 1 {
admit() next.ServeHTTP(w, r)
return return
} }
sessionGate.ServeHTTP(w, r) 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 // registerAdminRoutes wires the admin mux onto the outer mux. Called
// from httpServer.handler() during construction. // from httpServer.handler() during construction.
func (h *httpServer) registerAdminRoutes(mux *http.ServeMux, token string) { func (h *httpServer) registerAdminRoutes(mux *http.ServeMux, token string) {
+3 -31
View File
@@ -12,9 +12,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings" "strings"
"sync"
"time" "time"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
@@ -48,8 +46,6 @@ type auth struct {
log *slog.Logger log *slog.Logger
db *db.DB db *db.DB
publicURLOverride string // set from --public-url flag; used by Steam OpenID realm/return_to 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) ---- // ---- Password hashing (argon2id, PHC format) ----
@@ -135,14 +131,7 @@ func clientIP(r *http.Request) string {
// we retro-link the first admin to this Steam ID. Useful when the // we retro-link the first admin to this Steam ID. Useful when the
// operator is adding Steam login to an already-running panel. // operator is adding Steam login to an already-running panel.
// - Ignored if the first admin already has a steam_id (don't clobber). // - Ignored if the first admin already has a steam_id (don't clobber).
// bootstrapAdmin creates the first admin. envEmail (from PANEL_ADMIN_EMAIL) func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envPassword, initialSteamID string) error {
// 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) initialSteamID = strings.TrimSpace(initialSteamID)
if initialSteamID != "" && !steamID64RE.MatchString(initialSteamID) { if initialSteamID != "" && !steamID64RE.MatchString(initialSteamID) {
log.Warn("--initial-admin-steam-id isn't a 17-digit SteamID64, ignoring", "value", initialSteamID) log.Warn("--initial-admin-steam-id isn't a 17-digit SteamID64, ignoring", "value", initialSteamID)
@@ -194,7 +183,7 @@ func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envE
if err != nil { if err != nil {
return err return err
} }
userID, err := database.CreateUser(ctx, adminEmail, hash, "admin") userID, err := database.CreateUser(ctx, "admin@panel.local", hash, "admin")
if err != nil { if err != nil {
return err return err
} }
@@ -206,7 +195,7 @@ func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envE
const banner = "═══════════════════════════════════════════════════════════════════════════" const banner = "═══════════════════════════════════════════════════════════════════════════"
log.Warn(banner) log.Warn(banner)
log.Warn("ADMIN BOOTSTRAP — no users in DB, created default account") log.Warn("ADMIN BOOTSTRAP — no users in DB, created default account")
log.Warn(" email: " + adminEmail) log.Warn(" email: admin@panel.local")
if generated { if generated {
log.Warn(" password: " + password) log.Warn(" password: " + password)
log.Warn(" ^^^ log in once and change it via the UI ^^^") log.Warn(" ^^^ log in once and change it via the UI ^^^")
@@ -279,24 +268,11 @@ func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
return 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) u, err := a.db.GetUserByEmail(r.Context(), req.Email)
if err != nil { if err != nil {
if errors.Is(err, db.ErrUserNotFound) { if errors.Is(err, db.ErrUserNotFound) {
// Still do a bogus verify so login timing doesn't leak user enumeration. // 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") _, _ = 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") writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
return return
} }
@@ -304,7 +280,6 @@ func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
if u.Disabled { if u.Disabled {
a.loginLimiter.RecordFailure(ip, req.Email)
writeError(w, http.StatusUnauthorized, "disabled", "account is disabled") writeError(w, http.StatusUnauthorized, "disabled", "account is disabled")
return return
} }
@@ -314,12 +289,9 @@ func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
return return
} }
if !ok { if !ok {
a.loginLimiter.RecordFailure(ip, req.Email)
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect") writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
return return
} }
// Successful auth — clear any accumulated failure counters.
a.loginLimiter.Reset(ip, req.Email)
token, err := newSessionToken() token, err := newSessionToken()
if err != nil { if err != nil {
writeError(w, http.StatusInternalServerError, "token", err.Error()) writeError(w, http.StatusInternalServerError, "token", err.Error())
-179
View File
@@ -1,179 +0,0 @@
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
}
@@ -1,144 +0,0 @@
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)
}
}
}
+2 -33
View File
@@ -1398,7 +1398,7 @@ func main() {
// Bootstrap the admin account on first run. The generated password is // Bootstrap the admin account on first run. The generated password is
// printed to the log so the operator can grab it immediately. // printed to the log so the operator can grab it immediately.
if err := bootstrapAdmin(ctx, logger, database, os.Getenv("PANEL_ADMIN_EMAIL"), os.Getenv("PANEL_ADMIN_PASSWORD"), *initialAdminSteamID); err != nil { if err := bootstrapAdmin(ctx, logger, database, os.Getenv("PANEL_ADMIN_PASSWORD"), *initialAdminSteamID); err != nil {
logger.Error("admin bootstrap", "err", err) logger.Error("admin bootstrap", "err", err)
os.Exit(1) os.Exit(1)
} }
@@ -1408,20 +1408,7 @@ func main() {
logger.Warn("empyrion kit seed", "err", err) logger.Warn("empyrion kit seed", "err", err)
} }
// Flag wins; else fall back to PANEL_PUBLIC_URL (so the installer/env file authSvc := &auth{log: logger, db: database, publicURLOverride: *publicURL}
// 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} pairSvc := &pairService{ca: caBundle, db: database}
httpSrvHandler := &httpServer{ httpSrvHandler := &httpServer{
@@ -1456,24 +1443,6 @@ func main() {
"http", *httpAddr, "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() { go func() {
<-ctx.Done() <-ctx.Done()
logger.Info("shutdown signal received, stopping gracefully") logger.Info("shutdown signal received, stopping gracefully")
+1 -14
View File
@@ -125,24 +125,11 @@ func (p *pairService) createToken(w http.ResponseWriter, r *http.Request) {
return return
} }
id := "pt_" + randHex(8) 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{ if err := p.db.CreatePairToken(r.Context(), db.PairTokenRow{
ID: id, ID: id,
TokenHash: sha256Hex(raw), TokenHash: sha256Hex(raw),
Description: req.Description, Description: req.Description,
CreatedBy: createdBy, CreatedBy: user.ID,
ExpiresAt: time.Now().Add(ttl), ExpiresAt: time.Now().Add(ttl),
}); err != nil { }); err != nil {
writeError(w, http.StatusInternalServerError, "db", err.Error()) 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:'Ban…', onClick: () => triggerEahPlayerAct('ban', p.entityId) },
{ icon:'✅', label:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) }, { icon:'✅', label:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) },
{ divider:true }, { divider:true },
{ icon:'🎒', label:'View inventory', disabled:true, title:'Not available yet — inspect the player row for now.', onClick: () => triggerEahPlayerAct('inv', p.entityId) }, { icon:'🎒', label:'View inventory', disabled:stub, onClick: () => triggerEahPlayerAct('inv', p.entityId) },
{ icon:'🎁', label:'Drop kit…', disabled:stub, onClick: () => triggerEahPlayerAct('kit', 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:'Give item…', disabled:stub, onClick: () => triggerEahPlayerAct('give', p.entityId) },
{ icon:'💰', label:'+1k credits', disabled:stub, onClick: () => triggerEahPlayerAct('credits', 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 }) }) 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')); .then(()=>toast('Teleported','ok')).catch(e=>toast(e.message,'err'));
} }
if (act === 'inv') return toast('Inventory view is not available yet — inspect the player row for now.', 'warn'); if (act === 'inv') return alert('Inventory view: open in modal — TODO; for now use the existing player row.');
// Fallthrough — replay through eahPlayerAction. // Fallthrough — replay through eahPlayerAction.
const fakeBtn = document.createElement('button'); const fakeBtn = document.createElement('button');
fakeBtn.dataset.act = act; fakeBtn.dataset.pid = pid; 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:'Ban…', onClick: () => triggerEahPlayerAct('ban', p.entityId) },
{ icon:'✅', label:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) }, { icon:'✅', label:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) },
{ divider:true }, { divider:true },
{ icon:'🎒', label:'View inventory', disabled:true, title:'Not available yet — inspect the player row for now.', onClick: () => triggerEahPlayerAct('inv', p.entityId) }, { icon:'🎒', label:'View inventory', disabled:stub, onClick: () => triggerEahPlayerAct('inv', p.entityId) },
{ icon:'🎁', label:'Drop kit…', disabled:stub, onClick: () => triggerEahPlayerAct('kit', 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:'Give item…', disabled:stub, onClick: () => triggerEahPlayerAct('give', p.entityId) },
{ icon:'💰', label:'+1k credits', disabled:stub, onClick: () => triggerEahPlayerAct('credits', 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 }) }) 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')); .then(()=>toast('Teleported','ok')).catch(e=>toast(e.message,'err'));
} }
if (act === 'inv') return toast('Inventory view is not available yet — inspect the player row for now.', 'warn'); if (act === 'inv') return alert('Inventory view: open in modal — TODO; for now use the existing player row.');
// Fallthrough — replay through eahPlayerAction. // Fallthrough — replay through eahPlayerAction.
const fakeBtn = document.createElement('button'); const fakeBtn = document.createElement('button');
fakeBtn.dataset.act = act; fakeBtn.dataset.pid = pid; fakeBtn.dataset.act = act; fakeBtn.dataset.pid = pid;
+1 -28
View File
@@ -34,7 +34,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"net"
"net/http" "net/http"
"net/url" "net/url"
"regexp" "regexp"
@@ -315,33 +314,7 @@ func (a *auth) publicURL(r *http.Request) string {
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" { if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
scheme = "https" scheme = "https"
} }
base := scheme + "://" + r.Host return 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) { func redirectToLoginWithErr(w http.ResponseWriter, r *http.Request, msg string) {
@@ -1,23 +0,0 @@
-- 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;
-23
View File
@@ -63,29 +63,6 @@ With the managed `panel-postgres` container:
docker exec -it panel-postgres psql -U panel -d panel 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) ## Sign in with Steam (operator login)
Optional OpenID login for the dashboard. Two controller flags: Optional OpenID login for the dashboard. Two controller flags:
+5 -86
View File
@@ -57,73 +57,19 @@ PANEL_SKIP_AGENT=1 PANEL_HTTP_PORT=9090 PANEL_ADMIN_PASSWORD='s3cret' \
bash install.sh 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 ## Windows one-liner
PowerShell 5+, run **as Administrator**: PowerShell 5+. Docker Desktop must be installed and **running** first
(the script checks and refuses otherwise).
```powershell ```powershell
irm https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.ps1 | iex powershell -ExecutionPolicy Bypass -File install.ps1 -AdminPassword secret123
``` ```
**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`, Parameters (mirror the Linux env vars): `-RepoUrl`, `-PrebuiltUrl`,
`-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080), `-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080),
`-GrpcPort` (8443), `-AdminEmail`, `-AdminPassword`, `-DbUrl`, `-DbPassword`, `-GrpcPort` (8443), `-AdminPassword`, `-DbUrl`, `-DbPassword`,
`-DbContainer`, `-DbPort`, `-DbVolume`, `-SkipAgent`, `-InstallDocker`, `-SkipAgent`. The same `PANEL_*` env vars are honored as defaults.
`-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 The controller and agent are registered as **logon-time Scheduled
Tasks**. For a hardened always-on Windows service, wrap the same command Tasks**. For a hardened always-on Windows service, wrap the same command
@@ -264,21 +210,6 @@ sudo systemctl enable --now panel-controller panel-agent
journalctl -u panel-controller -f # ADMIN BOOTSTRAP banner lives here 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 ## Reverse proxy + real TLS
The dashboard speaks plain HTTP on `:8080`; put a reverse proxy with a The dashboard speaks plain HTTP on `:8080`; put a reverse proxy with a
@@ -286,18 +217,6 @@ real certificate in front of it for anything internet-facing. **Only
proxy the HTTP port** — agents talk to gRPC `:8443` directly (mTLS, proxy the HTTP port** — agents talk to gRPC `:8443` directly (mTLS,
LAN/VPN is fine; see [PAIRING.md](PAIRING.md)). 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 The dashboard uses SSE (`/api/events`) — disable proxy buffering on
that path or the live console will lag. that path or the live console will lag.
+7 -100
View File
@@ -18,27 +18,21 @@
[CmdletBinding()] [CmdletBinding()]
param( param(
[string]$RepoUrl = $(if ($env:PANEL_REPO_URL) { $env:PANEL_REPO_URL } else { 'https://git.pdxtechs.com/dbledeez/panel' }), [string]$RepoUrl = $(if ($env:PANEL_REPO_URL) { $env:PANEL_REPO_URL } else { 'https://git.pdxtechs.com/dbledeez/panel' }),
# Defaults to the published v0.9.2 windows/amd64 prebuilt zip so the installer # Defaults to the published v0.9.0 windows/amd64 prebuilt zip so the installer
# needs no Go toolchain. Pass -PrebuiltUrl '' (empty) to force a source build. # 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. # 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]$PrebuiltUrl = $(if ($null -ne $env:PANEL_PREBUILT_URL) { $env:PANEL_PREBUILT_URL } else { 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.0/panel-v0.9.0-windows-amd64.zip' }),
[string]$Version = $env:PANEL_VERSION, [string]$Version = $env:PANEL_VERSION,
[string]$InstallDir = $(if ($env:PANEL_DIR) { $env:PANEL_DIR } else { 'C:\Panel' }), [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] $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 }), [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]$AdminPassword = $env:PANEL_ADMIN_PASSWORD,
[string]$DbUrl = $env:PANEL_DB_URL, [string]$DbUrl = $env:PANEL_DB_URL,
[string]$DbPassword = $env:PANEL_DB_PASSWORD, [string]$DbPassword = $env:PANEL_DB_PASSWORD,
[string]$DbContainer = $(if ($env:PANEL_DB_CONTAINER) { $env:PANEL_DB_CONTAINER } else { 'panel-postgres' }), [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 }), [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' }), [string]$DbVolume = $(if ($env:PANEL_DB_VOLUME) { $env:PANEL_DB_VOLUME } else { 'panel_pgdata' }),
[switch]$SkipAgent, [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' $ErrorActionPreference = 'Stop'
# Run two independent panels on one host by giving each install distinct # Run two independent panels on one host by giving each install distinct
@@ -49,96 +43,13 @@ function Log($msg) { Write-Host "[panel] $msg" -ForegroundColor Green }
function Warn($msg) { Write-Host "[panel] $msg" -ForegroundColor Yellow } function Warn($msg) { Write-Host "[panel] $msg" -ForegroundColor Yellow }
function Fail($msg) { Write-Host "[panel] ERROR: $msg" -ForegroundColor Red; exit 1 } 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 ----------------------------------------------------- # ---- 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)) { if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
$wantDocker = $InstallDocker -or ($env:PANEL_INSTALL_DOCKER -eq '1') Fail "Docker not found. Install Docker Desktop (https://www.docker.com/products/docker-desktop/), start it, then re-run."
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 docker info *> $null
if ($LASTEXITCODE -ne 0) { if ($LASTEXITCODE -ne 0) {
Show-RerunAndExit "Docker is installed but its daemon isn't running yet — the panel is NOT installed yet." Fail "Docker daemon is not running. Start Docker Desktop and re-run."
} }
Log "docker present: $(docker --version)" Log "docker present: $(docker --version)"
@@ -190,7 +101,7 @@ if ($ScriptDir -and (Test-Path (Join-Path $ScriptDir 'go.work')) -and (Test-Path
# Running from inside the repo means build THIS tree. Don't let the prebuilt # 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 # 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). # 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' $prebuiltDefault = 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.0/panel-v0.9.0-windows-amd64.zip'
if (($null -eq $env:PANEL_PREBUILT_URL) -and ($PrebuiltUrl -eq $prebuiltDefault)) { if (($null -eq $env:PANEL_PREBUILT_URL) -and ($PrebuiltUrl -eq $prebuiltDefault)) {
$PrebuiltUrl = '' $PrebuiltUrl = ''
} }
@@ -240,8 +151,6 @@ if ($PrebuiltUrl) {
# ---- 4. Env file ------------------------------------------------------------ # ---- 4. Env file ------------------------------------------------------------
$envLines = @("PANEL_DB_URL=$DbUrl") $envLines = @("PANEL_DB_URL=$DbUrl")
if ($DbPassword) { $envLines += "PANEL_DB_PASSWORD=$DbPassword" } 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" } if ($AdminPassword) { $envLines += "PANEL_ADMIN_PASSWORD=$AdminPassword" }
$envLines += "PANEL_HTTP_PORT=$HttpPort" $envLines += "PANEL_HTTP_PORT=$HttpPort"
$envLines += "PANEL_GRPC_PORT=$GrpcPort" $envLines += "PANEL_GRPC_PORT=$GrpcPort"
@@ -256,8 +165,6 @@ Get-Process -Name controller, agent -ErrorAction SilentlyContinue |
Where-Object { $_.Path -like "$InstallDir*" } | Stop-Process -Force -ErrorAction SilentlyContinue Where-Object { $_.Path -like "$InstallDir*" } | Stop-Process -Force -ErrorAction SilentlyContinue
$env:PANEL_DB_URL = $DbUrl $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 } if ($AdminPassword) { $env:PANEL_ADMIN_PASSWORD = $AdminPassword }
Log "starting controller" Log "starting controller"
Start-Process -FilePath "$InstallDir\bin\controller.exe" ` Start-Process -FilePath "$InstallDir\bin\controller.exe" `
@@ -274,7 +181,7 @@ foreach ($i in 1..60) {
} }
if (-not $up) { Fail "controller did not come up - see $ctrlLog" } if (-not $up) { Fail "controller did not come up - see $ctrlLog" }
$adminEmail = if ($AdminEmail) { $AdminEmail } else { 'admin@panel.local' } $adminEmail = 'admin@panel.local'
# BUG-5b: always give the operator a path to the password. # BUG-5b: always give the operator a path to the password.
$adminShown = "(the one you set via -AdminPassword; also in $EnvFile)" $adminShown = "(the one you set via -AdminPassword; also in $EnvFile)"
if (-not $AdminPassword) { if (-not $AdminPassword) {
+6 -112
View File
@@ -6,7 +6,7 @@
# Non-interactive knobs (env vars, all optional): # Non-interactive knobs (env vars, all optional):
# PANEL_REPO_URL git/https base of the panel source (default: placeholder, see below) # 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) # PANEL_PREBUILT_URL tarball with prebuilt bin/{controller,agent,panelctl} + modules/ (skips Go build)
# (default: the published v0.9.2 linux/amd64 release — so # (default: the published v0.9.0 linux/amd64 release — so
# curl|bash needs no Go. Set it EMPTY to force a source build.) # 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_VERSION version string baked into the binaries (default: git describe || dev)
# PANEL_DIR install prefix (default: /opt/panel) # PANEL_DIR install prefix (default: /opt/panel)
@@ -34,23 +34,7 @@ PANEL_REPO_URL="${PANEL_REPO_URL:-https://git.pdxtechs.com/dbledeez/panel}"
# Go toolchain and no env. Explicitly set PANEL_PREBUILT_URL= (empty) to force the # 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 # git-clone + Go source-build fallback instead. A local checkout (running
# ./install.sh from inside the repo) always wins over this default — see §4. # ./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>). PANEL_PREBUILT_DEFAULT="https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.0/panel-v0.9.0-linux-amd64.tar.gz"
# 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 # Was the var set in the environment at all (even to empty)? Distinguishes an
# operator who explicitly wants prebuilt from the plain default. # 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 if [ "${PANEL_PREBUILT_URL+set}" = set ]; then PANEL_PREBUILT_URL_EXPLICIT=1; else PANEL_PREBUILT_URL_EXPLICIT=""; fi
@@ -59,12 +43,7 @@ PANEL_DIR="${PANEL_DIR:-/opt/panel}"
PANEL_DATA_DIR="${PANEL_DATA_DIR:-$PANEL_DIR/data}" PANEL_DATA_DIR="${PANEL_DATA_DIR:-$PANEL_DIR/data}"
PANEL_HTTP_PORT="${PANEL_HTTP_PORT:-8080}" PANEL_HTTP_PORT="${PANEL_HTTP_PORT:-8080}"
PANEL_GRPC_PORT="${PANEL_GRPC_PORT:-8443}" PANEL_GRPC_PORT="${PANEL_GRPC_PORT:-8443}"
PANEL_ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-}"
PANEL_ADMIN_PASSWORD="${PANEL_ADMIN_PASSWORD:-}" 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_URL="${PANEL_DB_URL:-}"
PANEL_DB_PASSWORD="${PANEL_DB_PASSWORD:-}" PANEL_DB_PASSWORD="${PANEL_DB_PASSWORD:-}"
PANEL_DB_CONTAINER="${PANEL_DB_CONTAINER:-panel-postgres}" PANEL_DB_CONTAINER="${PANEL_DB_CONTAINER:-panel-postgres}"
@@ -88,89 +67,6 @@ 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)" [ "$(id -u)" = 0 ] || die "run as root (sudo bash install.sh)"
command -v curl >/dev/null || die "curl is required" 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 ------------------------------------------------- # ---- 1. Distro detection -------------------------------------------------
DISTRO=unknown DISTRO=unknown
if [ -r /etc/os-release ]; then if [ -r /etc/os-release ]; then
@@ -308,9 +204,7 @@ umask 077
{ {
echo "PANEL_DB_URL=$PANEL_DB_URL" echo "PANEL_DB_URL=$PANEL_DB_URL"
[ -n "$PANEL_DB_PASSWORD" ] && echo "PANEL_DB_PASSWORD=$PANEL_DB_PASSWORD" [ -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_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_HTTP_PORT=$PANEL_HTTP_PORT"
echo "PANEL_GRPC_PORT=$PANEL_GRPC_PORT" echo "PANEL_GRPC_PORT=$PANEL_GRPC_PORT"
} > "$ENV_FILE" } > "$ENV_FILE"
@@ -433,10 +327,10 @@ for _ in $(seq 1 60); do
done 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" 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="${PANEL_ADMIN_EMAIL:-admin@panel.local}" ADMIN_EMAIL="admin@panel.local"
# BUG-5b: always give the operator a path to the password. If they supplied/chose # BUG-5b: always give the operator a path to the password. If they supplied one,
# one, say so and point at the env file; otherwise scrape the generated one below. # 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)" ADMIN_PW_SHOWN="(the one you set via PANEL_ADMIN_PASSWORD; also in $ENV_FILE)"
if [ -z "$PANEL_ADMIN_PASSWORD" ]; then if [ -z "$PANEL_ADMIN_PASSWORD" ]; then
if [ "$FIRST_BOOT" = 1 ]; then if [ "$FIRST_BOOT" = 1 ]; then
# The controller prints the generated password once in its log. # The controller prints the generated password once in its log.