Compare commits

...

15 Commits

Author SHA1 Message Date
df75875491 Merge branch 'release/beta-1.1' 2025-12-10 11:23:27 +01:00
27609dba2b fixed logs and config locations, updated connection closure handling 2025-12-10 11:22:09 +01:00
709abb30fa updated docs, minor changes 2025-12-08 09:36:40 +01:00
0c7909a701 added docs 2025-12-08 07:30:40 +01:00
7c9a7eedf9 Merge tag 'beta-1.0' into develop
Tagging version beta-1.0 beta-1.0
2025-12-08 07:01:46 +01:00
63f54167c4 Merge branch 'release/beta-1.0' 2025-12-08 07:01:45 +01:00
e198fc4b3f updated .gitignore 2025-12-08 07:01:05 +01:00
8e84523d4b continuous send for simulators 2025-12-08 06:58:43 +01:00
786b217f05 quality of life, updated config structure 2025-12-08 06:57:54 +01:00
8f75e6491f qol change 2025-12-07 17:21:20 +01:00
3d6774586e Gateway working, beta 2025-12-07 17:11:37 +01:00
de03c1fe3d almost done, queue fix next 2025-12-07 00:48:09 +01:00
7a30adc2e8 temp push 2025-12-02 10:34:45 +01:00
8f7db6256b eod commit 2025-12-01 22:30:36 +01:00
2372da942a updated cache 2025-12-01 21:12:46 +01:00
20 changed files with 1409 additions and 497 deletions

4
.gitignore vendored
View File

@@ -1,3 +1,7 @@
*
!*/
!*.*
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll

474
README.md
View File

@@ -2,14 +2,476 @@
Gateway between multiple HomesteadRelay's and the HomesteadToGo Bot. Gateway between multiple HomesteadRelay's and the HomesteadToGo Bot.
---
## dev notes ## HomesteadGateway Developer Documentation
perhaps drop database, instead ## Overview
HomesteadGateway is a WebSocket-based message routing gateway that facilitates bidirectional communication between game server mods/plugins and external bots (e.g., Discord bots). It acts as a relay, routing messages based on channel identifiers and managing message queues when endpoints are offline.
**Key Features:**
- WebSocket-based real-time communication
- Channel-based message routing
- Automatic message queuing for offline recipients
- API key authentication
- Connection keep-alive via ping/pong
- Support for multiple concurrent mod connections per channel
---
## Quick Start
1. Start the gateway (build/run your application that embeds the WebSocket gateway).
2. Connect a client to the WebSocket endpoint:
- URL: `ws://<host>:<port>/sync?api_key=<your_api_key>`
3. Immediately send a handshake JSON with type `mod` or `bot`.
4. On success, youll receive `{"status":"connected","type":"mod|bot"}`.
5. Exchange messages as JSON.
See the full details below.
---
## Architecture
### Connection Types
The gateway supports two types of connections:
1. **Mod Connection** - Game server mods/plugins that send and receive player messages
2. **Bot Connection** - External bots (typically Discord) that bridge messages to/from other platforms
### Message Flow
``` ```
Mod -> websocket /register { server_id, channel_id } // grabbed from mod config Mod (Server) ←→ Gateway ←→ Bot (Discord)
Bot -> websocket /ready { channel_id } // ready if Mod with fitting channel_id has called /register
Gateway -> memory cache (server_id -> channel_id; channel_id -> server_id) // mem enough Channel A Channel A
Mod/Bot -> websocket /ws { ... } -> Bot/Mod // sync
``` ```
Messages are routed based on `channel_id`:
- **Mod → Bot**: Messages from a mod are forwarded to the bot
- **Bot → Mod**: Messages from a bot are forwarded to the mod registered for that channel
---
## Connection Setup
### Endpoint
```
ws://<host>:<port>/sync?api_key=<your_api_key>
```
**Default Port:** 3333 (configurable)
### Authentication
Authentication is performed via API key, which can be provided in two ways:
1. **Query Parameter** (recommended):
```
ws://localhost:3333/sync?api_key=gateway
```
2. **HTTP Header**:
```
X-API-Key: gateway
```
### Connection Timeout
After connecting, you **must** send a handshake message within **60 seconds** or the connection will be closed.
---
## Handshake Protocol
### Step 1: Establish WebSocket Connection
Connect to the `/sync` endpoint with your API key.
### Step 2: Send Handshake Message
Immediately after connecting, send a JSON handshake message:
```json
{
"type": "mod", // or "bot"
"data": { ... }
}
```
#### Mod Handshake
For game server mods/plugins:
```json
{
"type": "mod",
"data": {
"server_id": "minecraft-server-001",
"channel_id": "123456789"
}
}
```
**Fields:**
- `server_id` (string, required): Unique identifier for your server instance
- `channel_id` (string, required): The Discord channel ID (or equivalent) this mod serves
#### Bot Handshake
For bots (Discord bots, etc.):
```json
{
"type": "bot",
"data": {
"bot_id": "discord-bot-123"
}
}
```
**Fields:**
- `bot_id` (string, required): The bots ID
**Note:** Only **one bot connection** is allowed at a time. Trying to connect a new bot will result in an `409 Conflict` Error.
### Step 3: Receive Acknowledgment
After sending the handshake, wait for an acknowledgment:
**Success Response:**
```json
{
"status": "connected",
"type": "mod|bot"
}
```
**Error Responses:**
```json
{
"message": "Malformed handshake.",
"code": 400
}
```
```json
{
"message": "Bot already connected.",
"code": 409
}
```
### Step 4: Begin Message Exchange
Once acknowledged, the connection is established and you can start sending/receiving messages.
---
## Message Protocol
### Sending Messages
After handshake, send messages as JSON:
#### From Mod to Bot
```json
{
"msg_id": "msg-unique-123",
"id": "minecraft-server-001",
"destination": {
"channel_id": "123456789"
},
"author": {
"id": "player-uuid-abc",
"name": "PlayerName"
},
"content": "Hello from the game server!",
"meta": {
"server_name": "Survival Server",
"world": "overworld"
},
"ts": "2025-12-08T10:30:00Z"
}
```
**Required Fields:**
- `msg_id` (string): Unique message identifier (generate client-side)
- `id` (string): Server ID (must match your handshake `server_id`)
- `destination.channel_id` (string): Target channel ID
- `author.id` (string): User/player identifier
- `content` (string): Message content (non-empty)
**Optional Fields:**
- `author.name` (string): Display name for the author
- `meta` (object): Additional metadata (arbitrary key-value pairs)
- `ts` (RFC3339 timestamp): Message timestamp (defaults to server time if omitted)
#### From Bot to Mod
```json
{
"msg_id": "discord-msg-456",
"id": "123456789",
"author": {
"id": "discord-user-789",
"name": "DiscordUser"
},
"content": "Hello from Discord!",
"meta": {
"platform": "discord",
"roles": ["admin"]
},
"ts": "2025-12-08T10:31:00Z"
}
```
**Required Fields:**
- `msg_id` (string): Unique message identifier
- `id` (string): Channel ID (from which channel the message originates)
- `author.id` (string): User identifier
- `content` (string): Message content (non-empty)
**Optional Fields:**
- `author.name` (string): Display name
- `meta` (object): Additional metadata
- `ts` (RFC3339 timestamp): Message timestamp
**Note:** Bot messages do **not** include a `destination` field, as the channel ID in `id` determines routing.
### Receiving Messages
Messages are received as JSON in the same format they were sent:
#### Mod Receives (from Bot)
```json
{
"type": "bot",
"channel_id": "123456789",
"author": {
"id": "discord-user-789",
"name": "DiscordUser"
},
"content": "Hello from Discord!",
"meta": {
"platform": "discord"
},
"ts": "2025-12-08T10:31:00Z",
"received_at": "2025-12-08T10:31:00.123Z",
"forwarded_at": "2025-12-08T10:31:00.125Z"
}
```
#### Bot Receives (from Mod)
```json
{
"type": "mod",
"channel_id": "123456789",
"author": {
"id": "player-uuid-abc",
"name": "PlayerName"
},
"content": "Hello from the game server!",
"meta": {
"server_name": "Survival Server"
},
"ts": "2025-12-08T10:30:00Z",
"received_at": "2025-12-08T10:30:00.100Z",
"forwarded_at": "2025-12-08T10:30:00.102Z"
}
```
**Additional Fields in Received Messages:**
- `type` (string): Origin type ("mod" or "bot")
- `channel_id` (string): The channel this message belongs to
- `received_at` (RFC3339): When gateway received the message
- `forwarded_at` (RFC3339): When gateway forwarded the message
### Message Acknowledgments
After sending each message, you'll receive an acknowledgment:
```json
{
"status": "completed",
"type": "mod"
}
```
**Status Values:**
- `completed`: Message was delivered immediately to recipient
- `queued`: Recipient is offline; message queued for later delivery
- `failed`: Message could not be delivered or queued
---
## Message Queuing
When a recipient is offline, messages are automatically queued:
- **Queue Size:** Configurable (default: 8 messages per channel)
- **Queue Behavior:** Circular buffer (oldest messages are overwritten when full)
- **Flush Trigger:** When recipient reconnects, all queued messages are delivered
### Example Flow
1. Mod sends message while bot is offline → Message queued
2. Bot connects and completes handshake → All queued messages flushed to bot
3. Bot sends message while mod is offline → Message queued
4. Mod connects → Queued messages flushed to mod
---
## Keep-Alive & Ping/Pong
The gateway sends **WebSocket ping messages every 30 seconds** to maintain connections.
**Client Responsibilities:**
1. **Respond to pings**: Your WebSocket library should automatically handle pong responses
2. **Handle pongs**: Set a pong handler to reset read deadlines
3. **Read Deadline**: The gateway sets a 60-second read deadline, reset on each pong
### Example (Go)
```go
conn.SetPongHandler(func(appData string) error {
conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
```
---
## Error Handling
### Connection Errors
**Handshake Errors:**
- `400` - Malformed handshake JSON
- `401` - Invalid or missing API key
- `409` - Bot already connected (only for bot handshakes)
- `500` - Internal server error
**Message Errors:**
- `400` - Malformed message (missing required fields)
Errors are sent as JSON:
```json
{
"message": "Malformed message.",
"code": 400
}
```
### Validation Rules
Messages are validated on receipt:
- `id` must not be empty
- `msg_id` must not be empty
- `author.id` must not be empty
- `content` must not be empty
- For mod messages: `destination.channel_id` must not be empty
### Websocket Closures
- `1000` - Normal closure
- `1001` - Going away
Handle other WebSocket closures as unexpected errors.
---
## Rate Limits & Restrictions
- **Message Size Limit:** 2 MB per message (configurable)
- **Read Limit:** Messages exceeding the limit will close the connection
- **Concurrent Mods:** Multiple mods *can* connect to the same channel ID (different server IDs)
- **Concurrent Bots:** Only **one bot connection** allowed globally
---
## Best Practices
### 1. Generate Unique Message IDs
Always generate unique `msg_id` values for each message. Consider using:
- UUID v4
- Timestamp + random suffix
- Sequential counter with prefix
### 2. Handle Reconnections
Implement automatic reconnection logic with exponential backoff:
```
1st retry: 1 second
2nd retry: 2 seconds
3rd retry: 4 seconds
Max: 30 seconds
```
### 3. Set Appropriate Timeouts
- Write timeout: 5 seconds (same as Gateway)
- Read timeout: 60 seconds (reset on pong)
### 4. Validate Before Sending
Check required fields locally before sending to avoid validation errors.
### 5. Monitor Acknowledgments
Track acknowledgment statuses:
- `completed`: Message delivered
- `queued`: Message queued
- `failed`: Log and potentially retry
### 6. Use Metadata
The `meta` field is used for:
- Server information (server name, region, version)
- User context (roles, permissions)
- Message context (reply-to, thread-id)
### 7. Thread-Safe Writes
Use mutex/locks when writing to WebSocket from multiple threads.
---
## API Reference
### Endpoints
#### `GET /sync`
WebSocket upgrade endpoint for mod/bot connections.
**Query Parameters:**
- `api_key` (required): Authentication token
#### `GET /health`
Health check endpoint.
**Response:**
```json
{
"status": "healthy"
}
```
---
## Configuration
Gateway configuration (`config.toml`):
```toml
[gateway]
http_port = 3333 # WebSocket port
websocket = "gateway" # API key
body_size = 1 # Max message size in MB
queue_max = 8 # Messages per channel queue
```
---

253
bot.go Normal file
View File

@@ -0,0 +1,253 @@
//go:build simbot
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"math/rand"
"net/url"
"os"
"os/signal"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/gorilla/websocket"
)
const (
gatewayURL = "ws://localhost:3333/sync"
apiKey = "gateway"
channelID = "123456789"
)
var (
minInterval = 500 * time.Millisecond
maxInterval = 5 * time.Second
)
type Handshake struct {
Type string `json:"type"`
Data json.RawMessage `json:"data"`
}
type BotHandshake struct {
BotID string `json:"bot_id"`
}
type GatewayAck struct {
Status string `json:"status"`
Type string `json:"type"`
}
type User struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Destination struct {
ID string `json:"channel_id,omitempty"`
}
type GatewayMessageIn struct {
ID string `json:"id"`
MsgID string `json:"msg_id"`
Destination Destination `json:"destination,omitempty"`
Author User `json:"author"`
Content string `json:"content"`
Meta map[string]interface{} `json:"meta,omitempty"`
Ts time.Time `json:"ts,omitempty"`
ReceivedAt time.Time `json:"-"`
}
func randomDuration(min, max time.Duration) time.Duration {
if max <= min {
return min
}
diff := int64(max - min)
n := rand.Int63n(diff)
return min + time.Duration(n)
}
func main() {
rand.Seed(time.Now().UnixNano())
botID := flag.String("bot", "sim-bot-1", "bot id")
sendAfter := flag.Duration("send-after", 0, "optional: send a bot->mod message after this delay (e.g. 2s)")
sendMsg := flag.String("msg", "Hello from bot!", "optional bot->mod test message content")
flag.Parse()
interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
u, err := url.Parse(gatewayURL)
if err != nil {
log.Fatalf("Failed to parse URL: %v", err)
}
q := u.Query()
q.Set("api_key", apiKey)
u.RawQuery = q.Encode()
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
log.Fatalf("Failed to connect: %v", err)
}
defer conn.Close()
var writeMu sync.Mutex
conn.SetPingHandler(func(appData string) error {
writeMu.Lock()
err := conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(5*time.Second))
writeMu.Unlock()
if err != nil {
log.Printf("Failed to send pong: %v", err)
return err
}
return nil
})
bhs := BotHandshake{BotID: "discord-bot"}
data, err := json.Marshal(bhs)
if err != nil {
_ = conn.Close()
log.Fatalf("Failed to marshal bot handshake: %v", err)
}
hs := Handshake{Type: "bot", Data: data}
writeMu.Lock()
if err := conn.WriteJSON(hs); err != nil {
writeMu.Unlock()
_ = conn.Close()
log.Fatalf("Failed to send handshake: %v", err)
}
writeMu.Unlock()
conn.SetReadDeadline(time.Now().Add(10 * time.Second))
msgType, raw, err := conn.ReadMessage()
if err != nil {
_ = conn.Close()
log.Fatalf("Failed to read handshake response: %v", err)
}
_ = conn.SetReadDeadline(time.Time{})
if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage {
log.Printf("Raw handshake reply: %s", string(raw))
var ack GatewayAck
if err := json.Unmarshal(raw, &ack); err != nil {
log.Printf("Handshake reply is not JSON or unmarshal failed: %v", err)
} else {
log.Printf("Parsed ack: status=%q, type=%q", ack.Status, ack.Type)
}
} else {
log.Printf("Handshake reply type=%d", msgType)
}
done := make(chan struct{})
go func() {
defer close(done)
for {
msgType, message, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("WebSocket error: %v", err)
} else {
log.Printf("Connection closed or read error: %v", err)
}
return
}
if msgType == websocket.TextMessage || msgType == websocket.BinaryMessage {
log.Printf("Received from gateway: %s", string(message))
}
}
}()
var msgCounter uint64 = 1
if *sendAfter > 0 {
go func() {
time.Sleep(*sendAfter)
msg := GatewayMessageIn{
MsgID: fmt.Sprintf("bot-msg-%06d", atomic.AddUint64(&msgCounter, 1)),
ID: channelID,
Author: User{
ID: *botID,
Name: "SimBot",
},
Content: *sendMsg,
Ts: time.Now().UTC(),
}
writeMu.Lock()
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := conn.WriteJSON(msg); err != nil {
log.Printf("Failed to send bot->mod test message: %v", err)
} else {
log.Printf("Sent bot->mod test message (channel=%s)", channelID)
}
_ = conn.SetWriteDeadline(time.Time{})
writeMu.Unlock()
}()
}
go func() {
for {
select {
case <-done:
return
default:
}
d := randomDuration(minInterval, maxInterval)
select {
case <-done:
return
case <-time.After(d):
msgNum := atomic.AddUint64(&msgCounter, 1)
msg := GatewayMessageIn{
MsgID: fmt.Sprintf("sim-bot-msg-%06d", msgNum),
ID: channelID,
Author: User{
ID: fmt.Sprintf("%s-%d", *botID, msgNum%1000),
Name: fmt.Sprintf("SimBot%d", msgNum%1000),
},
Content: fmt.Sprintf("Automated bot message #%d (delay %s)", msgNum, d),
Ts: time.Now().UTC(),
}
writeMu.Lock()
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := conn.WriteJSON(msg); err != nil {
writeMu.Unlock()
log.Printf("Failed to send automated bot message: %v", err)
return
}
_ = conn.SetWriteDeadline(time.Time{})
writeMu.Unlock()
log.Printf("Sent automated bot message %s", msg.MsgID)
}
}
}()
for {
select {
case <-done:
log.Println("Connection closed by server")
_ = conn.Close()
return
case <-interrupt:
log.Println("Interrupt received, closing connection...")
writeMu.Lock()
_ = conn.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
writeMu.Unlock()
select {
case <-done:
case <-time.After(time.Second):
}
_ = conn.Close()
return
}
}
}

View File

@@ -7,10 +7,4 @@ rotation = 3 # in days
http_port = 3333 http_port = 3333
websocket = "gateway" websocket = "gateway"
body_size = 2 # in MB body_size = 2 # in MB
queue_max = 8192 queue_max = 8
[database]
host_dsn = ""
username = ""
password = ""
database = ""

View File

@@ -12,11 +12,8 @@ func NewGatewayController(cfg config.Config) GatewayController {
panic(err) panic(err)
} }
modHandler := ws.NewLoggingModHandler(wsl)
botHandler := ws.NewLoggingBotHandler(wsl)
return GatewayController{ return GatewayController{
Websocket: ws.NewWsGateway(cfg.Gateway, wsl, wCloseFn, modHandler, botHandler), Websocket: ws.NewWebsocketGateway(cfg.Gateway, wsl, wCloseFn),
HttpServer: HttpGateway{}, HttpServer: HttpGateway{},
} }
} }

View File

@@ -1,8 +1,6 @@
package controller package controller
import ( import "homestead/homestead_gateway/ws"
"homestead/homestead_gateway/ws"
)
type GatewayController struct { type GatewayController struct {
Websocket *ws.WebsocketGateway Websocket *ws.WebsocketGateway

13
main.go
View File

@@ -3,11 +3,20 @@ package main
import ( import (
"flag" "flag"
"homestead/homestead_gateway/controller" "homestead/homestead_gateway/controller"
"homestead/homestead_gateway/util"
"homestead/homestead_gateway/util/config" "homestead/homestead_gateway/util/config"
"os"
"path"
) )
func main() { func main() {
cfgPath := flag.String("config", "config.toml", "configuration file") dir := util.GetPath()
if dir == "" {
dir, _ = os.Getwd()
}
file := path.Join(dir, "config.toml")
cfgPath := flag.String("config", file, "configuration file")
cfg, err := config.LoadConfig(*cfgPath) cfg, err := config.LoadConfig(*cfgPath)
if err != nil { if err != nil {
panic(err) panic(err)
@@ -19,3 +28,5 @@ func main() {
panic(err) panic(err)
} }
} }
// todo logs from exe not cwd

154
sim.go
View File

@@ -1,11 +1,17 @@
//go:build sim
package main package main
import ( import (
"encoding/json" "encoding/json"
"fmt"
"log" "log"
"math/rand"
"net/url" "net/url"
"os" "os"
"os/signal" "os/signal"
"sync"
"sync/atomic"
"syscall" "syscall"
"time" "time"
@@ -13,9 +19,17 @@ import (
) )
const ( const (
gatewayURL = "ws://localhost:3333/push" gatewayURL = "ws://localhost:3333/sync"
apiKey = "gateway" apiKey = "gateway"
serverID = "test-server-001" serverID = "test-server-001"
// THE CHANNEL ID the mod says it serves. Must match gateway expectation.
channelID = "1444253682777587804"
)
// send interval range (random between minInterval and maxInterval)
var (
minInterval = 500 * time.Millisecond
maxInterval = 5 * time.Second
) )
type Handshake struct { type Handshake struct {
@@ -23,8 +37,10 @@ type Handshake struct {
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
} }
// ModHandshake now includes ChannelID
type ModHandshake struct { type ModHandshake struct {
ServerID string `json:"server_id"` ServerID string `json:"server_id"`
ChannelID string `json:"channel_id"`
} }
type GatewayAck struct { type GatewayAck struct {
@@ -32,30 +48,32 @@ type GatewayAck struct {
Type string `json:"type"` Type string `json:"type"`
} }
type MinecraftUser struct { type User struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} }
type Destination struct { type Destination struct {
ChannelID string `json:"channel_id"` ID string `json:"channel_id,omitempty"`
} }
type GatewayModMessageIn struct { type GatewayMessageIn struct {
MsgID string `json:"msg_id"` ID string `json:"id"` // where am I from (channel_id or server_id)
Server string `json:"server"` MsgID string `json:"msg_id"` // msg id
Destination Destination `json:"destination"` Destination Destination `json:"destination,omitempty"` // where do I wanna go (channel_id or empty if from Bot)
Author MinecraftUser `json:"author"` Author User `json:"author"` // who sent the message
Content string `json:"content"` Content string `json:"content"` // message content
Meta map[string]interface{} `json:"meta,omitempty"` Meta map[string]interface{} `json:"meta,omitempty"` // additional metadata
Ts string `json:"ts,omitempty"` Ts time.Time `json:"ts,omitempty"` // timestamp
ReceivedAt time.Time `json:"-"` // ReceivedAt is populated by gateway (not from mod)
} }
func main() { func main() {
rand.Seed(time.Now().UnixNano())
interrupt := make(chan os.Signal, 1) interrupt := make(chan os.Signal, 1)
signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM) signal.Notify(interrupt, os.Interrupt, syscall.SIGTERM)
// Build WebSocket URL with API key
u, err := url.Parse(gatewayURL) u, err := url.Parse(gatewayURL)
if err != nil { if err != nil {
log.Fatalf("Failed to parse URL: %v", err) log.Fatalf("Failed to parse URL: %v", err)
@@ -66,7 +84,6 @@ func main() {
log.Printf("Connecting to %s", u.String()) log.Printf("Connecting to %s", u.String())
// Connect to WebSocket
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil) conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil { if err != nil {
log.Fatalf("Failed to connect: %v", err) log.Fatalf("Failed to connect: %v", err)
@@ -75,19 +92,23 @@ func main() {
log.Println("Connected to gateway") log.Println("Connected to gateway")
// Set up ping handler - respond to pings from server var writeMu sync.Mutex
conn.SetPingHandler(func(appData string) error { conn.SetPingHandler(func(appData string) error {
log.Println("Received ping from server, sending pong") log.Println("Received ping from server, sending pong")
err := conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(5*time.Second)) writeMu.Lock()
if err != nil { defer writeMu.Unlock()
if err := conn.WriteControl(websocket.PongMessage, []byte(appData), time.Now().Add(5*time.Second)); err != nil {
log.Printf("Failed to send pong: %v", err) log.Printf("Failed to send pong: %v", err)
return err return err
} }
return nil return nil
}) })
// Send handshake modHS := ModHandshake{
modHS := ModHandshake{ServerID: serverID} ServerID: serverID,
ChannelID: channelID,
}
modHSData, err := json.Marshal(modHS) modHSData, err := json.Marshal(modHS)
if err != nil { if err != nil {
log.Fatalf("Failed to marshal mod handshake: %v", err) log.Fatalf("Failed to marshal mod handshake: %v", err)
@@ -98,24 +119,22 @@ func main() {
Data: modHSData, Data: modHSData,
} }
writeMu.Lock()
if err := conn.WriteJSON(handshake); err != nil { if err := conn.WriteJSON(handshake); err != nil {
writeMu.Unlock()
log.Fatalf("Failed to send handshake: %v", err) log.Fatalf("Failed to send handshake: %v", err)
} }
writeMu.Unlock()
log.Println("Handshake sent") log.Println("Handshake sent")
// Read acknowledgment
var ack GatewayAck var ack GatewayAck
if err := conn.ReadJSON(&ack); err != nil { if err := conn.ReadJSON(&ack); err != nil {
log.Fatalf("Failed to read acknowledgment: %v", err) log.Fatalf("Failed to read acknowledgment: %v", err)
} }
log.Printf("Received acknowledgment: status=%q, type=%q", ack.Status, ack.Type)
log.Printf("Received acknowledgment: status=%s, type=%s", ack.Status, ack.Type)
// Channel for incoming messages
done := make(chan struct{}) done := make(chan struct{})
// Read loop - handles incoming messages and processes control frames
go func() { go func() {
defer close(done) defer close(done)
for { for {
@@ -124,66 +143,115 @@ func main() {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
log.Printf("WebSocket error: %v", err) log.Printf("WebSocket error: %v", err)
} else { } else {
log.Printf("Connection closed: %v", err) log.Printf("Connection closed/read error: %v", err)
} }
return return
} }
switch messageType {
// Only log text/binary messages (ping/pong handled by handlers) case websocket.TextMessage, websocket.BinaryMessage:
if messageType == websocket.TextMessage || messageType == websocket.BinaryMessage {
log.Printf("Received from server: %s", string(message)) log.Printf("Received from server: %s", string(message))
default:
} }
} }
}() }()
// Optional: Send a test message after connecting var msgCounter uint64 = 1
time.Sleep(2 * time.Second)
testMsg := GatewayModMessageIn{ func() {
MsgID: "test-msg-001", testMsg := GatewayMessageIn{
Server: serverID, MsgID: fmt.Sprintf("test-msg-%06d", atomic.AddUint64(&msgCounter, 1)),
ID: serverID,
Destination: Destination{ Destination: Destination{
ChannelID: "123456789", ID: channelID,
}, },
Author: MinecraftUser{ Author: User{
ID: "player-uuid-123", ID: "player-uuid-123",
Name: "TestPlayer", Name: "TestPlayer",
}, },
Content: "Hello from simulated mod!", Content: "Hello from simulated mod!",
Ts: time.Now().UTC().Format(time.RFC3339), Ts: time.Now().UTC(),
} }
writeMu.Lock()
if err := conn.WriteJSON(testMsg); err != nil { if err := conn.WriteJSON(testMsg); err != nil {
log.Printf("Failed to send test message: %v", err) log.Printf("Failed to send test message: %v", err)
} else { } else {
log.Println("Sent test message to gateway") log.Println("Sent initial test message to gateway")
}
writeMu.Unlock()
}()
go func() {
for {
d := randomDuration(minInterval, maxInterval)
select {
case <-done:
return
case <-time.After(d):
// build message
msgNum := atomic.AddUint64(&msgCounter, 1)
msg := GatewayMessageIn{
MsgID: fmt.Sprintf("sim-msg-%06d", msgNum),
ID: serverID,
Destination: Destination{
ID: channelID,
},
Author: User{
ID: fmt.Sprintf("sim-user-%d", msgNum%1000),
Name: fmt.Sprintf("SimUser%d", msgNum%1000),
},
Content: fmt.Sprintf("Random interval message #%d (delay %s)", msgNum, d),
Ts: time.Now().UTC(),
} }
log.Println("Connection established. Responding to pings. Press Ctrl+C to disconnect.") writeMu.Lock()
if err := conn.WriteJSON(msg); err != nil {
log.Printf("Failed to send simulated message: %v", err)
writeMu.Unlock()
return
}
writeMu.Unlock()
log.Printf("Sent simulated message %s (next wait up to %s)", msg.MsgID, maxInterval)
}
}
}()
log.Println("Connection established. Sending simulated messages at random intervals. Press Ctrl+C to disconnect.")
// Wait for interrupt or connection close
for { for {
select { select {
case <-done: case <-done:
log.Println("Connection closed") log.Println("Connection read loop closed, exiting")
return return
case <-interrupt: case <-interrupt:
log.Println("Interrupt received, closing connection...") log.Println("Interrupt received, closing connection...")
// Send close message writeMu.Lock()
err := conn.WriteMessage( err := conn.WriteMessage(
websocket.CloseMessage, websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""), websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""),
) )
writeMu.Unlock()
if err != nil { if err != nil {
log.Printf("Write close error: %v", err) log.Printf("Write close error: %v", err)
return
} }
select { select {
case <-done: case <-done:
case <-time.After(time.Second): case <-time.After(1 * time.Second):
} }
return return
} }
} }
} }
func randomDuration(min, max time.Duration) time.Duration {
if max <= min {
return min
}
diff := int64(max - min)
n := rand.Int63n(diff)
return min + time.Duration(n)
}

70
util/cache/cache.go vendored
View File

@@ -1,70 +0,0 @@
package cache
import "sync"
type Cache struct {
mu sync.RWMutex
s2c map[string]string
c2s map[string]string
}
func NewCache() *Cache {
return &Cache{
s2c: make(map[string]string),
c2s: make(map[string]string),
}
}
// Set creates or overwrites the pair a -> b and b -> a.
// It ensures any previous mappings involving a or b are removed first.
func (c *Cache) Set(serverId, channelId string) {
c.mu.Lock()
defer c.mu.Unlock()
if old, ok := c.s2c[serverId]; ok && old != channelId {
delete(c.c2s, old)
}
if old, ok := c.c2s[channelId]; ok && old != serverId {
delete(c.s2c, old)
}
c.s2c[serverId] = channelId
c.c2s[channelId] = serverId
}
func (c *Cache) GetByServerId(serverId string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
cId, ok := c.s2c[serverId]
return cId, ok
}
func (c *Cache) GetByChannelId(channelId string) (string, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
sId, ok := c.c2s[channelId]
return sId, ok
}
func (c *Cache) RemoveByServerId(serverId string) {
c.mu.RLock()
defer c.mu.RUnlock()
if channelId, ok := c.s2c[serverId]; ok {
delete(c.s2c, serverId)
delete(c.c2s, channelId)
}
}
func (c *Cache) RemoveByChannelId(channelId string) {
c.mu.RLock()
defer c.mu.RUnlock()
if serverId, ok := c.s2c[channelId]; ok {
delete(c.c2s, channelId)
delete(c.s2c, serverId)
}
}

16
util/cache/structs.go vendored
View File

@@ -1,16 +0,0 @@
package cache
import (
"sync"
)
type ShardedCache struct {
shards []*shard
shardMask uint32
}
type shard struct {
mu sync.RWMutex
s2c map[string]string // serverId -> channelId
c2s map[string]string // channelId -> serverId
}

View File

@@ -5,7 +5,6 @@ import "log/slog"
type Config struct { type Config struct {
Log LogConfig `toml:"log"` Log LogConfig `toml:"log"`
Gateway GatewayConfig `toml:"gateway"` Gateway GatewayConfig `toml:"gateway"`
Database DatabaseConfig `toml:"database"`
} }
type GatewayConfig struct { type GatewayConfig struct {
@@ -20,10 +19,3 @@ type LogConfig struct {
Directory string `toml:"directory"` Directory string `toml:"directory"`
Rotation int `toml:"rotation"` Rotation int `toml:"rotation"`
} }
type DatabaseConfig struct {
HostDSN string `toml:"host_dsn"`
Username string `toml:"username"`
Password string `toml:"password"`
Database string `toml:"database"`
}

View File

@@ -3,6 +3,7 @@ package logger
import ( import (
"context" "context"
"fmt" "fmt"
"homestead/homestead_gateway/util"
"homestead/homestead_gateway/util/config" "homestead/homestead_gateway/util/config"
"log/slog" "log/slog"
"os" "os"
@@ -23,6 +24,7 @@ func New(id string, cfg config.LogConfig) (*slog.Logger, func() error, error) {
cfg.Rotation = 7 cfg.Rotation = 7
} }
cfg.Directory = util.NormalizeLogPath(cfg.Directory)
console := slog.NewTextHandler(&prefixWriter{inner: os.Stderr, prefix: []byte("[" + id + "] "), startLine: true}, &slog.HandlerOptions{AddSource: true, Level: cfg.Level}) console := slog.NewTextHandler(&prefixWriter{inner: os.Stderr, prefix: []byte("[" + id + "] "), startLine: true}, &slog.HandlerOptions{AddSource: true, Level: cfg.Level})
router := newFileRouter(cfg.Directory, cfg.Rotation, id) router := newFileRouter(cfg.Directory, cfg.Rotation, id)
root := slogmulti.Fanout(console, router) root := slogmulti.Fanout(console, router)

51
util/util.go Normal file
View File

@@ -0,0 +1,51 @@
package util
import (
"os"
"path/filepath"
"time"
"github.com/gorilla/websocket"
)
func GetPath() string {
exe, err := os.Executable()
if err != nil {
return ""
}
exe, err = filepath.EvalSymlinks(exe)
if err != nil {
}
return filepath.Dir(exe)
}
func NormalizePath(path string) string {
return filepath.Clean(filepath.FromSlash(path))
}
func NormalizeLogPath(path string) string {
if filepath.IsAbs(path) {
return path
}
return NormalizePath(filepath.Join(GetPath(), path))
}
//
func CloseConnWithControlMessage(conn *websocket.Conn, typ int, text string) {
_ = conn.SetWriteDeadline(time.Now().Add(time.Second))
_ = conn.WriteControl(
typ, websocket.FormatCloseMessage(typ, text), time.Now().Add(time.Second),
)
_ = conn.Close()
}
func CloseConn(conn *websocket.Conn) {
CloseConnWithControlMessage(
conn, websocket.CloseNormalClosure,
"Disconnecting.",
)
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
func (wsg *WebsocketGateway) handlePush(w http.ResponseWriter, r *http.Request) { func (wsg *WebsocketGateway) handleSync(w http.ResponseWriter, r *http.Request) {
conn, err := wsg.validateAndUpgradeConnection(w, r) conn, err := wsg.validateAndUpgradeConnection(w, r)
if err != nil { if err != nil {
return return
@@ -28,80 +28,81 @@ func (wsg *WebsocketGateway) handlePush(w http.ResponseWriter, r *http.Request)
typ, data, err := conn.ReadMessage() typ, data, err := conn.ReadMessage()
if err != nil { if err != nil {
wsg.sendWebsocketError(conn, "Internal Server Error", 500) wsg.sendWebsocketError(conn, "Internal Server Error", 500, true)
wsg.logger.Error("Failed to read handshake.", "remote", conn.RemoteAddr().String(), "err", err) wsg.logger.Error("Failed to read handshake.", "remote", conn.RemoteAddr().String(), "err", err)
return return
} }
if typ != websocket.TextMessage && typ != websocket.BinaryMessage { if typ != websocket.TextMessage && typ != websocket.BinaryMessage {
wsg.sendWebsocketError(conn, "First message must be a handshake.", 400) wsg.sendWebsocketError(conn, "Initial message must be a handshake.", 400, true)
wsg.logger.Warn("Invalid handshake message type.", "remote", conn.RemoteAddr().String()) wsg.logger.Warn("Invalid handshake message type.", "remote", conn.RemoteAddr().String())
return return
} }
var handshake Handshake var handshake Handshake
if err := json.Unmarshal(data, &handshake); err != nil { if err := json.Unmarshal(data, &handshake); err != nil {
wsg.sendWebsocketError(conn, "Malformed handshake.", 400) wsg.sendWebsocketError(conn, "Malformed handshake.", 400, true)
wsg.logger.Warn("Malformed handshake.", "remote", conn.RemoteAddr().String(), "err", err) wsg.logger.Warn("Malformed handshake.", "remote", conn.RemoteAddr().String(), "err", err)
return return
} }
meta := connectionMetaData{connectionType: handshake.Type}
switch handshake.Type { switch handshake.Type {
case "mod": case "mod":
var mhs ModHandshake var mhs ModHandshake
if err := json.Unmarshal(handshake.Data, &mhs); err != nil { if err := json.Unmarshal(handshake.Data, &mhs); err != nil {
wsg.sendWebsocketError(conn, "Malformed mod handshake.", 400) wsg.sendWebsocketError(conn, "Malformed mod handshake.", 400, true)
wsg.logger.Warn("Malformed mod handshake.", "remote", conn.RemoteAddr().String(), "err", err) wsg.logger.Warn("Malformed mod handshake.", "remote", conn.RemoteAddr().String(), "err", err)
return return
} }
meta.id = mhs.ServerID if mhs.ServerID == "" || mhs.ChannelID == "" {
wsg.sendWebsocketError(conn, "Malformed mod handshake.", 400, true)
if err = wsg.sendWebsocketResponse(conn, GatewayAck{Status: "connected", Type: "mod"}); err != nil {
return return
} }
wsg.registerConn(conn, meta) if !wsg.registerConn(conn, "mod", mhs.ChannelID, mhs.ServerID) {
wsg.logger.Info("Mod connected via Websocket.", "remote", conn.RemoteAddr().String(), "server_id", mhs.ServerID) wsg.sendWebsocketError(conn, "Failed to register mod.", 500, true)
return
}
go wsg.modReadLoop(conn, meta) // replace with external handler mayhaps _ = wsg.sendWebsocketResponse(conn, GatewayAck{Status: "connected", Type: "mod"})
wsg.registry.FlushChannelWithSender(mhs.ChannelID, wsg.flush)
wsg.logger.Info("Mod connected via Websocket.", "remote", conn.RemoteAddr().String(), "id", mhs.ServerID)
go wsg.read(conn, "mod", mhs.ChannelID)
case "bot": case "bot":
var bhs BotHandshake var bhs BotHandshake
if err := json.Unmarshal(handshake.Data, &bhs); err != nil { if err := json.Unmarshal(handshake.Data, &bhs); err != nil {
wsg.sendWebsocketError(conn, "Malformed bot handshake.", 400) wsg.sendWebsocketError(conn, "Malformed bot handshake.", 400, true)
wsg.logger.Warn("Malformed bot handshake.", "remote", conn.RemoteAddr().String(), "err", err)
return return
} }
meta.id = bhs.BotID if bhs.BotID == "" {
wsg.sendWebsocketError(conn, "Malformed bot handshake.", 400, true)
if err = wsg.sendWebsocketResponse(conn, GatewayAck{Status: "connected", Type: "bot"}); err != nil {
return return
} }
wsg.registerConn(conn, meta) if !wsg.registerConn(conn, "bot", "", "") {
wsg.logger.Info("Bot connected via Websocket.", "remote", conn.RemoteAddr().String(), "bot_id", bhs.BotID) wsg.sendWebsocketError(conn, "Bot already connected.", 409, true)
return
}
go wsg.botReadLoop(conn, meta) // replace with external handler mayhaps _ = wsg.sendWebsocketResponse(conn, GatewayAck{Status: "connected", Type: "bot"})
wsg.registry.FlushAllToBotWithSender(wsg.flush)
wsg.logger.Info("Bot connected via Websocket.", "remote", conn.RemoteAddr().String(), "id", bhs.BotID)
go wsg.read(conn, "bot", "")
default: default:
wsg.sendWebsocketError(conn, "Unknown handshake.", 400) wsg.sendWebsocketError(conn, "Unknown handshake.", 400, true)
wsg.logger.Warn("Unknown connection type.", "remote", conn.RemoteAddr().String(), "type", handshake.Type) wsg.logger.Warn("Unknown connection type.", "remote", conn.RemoteAddr().String(), "type", handshake.Type)
return return
} }
} }
func (wsg *WebsocketGateway) handleReady(w http.ResponseWriter, r *http.Request) {}
func (wsg *WebsocketGateway) handleHealth(w http.ResponseWriter, r *http.Request) { func (wsg *WebsocketGateway) handleHealth(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(200) w.WriteHeader(200)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"status": "healthy"}) _ = json.NewEncoder(w).Encode(map[string]interface{}{"status": "healthy"})
} }
func (wsg *WebsocketGateway) handleRegister(w http.ResponseWriter, r *http.Request) {}

252
ws/registry.go Normal file
View File

@@ -0,0 +1,252 @@
package ws
import (
"fmt"
"homestead/homestead_gateway/util"
"time"
"github.com/gorilla/websocket"
)
func (q *BoundedQueue) Enqueue(m GatewayMessageOut) bool {
q.mu.Lock()
defer q.mu.Unlock()
if q.capacity == 0 {
return false
}
if q.length < q.capacity {
q.buf[(q.start+q.length)%q.capacity] = m
q.length++
return true
}
// overwrite oldest
q.buf[q.start] = m
q.start = (q.start + 1) % q.capacity
return true
}
func (q *BoundedQueue) PopAll() []GatewayMessageOut {
q.mu.Lock()
defer q.mu.Unlock()
if q.length == 0 {
return nil
}
out := make([]GatewayMessageOut, 0, q.length)
for i := 0; i < q.length; i++ {
out = append(out, q.buf[(q.start+i)%q.capacity])
}
q.start = 0
q.length = 0
return out
}
func (q *BoundedQueue) Len() int {
q.mu.Lock()
defer q.mu.Unlock()
return q.length
}
//
func (r *Registry) getOrCreate(channel string) *ChannelEntry {
r.mu.RLock()
e := r.entries[channel]
r.mu.RUnlock()
if e != nil {
return e
}
r.mu.Lock()
defer r.mu.Unlock()
if e = r.entries[channel]; e == nil {
e = newChannelEntry(channel, r.queueCap)
r.entries[channel] = e
}
return e
}
func (r *Registry) ForEach(cb func(channelID string)) {
r.mu.RLock()
ids := make([]string, 0, len(r.entries))
for id := range r.entries {
ids = append(ids, id)
}
r.mu.RUnlock()
for _, id := range ids {
cb(id)
}
}
//
// RegisterMod : map channel_id -> mod conn (serverID)
func (r *Registry) RegisterMod(channelID, serverID string, conn *websocket.Conn) {
e := r.getOrCreate(channelID)
e.mu.Lock()
defer e.mu.Unlock()
if e.Mod != nil && e.Mod.Conn != nil {
util.CloseConn(e.Mod.Conn)
}
e.Mod = &ConnWrapper{Conn: conn, ServerID: serverID, LastSeen: time.Now()}
// flush queued bot->mod messages for this channel
// caller should use FlushChannelWithSender to perform actual sends
}
// RegisterBot : single connection for bot. after registration call FlushAllToBotWithSender
func (r *Registry) RegisterBot(conn *websocket.Conn) {
r.botMu.Lock()
if r.bot != nil && r.bot.Conn != nil {
r.UnregisterBot()
}
r.bot = &ConnWrapper{Conn: conn, LastSeen: time.Now()}
r.botMu.Unlock()
}
func (r *Registry) UnregisterMod(channelID string) {
r.mu.RLock()
e := r.entries[channelID]
r.mu.RUnlock()
if e == nil {
return
}
e.mu.Lock()
modConn := e.Mod
e.Mod = nil
e.mu.Unlock()
if modConn != nil && modConn.Conn != nil {
util.CloseConn(modConn.Conn)
}
}
func (r *Registry) UnregisterBot() {
r.botMu.Lock()
botConn := r.bot
r.bot = nil
r.botMu.Unlock()
if botConn != nil && botConn.Conn != nil {
util.CloseConn(botConn.Conn)
}
}
func (r *Registry) Send(channelID string, out GatewayMessageOut, sendOverConn func(*websocket.Conn, GatewayMessageOut) error) (delivered bool, queued bool, err error) {
if out.Type == "mod" {
r.botMu.Lock()
b := r.bot
r.botMu.Unlock()
if b != nil && b.Conn != nil {
if err := sendOverConn(b.Conn, out); err == nil {
return true, false, nil
}
r.UnregisterBot()
}
e := r.getOrCreate(channelID)
e.mu.Lock()
enq := e.Queue.Enqueue(out)
e.mu.Unlock()
if !enq {
return false, false, fmt.Errorf("queue disabled")
}
return false, true, nil
}
e := r.getOrCreate(channelID)
e.mu.Lock()
mod := e.Mod
e.mu.Unlock()
if mod != nil && mod.Conn != nil {
if err := sendOverConn(mod.Conn, out); err == nil {
return true, false, nil
}
r.UnregisterMod(channelID)
}
e.mu.Lock()
enq := e.Queue.Enqueue(out)
e.mu.Unlock()
if !enq {
return false, false, fmt.Errorf("queue disabled")
}
return false, true, nil
}
//
func (r *Registry) FlushChannelWithSender(channelID string, sendOverConn func(*websocket.Conn, GatewayMessageOut) error) {
r.mu.RLock()
e := r.entries[channelID]
r.mu.RUnlock()
if e == nil {
return
}
e.mu.Lock()
if e.Mod == nil || e.Mod.Conn == nil {
e.mu.Unlock()
return
}
msgs := e.Queue.PopAll()
modConn := e.Mod.Conn
e.mu.Unlock()
for _, m := range msgs {
if err := sendOverConn(modConn, m); err != nil {
// if send fails, re-enqueue (best-effort), drop-oldest logic applies
e.mu.Lock()
_ = e.Queue.Enqueue(m)
e.mu.Unlock()
}
}
}
func (r *Registry) FlushAllToBotWithSender(sendOverConn func(*websocket.Conn, GatewayMessageOut) error) {
r.botMu.Lock()
b := r.bot
r.botMu.Unlock()
if b == nil || b.Conn == nil {
return
}
r.mu.RLock()
entries := make([]*ChannelEntry, 0, len(r.entries))
for _, e := range r.entries {
entries = append(entries, e)
}
r.mu.RUnlock()
for _, e := range entries {
e.mu.Lock()
msgs := e.Queue.PopAll()
e.mu.Unlock()
if len(msgs) == 0 {
continue
}
for _, m := range msgs {
if err := sendOverConn(b.Conn, m); err != nil {
e.mu.Lock()
_ = e.Queue.Enqueue(m)
e.mu.Unlock()
}
}
}
}

View File

@@ -1,9 +1,7 @@
package ws package ws
import ( import (
"context"
"encoding/json" "encoding/json"
"homestead/homestead_gateway/util/cache"
"log/slog" "log/slog"
"sync" "sync"
"time" "time"
@@ -17,74 +15,75 @@ type WebsocketGateway struct {
bodySizeBytes int64 bodySizeBytes int64
upgrader websocket.Upgrader upgrader websocket.Upgrader
connsMu sync.Mutex
conns map[*websocket.Conn]connectionMetaData
cache cache.Cache
modHandler ModHandler registry *Registry
botHandler BotHandler
logger *slog.Logger logger *slog.Logger
closeFn func() error closeFn func() error
} }
type MinecraftUser struct { //
ID string `json:"id"`
Name string `json:"name"` type Registry struct {
mu sync.RWMutex
entries map[string]*ChannelEntry
queueCap int
botMu sync.Mutex
bot *ConnWrapper
} }
type DiscordUser struct { type ConnWrapper struct {
Conn *websocket.Conn
ServerID string // set for mods (the server_id)
LastSeen time.Time
}
type BoundedQueue struct {
mu sync.Mutex
buf []GatewayMessageOut
start int
length int
capacity int
}
type ChannelEntry struct {
mu sync.Mutex
Channel string
Mod *ConnWrapper
Queue *BoundedQueue
}
//
type User struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
} }
type Destination struct { type Destination struct {
ChannelID string `json:"channel_id"` ID string `json:"channel_id,omitempty"`
} }
// GatewayModMessageIn : Mod -> Gateway -> Bot type GatewayMessageIn struct {
type GatewayModMessageIn struct { Type string
ID string `json:"id"`
MsgID string `json:"msg_id"` MsgID string `json:"msg_id"`
Server string `json:"server"` Destination Destination `json:"destination,omitempty"`
Destination Destination `json:"destination"` Author User `json:"author"`
Author MinecraftUser `json:"author"`
Content string `json:"content"` Content string `json:"content"`
Meta map[string]interface{} `json:"meta,omitempty"` Meta map[string]interface{} `json:"meta,omitempty"`
Ts string `json:"ts,omitempty"` Ts time.Time `json:"ts,omitempty"`
ReceivedAt time.Time `json:"-"` // ReceivedAt is populated by gateway (not from mod) ReceivedAt time.Time `json:"-"`
} }
// GatewayBotMessageIn : Bot -> Gateway -> Mod type GatewayMessageOut struct {
type GatewayBotMessageIn struct { Type string `json:"type"`
MsgID string `json:"msg_id"` ID string `json:"channel_id,omitempty"`
ChannelID string `json:"channel_id"` Author User `json:"author"`
Author DiscordUser `json:"author"`
Content string `json:"content"` Content string `json:"content"`
Meta map[string]interface{} `json:"meta,omitempty"` Meta map[string]interface{} `json:"meta,omitempty"`
Ts string `json:"ts,omitempty"` Ts time.Time `json:"ts,omitempty"`
ReceivedAt time.Time `json:"-"` // ReceivedAt is populated by gateway (not from bot)
}
// GatewayModMessageOut : Gateway<GatewayModMessageIn> -> Bot
type GatewayModMessageOut struct {
Type string `json:"type"` // "mod"
ChannelID string `json:"channel_id"`
Author MinecraftUser `json:"author"`
Content string `json:"content"`
Meta map[string]interface{} `json:"meta,omitempty"`
Ts string `json:"ts,omitempty"`
ReceivedAt time.Time `json:"received_at"`
ForwardedAt time.Time `json:"forwarded_at"`
}
// GatewayBotMessageOut : Gateway<GatewayBotMessageIn> -> Mod
type GatewayBotMessageOut struct {
Type string `json:"type"` // "bot"
ChannelID string `json:"channel_id"`
Author DiscordUser `json:"author"`
Content string `json:"content"`
Meta map[string]interface{} `json:"meta,omitempty"`
Ts string `json:"ts,omitempty"`
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
ForwardedAt time.Time `json:"forwarded_at"` ForwardedAt time.Time `json:"forwarded_at"`
} }
@@ -94,6 +93,8 @@ type GatewayAck struct {
Type string `json:"type"` Type string `json:"type"`
} }
//
type Handshake struct { type Handshake struct {
Type string `json:"type"` // "mod" or "bot" Type string `json:"type"` // "mod" or "bot"
Data json.RawMessage `json:"data"` Data json.RawMessage `json:"data"`
@@ -101,21 +102,9 @@ type Handshake struct {
type ModHandshake struct { type ModHandshake struct {
ServerID string `json:"server_id"` ServerID string `json:"server_id"`
ChannelID string `json:"channel_id"`
} }
type BotHandshake struct { type BotHandshake struct {
BotID string `json:"bot_id"` BotID string `json:"bot_id"`
} }
type ModHandler interface {
Handle(ctx context.Context, msg GatewayModMessageIn) error
}
type BotHandler interface {
Handle(ctx context.Context, msg GatewayBotMessageIn) error
}
type connectionMetaData struct {
connectionType string // "mod" or "bot"
id string // server_id or bot_id for logging
}

View File

@@ -1,70 +0,0 @@
package ws
import (
"context"
"encoding/json"
"log/slog"
"time"
)
type LoggingModHandler struct {
logger *slog.Logger
}
type LoggingBotHandler struct {
logger *slog.Logger
}
func NewLoggingModHandler(logger *slog.Logger) *LoggingModHandler {
return &LoggingModHandler{logger: logger}
}
func NewLoggingBotHandler(logger *slog.Logger) *LoggingBotHandler {
return &LoggingBotHandler{logger: logger}
}
func (h *LoggingModHandler) Handle(ctx context.Context, msg GatewayModMessageIn) error {
// For now, just log and pretend it's being forwarded
// TODO: Look up channel_id from database using server
// TODO: Forward to bot connection(s)
fwd := GatewayModMessageOut{
Type: "mod",
ChannelID: "TODO", // will come from database lookup
Author: msg.Author,
Content: msg.Content,
Meta: msg.Meta,
Ts: msg.Ts,
ReceivedAt: msg.ReceivedAt,
ForwardedAt: time.Now().UTC(),
}
b, _ := json.Marshal(fwd)
h.logger.Info("received mod message", "msg_id", msg.MsgID, "server", msg.Server, "Author", msg.Author.Name, "content", msg.Content)
h.logger.Debug("forwarding mod message", "msg_id", msg.MsgID, "server", msg.Server, "payload", string(b))
return nil
}
func (h *LoggingBotHandler) Handle(ctx context.Context, msg GatewayBotMessageIn) error {
// For now, just log and pretend it's being forwarded
// TODO: Look up server_id from database using channel_id
// TODO: Forward to mod connection(s)
fwd := GatewayBotMessageOut{
Type: "bot",
ChannelID: msg.ChannelID,
Author: msg.Author,
Content: msg.Content,
Meta: msg.Meta,
Ts: msg.Ts,
ReceivedAt: msg.ReceivedAt,
ForwardedAt: time.Now().UTC(),
}
b, _ := json.Marshal(fwd)
h.logger.Info("received bot message", "msg_id", msg.MsgID, "channel", msg.ChannelID, "author", msg.Author, "content", msg.Content)
h.logger.Debug("forwarding bot message", "msg_id", msg.MsgID, "channel", msg.ChannelID, "payload", string(b))
return nil
}

View File

@@ -4,8 +4,9 @@ import (
"context" "context"
"encoding/json" "encoding/json"
"errors" "errors"
"log/slog" "homestead/homestead_gateway/util"
"net/http" "net/http"
"strings"
"time" "time"
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
@@ -35,16 +36,28 @@ func (wsg *WebsocketGateway) deafen(srv *http.Server) {
// responses // responses
func (wsg *WebsocketGateway) flush(c *websocket.Conn, m GatewayMessageOut) error {
_ = c.SetWriteDeadline(time.Now().Add(5 * time.Second))
return c.WriteJSON(m)
}
func (wsg *WebsocketGateway) sendHttpError(w http.ResponseWriter, message string, code int) { func (wsg *WebsocketGateway) sendHttpError(w http.ResponseWriter, message string, code int) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code) w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]interface{}{"message": message, "code": code}) _ = json.NewEncoder(w).Encode(map[string]interface{}{"message": message, "code": code})
} }
func (wsg *WebsocketGateway) sendWebsocketError(conn *websocket.Conn, message string, code int) { func (wsg *WebsocketGateway) sendWebsocketPing(conn *websocket.Conn) {
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
_ = conn.WriteMessage(websocket.PingMessage, nil)
}
func (wsg *WebsocketGateway) sendWebsocketError(conn *websocket.Conn, message string, code int, close bool) {
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) _ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
_ = conn.WriteJSON(map[string]interface{}{"message": message, "code": code}) _ = conn.WriteJSON(map[string]interface{}{"message": message, "code": code})
_ = conn.Close() if close {
util.CloseConn(conn)
}
} }
func (wsg *WebsocketGateway) sendWebsocketResponse(conn *websocket.Conn, content interface{}) error { func (wsg *WebsocketGateway) sendWebsocketResponse(conn *websocket.Conn, content interface{}) error {
@@ -52,7 +65,7 @@ func (wsg *WebsocketGateway) sendWebsocketResponse(conn *websocket.Conn, content
if err := conn.WriteJSON(content); err != nil { if err := conn.WriteJSON(content); err != nil {
wsg.logger.Error("Failed to respond to connection.", "remote", conn.RemoteAddr().String(), "err", err) wsg.logger.Error("Failed to respond to connection.", "remote", conn.RemoteAddr().String(), "err", err)
_ = conn.Close() util.CloseConnWithControlMessage(conn, websocket.CloseAbnormalClosure, "Connection error.")
return err return err
} }
@@ -86,45 +99,106 @@ func (wsg *WebsocketGateway) validateApiKey(r *http.Request) bool {
return !(apiKey == "" || apiKey != wsg.apiKey) return !(apiKey == "" || apiKey != wsg.apiKey)
} }
func writeJSONSafe(c *websocket.Conn, v interface{}) error { func (wsg *WebsocketGateway) loggingMiddleware(next http.Handler) http.Handler {
_ = c.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := c.WriteJSON(v); err != nil {
// caller handles logging
return err
}
return nil
}
func loggingMiddleware(logger *slog.Logger, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now() start := time.Now()
next.ServeHTTP(w, r) next.ServeHTTP(w, r)
logger.Info("http request", "remote", r.RemoteAddr, "method", r.Method, "path", r.URL.Path, "duration", time.Since(start)) wsg.logger.Info("Incoming HTTP request.", "remote", r.RemoteAddr, "path", r.URL.Path, "duration", time.Since(start))
}) })
} }
// connections // connections
func (wsg *WebsocketGateway) registerConn(c *websocket.Conn, meta connectionMetaData) { func (wsg *WebsocketGateway) registerConn(conn *websocket.Conn, typ, channelId, serverId string) bool {
wsg.connsMu.Lock() if typ == "bot" {
wsg.conns[c] = meta wsg.registry.botMu.Lock()
wsg.connsMu.Unlock() if wsg.registry.bot != nil && wsg.registry.bot.Conn != nil {
wsg.registry.botMu.Unlock()
return false
}
wsg.registry.botMu.Unlock()
wsg.registry.RegisterBot(conn)
return true
}
wsg.registry.RegisterMod(channelId, serverId, conn)
return true
} }
func (wsg *WebsocketGateway) unregisterConn(c *websocket.Conn) { func (wsg *WebsocketGateway) unregisterConn(typ, channelId string) {
wsg.connsMu.Lock() if typ == "bot" {
delete(wsg.conns, c) wsg.registry.UnregisterBot()
wsg.connsMu.Unlock() return
}
wsg.registry.UnregisterMod(channelId)
} }
func (wsg *WebsocketGateway) closeAll() { func (wsg *WebsocketGateway) closeAll() {
wsg.connsMu.Lock()
defer wsg.connsMu.Unlock()
wsg.logger.Info("Closing all websocket connections.") wsg.logger.Info("Closing all websocket connections.")
for c := range wsg.conns { wsg.registry.UnregisterBot()
_ = c.WriteControl(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, "Shutting down."), time.Now().Add(time.Second))
_ = c.Close() wsg.registry.ForEach(func(channelID string) {
wsg.registry.UnregisterMod(channelID)
})
}
//
func NewUpgrader() websocket.Upgrader {
return websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // local by default; change for production
},
} }
} }
func NewRegistry(queueCap int) *Registry {
return &Registry{
entries: make(map[string]*ChannelEntry),
queueCap: queueCap,
}
}
func NewBoundedQueue(cap int) *BoundedQueue {
if cap <= 0 {
cap = 128
}
return &BoundedQueue{buf: make([]GatewayMessageOut, cap), capacity: cap}
}
func newChannelEntry(channel string, cap int) *ChannelEntry {
return &ChannelEntry{
Channel: channel,
Queue: NewBoundedQueue(cap),
}
}
//
func (m *GatewayMessageIn) Validate() error {
if strings.TrimSpace(m.ID) == "" {
return errors.New("id missing")
}
if strings.TrimSpace(m.MsgID) == "" {
return errors.New("msg_id missing")
}
if strings.TrimSpace(m.Author.ID) == "" {
return errors.New("author.id missing")
}
if strings.TrimSpace(m.Content) == "" {
return errors.New("content missing")
}
if m.Type == "mod" && strings.TrimSpace(m.Destination.ID) == "" {
return errors.New("destination.channel_id missing")
}
return nil
}
func (c *ConnWrapper) Alive() bool { return c != nil && c.Conn != nil }

View File

@@ -1,38 +0,0 @@
package ws
import (
"errors"
"strings"
)
func (m *GatewayModMessageIn) Validate() error {
if strings.TrimSpace(m.MsgID) == "" {
return errors.New("msg_id missing")
}
if strings.TrimSpace(m.Server) == "" {
return errors.New("server missing")
}
if strings.TrimSpace(m.Author.ID) == "" {
return errors.New("author.id missing")
}
if strings.TrimSpace(m.Content) == "" {
return errors.New("content missing")
}
return nil
}
func (m *GatewayBotMessageIn) Validate() error {
if strings.TrimSpace(m.MsgID) == "" {
return errors.New("msg_id missing")
}
if strings.TrimSpace(m.ChannelID) == "" {
return errors.New("channel_id missing")
}
if strings.TrimSpace(m.Author.ID) == "" {
return errors.New("author missing")
}
if strings.TrimSpace(m.Content) == "" {
return errors.New("content missing")
}
return nil
}

View File

@@ -15,23 +15,15 @@ import (
"github.com/gorilla/websocket" "github.com/gorilla/websocket"
) )
func NewWsGateway(cfg config.GatewayConfig, logger *slog.Logger, closefn func() error, modH ModHandler, botH BotHandler) *WebsocketGateway { func NewWebsocketGateway(cfg config.GatewayConfig, logger *slog.Logger, closefn func() error) *WebsocketGateway {
return &WebsocketGateway{ return &WebsocketGateway{
logger: logger, logger: logger,
closeFn: closefn, closeFn: closefn,
apiKey: cfg.Websocket,
upgrader: websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
CheckOrigin: func(r *http.Request) bool {
return true // local by default; change for production
},
},
conns: make(map[*websocket.Conn]connectionMetaData),
bodySizeBytes: int64(cfg.BodySize) * 1024 * 1024,
port: cfg.HttpPort, port: cfg.HttpPort,
modHandler: modH, apiKey: cfg.Websocket,
botHandler: botH, upgrader: NewUpgrader(),
registry: NewRegistry(cfg.QueueSize),
bodySizeBytes: int64(cfg.BodySize) * 1024 * 1024,
} }
} }
@@ -44,14 +36,12 @@ func (wsg *WebsocketGateway) Start() error {
func (wsg *WebsocketGateway) Serve(ctx context.Context, listenAddr string) error { func (wsg *WebsocketGateway) Serve(ctx context.Context, listenAddr string) error {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.HandleFunc("/push", wsg.handlePush) mux.HandleFunc("/sync", wsg.handleSync)
mux.HandleFunc("/ready", wsg.handleReady)
mux.HandleFunc("/health", wsg.handleHealth) mux.HandleFunc("/health", wsg.handleHealth)
mux.HandleFunc("/register", wsg.handleRegister)
srv := &http.Server{ srv := &http.Server{
Addr: listenAddr, Addr: listenAddr,
Handler: loggingMiddleware(wsg.logger, mux), Handler: wsg.loggingMiddleware(mux),
BaseContext: func(l net.Listener) context.Context { return ctx }, BaseContext: func(l net.Listener) context.Context { return ctx },
} }
errCh := make(chan error, 1) errCh := make(chan error, 1)
@@ -69,35 +59,33 @@ func (wsg *WebsocketGateway) Serve(ctx context.Context, listenAddr string) error
// //
func (wsg *WebsocketGateway) modReadLoop(c *websocket.Conn, meta connectionMetaData) { func (wsg *WebsocketGateway) read(conn *websocket.Conn, _type, channelId string) {
defer func() { defer func() {
wsg.unregisterConn(c) wsg.unregisterConn(_type, channelId)
_ = c.Close() wsg.logger.Info("Client disconnected.", "remote", conn.RemoteAddr().String())
wsg.logger.Info("mod disconnected", "server_id", meta.id, "remote", c.RemoteAddr().String())
}() }()
pingTicker := time.NewTicker(30 * time.Second) ticker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop() ctx, cancel := context.WithCancel(context.Background())
defer ticker.Stop()
defer cancel()
// Send pings in a separate goroutine
go func() { go func() {
for range pingTicker.C { for {
_ = c.SetWriteDeadline(time.Now().Add(5 * time.Second)) select {
if err := c.WriteMessage(websocket.PingMessage, nil); err != nil { case <-ctx.Done():
wsg.logger.Debug("write ping failed", "server_id", meta.id, "err", err)
return return
case <-ticker.C:
wsg.sendWebsocketPing(conn)
} }
wsg.logger.Debug("sent ping to mod", "server_id", meta.id)
} }
}() }()
for { for {
typ, data, err := c.ReadMessage() typ, data, err := conn.ReadMessage()
if err != nil { if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
wsg.logger.Warn("unexpected mod close", "server_id", meta.id, "err", err) wsg.logger.Error("Client unexpectedly closed the connection.", "err", err)
} else {
wsg.logger.Debug("mod read error", "server_id", meta.id, "err", err)
} }
return return
} }
@@ -106,89 +94,59 @@ func (wsg *WebsocketGateway) modReadLoop(c *websocket.Conn, meta connectionMetaD
continue continue
} }
var msg GatewayModMessageIn ts := time.Now().UTC()
if err := json.Unmarshal(data, &msg); err != nil { var message GatewayMessageIn
_ = writeJSONSafe(c, map[string]string{"error": "invalid json: " + err.Error()}) if err := json.Unmarshal(data, &message); err != nil {
wsg.logger.Warn("invalid json from mod", "server_id", meta.id, "remote", c.RemoteAddr().String(), "err", err) wsg.sendWebsocketError(conn, "Malformed message.", 400, false)
wsg.logger.Warn("Received malformed message json from client.", "remote", conn.RemoteAddr().String(), "err", err)
continue continue
} }
msg.ReceivedAt = time.Now().UTC() message.Type = _type
if err := msg.Validate(); err != nil { message.ReceivedAt = ts
_ = writeJSONSafe(c, map[string]string{"error": err.Error()}) if err := message.Validate(); err != nil {
wsg.logger.Warn("mod message validation failed", "server_id", meta.id, "remote", c.RemoteAddr().String(), "err", err) wsg.sendWebsocketError(conn, "Malformed message.", 400, false)
wsg.logger.Warn("Received malformed message json from client; validation failed.", "remote", conn.RemoteAddr().String(), "err", err)
continue continue
} }
// Handle the message (forward to bot, enrich, etc.) var outID string
if err := wsg.modHandler.Handle(context.Background(), msg); err != nil { if _type == "mod" {
_ = writeJSONSafe(c, map[string]string{"error": "handler error: " + err.Error()}) outID = channelId
wsg.logger.Error("mod handler error", "server_id", meta.id, "err", err)
continue
}
_ = writeJSONSafe(c, map[string]string{"status": "ok"})
}
}
func (wsg *WebsocketGateway) botReadLoop(c *websocket.Conn, meta connectionMetaData) {
defer func() {
wsg.unregisterConn(c)
_ = c.Close()
wsg.logger.Info("bot disconnected", "bot_id", meta.id, "remote", c.RemoteAddr().String())
}()
pingTicker := time.NewTicker(30 * time.Second)
defer pingTicker.Stop()
// Send pings in a separate goroutine
go func() {
for range pingTicker.C {
_ = c.SetWriteDeadline(time.Now().Add(5 * time.Second))
if err := c.WriteMessage(websocket.PingMessage, nil); err != nil {
wsg.logger.Debug("write ping failed", "bot_id", meta.id, "err", err)
return
}
wsg.logger.Debug("sent ping to bot", "bot_id", meta.id)
}
}()
for {
typ, data, err := c.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
wsg.logger.Warn("unexpected bot close", "bot_id", meta.id, "err", err)
} else { } else {
wsg.logger.Debug("bot read error", "bot_id", meta.id, "err", err) outID = message.ID
}
return
} }
if typ != websocket.TextMessage && typ != websocket.BinaryMessage { out := GatewayMessageOut{
Type: message.Type,
ID: outID,
Author: message.Author,
Content: message.Content,
Meta: message.Meta,
Ts: message.Ts,
ReceivedAt: message.ReceivedAt,
ForwardedAt: time.Now().UTC(),
}
delivered, queued, err := wsg.registry.Send(outID, out, wsg.flush)
if err != nil {
wsg.logger.Error("Registry queue/delivery error.", "remote", conn.RemoteAddr().String(), "err", err)
_ = wsg.sendWebsocketResponse(conn, GatewayAck{Status: "failed", Type: message.Type})
continue continue
} }
var msg GatewayBotMessageIn if delivered {
if err := json.Unmarshal(data, &msg); err != nil { _ = wsg.sendWebsocketResponse(conn, GatewayAck{Status: "completed", Type: message.Type})
_ = writeJSONSafe(c, map[string]string{"error": "invalid json: " + err.Error()})
wsg.logger.Warn("invalid json from bot", "bot_id", meta.id, "remote", c.RemoteAddr().String(), "err", err)
continue continue
} }
msg.ReceivedAt = time.Now().UTC() if queued {
if err := msg.Validate(); err != nil { _ = wsg.sendWebsocketResponse(conn, GatewayAck{Status: "queued", Type: message.Type})
_ = writeJSONSafe(c, map[string]string{"error": err.Error()})
wsg.logger.Warn("bot message validation failed", "bot_id", meta.id, "remote", c.RemoteAddr().String(), "err", err)
continue continue
} }
// Handle the message (forward to mod, enrich, etc.) _ = wsg.sendWebsocketResponse(conn, GatewayAck{Status: "failed", Type: message.Type})
if err := wsg.botHandler.Handle(context.Background(), msg); err != nil {
_ = writeJSONSafe(c, map[string]string{"error": "handler error: " + err.Error()})
wsg.logger.Error("bot handler error", "bot_id", meta.id, "err", err)
continue
}
_ = writeJSONSafe(c, map[string]string{"status": "ok"})
} }
} }