590 lines
16 KiB
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
|
|
}
|