Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a00bd620a1 |
@@ -33,23 +33,6 @@ credentials printed in the controller log (or pre-seed via
|
||||
`PANEL_ADMIN_PASSWORD`). Full options, docker-compose, and from-source
|
||||
paths: [docs/INSTALL.md](docs/INSTALL.md).
|
||||
|
||||
> **Availability / self-hosting the source.** The one-line installer fetches
|
||||
> both the source and the default prebuilt release from `git.pdxtechs.com`. If
|
||||
> that host is unreachable (or you want to pin your own mirror), point the
|
||||
> installer at any Git host or tarball you control:
|
||||
>
|
||||
> ```bash
|
||||
> # Build from your own mirror of the source:
|
||||
> PANEL_REPO_URL=https://github.com/you/panel PANEL_PREBUILT_URL= sudo bash install.sh
|
||||
>
|
||||
> # Or install a prebuilt tarball you host yourself (no Go needed):
|
||||
> PANEL_PREBUILT_URL=https://mirror.example.com/panel-linux-amd64.tar.gz sudo bash install.sh
|
||||
> ```
|
||||
>
|
||||
> Setting `PANEL_PREBUILT_URL` to an empty value forces a from-source build
|
||||
> against `PANEL_REPO_URL`. See [docs/INSTALL.md](docs/INSTALL.md) for the full
|
||||
> mirror/self-host path.
|
||||
|
||||
```
|
||||
┌─────────────────────────┐
|
||||
│ Operator │
|
||||
|
||||
@@ -35,13 +35,6 @@ import (
|
||||
// Container IDs (the string returned by Create) are Docker's full container IDs.
|
||||
type DockerRuntime struct {
|
||||
cli *client.Client
|
||||
// hostNetUnsupported is true when the daemon is Docker Desktop
|
||||
// (Windows/macOS), where NetworkMode=host binds inside the hidden
|
||||
// Linux VM and is NOT reachable from the host LAN. On such daemons we
|
||||
// fall back to bridge networking + published ports so the panel's
|
||||
// allocated ports actually reach players. Detected once at startup;
|
||||
// overridable via PANEL_FORCE_BRIDGE=1 / PANEL_FORCE_HOST_NET=1.
|
||||
hostNetUnsupported bool
|
||||
}
|
||||
|
||||
// NewDocker connects to the local Docker daemon using environment-configured
|
||||
@@ -51,52 +44,7 @@ func NewDocker() (*DockerRuntime, error) {
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docker client: %w", err)
|
||||
}
|
||||
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
|
||||
return &DockerRuntime{cli: cli}, nil
|
||||
}
|
||||
|
||||
// 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 —
|
||||
// the container shares the host's network namespace so publish-port
|
||||
// plumbing is meaningless there.
|
||||
var netMode string
|
||||
netMode, exposed, bindings = applyHostNetPolicy(r, spec.NetworkMode, exposed, bindings)
|
||||
if spec.NetworkMode == "host" && netMode != "host" && len(bindings) > 0 && spec.LogSink != nil {
|
||||
spec.LogSink("Docker Desktop detected: publishing ports via bridge instead of host networking (host mode is not LAN-reachable on Docker Desktop)")
|
||||
if spec.NetworkMode == "host" {
|
||||
bindings = nil
|
||||
exposed = nil
|
||||
}
|
||||
|
||||
envSlice := make([]string, 0, len(spec.Env))
|
||||
@@ -197,7 +144,7 @@ func (r *DockerRuntime) Create(ctx context.Context, spec InstanceSpec) (string,
|
||||
hostCfg := &container.HostConfig{
|
||||
Mounts: mounts,
|
||||
PortBindings: bindings,
|
||||
NetworkMode: container.NetworkMode(netMode),
|
||||
NetworkMode: container.NetworkMode(spec.NetworkMode),
|
||||
SecurityOpt: spec.SecurityOpts,
|
||||
RestartPolicy: container.RestartPolicy{
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -133,40 +133,19 @@ func (h *httpServer) requireSessionOrAdminToken(isAPI bool, next http.Handler) h
|
||||
}
|
||||
want := []byte(h.adminToken)
|
||||
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) {
|
||||
admit()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if got := r.Header.Get(adminTokenHeader); got != "" &&
|
||||
subtle.ConstantTimeCompare([]byte(got), want) == 1 {
|
||||
admit()
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
sessionGate.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// serviceAdminUser is the synthetic identity given to service callers
|
||||
// (loopback / admin-token) that don't carry a session cookie. ID 0 never
|
||||
// collides with a real DB user (serial starts at 1); handlers that only
|
||||
// check Role == "admin" (createToken, user creation) accept it.
|
||||
var serviceAdminUser = &db.UserRow{ID: 0, Email: "service@panel.local", Role: "admin"}
|
||||
|
||||
// registerAdminRoutes wires the admin mux onto the outer mux. Called
|
||||
// from httpServer.handler() during construction.
|
||||
func (h *httpServer) registerAdminRoutes(mux *http.ServeMux, token string) {
|
||||
|
||||
@@ -12,9 +12,7 @@ import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/argon2"
|
||||
@@ -48,8 +46,6 @@ type auth struct {
|
||||
log *slog.Logger
|
||||
db *db.DB
|
||||
publicURLOverride string // set from --public-url flag; used by Steam OpenID realm/return_to
|
||||
steamRealmWarned sync.Once
|
||||
loginLimiter *loginLimiter // brute-force guard for handleLogin
|
||||
}
|
||||
|
||||
// ---- Password hashing (argon2id, PHC format) ----
|
||||
@@ -135,14 +131,7 @@ func clientIP(r *http.Request) string {
|
||||
// we retro-link the first admin to this Steam ID. Useful when the
|
||||
// operator is adding Steam login to an already-running panel.
|
||||
// - Ignored if the first admin already has a steam_id (don't clobber).
|
||||
// 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"
|
||||
}
|
||||
func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envPassword, initialSteamID string) error {
|
||||
initialSteamID = strings.TrimSpace(initialSteamID)
|
||||
if initialSteamID != "" && !steamID64RE.MatchString(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 {
|
||||
return err
|
||||
}
|
||||
userID, err := database.CreateUser(ctx, adminEmail, hash, "admin")
|
||||
userID, err := database.CreateUser(ctx, "admin@panel.local", hash, "admin")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -206,7 +195,7 @@ func bootstrapAdmin(ctx context.Context, log *slog.Logger, database *db.DB, envE
|
||||
const banner = "═══════════════════════════════════════════════════════════════════════════"
|
||||
log.Warn(banner)
|
||||
log.Warn("ADMIN BOOTSTRAP — no users in DB, created default account")
|
||||
log.Warn(" email: " + adminEmail)
|
||||
log.Warn(" email: admin@panel.local")
|
||||
if generated {
|
||||
log.Warn(" password: " + password)
|
||||
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
|
||||
}
|
||||
|
||||
ip := normalizeIP(clientIP(r))
|
||||
if ok, retry := a.loginLimiter.Allowed(ip, req.Email); !ok {
|
||||
secs := int(retry.Seconds())
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
w.Header().Set("Retry-After", strconv.Itoa(secs))
|
||||
writeError(w, http.StatusTooManyRequests, "rate_limited",
|
||||
"too many failed login attempts; try again later")
|
||||
return
|
||||
}
|
||||
|
||||
u, err := a.db.GetUserByEmail(r.Context(), req.Email)
|
||||
if err != nil {
|
||||
if errors.Is(err, db.ErrUserNotFound) {
|
||||
// Still do a bogus verify so login timing doesn't leak user enumeration.
|
||||
_, _ = verifyPassword(req.Password, "$argon2id$v=19$m=65536,t=3,p=2$AAAAAAAAAAAAAAAAAAAAAA$BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB")
|
||||
a.loginLimiter.RecordFailure(ip, req.Email)
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
|
||||
return
|
||||
}
|
||||
@@ -304,7 +280,6 @@ func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if u.Disabled {
|
||||
a.loginLimiter.RecordFailure(ip, req.Email)
|
||||
writeError(w, http.StatusUnauthorized, "disabled", "account is disabled")
|
||||
return
|
||||
}
|
||||
@@ -314,12 +289,9 @@ func (a *auth) handleLogin(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
a.loginLimiter.RecordFailure(ip, req.Email)
|
||||
writeError(w, http.StatusUnauthorized, "invalid_credentials", "email or password incorrect")
|
||||
return
|
||||
}
|
||||
// Successful auth — clear any accumulated failure counters.
|
||||
a.loginLimiter.Reset(ip, req.Email)
|
||||
token, err := newSessionToken()
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "token", err.Error())
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1398,7 +1398,7 @@ func main() {
|
||||
|
||||
// Bootstrap the admin account on first run. The generated password is
|
||||
// printed to the log so the operator can grab it immediately.
|
||||
if err := bootstrapAdmin(ctx, logger, database, os.Getenv("PANEL_ADMIN_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)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -1408,20 +1408,7 @@ func main() {
|
||||
logger.Warn("empyrion kit seed", "err", err)
|
||||
}
|
||||
|
||||
// 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),
|
||||
}
|
||||
authSvc := &auth{log: logger, db: database, publicURLOverride: *publicURL}
|
||||
pairSvc := &pairService{ca: caBundle, db: database}
|
||||
|
||||
httpSrvHandler := &httpServer{
|
||||
@@ -1456,24 +1443,6 @@ func main() {
|
||||
"http", *httpAddr,
|
||||
)
|
||||
|
||||
// Startup safety: if the HTTP dashboard binds a non-loopback address and
|
||||
// the operator hasn't signaled a reverse proxy (no --public-url /
|
||||
// PANEL_PUBLIC_URL), the dashboard is reachable over plain HTTP from the
|
||||
// LAN — and, if the host is internet-facing, from the internet. We don't
|
||||
// change the default bind for compat; we just warn loudly.
|
||||
if httpBindIsNonLoopback(*httpAddr) && strings.TrimSpace(effectivePublicURL) == "" {
|
||||
const bar = "═══════════════════════════════════════════════════════════════════════════"
|
||||
logger.Warn(bar)
|
||||
logger.Warn("SECURITY — HTTP dashboard is bound to a NON-LOOPBACK address over plain HTTP",
|
||||
"http", *httpAddr)
|
||||
logger.Warn(" It is reachable by anything that can route to this host — the whole LAN,")
|
||||
logger.Warn(" and the public internet if this port is exposed. Credentials and session")
|
||||
logger.Warn(" cookies would travel UNENCRYPTED.")
|
||||
logger.Warn(" Put it behind TLS (reverse proxy) and set --public-url / PANEL_PUBLIC_URL,")
|
||||
logger.Warn(" or bind to 127.0.0.1 with --http 127.0.0.1:8080.")
|
||||
logger.Warn(bar)
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
logger.Info("shutdown signal received, stopping gracefully")
|
||||
|
||||
@@ -125,24 +125,11 @@ func (p *pairService) createToken(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
id := "pt_" + randHex(8)
|
||||
|
||||
// created_by is a NOT NULL FK -> users(id). The synthetic service-admin
|
||||
// identity (loopback / admin-token header callers, see serviceAdminUser)
|
||||
// has ID 0, which no real user row has, so inserting it fails the FK with
|
||||
// a 500. Resolve such callers to the first real admin so the audit row
|
||||
// still points at a genuine account. If there is somehow no admin, fall
|
||||
// back to the raw ID and let the DB surface the error.
|
||||
createdBy := user.ID
|
||||
if createdBy == 0 {
|
||||
if admin, err := p.db.GetFirstAdmin(r.Context()); err == nil {
|
||||
createdBy = admin.ID
|
||||
}
|
||||
}
|
||||
if err := p.db.CreatePairToken(r.Context(), db.PairTokenRow{
|
||||
ID: id,
|
||||
TokenHash: sha256Hex(raw),
|
||||
Description: req.Description,
|
||||
CreatedBy: createdBy,
|
||||
CreatedBy: user.ID,
|
||||
ExpiresAt: time.Now().Add(ttl),
|
||||
}); err != nil {
|
||||
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:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) },
|
||||
{ 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:'Give item…', disabled:stub, onClick: () => triggerEahPlayerAct('give', p.entityId) },
|
||||
{ icon:'💰', label:'+1k credits', disabled:stub, onClick: () => triggerEahPlayerAct('credits', p.entityId) },
|
||||
@@ -23272,7 +23272,7 @@ function triggerEahPlayerAct(act, pid, p) {
|
||||
return eahFetch(`players/${pid}/teleport`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ x, y, z }) })
|
||||
.then(()=>toast('Teleported','ok')).catch(e=>toast(e.message,'err'));
|
||||
}
|
||||
if (act === 'inv') return 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.
|
||||
const fakeBtn = document.createElement('button');
|
||||
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:'Unban', onClick: () => triggerEahPlayerAct('unban', p.entityId) },
|
||||
{ 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:'Give item…', disabled:stub, onClick: () => triggerEahPlayerAct('give', p.entityId) },
|
||||
{ icon:'💰', label:'+1k credits', disabled:stub, onClick: () => triggerEahPlayerAct('credits', p.entityId) },
|
||||
@@ -19242,7 +19242,7 @@ function triggerEahPlayerAct(act, pid, p) {
|
||||
return eahFetch(`players/${pid}/teleport`, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({ x, y, z }) })
|
||||
.then(()=>toast('Teleported','ok')).catch(e=>toast(e.message,'err'));
|
||||
}
|
||||
if (act === 'inv') return 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.
|
||||
const fakeBtn = document.createElement('button');
|
||||
fakeBtn.dataset.act = act; fakeBtn.dataset.pid = pid;
|
||||
|
||||
@@ -34,7 +34,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
@@ -315,33 +314,7 @@ func (a *auth) publicURL(r *http.Request) string {
|
||||
if r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" {
|
||||
scheme = "https"
|
||||
}
|
||||
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
|
||||
return scheme + "://" + r.Host
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -63,29 +63,6 @@ With the managed `panel-postgres` container:
|
||||
docker exec -it panel-postgres psql -U panel -d panel
|
||||
```
|
||||
|
||||
## Adding more users
|
||||
|
||||
The account you set at install is the **first admin**. To add teammates,
|
||||
open the **🎮 Steam Accounts** panel (sidebar) — this is the user-management
|
||||
screen. As an admin you can:
|
||||
|
||||
- **List** every panel user and their linked Steam ID.
|
||||
- **Add a user** by their 17-digit **SteamID64**, as role `user` or `admin`
|
||||
(`POST /api/users`). They then log in with **Sign in with Steam** — no
|
||||
password for you to manage or hand out.
|
||||
- **Link / unlink** a Steam ID on any account.
|
||||
|
||||
The intended model: you (the install admin) sign in with your email +
|
||||
password; everyone else you add is **Steam-based**. That's ideal for a
|
||||
gaming community — players already have Steam.
|
||||
|
||||
**Limitation, stated plainly:** there is currently **no UI/API to create
|
||||
additional *email + password* users** — `POST /api/users` is Steam-only.
|
||||
If you need a second non-Steam login, add it at the database level or with
|
||||
`reset-admin --email <addr> --password <pw>` (see
|
||||
[Resetting a lost admin password](#resetting-a-lost-admin-password)). A
|
||||
proper "invite a password user" flow is a known follow-up.
|
||||
|
||||
## Sign in with Steam (operator login)
|
||||
|
||||
Optional OpenID login for the dashboard. Two controller flags:
|
||||
|
||||
+5
-86
@@ -57,73 +57,19 @@ PANEL_SKIP_AGENT=1 PANEL_HTTP_PORT=9090 PANEL_ADMIN_PASSWORD='s3cret' \
|
||||
bash install.sh
|
||||
```
|
||||
|
||||
### Availability & self-hosting (mirroring the source)
|
||||
|
||||
By default the installer fetches from a single upstream, `git.pdxtechs.com`:
|
||||
`PANEL_REPO_URL` supplies the Git source, and `PANEL_PREBUILT_URL` defaults to
|
||||
the published prebuilt release tarball on that same host. If that host is down,
|
||||
rate-limited, or you simply don't want a single external dependency in your
|
||||
install path, override either one to point at a mirror or self-hosted copy you
|
||||
control:
|
||||
|
||||
```bash
|
||||
# (a) Build from your own Git mirror of the source (empty PANEL_PREBUILT_URL
|
||||
# forces a from-source build; needs Go >= 1.26):
|
||||
PANEL_REPO_URL=https://github.com/you/panel PANEL_PREBUILT_URL= sudo bash install.sh
|
||||
|
||||
# (b) Install a prebuilt tarball you host yourself (no Go toolchain needed).
|
||||
# The tarball must contain bin/{controller,agent,panelctl} + modules/:
|
||||
PANEL_PREBUILT_URL=https://mirror.example.com/panel-linux-amd64.tar.gz sudo bash install.sh
|
||||
|
||||
# (c) Air-gapped: run install.sh from a local checkout of the repo. A local
|
||||
# checkout (go.work + controller/ present next to install.sh) always wins
|
||||
# over the git.pdxtechs.com default and builds the tree in place.
|
||||
sudo bash /path/to/panel/install.sh
|
||||
```
|
||||
|
||||
To produce your own prebuilt tarball for (b), cross-build the three binaries
|
||||
and `tar czf` them alongside `modules/` — see [Build](#from-source) below and
|
||||
the release layout `bin/{controller,agent,panelctl}` + `modules/`. On Windows
|
||||
the same overrides apply to `install.ps1`.
|
||||
|
||||
## Windows one-liner
|
||||
|
||||
PowerShell 5+, run **as Administrator**:
|
||||
PowerShell 5+. Docker Desktop must be installed and **running** first
|
||||
(the script checks and refuses otherwise).
|
||||
|
||||
```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`,
|
||||
`-Version`, `-InstallDir` (default `C:\Panel`), `-HttpPort` (8080),
|
||||
`-GrpcPort` (8443), `-AdminEmail`, `-AdminPassword`, `-DbUrl`, `-DbPassword`,
|
||||
`-DbContainer`, `-DbPort`, `-DbVolume`, `-SkipAgent`, `-InstallDocker`,
|
||||
`-SkipDockerInstall`. The same `PANEL_*` env vars are honored as defaults.
|
||||
|
||||
When run in a real console it prompts you to choose the admin email + password
|
||||
up front (no log-reading); when piped (`irm | iex`) it uses env/param values or
|
||||
generates a password.
|
||||
`-GrpcPort` (8443), `-AdminPassword`, `-DbUrl`, `-DbPassword`,
|
||||
`-SkipAgent`. The same `PANEL_*` env vars are honored as defaults.
|
||||
|
||||
The controller and agent are registered as **logon-time Scheduled
|
||||
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
|
||||
```
|
||||
|
||||
## Do you even need a reverse proxy?
|
||||
|
||||
**Not necessarily.** The panel serves its own dashboard on `:8080` and works
|
||||
fine with **no proxy at all**:
|
||||
|
||||
- **Home / LAN use** — just browse to `http://<your-box-ip>:8080`. Steam
|
||||
sign-in works too (Steam returns you to that same address). Nothing else
|
||||
to set up.
|
||||
- **You just want HTTPS and a domain** — a reverse proxy (Caddy is the
|
||||
one-file option below) gives you a real cert. Optional, but nice.
|
||||
|
||||
You **do** want a proxy the moment the panel is internet-facing (real TLS,
|
||||
not plain HTTP over the internet). If you add one, read the Steam note below
|
||||
— a misconfigured proxy is the one thing that quietly breaks Steam login.
|
||||
|
||||
## Reverse proxy + real TLS
|
||||
|
||||
The dashboard speaks plain HTTP on `:8080`; put a reverse proxy with a
|
||||
@@ -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,
|
||||
LAN/VPN is fine; see [PAIRING.md](PAIRING.md)).
|
||||
|
||||
> **Steam login behind a proxy — read this.** When a proxy terminates TLS,
|
||||
> the controller must know its real public HTTPS URL or Steam's return trip
|
||||
> lands on the wrong origin and login fails. Do **one** of:
|
||||
> - start the controller with `--public-url https://panel.example.com`
|
||||
> (most reliable — set it and forget it), **or**
|
||||
> - make sure your proxy forwards the `X-Forwarded-Proto` header (the Caddy
|
||||
> and nginx examples below already do the right thing).
|
||||
>
|
||||
> If you skip both, the controller logs a warning at first Steam-login
|
||||
> attempt telling you exactly this. With **no proxy** (direct IP/LAN), none
|
||||
> of this applies — Steam login just works.
|
||||
|
||||
The dashboard uses SSE (`/api/events`) — disable proxy buffering on
|
||||
that path or the live console will lag.
|
||||
|
||||
|
||||
+6
-99
@@ -18,27 +18,21 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[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.
|
||||
# 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]$InstallDir = $(if ($env:PANEL_DIR) { $env:PANEL_DIR } else { 'C:\Panel' }),
|
||||
[int] $HttpPort = $(if ($env:PANEL_HTTP_PORT) { [int]$env:PANEL_HTTP_PORT } else { 8080 }),
|
||||
[int] $GrpcPort = $(if ($env:PANEL_GRPC_PORT) { [int]$env:PANEL_GRPC_PORT } else { 8443 }),
|
||||
[string]$AdminEmail = $env:PANEL_ADMIN_EMAIL,
|
||||
[string]$PublicUrl = $env:PANEL_PUBLIC_URL,
|
||||
[string]$AdminPassword = $env:PANEL_ADMIN_PASSWORD,
|
||||
[string]$DbUrl = $env:PANEL_DB_URL,
|
||||
[string]$DbPassword = $env:PANEL_DB_PASSWORD,
|
||||
[string]$DbContainer = $(if ($env:PANEL_DB_CONTAINER) { $env:PANEL_DB_CONTAINER } else { 'panel-postgres' }),
|
||||
[int] $DbPort = $(if ($env:PANEL_DB_PORT) { [int]$env:PANEL_DB_PORT } else { 5432 }),
|
||||
[string]$DbVolume = $(if ($env:PANEL_DB_VOLUME) { $env:PANEL_DB_VOLUME } else { 'panel_pgdata' }),
|
||||
[switch]$SkipAgent,
|
||||
# -InstallDocker (or PANEL_INSTALL_DOCKER=1) auto-installs Docker Desktop via
|
||||
# winget without prompting; -SkipDockerInstall keeps the old fail-if-missing.
|
||||
[switch]$InstallDocker,
|
||||
[switch]$SkipDockerInstall
|
||||
[switch]$SkipAgent
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
# 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 Fail($msg) { Write-Host "[panel] ERROR: $msg" -ForegroundColor Red; exit 1 }
|
||||
|
||||
# The literal one-liner to paste to run this installer again. Used by the
|
||||
# "you must reboot / start Docker, then run this again" messages so the user
|
||||
# is never left guessing what "re-run" means.
|
||||
$RerunCommand = 'irm https://git.pdxtechs.com/dbledeez/panel/raw/branch/main/install.ps1 | iex'
|
||||
|
||||
# Show-RerunAndExit prints a loud, unmissable "not done yet — run this again"
|
||||
# banner with the exact command, then exits. Windows Docker Desktop needs a
|
||||
# reboot on first install, so a second run of the installer is expected and
|
||||
# normal — this makes that crystal clear instead of a terse error.
|
||||
function Show-RerunAndExit($why) {
|
||||
Write-Host ''
|
||||
Write-Host '===============================================================' -ForegroundColor Yellow
|
||||
Write-Host ' ACTION NEEDED — the panel is not installed yet' -ForegroundColor Yellow
|
||||
Write-Host '===============================================================' -ForegroundColor Yellow
|
||||
Write-Host " $why" -ForegroundColor Yellow
|
||||
Write-Host ''
|
||||
Write-Host ' Do this, in order:' -ForegroundColor Yellow
|
||||
Write-Host ' 1. REBOOT if Windows/winget asked you to (Docker Desktop needs' -ForegroundColor Yellow
|
||||
Write-Host ' WSL2 + virtualization, which require a restart).' -ForegroundColor Yellow
|
||||
Write-Host ' 2. Start Docker Desktop and wait until the whale icon in the' -ForegroundColor Yellow
|
||||
Write-Host ' system tray stops animating (daemon ready).' -ForegroundColor Yellow
|
||||
Write-Host ' 3. >>> RUN THIS INSTALLER AGAIN <<< in an Administrator' -ForegroundColor Green
|
||||
Write-Host ' PowerShell window. It will pick up from here and finish:' -ForegroundColor Green
|
||||
Write-Host ''
|
||||
Write-Host " $RerunCommand" -ForegroundColor Cyan
|
||||
Write-Host ''
|
||||
Write-Host ' (Running it again is safe and expected — it is idempotent.)' -ForegroundColor Yellow
|
||||
Write-Host '===============================================================' -ForegroundColor Yellow
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---- 0. Admin account: prompt when interactive -----------------------------
|
||||
# A real person (interactive console) is asked to choose the admin login +
|
||||
# password up front. When run non-interactively (irm | iex with no console,
|
||||
# CI) the prompt is skipped: -AdminPassword / $env:PANEL_ADMIN_PASSWORD is used
|
||||
# and a missing password is generated by the controller as before.
|
||||
if (-not $AdminPassword -and [Environment]::UserInteractive -and $Host.UI.RawUI) {
|
||||
Write-Host ''
|
||||
Write-Host '-- Set up your panel admin login --' -ForegroundColor Cyan
|
||||
if (-not $AdminEmail) {
|
||||
$e = Read-Host 'Admin email [admin@panel.local]'
|
||||
if ($e) { $AdminEmail = $e }
|
||||
}
|
||||
while (-not $AdminPassword) {
|
||||
$s1 = Read-Host 'Admin password (min 8 chars, blank = auto-generate)' -AsSecureString
|
||||
$p1 = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
||||
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s1))
|
||||
if (-not $p1) { Warn 'no password entered - the controller will generate one and print it.'; break }
|
||||
if ($p1.Length -lt 8) { Write-Host ' password too short (need 8+); try again.'; continue }
|
||||
$s2 = Read-Host 'Confirm password' -AsSecureString
|
||||
$p2 = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
|
||||
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($s2))
|
||||
if ($p1 -ne $p2) { Write-Host ' passwords did not match; try again.'; continue }
|
||||
$AdminPassword = $p1
|
||||
}
|
||||
}
|
||||
|
||||
# ---- 1. Docker Desktop -----------------------------------------------------
|
||||
# The panel needs Docker for Postgres + every game server. Unlike Linux we
|
||||
# can't fully script Docker Desktop (it's a GUI install + WSL2 + reboot), but
|
||||
# we CAN kick it off via winget and guide the rest. -InstallDocker / env
|
||||
# PANEL_INSTALL_DOCKER=1 answers "yes" non-interactively; -SkipDockerInstall
|
||||
# forces the old fail-fast behavior.
|
||||
if (-not (Get-Command docker -ErrorAction SilentlyContinue)) {
|
||||
$wantDocker = $InstallDocker -or ($env:PANEL_INSTALL_DOCKER -eq '1')
|
||||
if (-not $wantDocker -and -not $SkipDockerInstall -and [Environment]::UserInteractive -and $Host.UI.RawUI) {
|
||||
Write-Host ''
|
||||
Write-Host 'Docker Desktop is required (runs Postgres + your game servers) and is not installed.' -ForegroundColor Yellow
|
||||
$ans = Read-Host 'Install Docker Desktop now via winget? [Y/n]'
|
||||
$wantDocker = ($ans -eq '' -or $ans -match '^[Yy]')
|
||||
}
|
||||
if ($wantDocker) {
|
||||
if (-not (Get-Command winget -ErrorAction SilentlyContinue)) {
|
||||
Fail @"
|
||||
winget isn't available to auto-install Docker. Install Docker Desktop manually:
|
||||
https://www.docker.com/products/docker-desktop/
|
||||
Then start it (wait for the whale icon to settle) and re-run this installer.
|
||||
"@
|
||||
}
|
||||
Log "installing Docker Desktop via winget (this is large; a reboot is usually required)"
|
||||
winget install --id Docker.DockerDesktop -e --accept-source-agreements --accept-package-agreements
|
||||
if ($LASTEXITCODE -ne 0) { Fail "winget install of Docker Desktop failed (exit $LASTEXITCODE). Install it manually, then re-run." }
|
||||
Show-RerunAndExit "Docker Desktop was just installed — the panel is NOT installed yet."
|
||||
} else {
|
||||
Fail "Docker not found. Install Docker Desktop (https://www.docker.com/products/docker-desktop/), start it, then re-run."
|
||||
}
|
||||
}
|
||||
docker info *> $null
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
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)"
|
||||
|
||||
@@ -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
|
||||
# default override it. An explicit -PrebuiltUrl or $env:PANEL_PREBUILT_URL still
|
||||
# forces prebuilt (it won't equal the untouched default / the env is non-null).
|
||||
$prebuiltDefault = 'https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.2/panel-v0.9.2-windows-amd64.zip'
|
||||
$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)) {
|
||||
$PrebuiltUrl = ''
|
||||
}
|
||||
@@ -240,8 +151,6 @@ if ($PrebuiltUrl) {
|
||||
# ---- 4. Env file ------------------------------------------------------------
|
||||
$envLines = @("PANEL_DB_URL=$DbUrl")
|
||||
if ($DbPassword) { $envLines += "PANEL_DB_PASSWORD=$DbPassword" }
|
||||
if ($AdminEmail) { $envLines += "PANEL_ADMIN_EMAIL=$AdminEmail" }
|
||||
if ($PublicUrl) { $envLines += "PANEL_PUBLIC_URL=$PublicUrl" }
|
||||
if ($AdminPassword) { $envLines += "PANEL_ADMIN_PASSWORD=$AdminPassword" }
|
||||
$envLines += "PANEL_HTTP_PORT=$HttpPort"
|
||||
$envLines += "PANEL_GRPC_PORT=$GrpcPort"
|
||||
@@ -256,8 +165,6 @@ Get-Process -Name controller, agent -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Path -like "$InstallDir*" } | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
|
||||
$env:PANEL_DB_URL = $DbUrl
|
||||
if ($AdminEmail) { $env:PANEL_ADMIN_EMAIL = $AdminEmail }
|
||||
if ($PublicUrl) { $env:PANEL_PUBLIC_URL = $PublicUrl }
|
||||
if ($AdminPassword) { $env:PANEL_ADMIN_PASSWORD = $AdminPassword }
|
||||
Log "starting controller"
|
||||
Start-Process -FilePath "$InstallDir\bin\controller.exe" `
|
||||
@@ -274,7 +181,7 @@ foreach ($i in 1..60) {
|
||||
}
|
||||
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.
|
||||
$adminShown = "(the one you set via -AdminPassword; also in $EnvFile)"
|
||||
if (-not $AdminPassword) {
|
||||
|
||||
+6
-112
@@ -6,7 +6,7 @@
|
||||
# Non-interactive knobs (env vars, all optional):
|
||||
# PANEL_REPO_URL git/https base of the panel source (default: placeholder, see below)
|
||||
# PANEL_PREBUILT_URL tarball with prebuilt bin/{controller,agent,panelctl} + modules/ (skips Go build)
|
||||
# (default: the published v0.9.2 linux/amd64 release — so
|
||||
# (default: the published v0.9.0 linux/amd64 release — so
|
||||
# curl|bash needs no Go. Set it EMPTY to force a source build.)
|
||||
# PANEL_VERSION version string baked into the binaries (default: git describe || dev)
|
||||
# PANEL_DIR install prefix (default: /opt/panel)
|
||||
@@ -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
|
||||
# git-clone + Go source-build fallback instead. A local checkout (running
|
||||
# ./install.sh from inside the repo) always wins over this default — see §4.
|
||||
# Multi-arch: map the running machine's arch to the release naming (…-linux-<arch>).
|
||||
# Only linux/amd64 has a published prebuilt release asset today — so on any other
|
||||
# arch we DON'T point at a nonexistent tarball. We leave PANEL_PREBUILT_DEFAULT
|
||||
# empty for non-amd64, which makes the default resolve to a source build (needs Go).
|
||||
PANEL_ARCH_RAW="$(uname -m 2>/dev/null || echo unknown)"
|
||||
case "$PANEL_ARCH_RAW" in
|
||||
x86_64|amd64) PANEL_ARCH=amd64 ;;
|
||||
aarch64|arm64) PANEL_ARCH=arm64 ;;
|
||||
*) PANEL_ARCH="$PANEL_ARCH_RAW" ;;
|
||||
esac
|
||||
if [ "$PANEL_ARCH" = amd64 ]; then
|
||||
PANEL_PREBUILT_DEFAULT="https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.2/panel-v0.9.2-linux-amd64.tar.gz"
|
||||
else
|
||||
# No prebuilt release asset exists for $PANEL_ARCH yet. Falling back to a
|
||||
# source build (requires Go) rather than fetching the wrong-arch amd64 binary.
|
||||
PANEL_PREBUILT_DEFAULT=""
|
||||
fi
|
||||
PANEL_PREBUILT_DEFAULT="https://git.pdxtechs.com/dbledeez/panel/releases/download/v0.9.0/panel-v0.9.0-linux-amd64.tar.gz"
|
||||
# Was the var set in the environment at all (even to empty)? Distinguishes an
|
||||
# operator who explicitly wants prebuilt from the plain default.
|
||||
if [ "${PANEL_PREBUILT_URL+set}" = set ]; then PANEL_PREBUILT_URL_EXPLICIT=1; else PANEL_PREBUILT_URL_EXPLICIT=""; fi
|
||||
@@ -59,12 +43,7 @@ PANEL_DIR="${PANEL_DIR:-/opt/panel}"
|
||||
PANEL_DATA_DIR="${PANEL_DATA_DIR:-$PANEL_DIR/data}"
|
||||
PANEL_HTTP_PORT="${PANEL_HTTP_PORT:-8080}"
|
||||
PANEL_GRPC_PORT="${PANEL_GRPC_PORT:-8443}"
|
||||
PANEL_ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-}"
|
||||
PANEL_ADMIN_PASSWORD="${PANEL_ADMIN_PASSWORD:-}"
|
||||
# Public HTTPS URL when behind a reverse proxy — required for Steam login to
|
||||
# work through the proxy (e.g. https://panel.example.com). Leave empty for
|
||||
# direct LAN/IP access.
|
||||
PANEL_PUBLIC_URL="${PANEL_PUBLIC_URL:-}"
|
||||
PANEL_DB_URL="${PANEL_DB_URL:-}"
|
||||
PANEL_DB_PASSWORD="${PANEL_DB_PASSWORD:-}"
|
||||
PANEL_DB_CONTAINER="${PANEL_DB_CONTAINER:-panel-postgres}"
|
||||
@@ -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)"
|
||||
command -v curl >/dev/null || die "curl is required"
|
||||
|
||||
# ---- Arch note -----------------------------------------------------------
|
||||
if [ "$PANEL_ARCH" = amd64 ]; then
|
||||
log "arch: $PANEL_ARCH_RAW ($PANEL_ARCH) — using prebuilt release by default"
|
||||
elif [ -n "$PANEL_PREBUILT_URL_EXPLICIT" ] && [ -n "$PANEL_PREBUILT_URL" ]; then
|
||||
log "arch: $PANEL_ARCH_RAW ($PANEL_ARCH) — using operator-supplied PANEL_PREBUILT_URL"
|
||||
else
|
||||
log "arch: $PANEL_ARCH_RAW ($PANEL_ARCH) — no prebuilt for $PANEL_ARCH, building from source (needs Go)"
|
||||
fi
|
||||
|
||||
# ---- Port pre-flight -----------------------------------------------------
|
||||
# Fail early and loudly if any port we're about to bind is already taken,
|
||||
# instead of letting the controller or the Postgres container crash mid-install
|
||||
# with an opaque "address already in use". Uses whichever probe tool exists.
|
||||
port_in_use() {
|
||||
# $1 = port. Returns 0 (true) if something is LISTENing on it.
|
||||
_p="$1"
|
||||
if command -v ss >/dev/null 2>&1; then
|
||||
ss -H -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${_p}\$"
|
||||
elif command -v netstat >/dev/null 2>&1; then
|
||||
netstat -ltn 2>/dev/null | awk '{print $4}' | grep -Eq "[:.]${_p}\$"
|
||||
elif command -v lsof >/dev/null 2>&1; then
|
||||
lsof -iTCP:"${_p}" -sTCP:LISTEN -Pn >/dev/null 2>&1
|
||||
else
|
||||
warn "no ss/netstat/lsof available — skipping port pre-flight for :${_p}"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
_preflight_port() {
|
||||
# $1 = port, $2 = human label, $3 = env var name to override
|
||||
if port_in_use "$1"; then
|
||||
die "port $1 ($2) is already in use.
|
||||
Free it, or choose another with: $3=<port> (re-run the installer).
|
||||
e.g. $3=$(( $1 + 1 )) sudo bash install.sh"
|
||||
fi
|
||||
}
|
||||
_preflight_port "$PANEL_HTTP_PORT" "dashboard HTTP" "PANEL_HTTP_PORT"
|
||||
_preflight_port "$PANEL_GRPC_PORT" "agent gRPC" "PANEL_GRPC_PORT"
|
||||
# The managed Postgres binds 127.0.0.1:$PANEL_DB_PORT. Skip this check when an
|
||||
# external DB is supplied (we don't start a container then) OR when the managed
|
||||
# container already exists (a re-run legitimately re-binds its own port).
|
||||
if [ -z "$PANEL_DB_URL" ] && ! docker inspect "$PG_CONTAINER" >/dev/null 2>&1; then
|
||||
_preflight_port "$PANEL_DB_PORT" "Postgres" "PANEL_DB_PORT"
|
||||
fi
|
||||
|
||||
# ---- 0. Admin account: prompt when interactive ---------------------------
|
||||
# When a real person runs this (a TTY is available), ask them to choose the
|
||||
# admin login + password up front — no digging a generated password out of
|
||||
# the log afterwards. When piped non-interactively (curl | bash with no TTY,
|
||||
# CI, cloud-init) we skip the prompt: any PANEL_ADMIN_* env values are used,
|
||||
# and a missing password is generated by the controller as before.
|
||||
if [ -z "$PANEL_ADMIN_PASSWORD" ] && [ -r /dev/tty ] && { [ -t 0 ] || [ -t 1 ]; }; then
|
||||
printf '\n\033[1;36m── Set up your panel admin login ──\033[0m\n' > /dev/tty
|
||||
if [ -z "$PANEL_ADMIN_EMAIL" ]; then
|
||||
printf 'Admin email [admin@panel.local]: ' > /dev/tty
|
||||
read -r _in_email < /dev/tty || _in_email=""
|
||||
PANEL_ADMIN_EMAIL="${_in_email:-}"
|
||||
fi
|
||||
while [ -z "$PANEL_ADMIN_PASSWORD" ]; do
|
||||
printf 'Admin password (min 8 chars, blank = auto-generate): ' > /dev/tty
|
||||
stty -echo 2>/dev/null < /dev/tty
|
||||
read -r _pw1 < /dev/tty || _pw1=""
|
||||
stty echo 2>/dev/null < /dev/tty; printf '\n' > /dev/tty
|
||||
if [ -z "$_pw1" ]; then
|
||||
warn "no password entered — the controller will generate one and print it."
|
||||
break
|
||||
fi
|
||||
if [ "${#_pw1}" -lt 8 ]; then
|
||||
printf ' password too short (need 8+); try again.\n' > /dev/tty
|
||||
continue
|
||||
fi
|
||||
printf 'Confirm password: ' > /dev/tty
|
||||
stty -echo 2>/dev/null < /dev/tty
|
||||
read -r _pw2 < /dev/tty || _pw2=""
|
||||
stty echo 2>/dev/null < /dev/tty; printf '\n' > /dev/tty
|
||||
if [ "$_pw1" != "$_pw2" ]; then
|
||||
printf ' passwords did not match; try again.\n' > /dev/tty
|
||||
continue
|
||||
fi
|
||||
PANEL_ADMIN_PASSWORD="$_pw1"
|
||||
done
|
||||
unset _in_email _pw1 _pw2
|
||||
fi
|
||||
|
||||
# ---- 1. Distro detection -------------------------------------------------
|
||||
DISTRO=unknown
|
||||
if [ -r /etc/os-release ]; then
|
||||
@@ -308,9 +204,7 @@ umask 077
|
||||
{
|
||||
echo "PANEL_DB_URL=$PANEL_DB_URL"
|
||||
[ -n "$PANEL_DB_PASSWORD" ] && echo "PANEL_DB_PASSWORD=$PANEL_DB_PASSWORD"
|
||||
[ -n "$PANEL_ADMIN_EMAIL" ] && echo "PANEL_ADMIN_EMAIL=$PANEL_ADMIN_EMAIL"
|
||||
[ -n "$PANEL_ADMIN_PASSWORD" ] && echo "PANEL_ADMIN_PASSWORD=$PANEL_ADMIN_PASSWORD"
|
||||
[ -n "$PANEL_PUBLIC_URL" ] && echo "PANEL_PUBLIC_URL=$PANEL_PUBLIC_URL"
|
||||
echo "PANEL_HTTP_PORT=$PANEL_HTTP_PORT"
|
||||
echo "PANEL_GRPC_PORT=$PANEL_GRPC_PORT"
|
||||
} > "$ENV_FILE"
|
||||
@@ -433,10 +327,10 @@ for _ in $(seq 1 60); do
|
||||
done
|
||||
curl -fsS "http://127.0.0.1:$PANEL_HTTP_PORT/login" >/dev/null 2>&1 || die "controller did not come up — check: journalctl -u panel-controller"
|
||||
|
||||
ADMIN_EMAIL="${PANEL_ADMIN_EMAIL:-admin@panel.local}"
|
||||
# BUG-5b: always give the operator a path to the password. If they supplied/chose
|
||||
# one, say so and point at the env file; otherwise scrape the generated one below.
|
||||
ADMIN_PW_SHOWN="(the password you set at install; also in $ENV_FILE)"
|
||||
ADMIN_EMAIL="admin@panel.local"
|
||||
# BUG-5b: always give the operator a path to the password. If they supplied 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)"
|
||||
if [ -z "$PANEL_ADMIN_PASSWORD" ]; then
|
||||
if [ "$FIRST_BOOT" = 1 ]; then
|
||||
# The controller prints the generated password once in its log.
|
||||
|
||||
Reference in New Issue
Block a user