Phase8
This commit is contained in:
37
apps/edge-gateway/internal/config/config.go
Normal file
37
apps/edge-gateway/internal/config/config.go
Normal 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
|
||||
}
|
||||
119
apps/edge-gateway/internal/controlplane/client.go
Normal file
119
apps/edge-gateway/internal/controlplane/client.go
Normal 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)
|
||||
}
|
||||
174
apps/edge-gateway/internal/mc/handshake.go
Normal file
174
apps/edge-gateway/internal/mc/handshake.go
Normal 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
|
||||
}
|
||||
59
apps/edge-gateway/internal/mc/handshake_test.go
Normal file
59
apps/edge-gateway/internal/mc/handshake_test.go
Normal 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)
|
||||
}
|
||||
160
apps/edge-gateway/internal/proxy/gateway.go
Normal file
160
apps/edge-gateway/internal/proxy/gateway.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user