panel — open-source game server manager (public release)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 19:19:43 -07:00
commit 658bda1d24
2160 changed files with 300413 additions and 0 deletions
+197
View File
@@ -0,0 +1,197 @@
package main
// Per-agent host-port allocator.
//
// Why this exists: a probe-only "is the port currently bound?" check
// fails when servers are stopped — their intent isn't visible. Two ARK
// servers, one running on 7777, one stopped (also wanted 7777). Probe
// sees 7777 bound and bumps the new instance to 7778; later, the
// stopped server starts and now ITS 7777 collides with whatever now
// owns 7778. Cascading misery.
//
// The fix is to make the panel the source of truth for port assignments:
// every instance row carries its `assigned_ports` jsonb, and at create
// time the allocator considers BOTH live socket probes AND the
// per-agent reservation set from the DB.
//
// Operators set a per-agent port window (start, end) via the agent edit
// modal — typically 7000-7999 for one box, 8000-8999 for another. New
// instances get ports from that window in declaration order. Falls
// back to the global default 7000-9999 if the agent has no window set.
import (
"context"
"fmt"
"net"
"github.com/dbledeez/panel/controller/internal/db"
modulepkg "github.com/dbledeez/panel/pkg/module"
panelv1 "github.com/dbledeez/panel/proto/panel/v1"
)
// Default window when an agent has no port range configured. Wide
// enough to comfortably host ~50 game servers, narrow enough to keep
// out of OS-reserved high ranges.
const (
defaultPortRangeStart = 7000
defaultPortRangeEnd = 9999
)
// allocatePorts picks host ports for every port the manifest declares,
// respecting:
// - the agent's configured port window (or default 7000-9999)
// - host ports already reserved by OTHER instances on this same agent
// (read from the DB — captures stopped instances too, the whole point)
// - any operator-supplied overrides in `requested` (which usually come
// from the changePorts UI; preserved unless they collide)
// - live socket probes (catches non-panel processes squatting a port)
//
// Returns a fresh PortMap slice the caller passes to the agent in
// InstanceCreate.Ports — the agent uses these verbatim. Also returns
// a name→hostport map for persistence.
//
// Returns an error if the window is exhausted for any port — operator
// must widen the range or delete some instances.
func allocatePorts(
ctx context.Context,
dbHandle *db.DB,
agentID, instanceID string,
manifest *modulepkg.Manifest,
requested []*panelv1.PortMap,
) ([]*panelv1.PortMap, map[string]uint32, error) {
if manifest == nil || manifest.Runtime.Docker == nil {
return nil, map[string]uint32{}, nil
}
// Agent port range — falls back to global default.
rangeStart, rangeEnd := defaultPortRangeStart, defaultPortRangeEnd
if dbHandle != nil {
if s, e, err := dbHandle.GetAgentPortRange(ctx, agentID); err == nil && s > 0 && e > 0 {
rangeStart, rangeEnd = int(s), int(e)
}
}
// Build the "already-taken" set from every OTHER instance on this
// agent. Includes stopped ones — that's the whole point.
taken := map[string]bool{} // key: "tcp:7777"
if dbHandle != nil {
rows, err := dbHandle.ListInstances(ctx, agentID)
if err == nil {
for _, r := range rows {
if r.InstanceID == instanceID {
continue // exclude self
}
for _, hp := range r.AssignedPorts {
if hp == 0 {
continue
}
// Mark BOTH protocols as taken — we don't know from
// AssignedPorts alone whether it was tcp or udp, and
// at the host level a port is either bound or not.
taken[fmt.Sprintf("tcp:%d", hp)] = true
taken[fmt.Sprintf("udp:%d", hp)] = true
}
}
}
}
// Operator-supplied requested overrides — usually only on changePorts.
// We honor them when they fit the window AND aren't taken; otherwise
// fall back to allocator. Letting an operator pin a port outside the
// window would defeat the whole "this agent uses 7000-7999" guarantee.
requestedByName := map[string]uint32{}
for _, p := range requested {
if p != nil && p.Name != "" && p.HostPort != 0 {
requestedByName[p.Name] = p.HostPort
}
}
out := make([]*panelv1.PortMap, 0, len(manifest.Ports))
assigned := map[string]uint32{}
for _, p := range manifest.Ports {
if p.Name == "" || p.Default == 0 {
continue
}
proto := p.Proto
if proto != "tcp" && proto != "udp" {
proto = "tcp"
}
// Try the operator's requested port first if it's available.
var chosen uint32
if want, ok := requestedByName[p.Name]; ok {
if isAvailable(int(want), proto, p.Internal, taken) {
chosen = want
}
}
// Otherwise scan the agent's window for a free port. We include
// the manifest default in the scan (so single-instance setups
// keep their canonical 7777) by starting at max(rangeStart, default)
// only when the default falls inside the window — otherwise just
// scan the window from start.
if chosen == 0 {
scanStart := rangeStart
if int(p.Default) >= rangeStart && int(p.Default) <= rangeEnd {
// Try the canonical default first (preserves ports for
// the first instance); if taken, fall through to scan.
if isAvailable(int(p.Default), proto, p.Internal, taken) {
chosen = uint32(p.Default)
}
}
if chosen == 0 {
for port := scanStart; port <= rangeEnd; port++ {
if isAvailable(port, proto, p.Internal, taken) {
chosen = uint32(port)
break
}
}
}
}
if chosen == 0 {
return nil, nil, fmt.Errorf("no free %s port for %q in agent window %d-%d (all reserved or in use)", proto, p.Name, rangeStart, rangeEnd)
}
// Mark the chosen port as taken so sibling ports in this same
// allocation cycle don't re-pick it.
taken[fmt.Sprintf("tcp:%d", chosen)] = true
taken[fmt.Sprintf("udp:%d", chosen)] = true
assigned[p.Name] = chosen
out = append(out, &panelv1.PortMap{
Name: p.Name,
Proto: proto,
ContainerPort: uint32(p.Default), // container-side stays at the manifest default
HostPort: chosen,
Internal: p.Internal,
})
}
return out, assigned, nil
}
// isAvailable returns true when port is not already in `taken` AND can
// actually be bound right now (catches non-panel processes squatting it).
func isAvailable(port int, proto string, internal bool, taken map[string]bool) bool {
if port < 1 || port > 65535 {
return false
}
key := fmt.Sprintf("%s:%d", proto, port)
if taken[key] {
return false
}
host := "0.0.0.0"
if internal {
host = "127.0.0.1"
}
addr := fmt.Sprintf("%s:%d", host, port)
if proto == "udp" {
l, err := net.ListenPacket("udp", addr)
if err != nil {
return false
}
_ = l.Close()
return true
}
l, err := net.Listen("tcp", addr)
if err != nil {
return false
}
_ = l.Close()
return true
}