MWSE/internal/services/ippressure.go

590 lines
16 KiB
Go

package services
import (
"fmt"
"math/rand/v2"
"sync"
"git.saqut.com/saqut/mwse/internal/protocol"
"git.saqut.com/saqut/mwse/internal/ws"
)
// shortCodeAlphabet is the 22-letter set the original used (J, Q, U, W absent).
// Three letters give 22³ = 10,648 unique codes.
const shortCodeAlphabet = "ABCDEFGHIKLMNOPRSTVXYZ"
// randomProbes is how many random candidates we try before falling back to a
// sequential scan. At low occupancy the first probe almost always succeeds.
const randomProbes = 256
// Announcer receives address allocation events for external monitoring.
type Announcer interface {
Announce(kind, action, clientID string, value any)
}
type noopAnnouncer struct{}
func (noopAnnouncer) Announce(string, string, string, any) {}
// SubNet is a /24 virtual sub-network (10.A.B.0/24).
// Clients in a group or room can draw IPs from within a dedicated prefix
// so they appear to share the same network segment.
//
// Thread-safety: protected by its own mutex; IPPressure's outer mutex guards
// the subnet registry but not the per-subnet host tables.
type SubNet struct {
Prefix string // "10.A.B" — the /24 prefix
Owner string // room / group id that claimed this subnet
mu sync.Mutex
hosts map[byte]string // host byte [1..254] → clientID
}
// Alloc assigns a random host address within this /24 to clientID.
func (sn *SubNet) Alloc(clientID string) (string, bool) {
sn.mu.Lock()
defer sn.mu.Unlock()
// Random probe first.
for range randomProbes {
h := byte(rand.IntN(254)) + 1 // [1..254]
if _, busy := sn.hosts[h]; !busy {
sn.hosts[h] = clientID
return fmt.Sprintf("%s.%d", sn.Prefix, h), true
}
}
// Sequential fallback (subnet is more than 98% full).
for h := byte(1); h < 255; h++ {
if _, busy := sn.hosts[h]; !busy {
sn.hosts[h] = clientID
return fmt.Sprintf("%s.%d", sn.Prefix, h), true
}
}
return "", false // /24 exhausted (~254 hosts)
}
// Release frees the host address previously allocated to clientID.
func (sn *SubNet) Release(clientID string) {
sn.mu.Lock()
defer sn.mu.Unlock()
for h, id := range sn.hosts {
if id == clientID {
delete(sn.hosts, h)
return
}
}
}
// Whois returns the clientID that holds the given IP within this subnet.
func (sn *SubNet) Whois(ip string) (string, bool) {
sn.mu.Lock()
defer sn.mu.Unlock()
var host byte
_, err := fmt.Sscanf(ip, sn.Prefix+".%d", &host)
if err != nil {
return "", false
}
id, ok := sn.hosts[host]
return id, ok
}
// IPPressure allocates virtual addresses to clients.
//
// Allocation strategy (#41): random probe with sequential fallback.
// At typical occupancy (< 1 % of the address space) the first random
// candidate is free, so allocation is O(1) in practice.
//
// Sub-networks (#40): callers can reserve a /24 prefix and hand out IPs
// within it. The global flat space and the subnet space are disjoint; a
// client that holds a subnet IP is also tracked in busyIP so whois/APIPAddress
// queries still work.
type IPPressure struct {
ann Announcer
mu sync.Mutex
busyNumber map[int]string // number → clientID
busyCode map[string]string // shortcode → clientID
busyIP map[string]string // ip → clientID (both flat and subnet)
// Sub-network registry.
subnets map[string]*SubNet // prefix ("10.A.B") → SubNet
clientSN map[string]string // clientID → subnet prefix (for disconnect cleanup)
}
// NewIPPressure builds an allocator. A nil announcer becomes a no-op.
func NewIPPressure(ann Announcer) *IPPressure {
if ann == nil {
ann = noopAnnouncer{}
}
return &IPPressure{
ann: ann,
busyNumber: make(map[int]string),
busyCode: make(map[string]string),
busyIP: make(map[string]string),
subnets: make(map[string]*SubNet),
clientSN: make(map[string]string),
}
}
// ---- Flat IP address (10.x.x.x, random) --------------------------------
func (p *IPPressure) lockIP(clientID string) string {
p.mu.Lock()
defer p.mu.Unlock()
// Random probe across the full 10.0.0.0/8.
for range randomProbes {
a := byte(rand.IntN(255)) + 1
b := byte(rand.IntN(256))
c := byte(rand.IntN(255)) + 1
ip := fmt.Sprintf("10.%d.%d.%d", a, b, c)
if _, busy := p.busyIP[ip]; !busy {
p.busyIP[ip] = clientID
p.ann.Announce("AP_IPADDRESS", "LOCK", clientID, ip)
return ip
}
}
// Sequential fallback (extremely unlikely with random probes).
for a := 1; a <= 255; a++ {
for b := 0; b <= 255; b++ {
for c := 1; c <= 255; c++ {
ip := fmt.Sprintf("10.%d.%d.%d", a, b, c)
if _, busy := p.busyIP[ip]; !busy {
p.busyIP[ip] = clientID
p.ann.Announce("AP_IPADDRESS", "LOCK", clientID, ip)
return ip
}
}
}
}
return "" // address space exhausted (> ~16 million clients)
}
func (p *IPPressure) releaseIP(ip string) {
p.mu.Lock()
defer p.mu.Unlock()
if clientID, ok := p.busyIP[ip]; ok {
p.ann.Announce("AP_IPADDRESS", "RELEASE", clientID, ip)
delete(p.busyIP, ip)
}
}
func (p *IPPressure) whoisIP(ip string) (string, bool) {
p.mu.Lock()
defer p.mu.Unlock()
id, ok := p.busyIP[ip]
return id, ok
}
// ---- Number (random in [24, 99999]) -------------------------------------
func (p *IPPressure) lockNumber(clientID string) int {
p.mu.Lock()
defer p.mu.Unlock()
const lo, hi = 24, 99999
for range randomProbes {
n := rand.IntN(hi-lo+1) + lo
if _, busy := p.busyNumber[n]; !busy {
p.busyNumber[n] = clientID
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
return n
}
}
// Sequential fallback.
for n := lo; n <= hi; n++ {
if _, busy := p.busyNumber[n]; !busy {
p.busyNumber[n] = clientID
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
return n
}
}
// Beyond hi: keep counting up without bound.
for n := hi + 1; ; n++ {
if _, busy := p.busyNumber[n]; !busy {
p.busyNumber[n] = clientID
p.ann.Announce("AP_NUMBER", "LOCK", clientID, n)
return n
}
}
}
func (p *IPPressure) releaseNumber(n int) {
p.mu.Lock()
defer p.mu.Unlock()
if clientID, ok := p.busyNumber[n]; ok {
p.ann.Announce("AP_NUMBER", "RELEASE", clientID, n)
delete(p.busyNumber, n)
}
}
func (p *IPPressure) whoisNumber(n int) (string, bool) {
p.mu.Lock()
defer p.mu.Unlock()
id, ok := p.busyNumber[n]
return id, ok
}
// ---- Short code (random 3-letter from restricted alphabet) --------------
func (p *IPPressure) lockCode(clientID string) string {
p.mu.Lock()
defer p.mu.Unlock()
n := len(shortCodeAlphabet)
for range randomProbes {
code := string([]byte{
shortCodeAlphabet[rand.IntN(n)],
shortCodeAlphabet[rand.IntN(n)],
shortCodeAlphabet[rand.IntN(n)],
})
if _, busy := p.busyCode[code]; !busy {
p.busyCode[code] = clientID
p.ann.Announce("AP_SHORTCODE", "LOCK", clientID, code)
return code
}
}
// Sequential fallback.
for _, a := range shortCodeAlphabet {
for _, b := range shortCodeAlphabet {
for _, d := range shortCodeAlphabet {
code := string([]rune{a, b, d})
if _, busy := p.busyCode[code]; !busy {
p.busyCode[code] = clientID
p.ann.Announce("AP_SHORTCODE", "LOCK", clientID, code)
return code
}
}
}
}
return "" // all 10,648 codes in use
}
func (p *IPPressure) releaseCode(code string) {
p.mu.Lock()
defer p.mu.Unlock()
if clientID, ok := p.busyCode[code]; ok {
p.ann.Announce("AP_SHORTCODE", "RELEASE", clientID, code)
delete(p.busyCode, code)
}
}
func (p *IPPressure) whoisCode(code string) (string, bool) {
p.mu.Lock()
defer p.mu.Unlock()
id, ok := p.busyCode[code]
return id, ok
}
// ---- Sub-network (#40) -------------------------------------------------
// allocSubNet reserves a random /24 prefix (10.A.B.0/24) for the caller.
// The prefix is held until releaseSubNet is called or the client disconnects.
func (p *IPPressure) allocSubNet(ownerID string) (*SubNet, bool) {
p.mu.Lock()
defer p.mu.Unlock()
for range randomProbes {
a := byte(rand.IntN(255)) + 1
b := byte(rand.IntN(256))
prefix := fmt.Sprintf("10.%d.%d", a, b)
if _, busy := p.subnets[prefix]; !busy {
sn := &SubNet{Prefix: prefix, Owner: ownerID, hosts: make(map[byte]string)}
p.subnets[prefix] = sn
p.ann.Announce("AP_SUBNET", "ALLOC", ownerID, prefix)
return sn, true
}
}
// Sequential fallback.
for a := 1; a <= 255; a++ {
for b := 0; b <= 255; b++ {
prefix := fmt.Sprintf("10.%d.%d", a, b)
if _, busy := p.subnets[prefix]; !busy {
sn := &SubNet{Prefix: prefix, Owner: ownerID, hosts: make(map[byte]string)}
p.subnets[prefix] = sn
p.ann.Announce("AP_SUBNET", "ALLOC", ownerID, prefix)
return sn, true
}
}
}
return nil, false
}
// getSubNet looks up the SubNet owned by ownerID.
func (p *IPPressure) getSubNet(ownerID string) (*SubNet, bool) {
p.mu.Lock()
defer p.mu.Unlock()
for _, sn := range p.subnets {
if sn.Owner == ownerID {
return sn, true
}
}
return nil, false
}
// releaseSubNet frees a /24 prefix and removes all host addresses it contained
// from the global busyIP table.
func (p *IPPressure) releaseSubNet(prefix string) {
p.mu.Lock()
sn, ok := p.subnets[prefix]
if !ok {
p.mu.Unlock()
return
}
delete(p.subnets, prefix)
p.mu.Unlock()
sn.mu.Lock()
defer sn.mu.Unlock()
for h, cid := range sn.hosts {
ip := fmt.Sprintf("%s.%d", prefix, h)
p.mu.Lock()
delete(p.busyIP, ip)
p.mu.Unlock()
p.ann.Announce("AP_SUBNET_IP", "RELEASE", cid, ip)
}
p.ann.Announce("AP_SUBNET", "RELEASE", sn.Owner, prefix)
}
// ---- Service registration ----------------------------------------------
// registerIPPressure wires the alloc/realloc/release/whois handlers and the
// disconnect cleanup. The allocator instance is returned for tests.
func registerIPPressure(hub *ws.Hub, ann Announcer) *IPPressure {
p := NewIPPressure(ann)
// --- Flat IP address ---
hub.Register("alloc/APIPAddress", func(c *ws.Client, m protocol.Message) any {
if ip := c.APIP(); ip != "" {
return map[string]any{"status": "success", "ip": ip}
}
ip := p.lockIP(c.ID)
if ip == "" {
return map[string]any{"status": "fail"}
}
c.SetAPIP(ip)
return map[string]any{"status": "success", "ip": ip}
})
hub.Register("realloc/APIPAddress", func(c *ws.Client, m protocol.Message) any {
old := c.APIP()
if old == "" {
return map[string]any{"status": "fail"}
}
ip := p.lockIP(c.ID)
if ip == "" {
return map[string]any{"status": "fail"}
}
p.releaseIP(old)
c.SetAPIP(ip)
return map[string]any{"status": "success", "ip": ip}
})
hub.Register("release/APIPAddress", func(c *ws.Client, m protocol.Message) any {
p.releaseIP(c.APIP())
c.SetAPIP("")
return success()
})
hub.Register("whois/APIPAddress", func(c *ws.Client, m protocol.Message) any {
if id, ok := p.whoisIP(m.Str("whois")); ok {
return map[string]any{"status": "success", "socket": id}
}
return map[string]any{"status": "fail"}
})
// --- Number ---
hub.Register("alloc/APNumber", func(c *ws.Client, m protocol.Message) any {
if n := c.APNumber(); n != 0 {
return map[string]any{"status": "success", "number": n}
}
n := p.lockNumber(c.ID)
c.SetAPNumber(n)
return map[string]any{"status": "success", "number": n}
})
hub.Register("realloc/APNumber", func(c *ws.Client, m protocol.Message) any {
old := c.APNumber()
if old == 0 {
return map[string]any{"status": "fail"}
}
n := p.lockNumber(c.ID)
p.releaseNumber(old)
c.SetAPNumber(n)
return map[string]any{"status": "success", "number": n}
})
hub.Register("release/APNumber", func(c *ws.Client, m protocol.Message) any {
p.releaseNumber(c.APNumber())
c.SetAPNumber(0)
return success()
})
hub.Register("whois/APNumber", func(c *ws.Client, m protocol.Message) any {
if id, ok := p.whoisNumber(m.Int("whois")); ok {
return map[string]any{"status": "success", "socket": id}
}
return map[string]any{"status": "fail"}
})
// --- Short code ---
hub.Register("alloc/APShortCode", func(c *ws.Client, m protocol.Message) any {
if code := c.APShortCode(); code != "" {
return map[string]any{"status": "success", "code": code}
}
code := p.lockCode(c.ID)
if code == "" {
return map[string]any{"status": "fail"}
}
c.SetAPShortCode(code)
return map[string]any{"status": "success", "code": code}
})
hub.Register("realloc/APShortCode", func(c *ws.Client, m protocol.Message) any {
old := c.APShortCode()
if old == "" {
return map[string]any{"status": "fail"}
}
code := p.lockCode(c.ID)
if code == "" {
return map[string]any{"status": "fail"}
}
p.releaseCode(old)
c.SetAPShortCode(code)
return map[string]any{"status": "success", "code": code}
})
hub.Register("release/APShortCode", func(c *ws.Client, m protocol.Message) any {
p.releaseCode(c.APShortCode())
c.SetAPShortCode("")
return success()
})
hub.Register("whois/APShortCode", func(c *ws.Client, m protocol.Message) any {
if id, ok := p.whoisCode(m.Str("whois")); ok {
return map[string]any{"status": "success", "socket": id}
}
return map[string]any{"status": "fail"}
})
// --- Sub-network (#40) ---
// alloc/APSubNet: claim a /24 prefix for a group/room. The caller becomes
// the subnet owner; its members can then call alloc/APSubNetIP.
hub.Register("alloc/APSubNet", func(c *ws.Client, m protocol.Message) any {
// Idempotent: return existing prefix if the caller already owns one.
if sn, ok := p.getSubNet(c.ID); ok {
return map[string]any{"status": "success", "prefix": sn.Prefix}
}
sn, ok := p.allocSubNet(c.ID)
if !ok {
return map[string]any{"status": "fail"}
}
// Track on the client so disconnect cleanup works.
p.mu.Lock()
p.clientSN[c.ID] = sn.Prefix
p.mu.Unlock()
return map[string]any{"status": "success", "prefix": sn.Prefix}
})
// release/APSubNet: free the prefix. All host IPs are released.
hub.Register("release/APSubNet", func(c *ws.Client, m protocol.Message) any {
p.mu.Lock()
prefix, had := p.clientSN[c.ID]
if had {
delete(p.clientSN, c.ID)
}
p.mu.Unlock()
if had {
p.releaseSubNet(prefix)
}
return success()
})
// alloc/APSubNetIP: allocate a host IP within the subnet identified by
// the "prefix" field. The caller need not be the owner.
hub.Register("alloc/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
prefix := m.Str("prefix")
p.mu.Lock()
sn, ok := p.subnets[prefix]
p.mu.Unlock()
if !ok {
return map[string]any{"status": "fail", "message": "subnet not found"}
}
ip, ok := sn.Alloc(c.ID)
if !ok {
return map[string]any{"status": "fail", "message": "subnet exhausted"}
}
// Register in the global table so flat whois works.
p.mu.Lock()
p.busyIP[ip] = c.ID
p.mu.Unlock()
// If the client later disconnects, release via the subnet too.
p.mu.Lock()
p.clientSN[c.ID] = prefix
p.mu.Unlock()
c.SetAPIP(ip)
return map[string]any{"status": "success", "ip": ip}
})
// release/APSubNetIP: free the current subnet IP of the caller.
hub.Register("release/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
ip := c.APIP()
if ip == "" {
return success()
}
p.mu.Lock()
prefix := p.clientSN[c.ID]
sn, hasSN := p.subnets[prefix]
delete(p.clientSN, c.ID)
delete(p.busyIP, ip)
p.mu.Unlock()
if hasSN {
sn.Release(c.ID)
}
c.SetAPIP("")
return success()
})
// whois/APSubNetIP: look up a host IP within a given subnet.
hub.Register("whois/APSubNetIP", func(c *ws.Client, m protocol.Message) any {
prefix := m.Str("prefix")
ip := m.Str("whois")
p.mu.Lock()
sn, ok := p.subnets[prefix]
p.mu.Unlock()
if !ok {
return map[string]any{"status": "fail"}
}
if id, ok := sn.Whois(ip); ok {
return map[string]any{"status": "success", "socket": id}
}
return map[string]any{"status": "fail"}
})
// Release every address when a client disconnects.
hub.OnDisconnect(func(c *ws.Client) {
if ip := c.APIP(); ip != "" {
p.releaseIP(ip)
// Also release from subnet if applicable.
p.mu.Lock()
if prefix, ok := p.clientSN[c.ID]; ok {
if sn, snOK := p.subnets[prefix]; snOK {
sn.Release(c.ID)
}
delete(p.clientSN, c.ID)
}
p.mu.Unlock()
}
if c.APNumber() != 0 {
p.releaseNumber(c.APNumber())
}
if code := c.APShortCode(); code != "" {
p.releaseCode(code)
}
// Release owned subnet prefix on disconnect.
p.mu.Lock()
prefix, hadSN := p.clientSN[c.ID]
if hadSN {
delete(p.clientSN, c.ID)
}
p.mu.Unlock()
if hadSN {
p.releaseSubNet(prefix)
}
})
return p
}