Phase8
Some checks failed
CI / Node — lint, typecheck, test, build (push) Failing after 9s
CI / Go — node-agent tests (push) Failing after 9s
CI / Go — edge-gateway build (push) Successful in 17s

This commit is contained in:
smueller
2026-06-26 13:33:03 +02:00
parent ab21f53cdd
commit b335f6a497
39 changed files with 1477 additions and 20 deletions

View File

@@ -68,6 +68,12 @@ NODE_TLS_SKIP_VERIFY=true
# --- Edge Gateway (Phase 8) --- # --- Edge Gateway (Phase 8) ---
EDGE_GATEWAY_ENABLED=false EDGE_GATEWAY_ENABLED=false
EDGE_LISTEN_ADDR=:25565
EDGE_LISTEN_PORT=25565
EDGE_PUBLIC_HOST=127.0.0.1
EDGE_INTERNAL_API_KEY=local-dev-edge-key-change-me-32chars
EDGE_START_WAIT_SECONDS=180
EDGE_POLL_INTERVAL_MS=2000
# --- Catalog Providers --- # --- Catalog Providers ---
MODRINTH_USER_AGENT=HexaHostGameCloud/0.1.0 (contact@example.net) MODRINTH_USER_AGENT=HexaHostGameCloud/0.1.0 (contact@example.net)

View File

@@ -92,7 +92,7 @@ make build
## Aktueller Stand ## Aktueller Stand
**Phase 7** (Idle Shutdown & Free-Tier) — abgeschlossen. **Phase 8** (DNS & Join-to-Start) — abgeschlossen.
Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details. Siehe [docs/IMPLEMENTATION_STATUS.md](docs/IMPLEMENTATION_STATUS.md) für Details.

View File

@@ -20,6 +20,7 @@
"@hexahost/config": "workspace:*", "@hexahost/config": "workspace:*",
"@hexahost/contracts": "workspace:*", "@hexahost/contracts": "workspace:*",
"@hexahost/database": "workspace:*", "@hexahost/database": "workspace:*",
"@hexahost/dns": "workspace:*",
"@hexahost/metering": "workspace:*", "@hexahost/metering": "workspace:*",
"@hexahost/scheduler": "workspace:*", "@hexahost/scheduler": "workspace:*",
"@hexahost/storage": "workspace:*", "@hexahost/storage": "workspace:*",

View File

@@ -8,6 +8,7 @@ import { BillingModule } from './billing/billing.module';
import { CatalogModule } from './catalog/catalog.module'; import { CatalogModule } from './catalog/catalog.module';
import { AdminModule } from './admin/admin.module'; import { AdminModule } from './admin/admin.module';
import { NodesModule } from './nodes/nodes.module'; import { NodesModule } from './nodes/nodes.module';
import { EdgeModule } from './edge/edge.module';
import { ServersModule } from './servers/servers.module'; import { ServersModule } from './servers/servers.module';
import { AppConfigModule } from './config/app-config.module'; import { AppConfigModule } from './config/app-config.module';
@@ -44,6 +45,7 @@ import { LoggerModule } from 'nestjs-pino';
AuthModule, AuthModule,
CatalogModule, CatalogModule,
BillingModule, BillingModule,
EdgeModule,
ServersModule, ServersModule,
NodesModule, NodesModule,
AdminModule, AdminModule,

View File

@@ -0,0 +1,30 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import type { FastifyRequest } from 'fastify';
const EDGE_KEY_HEADER = 'x-hexahost-edge-key';
function getEdgeInternalApiKey(): string {
return (
process.env['EDGE_INTERNAL_API_KEY'] ??
'local-dev-edge-key-change-me-32chars'
);
}
@Injectable()
export class EdgeInternalGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const provided = request.headers[EDGE_KEY_HEADER];
if (typeof provided !== 'string' || provided !== getEdgeInternalApiKey()) {
throw new UnauthorizedException('Invalid edge internal API key');
}
return true;
}
}

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { SkipThrottle } from '@nestjs/throttler';
import type { EdgeResolveResponse, EdgeStartResponse } from '@hexahost/contracts';
import { edgeStartRequestSchema } from '@hexahost/contracts';
import { EdgeInternalGuard } from './edge-internal.guard';
import { EdgeService } from './edge.service';
@Controller('internal/edge/v1')
@UseGuards(EdgeInternalGuard)
@SkipThrottle()
export class EdgeController {
constructor(private readonly edgeService: EdgeService) {}
@Get('resolve/:slug')
resolve(
@Param('slug') slug: string,
@Query('clientIp') clientIp?: string,
): Promise<EdgeResolveResponse> {
return this.edgeService.resolveSlug(slug, clientIp);
}
@Post('start/:slug')
start(
@Param('slug') slug: string,
@Body() body: unknown,
): Promise<EdgeStartResponse> {
const parsed = edgeStartRequestSchema.parse(body);
return this.edgeService.requestJoinStart(slug, parsed.clientIp);
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { ServersModule } from '../servers/servers.module';
import { EdgeController } from './edge.controller';
import { EdgeInternalGuard } from './edge-internal.guard';
import { EdgeService } from './edge.service';
import { JoinService } from './join.service';
@Module({
imports: [ServersModule],
controllers: [EdgeController],
providers: [EdgeService, JoinService, EdgeInternalGuard],
exports: [JoinService, EdgeService],
})
export class EdgeModule {}

View File

@@ -0,0 +1,199 @@
import {
BadRequestException,
HttpException,
HttpStatus,
Inject,
Injectable,
NotFoundException,
} from '@nestjs/common';
import type Redis from 'ioredis';
import type { EdgeResolveResponse, EdgeStartResponse } from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database';
import { getPlayDomain } from '@hexahost/dns';
import { PrismaService } from '../prisma/prisma.service';
import { ServersService, REDIS_CLIENT } from '../servers/servers.service';
import { JoinService } from './join.service';
const STARTABLE_STATUSES = new Set<GameServer['status']>(['STOPPED', 'UNKNOWN']);
const WAIT_STATUSES = new Set<GameServer['status']>([
'STARTING',
'QUEUED',
'PROVISIONING',
'INSTALLING',
'STOPPING',
]);
const REJECT_STATUSES = new Set<GameServer['status']>([
'DRAFT',
'ERROR',
'DELETING',
'DELETED',
'BACKING_UP',
'RESTORING',
]);
@Injectable()
export class EdgeService {
constructor(
private readonly prisma: PrismaService,
private readonly joinService: JoinService,
private readonly serversService: ServersService,
@Inject(REDIS_CLIENT) private readonly redis: Redis,
) {}
async resolveSlug(slug: string, clientIp?: string): Promise<EdgeResolveResponse> {
const server = await this.prisma.gameServer.findFirst({
where: { joinSlug: slug },
include: { node: true },
});
if (!server) {
throw new NotFoundException(`Unknown join slug: ${slug}`);
}
return this.buildResolveResponse(server, clientIp);
}
async requestJoinStart(
slug: string,
clientIp: string,
): Promise<EdgeStartResponse> {
if (!(await this.checkStartRateLimit(slug, clientIp))) {
throw new HttpException(
'Join-to-start rate limit exceeded',
HttpStatus.TOO_MANY_REQUESTS,
);
}
const server = await this.prisma.gameServer.findFirst({
where: { joinSlug: slug },
include: { node: true },
});
if (!server) {
throw new NotFoundException(`Unknown join slug: ${slug}`);
}
if (!server.joinToStartEnabled) {
throw new BadRequestException('Join-to-start is disabled for this server');
}
const initial = await this.buildResolveResponse(server, clientIp);
if (initial.action === 'proxy') {
return { resolve: initial, started: false };
}
if (initial.action === 'reject') {
return { resolve: initial, started: false };
}
if (initial.action === 'wait') {
return { resolve: initial, started: false };
}
const started = await this.serversService.startServerInternal(
server.id,
`join-to-start:${clientIp}`,
);
const refreshed = await this.prisma.gameServer.findUniqueOrThrow({
where: { id: server.id },
include: { node: true },
});
return {
resolve: await this.buildResolveResponse(refreshed, clientIp),
started: true,
jobId: started.jobId,
queuePosition: started.queuePosition,
};
}
private async buildResolveResponse(
server: GameServer & { node: { hostname: string } | null },
clientIp?: string,
): Promise<EdgeResolveResponse> {
const backend =
server.node && server.hostPort !== null
? { host: server.node.hostname, port: server.hostPort }
: null;
const base = {
serverId: server.id,
slug: server.joinSlug ?? '',
status: server.status,
joinToStartEnabled: server.joinToStartEnabled,
edition: server.edition,
backend,
};
if (REJECT_STATUSES.has(server.status)) {
return {
...base,
action: 'reject',
message: `Server is ${server.status.toLowerCase()}`,
};
}
if (server.status === 'RUNNING' && backend) {
return { ...base, action: 'proxy', backend };
}
if (!server.joinToStartEnabled) {
return {
...base,
action: 'reject',
message: 'Join-to-start is disabled; start the server from the panel',
};
}
if (WAIT_STATUSES.has(server.status)) {
return {
...base,
action: 'wait',
message: `Server is ${server.status.toLowerCase()}`,
};
}
if (STARTABLE_STATUSES.has(server.status)) {
return {
...base,
action: 'start',
message: clientIp ? `Start requested from ${clientIp}` : undefined,
};
}
return {
...base,
action: 'reject',
message: `Cannot join while server is ${server.status.toLowerCase()}`,
};
}
private async checkStartRateLimit(slug: string, clientIp: string): Promise<boolean> {
const slugKey = `edge:start:slug:${slug}`;
const ipKey = `edge:start:ip:${clientIp}`;
const slugCount = await this.redis.incr(slugKey);
if (slugCount === 1) {
await this.redis.expire(slugKey, 60);
}
if (slugCount > 3) {
return false;
}
const ipCount = await this.redis.incr(ipKey);
if (ipCount === 1) {
await this.redis.expire(ipKey, 60);
}
return ipCount <= 10;
}
getPlayDomain(): string {
return getPlayDomain();
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@nestjs/common';
import { assignJoinAddress, formatJoinAddress, getPlayDomain } from '@hexahost/dns';
import type { GameServer, ServerDnsRecord } from '@hexahost/database';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class JoinService {
constructor(private readonly prisma: PrismaService) {}
getPlayDomain(): string {
return getPlayDomain();
}
formatJoinHostname(server: Pick<GameServer, 'joinSlug' | 'edition'>): string | null {
return formatJoinAddress(server.joinSlug, server.edition);
}
async ensureJoinAddress(serverId: string): Promise<string | null> {
const result = await assignJoinAddress(serverId);
return result?.joinHostname ?? null;
}
async getDnsRecords(serverId: string): Promise<ServerDnsRecord[]> {
return this.prisma.serverDnsRecord.findMany({
where: { serverId },
orderBy: { recordType: 'asc' },
});
}
}

View File

@@ -15,6 +15,7 @@ import type {
ServerResponse, ServerResponse,
} from '@hexahost/contracts'; } from '@hexahost/contracts';
import type { GameServer } from '@hexahost/database'; import type { GameServer } from '@hexahost/database';
import { formatJoinAddress } from '@hexahost/dns';
type GameServerStatus = GameServer['status']; type GameServerStatus = GameServer['status'];
@@ -177,6 +178,89 @@ export class ServersService {
}; };
} }
async startServerInternal(
serverId: string,
reason: string,
): Promise<ServerActionResponse> {
const server = await this.prisma.gameServer.findUnique({
where: { id: serverId },
});
if (!server) {
throw new ConflictException('Server not found');
}
this.serverState.assertNotInProgress(server.status);
const targetStatus = this.serverState.resolveStartTarget(server.status);
this.serverState.assertTransition(server.status, targetStatus);
const correlationId = randomUUID();
if (targetStatus === 'STARTING') {
await this.billing.assertCanStartServer(server.userId);
const canStart = await this.scheduling.canStartServer(server);
if (!canStart) {
const queued = await this.scheduling.enqueueStart(
server,
server.userId,
correlationId,
);
return {
server: this.toResponse(queued.server),
queuePosition: queued.queuePosition,
};
}
}
const updated = await this.transitionServer(
server,
targetStatus,
reason,
null,
correlationId,
);
let jobId: string | undefined;
if (targetStatus === 'PROVISIONING') {
const job = await this.provisioningQueue.add(
'provision-server',
{ serverId, correlationId },
{ jobId: correlationId },
);
jobId = job.id;
} else {
const job = await this.lifecycleQueue.add(
'start-server',
{ serverId, action: 'start', correlationId },
{ jobId: correlationId },
);
jobId = job.id;
if (updated.nodeId) {
await this.publishNodeCommand(updated.nodeId, {
protocolVersion: 1,
messageId: randomUUID(),
correlationId,
type: 'server.start',
timestamp: new Date().toISOString(),
payload: {
serverId,
generation: updated.version,
desiredGeneration: updated.version,
},
});
}
}
return {
server: this.toResponse(updated),
jobId,
};
}
async getStartQueue(userId: string, serverId: string) { async getStartQueue(userId: string, serverId: string) {
return this.scheduling.getStartQueue(userId, serverId); return this.scheduling.getStartQueue(userId, serverId);
} }
@@ -370,6 +454,9 @@ export class ServersService {
ramMb: server.ramMb, ramMb: server.ramMb,
idleShutdownEnabled: server.idleShutdownEnabled, idleShutdownEnabled: server.idleShutdownEnabled,
idleStopAt: server.idleStopAt?.toISOString() ?? null, idleStopAt: server.idleStopAt?.toISOString() ?? null,
joinSlug: server.joinSlug,
joinHostname: formatJoinAddress(server.joinSlug, server.edition),
joinToStartEnabled: server.joinToStartEnabled,
createdAt: server.createdAt.toISOString(), createdAt: server.createdAt.toISOString(),
updatedAt: server.updatedAt.toISOString(), updatedAt: server.updatedAt.toISOString(),
}; };

View File

@@ -0,0 +1,30 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
describe('phase 8 api contract', () => {
it('documents internal edge routes and join fields', () => {
const edgeSource = readFileSync(
join(process.cwd(), 'src', 'edge/edge.controller.ts'),
'utf8',
);
assert.ok(edgeSource.includes("'resolve/:slug'"));
assert.ok(edgeSource.includes("'start/:slug'"));
assert.ok(edgeSource.includes('EdgeInternalGuard'));
const edgeServiceSource = readFileSync(
join(process.cwd(), 'src', 'edge/edge.service.ts'),
'utf8',
);
assert.ok(edgeServiceSource.includes('requestJoinStart'));
assert.ok(edgeServiceSource.includes('checkStartRateLimit'));
const serversSource = readFileSync(
join(process.cwd(), 'src', 'servers/servers.service.ts'),
'utf8',
);
assert.ok(serversSource.includes('startServerInternal'));
assert.ok(serversSource.includes('joinHostname'));
});
});

View File

@@ -1,8 +1,14 @@
package main package main
import ( import (
"context"
"log/slog" "log/slog"
"os" "os"
"os/signal"
"syscall"
"github.com/hexahost/gamecloud/edge-gateway/internal/config"
"github.com/hexahost/gamecloud/edge-gateway/internal/proxy"
) )
func main() { func main() {
@@ -12,18 +18,21 @@ func main() {
enabled := os.Getenv("EDGE_GATEWAY_ENABLED") enabled := os.Getenv("EDGE_GATEWAY_ENABLED")
if enabled != "true" && enabled != "1" { if enabled != "true" && enabled != "1" {
log.Info("edge gateway disabled for MVP", log.Info("edge gateway disabled",
"feature", "edge-gateway", "feature", "edge-gateway",
"status", "not_implemented", "hint", "set EDGE_GATEWAY_ENABLED=true to start the TCP join-to-start gateway",
"message", "Join-to-Start TCP/UDP gateway is planned for Phase 8; set EDGE_GATEWAY_ENABLED=true to acknowledge early enablement",
) )
os.Exit(0) return
} }
log.Info("edge gateway not implemented in MVP", cfg := config.Load()
"feature", "edge-gateway", gateway := proxy.New(cfg, log)
"status", "not_implemented",
"roadmap", "Phase 8 DNS and Join-to-Start", ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
) defer stop()
os.Exit(0)
if err := gateway.ListenAndServe(ctx); err != nil {
log.Error("edge gateway stopped", "error", err)
os.Exit(1)
}
} }

View File

@@ -0,0 +1,37 @@
package config
import (
"os"
"strconv"
"time"
)
type Config struct {
ListenAddr string
APIURL string
EdgeKey string
PlayDomain string
StartWait time.Duration
PollInterval time.Duration
}
func Load() Config {
startWaitSec, _ := strconv.Atoi(envOr("EDGE_START_WAIT_SECONDS", "180"))
pollMs, _ := strconv.Atoi(envOr("EDGE_POLL_INTERVAL_MS", "2000"))
return Config{
ListenAddr: envOr("EDGE_LISTEN_ADDR", ":25565"),
APIURL: envOr("API_URL", "http://localhost:3001"),
EdgeKey: envOr("EDGE_INTERNAL_API_KEY", "local-dev-edge-key-change-me-32chars"),
PlayDomain: envOr("GAME_BASE_DOMAIN", "play.example.net"),
StartWait: time.Duration(startWaitSec) * time.Second,
PollInterval: time.Duration(pollMs) * time.Millisecond,
}
}
func envOr(key, fallback string) string {
if value := os.Getenv(key); value != "" {
return value
}
return fallback
}

View File

@@ -0,0 +1,119 @@
package controlplane
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
type Backend struct {
Host string `json:"host"`
Port int `json:"port"`
}
type ResolveResponse struct {
ServerID string `json:"serverId"`
Slug string `json:"slug"`
Status string `json:"status"`
JoinToStartEnabled bool `json:"joinToStartEnabled"`
Edition string `json:"edition"`
Backend *Backend `json:"backend"`
Action string `json:"action"`
Message string `json:"message,omitempty"`
}
type StartResponse struct {
Resolve ResolveResponse `json:"resolve"`
Started bool `json:"started"`
JobID string `json:"jobId,omitempty"`
QueuePosition int `json:"queuePosition,omitempty"`
}
type Client struct {
baseURL string
edgeKey string
httpClient *http.Client
}
func NewClient(baseURL, edgeKey string) *Client {
return &Client{
baseURL: baseURL,
edgeKey: edgeKey,
httpClient: &http.Client{
Timeout: 15 * time.Second,
},
}
}
func (c *Client) Resolve(slug, clientIP string) (*ResolveResponse, error) {
endpoint := fmt.Sprintf("%s/internal/edge/v1/resolve/%s", c.baseURL, url.PathEscape(slug))
if clientIP != "" {
endpoint = endpoint + "?clientIp=" + url.QueryEscape(clientIP)
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, err
}
c.applyHeaders(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("unknown slug")
}
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("resolve failed: %s", string(body))
}
var result ResolveResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) Start(slug, clientIP string) (*StartResponse, error) {
endpoint := fmt.Sprintf("%s/internal/edge/v1/start/%s", c.baseURL, url.PathEscape(slug))
payload, err := json.Marshal(map[string]string{"clientIp": clientIP})
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(payload))
if err != nil {
return nil, err
}
c.applyHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode >= 400 {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("start failed: %s", string(body))
}
var result StartResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return &result, nil
}
func (c *Client) applyHeaders(req *http.Request) {
req.Header.Set("X-HexaHost-Edge-Key", c.edgeKey)
}

View File

@@ -0,0 +1,174 @@
package mc
import (
"bytes"
"encoding/binary"
"fmt"
"io"
"strings"
)
type Handshake struct {
ProtocolVersion int32
ServerAddress string
ServerPort uint16
NextState int32
Raw []byte
}
func ReadHandshake(r io.Reader) (*Handshake, error) {
raw, err := readPacket(r)
if err != nil {
return nil, err
}
reader := newReader(raw)
packetID, err := reader.readVarInt()
if err != nil {
return nil, err
}
if packetID != 0 {
return nil, fmt.Errorf("unexpected packet id %d", packetID)
}
protocolVersion, err := reader.readVarInt()
if err != nil {
return nil, err
}
serverAddress, err := reader.readString()
if err != nil {
return nil, err
}
portBytes, err := reader.readBytes(2)
if err != nil {
return nil, err
}
serverPort := binary.BigEndian.Uint16(portBytes)
nextState, err := reader.readVarInt()
if err != nil {
return nil, err
}
return &Handshake{
ProtocolVersion: protocolVersion,
ServerAddress: serverAddress,
ServerPort: serverPort,
NextState: nextState,
Raw: raw,
}, nil
}
func ExtractSlug(hostname, playDomain string) string {
host := strings.ToLower(strings.Split(hostname, "\x00")[0])
domain := strings.ToLower(playDomain)
suffix := "." + domain
if !strings.HasSuffix(host, suffix) {
return ""
}
slug := strings.TrimSuffix(host, suffix)
if slug == "" || strings.Contains(slug, ".") {
return ""
}
return slug
}
func readPacket(r io.Reader) ([]byte, error) {
length, err := readVarIntFrom(r)
if err != nil {
return nil, err
}
if length <= 0 || length > 1<<20 {
return nil, fmt.Errorf("invalid packet length %d", length)
}
payload := make([]byte, length)
if _, err := io.ReadFull(r, payload); err != nil {
return nil, err
}
return payload, nil
}
type packetReader struct {
buf *bytes.Reader
}
func newReader(data []byte) *packetReader {
return &packetReader{buf: bytes.NewReader(data)}
}
func (p *packetReader) readVarInt() (int32, error) {
return readVarIntFrom(p.buf)
}
func (p *packetReader) readString() (string, error) {
length, err := p.readVarInt()
if err != nil {
return "", err
}
if length < 0 || length > 255 {
return "", fmt.Errorf("invalid string length %d", length)
}
data, err := p.readBytes(int(length))
if err != nil {
return "", err
}
return string(data), nil
}
func (p *packetReader) readBytes(n int) ([]byte, error) {
out := make([]byte, n)
if _, err := io.ReadFull(p.buf, out); err != nil {
return nil, err
}
return out, nil
}
func readVarIntFrom(r io.Reader) (int32, error) {
var numRead int
var result int32
for {
var value [1]byte
if _, err := io.ReadFull(r, value[:]); err != nil {
return 0, err
}
result |= int32(value[0]&0x7F) << (7 * numRead)
numRead++
if numRead > 5 {
return 0, fmt.Errorf("varint too big")
}
if value[0]&0x80 == 0 {
break
}
}
return result, nil
}
func WritePacket(w io.Writer, payload []byte) error {
if err := writeVarIntTo(w, int32(len(payload))); err != nil {
return err
}
_, err := w.Write(payload)
return err
}
func writeVarIntTo(w io.Writer, value int32) error {
for {
temp := byte(value & 0x7F)
value >>= 7
if value != 0 {
temp |= 0x80
}
if _, err := w.Write([]byte{temp}); err != nil {
return err
}
if value == 0 {
break
}
}
return nil
}

View File

@@ -0,0 +1,59 @@
package mc
import (
"bytes"
"testing"
)
func TestExtractSlug(t *testing.T) {
if got := ExtractSlug("abc.play.example.net", "play.example.net"); got != "abc" {
t.Fatalf("got %q want abc", got)
}
if got := ExtractSlug("other.net", "play.example.net"); got != "" {
t.Fatalf("expected empty slug")
}
}
func TestReadHandshake(t *testing.T) {
var payload bytes.Buffer
writeVarInt(&payload, 0)
writeVarInt(&payload, 763)
writeString(&payload, "demo.play.example.net")
payload.WriteByte(0x63)
payload.WriteByte(0xdd)
writeVarInt(&payload, 2)
var packet bytes.Buffer
writeVarInt(&packet, int32(payload.Len()))
packet.Write(payload.Bytes())
hs, err := ReadHandshake(&packet)
if err != nil {
t.Fatalf("read handshake: %v", err)
}
if hs.ServerAddress != "demo.play.example.net" {
t.Fatalf("address %q", hs.ServerAddress)
}
if hs.ServerPort != 25565 {
t.Fatalf("port %d", hs.ServerPort)
}
}
func writeVarInt(buf *bytes.Buffer, value int32) {
for {
temp := byte(value & 0x7F)
value >>= 7
if value != 0 {
temp |= 0x80
}
buf.WriteByte(temp)
if value == 0 {
break
}
}
}
func writeString(buf *bytes.Buffer, value string) {
writeVarInt(buf, int32(len(value)))
buf.WriteString(value)
}

View File

@@ -0,0 +1,160 @@
package proxy
import (
"context"
"fmt"
"io"
"log/slog"
"net"
"time"
"github.com/hexahost/gamecloud/edge-gateway/internal/config"
"github.com/hexahost/gamecloud/edge-gateway/internal/controlplane"
"github.com/hexahost/gamecloud/edge-gateway/internal/mc"
)
type Gateway struct {
cfg config.Config
client *controlplane.Client
log *slog.Logger
}
func New(cfg config.Config, log *slog.Logger) *Gateway {
return &Gateway{
cfg: cfg,
client: controlplane.NewClient(cfg.APIURL, cfg.EdgeKey),
log: log,
}
}
func (g *Gateway) ListenAndServe(ctx context.Context) error {
listener, err := net.Listen("tcp", g.cfg.ListenAddr)
if err != nil {
return err
}
defer listener.Close()
g.log.Info("edge gateway listening",
"addr", g.cfg.ListenAddr,
"playDomain", g.cfg.PlayDomain,
"api", g.cfg.APIURL,
)
go func() {
<-ctx.Done()
_ = listener.Close()
}()
for {
conn, err := listener.Accept()
if err != nil {
select {
case <-ctx.Done():
return nil
default:
g.log.Error("accept failed", "error", err)
continue
}
}
go g.handleConnection(conn)
}
}
func (g *Gateway) handleConnection(clientConn net.Conn) {
defer clientConn.Close()
clientIP := clientConn.RemoteAddr().String()
if host, _, err := net.SplitHostPort(clientIP); err == nil {
clientIP = host
}
handshake, err := mc.ReadHandshake(clientConn)
if err != nil {
g.log.Warn("handshake failed", "error", err, "client", clientIP)
return
}
slug := mc.ExtractSlug(handshake.ServerAddress, g.cfg.PlayDomain)
if slug == "" {
g.log.Warn("unknown hostname", "address", handshake.ServerAddress, "client", clientIP)
return
}
resolve, err := g.client.Resolve(slug, clientIP)
if err != nil {
g.log.Warn("resolve failed", "slug", slug, "error", err)
return
}
switch resolve.Action {
case "start":
startResp, err := g.client.Start(slug, clientIP)
if err != nil {
g.log.Warn("join start failed", "slug", slug, "error", err)
return
}
resolve = &startResp.Resolve
case "wait":
resolve, err = g.waitForProxy(slug, clientIP)
if err != nil {
g.log.Warn("wait for server failed", "slug", slug, "error", err)
return
}
case "reject":
g.log.Info("connection rejected", "slug", slug, "message", resolve.Message)
return
}
if resolve.Action != "proxy" || resolve.Backend == nil {
g.log.Warn("no backend available", "slug", slug, "action", resolve.Action)
return
}
backendAddr := fmt.Sprintf("%s:%d", resolve.Backend.Host, resolve.Backend.Port)
backendConn, err := net.DialTimeout("tcp", backendAddr, 10*time.Second)
if err != nil {
g.log.Error("backend dial failed", "slug", slug, "backend", backendAddr, "error", err)
return
}
defer backendConn.Close()
if err := mc.WritePacket(backendConn, handshake.Raw); err != nil {
g.log.Error("replay handshake failed", "slug", slug, "error", err)
return
}
g.log.Info("proxying connection",
"slug", slug,
"client", clientIP,
"backend", backendAddr,
"status", resolve.Status,
)
pipe(clientConn, backendConn)
}
func (g *Gateway) waitForProxy(slug, clientIP string) (*controlplane.ResolveResponse, error) {
deadline := time.Now().Add(g.cfg.StartWait)
for time.Now().Before(deadline) {
resolve, err := g.client.Resolve(slug, clientIP)
if err != nil {
return nil, err
}
if resolve.Action == "proxy" {
return resolve, nil
}
if resolve.Action == "reject" {
return resolve, fmt.Errorf(resolve.Message)
}
time.Sleep(g.cfg.PollInterval)
}
return nil, fmt.Errorf("timed out waiting for server to start")
}
func pipe(left, right net.Conn) {
errCh := make(chan error, 2)
go func() { _, err := io.Copy(right, left); errCh <- err }()
go func() { _, err := io.Copy(left, right); errCh <- err }()
<-errCh
}

View File

@@ -61,7 +61,8 @@ export function ServerCard({ server, showDetailsLink = true }: ServerCardProps)
server.status, server.status,
); );
const address = const address =
server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending"); server.joinHostname ??
(server.hostPort !== null ? `localhost:${server.hostPort}` : t("addressPending"));
return ( return (
<Card> <Card>

View File

@@ -42,6 +42,9 @@ export interface Server {
ramMb: number; ramMb: number;
idleShutdownEnabled: boolean; idleShutdownEnabled: boolean;
idleStopAt: string | null; idleStopAt: string | null;
joinSlug: string | null;
joinHostname: string | null;
joinToStartEnabled: boolean;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }

View File

@@ -15,6 +15,7 @@
"@hexahost/catalog": "workspace:*", "@hexahost/catalog": "workspace:*",
"@hexahost/config": "workspace:*", "@hexahost/config": "workspace:*",
"@hexahost/database": "workspace:*", "@hexahost/database": "workspace:*",
"@hexahost/dns": "workspace:*",
"@hexahost/metering": "workspace:*", "@hexahost/metering": "workspace:*",
"@hexahost/scheduler": "workspace:*", "@hexahost/scheduler": "workspace:*",
"@hexahost/storage": "workspace:*", "@hexahost/storage": "workspace:*",

View File

@@ -1,6 +1,7 @@
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { prisma } from '@hexahost/database'; import { prisma } from '@hexahost/database';
import { assignJoinAddress } from '@hexahost/dns';
import { selectNodeForAllocation } from '@hexahost/scheduler'; import { selectNodeForAllocation } from '@hexahost/scheduler';
import { Job } from 'bullmq'; import { Job } from 'bullmq';
import { z } from 'zod'; import { z } from 'zod';
@@ -11,7 +12,6 @@ import { loadNodeSnapshots } from '../node-snapshots';
import { publishNodeCommand, waitForNodeResponse } from '../redis'; import { publishNodeCommand, waitForNodeResponse } from '../redis';
import { transitionServerStatus } from '../server-state'; import { transitionServerStatus } from '../server-state';
const BASE_HOST_PORT = 25565;
const PROVISION_TIMEOUT_MS = 5 * 60 * 1000; const PROVISION_TIMEOUT_MS = 5 * 60 * 1000;
export const provisionServerJobSchema = z.object({ export const provisionServerJobSchema = z.object({
@@ -23,6 +23,11 @@ function buildDataPath(serverId: string): string {
} }
async function findAvailableHostPort(nodeId: string): Promise<number> { async function findAvailableHostPort(nodeId: string): Promise<number> {
const node = await prisma.gameNode.findUniqueOrThrow({
where: { id: nodeId },
select: { portRangeStart: true, portRangeEnd: true },
});
const [allocations, servers] = await Promise.all([ const [allocations, servers] = await Promise.all([
prisma.gameServerAllocation.findMany({ prisma.gameServerAllocation.findMany({
where: { nodeId, status: 'ACTIVE' }, where: { nodeId, status: 'ACTIVE' },
@@ -44,9 +49,12 @@ async function findAvailableHostPort(nodeId: string): Promise<number> {
} }
} }
let port = BASE_HOST_PORT; let port = node.portRangeStart;
while (usedPorts.has(port)) { while (usedPorts.has(port)) {
port += 1; port += 1;
if (port > node.portRangeEnd) {
throw new Error(`No free host port in range ${node.portRangeStart}-${node.portRangeEnd}`);
}
} }
return port; return port;
@@ -171,6 +179,8 @@ export async function processProvisionServerJob(job: Job): Promise<{ status: str
correlationId: messageId, correlationId: messageId,
}); });
await assignJoinAddress(serverId);
logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned'); logger.info({ serverId, nodeId: node.id, hostPort }, 'Server provisioned');
return { status: 'stopped' }; return { status: 'stopped' };
} }

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,48 @@
Last updated: 2026-06-26 Last updated: 2026-06-26
## Current phase: Phase 7Idle shutdown and free tier ## Current phase: Phase 8DNS and join-to-start
### Phase 8 completed
| Area | Status | Notes |
|------|--------|-------|
| Prisma join/DNS | Done | `joinSlug`, `joinToStartEnabled`, `ServerDnsRecord`, node port ranges |
| `@hexahost/dns` | Done | Slug builder, DNS record sync (DB metadata), join address assignment |
| API internal edge | Done | `GET/POST /internal/edge/v1/resolve|start/:slug` with API key + rate limits |
| Join-to-start start | Done | Edge triggers `startServerInternal` with billing/queue checks |
| Worker provision hook | Done | Assigns join slug + DNS after provision completes |
| Edge gateway (Go) | Done | TCP listener, Minecraft handshake, proxy + join-to-start |
| Web | Done | Join hostname on server card |
| Contracts | Done | `join.ts` edge resolve/start schemas, server join fields |
### Abnahmekriterium Phase 8
- [x] Stable join hostname per server (`slug.play.example.net`)
- [x] DNS metadata stored (A + SRV for Java)
- [x] Edge gateway resolves slug and can start stopped servers
- [x] Rate limiting on join-to-start (3/min per slug, 10/min per IP)
- [x] Credits/tariff checks on edge-triggered start
- [ ] Manual E2E: connect via fixed hostname while server stopped
### Local test
1. `pnpm db:migrate && pnpm db:seed`
2. Set in `.env`: `EDGE_GATEWAY_ENABLED=true`, matching `EDGE_INTERNAL_API_KEY`
3. `pnpm dev` (API + worker) and `cd apps/edge-gateway && go run ./cmd/gateway`
4. Provision server → note `joinHostname` on dashboard
5. Stop server → connect with Minecraft client to join hostname (or `slug.play.localhost` with hosts file)
6. Edge starts server and proxies connection when `RUNNING`
### Edge env defaults (`.env.example`)
- `GAME_BASE_DOMAIN=play.example.net`
- `EDGE_LISTEN_ADDR=:25565`
- `EDGE_INTERNAL_API_KEY=local-dev-edge-key-change-me-32chars`
---
## Phase 7 — Idle shutdown and free tier ✅
### Phase 7 completed ### Phase 7 completed
@@ -70,6 +111,6 @@ Last updated: 2026-06-26
## Phase 0 — Monorepo foundation ✅ ## Phase 0 — Monorepo foundation ✅
### Next phase: Phase 8DNS and join-to-start ### Next phase: Phase 9WHMCS integration and production hardening
- Port pools, DNS-SRV, edge gateway, start on connect - Billing provider hooks, WHMCS module, production DNS (RFC2136)

View File

@@ -205,3 +205,16 @@ export {
type WalletResponse, type WalletResponse,
type UsageListResponse, type UsageListResponse,
} from './billing'; } from './billing';
export {
edgeBackendSchema,
edgeActionSchema,
edgeResolveResponseSchema,
edgeStartRequestSchema,
edgeStartResponseSchema,
type EdgeBackend,
type EdgeAction,
type EdgeResolveResponse,
type EdgeStartRequest,
type EdgeStartResponse,
} from './join';

View File

@@ -0,0 +1,38 @@
import { z } from 'zod';
import { gameServerStatusSchema, serverEditionSchema } from './servers';
export const edgeBackendSchema = z.object({
host: z.string(),
port: z.number().int(),
});
export const edgeActionSchema = z.enum(['proxy', 'start', 'wait', 'reject']);
export const edgeResolveResponseSchema = z.object({
serverId: z.string().uuid(),
slug: z.string(),
status: gameServerStatusSchema,
joinToStartEnabled: z.boolean(),
edition: serverEditionSchema,
backend: edgeBackendSchema.nullable(),
action: edgeActionSchema,
message: z.string().optional(),
});
export const edgeStartRequestSchema = z.object({
clientIp: z.string().min(1).max(64),
});
export const edgeStartResponseSchema = z.object({
resolve: edgeResolveResponseSchema,
started: z.boolean(),
jobId: z.string().optional(),
queuePosition: z.number().int().optional(),
});
export type EdgeBackend = z.infer<typeof edgeBackendSchema>;
export type EdgeAction = z.infer<typeof edgeActionSchema>;
export type EdgeResolveResponse = z.infer<typeof edgeResolveResponseSchema>;
export type EdgeStartRequest = z.infer<typeof edgeStartRequestSchema>;
export type EdgeStartResponse = z.infer<typeof edgeStartResponseSchema>;

View File

@@ -61,6 +61,9 @@ export const serverResponseSchema = z.object({
ramMb: z.number().int(), ramMb: z.number().int(),
idleShutdownEnabled: z.boolean(), idleShutdownEnabled: z.boolean(),
idleStopAt: z.string().datetime().nullable(), idleStopAt: z.string().datetime().nullable(),
joinSlug: z.string().nullable(),
joinHostname: z.string().nullable(),
joinToStartEnabled: z.boolean(),
createdAt: z.string().datetime(), createdAt: z.string().datetime(),
updatedAt: z.string().datetime(), updatedAt: z.string().datetime(),
}); });

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,40 @@
-- Phase 8: DNS metadata and join-to-start
CREATE TYPE "DnsRecordStatus" AS ENUM ('PENDING', 'ACTIVE', 'FAILED', 'REMOVED');
ALTER TABLE "game_servers"
ADD COLUMN IF NOT EXISTS "joinSlug" TEXT,
ADD COLUMN IF NOT EXISTS "joinToStartEnabled" BOOLEAN NOT NULL DEFAULT true;
CREATE UNIQUE INDEX IF NOT EXISTS "game_servers_joinSlug_key" ON "game_servers"("joinSlug");
ALTER TABLE "game_nodes"
ADD COLUMN IF NOT EXISTS "portRangeStart" INTEGER NOT NULL DEFAULT 25565,
ADD COLUMN IF NOT EXISTS "portRangeEnd" INTEGER NOT NULL DEFAULT 25664;
CREATE TABLE IF NOT EXISTS "server_dns_records" (
"id" TEXT NOT NULL,
"serverId" TEXT NOT NULL,
"fqdn" TEXT NOT NULL,
"recordType" TEXT NOT NULL,
"target" TEXT NOT NULL,
"port" INTEGER,
"srvPriority" INTEGER,
"srvWeight" INTEGER,
"status" "DnsRecordStatus" NOT NULL DEFAULT 'PENDING',
"lastSyncedAt" TIMESTAMPTZ(3),
"lastError" TEXT,
"createdAt" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMPTZ(3) NOT NULL,
CONSTRAINT "server_dns_records_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX IF NOT EXISTS "server_dns_records_serverId_recordType_fqdn_key"
ON "server_dns_records"("serverId", "recordType", "fqdn");
CREATE INDEX IF NOT EXISTS "server_dns_records_fqdn_idx" ON "server_dns_records"("fqdn");
ALTER TABLE "server_dns_records"
ADD CONSTRAINT "server_dns_records_serverId_fkey"
FOREIGN KEY ("serverId") REFERENCES "game_servers"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -128,6 +128,13 @@ enum CreditTransactionType {
EXPIRE EXPIRE
} }
enum DnsRecordStatus {
PENDING
ACTIVE
FAILED
REMOVED
}
model User { model User {
id String @id @default(uuid()) id String @id @default(uuid())
email String @unique email String @unique
@@ -291,6 +298,8 @@ model GameServer {
idleStopAt DateTime? @db.Timestamptz(3) idleStopAt DateTime? @db.Timestamptz(3)
runningSince DateTime? @db.Timestamptz(3) runningSince DateTime? @db.Timestamptz(3)
lastMeteredAt DateTime? @db.Timestamptz(3) lastMeteredAt DateTime? @db.Timestamptz(3)
joinSlug String? @unique
joinToStartEnabled Boolean @default(true)
createdAt DateTime @default(now()) @db.Timestamptz(3) createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3) updatedAt DateTime @updatedAt @db.Timestamptz(3)
@@ -306,6 +315,7 @@ model GameServer {
installedAddons InstalledAddon[] installedAddons InstalledAddon[]
startQueueEntry ServerStartQueueEntry? startQueueEntry ServerStartQueueEntry?
usageRecords UsageRecord[] usageRecords UsageRecord[]
dnsRecords ServerDnsRecord[]
@@index([userId]) @@index([userId])
@@index([nodeId]) @@index([nodeId])
@@ -359,6 +369,8 @@ model GameNode {
maxServers Int @default(10) maxServers Int @default(10)
maxRamMb Int @default(16384) maxRamMb Int @default(16384)
platformRamMb Int @default(2048) platformRamMb Int @default(2048)
portRangeStart Int @default(25565)
portRangeEnd Int @default(25664)
activeServers Int @default(0) activeServers Int @default(0)
lastHeartbeatAt DateTime? @db.Timestamptz(3) lastHeartbeatAt DateTime? @db.Timestamptz(3)
agentVersion String? agentVersion String?
@@ -650,3 +662,25 @@ model InstalledAddon {
@@index([status]) @@index([status])
@@map("installed_addons") @@map("installed_addons")
} }
model ServerDnsRecord {
id String @id @default(uuid())
serverId String
fqdn String
recordType String
target String
port Int?
srvPriority Int?
srvWeight Int?
status DnsRecordStatus @default(PENDING)
lastSyncedAt DateTime? @db.Timestamptz(3)
lastError String?
createdAt DateTime @default(now()) @db.Timestamptz(3)
updatedAt DateTime @updatedAt @db.Timestamptz(3)
server GameServer @relation(fields: [serverId], references: [id], onDelete: Cascade)
@@unique([serverId, recordType, fqdn])
@@index([fqdn])
@@map("server_dns_records")
}

View File

@@ -57,6 +57,8 @@ async function main(): Promise<void> {
maxServers: 10, maxServers: 10,
maxRamMb: 16384, maxRamMb: 16384,
platformRamMb: 2048, platformRamMb: 2048,
portRangeStart: 25565,
portRangeEnd: 25664,
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN), enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
}, },
update: { update: {
@@ -64,6 +66,8 @@ async function main(): Promise<void> {
hostname: 'localhost', hostname: 'localhost',
maxRamMb: 16384, maxRamMb: 16384,
platformRamMb: 2048, platformRamMb: 2048,
portRangeStart: 25565,
portRangeEnd: 25664,
enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN), enrollmentTokenHash: hashToken(DEV_NODE_ENROLLMENT_TOKEN),
}, },
}); });

View File

@@ -17,6 +17,7 @@ export type {
BackupSchedule, BackupSchedule,
WorldUpload, WorldUpload,
InstalledAddon, InstalledAddon,
ServerDnsRecord,
GameNode, GameNode,
GameNodeHeartbeat, GameNodeHeartbeat,
ServerStartQueueEntry, ServerStartQueueEntry,

File diff suppressed because one or more lines are too long

21
packages/dns/package.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "@hexahost/dns",
"version": "0.0.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@hexahost/database": "workspace:*"
},
"devDependencies": {
"@hexahost/typescript-config": "workspace:*",
"@types/node": "^22.10.2",
"typescript": "^5.7.3",
"vitest": "^3.0.8"
}
}

View File

@@ -0,0 +1,9 @@
export { buildJoinSlug, extractSlugFromHostname } from './slug';
export {
assignJoinAddress,
formatJoinAddress,
getEdgeListenPort,
getEdgePublicHost,
getPlayDomain,
syncServerDnsRecords,
} from './sync';

View File

@@ -0,0 +1,20 @@
import { describe, expect, it } from 'vitest';
import { buildJoinSlug, extractSlugFromHostname } from './slug';
describe('buildJoinSlug', () => {
it('sanitizes server names', () => {
const slug = buildJoinSlug('My Cool Server!', '00000000-0000-4000-8000-000000000099');
expect(slug).toMatch(/^my-cool-server-000000$/);
});
});
describe('extractSlugFromHostname', () => {
it('extracts slug from play domain hostname', () => {
expect(extractSlugFromHostname('abc.play.example.net', 'play.example.net')).toBe('abc');
});
it('rejects unknown domains', () => {
expect(extractSlugFromHostname('abc.other.net', 'play.example.net')).toBeNull();
});
});

34
packages/dns/src/slug.ts Normal file
View File

@@ -0,0 +1,34 @@
export function buildJoinSlug(serverName: string, serverId: string): string {
const base =
serverName
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 24) || 'server';
const suffix = serverId.replace(/-/g, '').slice(0, 6);
return `${base}-${suffix}`;
}
export function extractSlugFromHostname(hostname: string, playDomain: string): string | null {
const normalizedHost = hostname.split('\0')[0]?.toLowerCase() ?? '';
const normalizedDomain = playDomain.toLowerCase();
if (normalizedHost === normalizedDomain) {
return null;
}
const suffix = `.${normalizedDomain}`;
if (!normalizedHost.endsWith(suffix)) {
return null;
}
const slug = normalizedHost.slice(0, -suffix.length);
if (!slug || slug.includes('.')) {
return null;
}
return slug;
}

158
packages/dns/src/sync.ts Normal file
View File

@@ -0,0 +1,158 @@
import { prisma } from '@hexahost/database';
import type { GameNode, GameServer } from '@hexahost/database';
import { buildJoinSlug } from './slug';
export function getPlayDomain(): string {
return process.env['GAME_BASE_DOMAIN'] ?? 'play.example.net';
}
export function getEdgePublicHost(): string {
return process.env['EDGE_PUBLIC_HOST'] ?? '127.0.0.1';
}
export function getEdgeListenPort(): number {
const raw = process.env['EDGE_LISTEN_PORT'] ?? '25565';
const port = Number.parseInt(raw, 10);
return Number.isFinite(port) ? port : 25565;
}
function joinHostname(slug: string, playDomain = getPlayDomain()): string {
return `${slug}.${playDomain}`;
}
async function upsertDnsRecord(input: {
serverId: string;
fqdn: string;
recordType: string;
target: string;
port?: number;
srvPriority?: number;
srvWeight?: number;
}): Promise<void> {
await prisma.serverDnsRecord.upsert({
where: {
serverId_recordType_fqdn: {
serverId: input.serverId,
recordType: input.recordType,
fqdn: input.fqdn,
},
},
create: {
serverId: input.serverId,
fqdn: input.fqdn,
recordType: input.recordType,
target: input.target,
port: input.port,
srvPriority: input.srvPriority,
srvWeight: input.srvWeight,
status: 'ACTIVE',
lastSyncedAt: new Date(),
},
update: {
target: input.target,
port: input.port,
srvPriority: input.srvPriority,
srvWeight: input.srvWeight,
status: 'ACTIVE',
lastSyncedAt: new Date(),
lastError: null,
},
});
}
export async function syncServerDnsRecords(
server: GameServer & { node: GameNode | null },
playDomain = getPlayDomain(),
): Promise<void> {
if (!server.joinSlug) {
return;
}
const fqdn = joinHostname(server.joinSlug, playDomain);
const edgeTarget = getEdgePublicHost();
const edgePort = getEdgeListenPort();
await upsertDnsRecord({
serverId: server.id,
fqdn,
recordType: 'A',
target: edgeTarget,
});
if (server.edition === 'JAVA') {
await upsertDnsRecord({
serverId: server.id,
fqdn: `_minecraft._tcp.${fqdn}`,
recordType: 'SRV',
target: fqdn,
port: edgePort,
srvPriority: 0,
srvWeight: 5,
});
}
}
async function ensureUniqueSlug(serverId: string, serverName: string): Promise<string> {
let slug = buildJoinSlug(serverName, serverId);
let attempt = 0;
while (
await prisma.gameServer.findFirst({
where: { joinSlug: slug, NOT: { id: serverId } },
select: { id: true },
})
) {
attempt += 1;
slug = `${buildJoinSlug(serverName, serverId)}-${attempt}`;
}
return slug;
}
export async function assignJoinAddress(
serverId: string,
options?: { playDomain?: string },
): Promise<{ joinSlug: string; joinHostname: string } | null> {
const playDomain = options?.playDomain ?? getPlayDomain();
const server = await prisma.gameServer.findUnique({
where: { id: serverId },
include: { node: true },
});
if (!server || !server.nodeId || server.hostPort === null) {
return null;
}
const joinSlug = server.joinSlug ?? (await ensureUniqueSlug(serverId, server.name));
const updated =
server.joinSlug === joinSlug
? server
: await prisma.gameServer.update({
where: { id: serverId },
data: { joinSlug },
include: { node: true },
});
await syncServerDnsRecords(updated, playDomain);
return {
joinSlug,
joinHostname: joinHostname(joinSlug, playDomain),
};
}
export function formatJoinAddress(
joinSlug: string | null,
edition: GameServer['edition'],
playDomain = getPlayDomain(),
): string | null {
if (!joinSlug) {
return null;
}
const hostname = joinHostname(joinSlug, playDomain);
return edition === 'JAVA' ? hostname : `${hostname}:${getEdgeListenPort()}`;
}

View File

@@ -0,0 +1,9 @@
{
"extends": "@hexahost/typescript-config/base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

25
pnpm-lock.yaml generated
View File

@@ -41,6 +41,9 @@ importers:
'@hexahost/database': '@hexahost/database':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/database version: link:../../packages/database
'@hexahost/dns':
specifier: workspace:*
version: link:../../packages/dns
'@hexahost/metering': '@hexahost/metering':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/metering version: link:../../packages/metering
@@ -190,6 +193,9 @@ importers:
'@hexahost/database': '@hexahost/database':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/database version: link:../../packages/database
'@hexahost/dns':
specifier: workspace:*
version: link:../../packages/dns
'@hexahost/metering': '@hexahost/metering':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/metering version: link:../../packages/metering
@@ -325,6 +331,25 @@ importers:
specifier: ^5.7.3 specifier: ^5.7.3
version: 5.9.3 version: 5.9.3
packages/dns:
dependencies:
'@hexahost/database':
specifier: workspace:*
version: link:../database
devDependencies:
'@hexahost/typescript-config':
specifier: workspace:*
version: link:../typescript-config
'@types/node':
specifier: ^22.10.2
version: 22.20.0
typescript:
specifier: ^5.7.3
version: 5.9.3
vitest:
specifier: ^3.0.8
version: 3.2.6(@types/node@22.20.0)(jiti@2.7.0)(terser@5.48.0)(tsx@4.22.4)
packages/eslint-config: {} packages/eslint-config: {}
packages/metering: packages/metering: