Skip to content

External Access — Port Allocation Design

Overview

Users can open additional ports on their server through the External Access tab. They choose an internal container port and protocol (TCP/UDP); the system assigns a random available port on the same IP already in use by the order's primary ExternalService provision. Users get 3 free allocations per order; beyond that they must purchase more.

Current Infrastructure

  • ip_pools table + IpPool domain type: tracks IP addresses with port ranges and exposure kind.
  • port_allocations table + PortAllocation domain type: one row per (ip_pool, port, protocol), linked to a provision_id.
  • ExternalServiceConfig on ProvisionConfig::ExternalService carries port_mappings: Vec<ExternalPortMapping>.
  • PortProtocol enum: Tcp | Udp | Both.

1. Domain Changes

1.1 Quota tracking — order_port_quotas

CREATE TABLE order_port_quotas (
    order_id   UUID PRIMARY KEY REFERENCES orders(id),
    extra_slots INT NOT NULL DEFAULT 0,
    updated_at TIMESTAMPTZ NOT NULL
);

Effective quota = 3 + extra_slots.

1.2 PortAllocation additions

  • Add order_id UUID NOT NULL REFERENCES orders(id) — enables count-by-order without joins
  • Add internal_port: u16 — needed for K8s routing
async fn find_by_order(&self, order_id: &OrderId) -> exn::Result<Vec<PortAllocation>, RepositoryError>;
async fn count_by_order(&self, order_id: &OrderId) -> exn::Result<usize, RepositoryError>;
async fn delete(&self, id: &PortAllocationId) -> exn::Result<(), RepositoryError>;

2. Application Service — ExternalAccessService

pub trait ExternalAccessService: shaku::Interface {
    async fn list_allocations(&self, order_id: &OrderId) -> exn::Result<Vec<PortAllocation>, ExternalAccessError>;
    async fn request_allocation(
        &self,
        order_id: &OrderId,
        internal_port: u16,
        protocol: PortProtocol,
    ) -> exn::Result<PortAllocation, ExternalAccessError>;
    async fn release_allocation(
        &self,
        order_id: &OrderId,
        allocation_id: &PortAllocationId,
    ) -> exn::Result<(), ExternalAccessError>;
}

pub enum ExternalAccessError {
    OrderNotFound,
    OrderNotProvisioned,
    QuotaExceeded,
    PortProtocolConflict,
    NoPoolAvailable,
    AllocationNotFound,
    Repository(RepositoryError),
}

request_allocation logic

  1. Load order, verify it's Active
  2. Find the order's ExternalService provision — use its ip_pool_id
  3. Count existing PortAllocation rows for this order; check against quota
  4. Reject if internal_port + protocol is already allocated for this order
  5. Load find_used_ports(ip_pool_id) and the pool's port range; build the free set; pick a random port
  6. Persist a new PortAllocation row
  7. Patch the K8s ExternalService to add the new port mapping via ClusterService

Port collision: retry up to 3 times, relying on the DB unique constraint on (ip_pool_id, port, protocol).

3. ClusterService changes

async fn patch_external_service_ports(
    &self,
    provision: &Provision,
    allocations: &[PortAllocation],
) -> exn::Result<(), ClusterServiceError>;

Reconstructs the full port list (primary game port + all user allocations) and patches the existing K8s Service.

4. API Endpoints

Method Path Description
GET /api/v1/orders/{order_id}/external-access List allocations + quota info
POST /api/v1/orders/{order_id}/external-access Request a new allocation
DELETE /api/v1/orders/{order_id}/external-access/{allocation_id} Release an allocation
POST /api/v1/admin/orders/{order_id}/external-access/quota Grant extra slots (admin)

GET response

{
  "free_slots": 3,
  "extra_slots": 0,
  "used": 1,
  "allocations": [
    {
      "id": "...",
      "external_ip": "1.2.3.4",
      "external_port": 32145,
      "internal_port": 25565,
      "protocol": "Tcp",
      "allocated_at": "..."
    }
  ]
}

POST request

{ "internal_port": 19132, "protocol": "Udp" }

Returns: 409 QuotaExceeded, 409 PortProtocolConflict, or 503 NoPoolAvailable on failure.

Admin quota endpoint

{ "extra_slots": 3 }

Sets extra_slots absolutely (not incrementally).

5. Quota System — Purchasing More

Intentionally out of scope for the initial implementation. Future options: - Catalog add-on: a PortSlotPack catalog item; on invoice paid, increment extra_slots - Manual: admin grants slots via the admin endpoint or direct DB update

6. Frontend — External Access UI

ExternalAccessTab (dashboard/components/orders/external-access-tab.tsx): - Fetches GET .../external-access on mount - Shows quota bar: {used} / {free_slots + extra_slots} allocations used - Lists allocations: external IP:port | internal port | protocol | delete button - "Open Port" button → OpenPortDialog (internal port input + protocol select)

7. Implementation Order

Phase What
1 DB & Domain (migrations, OrderPortQuota, PortAllocation updates)
2 ExternalAccessServiceImpl (list, request, release)
3 patch_external_service_ports in KubernetesClusterService
4 HTTP endpoints
5 Frontend (ExternalAccessTab, OpenPortDialog, PortAllocationRow)
6 Billing hook (deferred)