panel — open-source game server manager (public release)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user