658bda1d24
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
198 lines
6.5 KiB
Go
198 lines
6.5 KiB
Go
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
|
|
}
|