Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 295eb22826 |
@@ -0,0 +1,10 @@
|
|||||||
|
# Force LF on all shell scripts, Dockerfiles and entrypoints so a checkout on a
|
||||||
|
# Windows box (core.autocrlf=true) can never ship CRLF into a release tarball.
|
||||||
|
# CRLF in an entrypoint makes the shebang "#!/bin/bash\r", which the Linux kernel
|
||||||
|
# cannot exec ("no such file or directory") — it crash-loops every container.
|
||||||
|
*.sh text eol=lf
|
||||||
|
entrypoint.sh text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
install.sh text eol=lf
|
||||||
@@ -20,7 +20,7 @@ or Scheduled Tasks (Windows), and the local agent is auto-paired over mTLS.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux (Debian/Ubuntu/Fedora/RHEL/Raspbian) — run as root:
|
# Linux (Debian/Ubuntu/Fedora/RHEL/Raspbian) — run as root:
|
||||||
curl -fsSL https://raw.githubusercontent.com/dbledeez/panel/main/install.sh | sudo bash
|
curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -33,6 +33,23 @@ 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 │
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ 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
|
||||||
@@ -44,7 +51,52 @@ 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)
|
||||||
}
|
}
|
||||||
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.
|
// 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 —
|
// 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.
|
||||||
if spec.NetworkMode == "host" {
|
var netMode string
|
||||||
bindings = nil
|
netMode, exposed, bindings = applyHostNetPolicy(r, spec.NetworkMode, exposed, bindings)
|
||||||
exposed = nil
|
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))
|
envSlice := make([]string, 0, len(spec.Env))
|
||||||
@@ -144,7 +197,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(spec.NetworkMode),
|
NetworkMode: container.NetworkMode(netMode),
|
||||||
SecurityOpt: spec.SecurityOpts,
|
SecurityOpt: spec.SecurityOpts,
|
||||||
RestartPolicy: container.RestartPolicy{
|
RestartPolicy: container.RestartPolicy{
|
||||||
Name: container.RestartPolicyMode(restart),
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -133,19 +133,40 @@ 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) {
|
||||||
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)
|
next.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
if isLoopback(r.RemoteAddr) {
|
||||||
|
admit()
|
||||||
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 {
|
||||||
next.ServeHTTP(w, r)
|
admit()
|
||||||
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) {
|
||||||
|
|||||||
@@ -12,7 +12,9 @@ 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"
|
||||||
@@ -46,6 +48,8 @@ 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) ----
|
||||||
@@ -131,7 +135,14 @@ 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).
|
||||||
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)
|
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)
|
||||||
@@ -183,7 +194,7 @@ func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envP
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
userID, err := database.CreateUser(ctx, "admin@panel.local", hash, "admin")
|
userID, err := database.CreateUser(ctx, adminEmail, hash, "admin")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -195,7 +206,7 @@ func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envP
|
|||||||
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: admin@panel.local")
|
log.Warn(" email: " + adminEmail)
|
||||||
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 ^^^")
|
||||||
@@ -268,11 +279,24 @@ 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
|
||||||
}
|
}
|
||||||
@@ -280,6 +304,7 @@ 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
|
||||||
}
|
}
|
||||||
@@ -289,9 +314,12 @@ 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())
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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_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)
|
logger.Error("admin bootstrap", "err", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
@@ -1408,7 +1408,20 @@ func main() {
|
|||||||
logger.Warn("empyrion kit seed", "err", err)
|
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}
|
pairSvc := &pairService{ca: caBundle, db: database}
|
||||||
|
|
||||||
httpSrvHandler := &httpServer{
|
httpSrvHandler := &httpServer{
|
||||||
@@ -1443,6 +1456,24 @@ 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")
|
||||||
|
|||||||
@@ -125,11 +125,24 @@ 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: user.ID,
|
CreatedBy: createdBy,
|
||||||
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())
|
||||||
|
|||||||
@@ -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: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:'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 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.
|
// 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;
|
||||||
|
|||||||
@@ -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: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:'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 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.
|
// 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;
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"regexp"
|
"regexp"
|
||||||
@@ -314,7 +315,33 @@ 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"
|
||||||
}
|
}
|
||||||
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) {
|
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;
|
||||||
@@ -63,6 +63,29 @@ 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:
|
||||||
|
|||||||
+88
-7
@@ -18,7 +18,7 @@ Then: [Postgres provisioning](#postgres-provisioning),
|
|||||||
Debian, Ubuntu, Fedora, CentOS/RHEL, Raspbian. Run as root:
|
Debian, Ubuntu, Fedora, CentOS/RHEL, Raspbian. Run as root:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -fsSL https://raw.githubusercontent.com/dbledeez/panel/main/install.sh | sudo bash
|
curl -fsSL https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.sh | sudo bash
|
||||||
```
|
```
|
||||||
|
|
||||||
What it does (idempotent — safe to re-run; existing containers, units,
|
What it does (idempotent — safe to re-run; existing containers, units,
|
||||||
@@ -38,7 +38,7 @@ Tunables (env vars, all optional — from `install.sh`'s header):
|
|||||||
|
|
||||||
| Env var | Meaning | Default |
|
| Env var | Meaning | Default |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `PANEL_REPO_URL` | git/https base of the panel source | github repo |
|
| `PANEL_REPO_URL` | git/https base of the panel source | the upstream gitea repo |
|
||||||
| `PANEL_PREBUILT_URL` | tarball with prebuilt `bin/{controller,agent,panelctl}` + `modules/` (skips the Go build) | — |
|
| `PANEL_PREBUILT_URL` | tarball with prebuilt `bin/{controller,agent,panelctl}` + `modules/` (skips the Go build) | — |
|
||||||
| `PANEL_VERSION` | version string baked into the binaries | `git describe \|\| dev` |
|
| `PANEL_VERSION` | version string baked into the binaries | `git describe \|\| dev` |
|
||||||
| `PANEL_DIR` | install prefix | `/opt/panel` |
|
| `PANEL_DIR` | install prefix | `/opt/panel` |
|
||||||
@@ -57,19 +57,73 @@ 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+. Docker Desktop must be installed and **running** first
|
PowerShell 5+, run **as Administrator**:
|
||||||
(the script checks and refuses otherwise).
|
|
||||||
|
|
||||||
```powershell
|
```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`,
|
Parameters (mirror the Linux env vars): `-RepoUrl`, `-PrebuiltUrl`,
|
||||||
`-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080),
|
`-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080),
|
||||||
`-GrpcPort` (8443), `-AdminPassword`, `-DbUrl`, `-DbPassword`,
|
`-GrpcPort` (8443), `-AdminEmail`, `-AdminPassword`, `-DbUrl`, `-DbPassword`,
|
||||||
`-SkipAgent`. The same `PANEL_*` env vars are honored as defaults.
|
`-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
|
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
|
||||||
@@ -210,6 +264,21 @@ 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
|
||||||
@@ -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,
|
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.
|
||||||
|
|
||||||
|
|||||||
+100
-7
@@ -18,21 +18,27 @@
|
|||||||
[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.0 windows/amd64 prebuilt zip so the installer
|
# 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.
|
# 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.0/panel-v0.9.0-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.2/panel-v0.9.2-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
|
||||||
@@ -43,13 +49,96 @@ 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)) {
|
||||||
Fail "Docker not found. Install Docker Desktop (https://www.docker.com/products/docker-desktop/), start it, then re-run."
|
$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
|
docker info *> $null
|
||||||
if ($LASTEXITCODE -ne 0) {
|
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)"
|
Log "docker present: $(docker --version)"
|
||||||
|
|
||||||
@@ -101,7 +190,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.0/panel-v0.9.0-windows-amd64.zip'
|
$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)) {
|
if (($null -eq $env:PANEL_PREBUILT_URL) -and ($PrebuiltUrl -eq $prebuiltDefault)) {
|
||||||
$PrebuiltUrl = ''
|
$PrebuiltUrl = ''
|
||||||
}
|
}
|
||||||
@@ -151,6 +240,8 @@ 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"
|
||||||
@@ -165,6 +256,8 @@ 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" `
|
||||||
@@ -181,7 +274,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 = 'admin@panel.local'
|
$adminEmail = if ($AdminEmail) { $AdminEmail } else { '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) {
|
||||||
|
|||||||
+115
-7
@@ -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.0 linux/amd64 release — so
|
# (default: the published v0.9.2 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,7 +34,23 @@ 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.
|
||||||
PANEL_PREBUILT_DEFAULT="https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.0/panel-v0.9.0-linux-amd64.tar.gz"
|
# 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
|
# 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
|
||||||
@@ -43,7 +59,12 @@ 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}"
|
||||||
@@ -67,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)"
|
[ "$(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
|
||||||
@@ -122,7 +226,9 @@ else
|
|||||||
fi
|
fi
|
||||||
PANEL_DB_URL="postgres://panel:${PANEL_DB_PASSWORD}@127.0.0.1:$PANEL_DB_PORT/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"
|
log "waiting for postgres to accept connections"
|
||||||
for _ in $(seq 1 30); do
|
# First-boot does a full initdb + CREATE DATABASE + restart; on slow disks or
|
||||||
|
# the vfs storage driver this can take well over 30s, so budget 120s.
|
||||||
|
for _ in $(seq 1 120); do
|
||||||
if docker exec "$PG_CONTAINER" pg_isready -U panel -d panel >/dev/null 2>&1; then break; fi
|
if docker exec "$PG_CONTAINER" pg_isready -U panel -d panel >/dev/null 2>&1; then break; fi
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
@@ -204,7 +310,9 @@ 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"
|
||||||
@@ -327,10 +435,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="admin@panel.local"
|
ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-admin@panel.local}"
|
||||||
# BUG-5b: always give the operator a path to the password. If they supplied one,
|
# BUG-5b: always give the operator a path to the password. If they supplied/chose
|
||||||
# say so and point at the env file; otherwise scrape the generated one below.
|
# one, say so and point at the env file; otherwise scrape the generated one below.
|
||||||
ADMIN_PW_SHOWN="(the one you set via PANEL_ADMIN_PASSWORD; also in $ENV_FILE)"
|
ADMIN_PW_SHOWN="(the password you set at install; 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user