387 lines
9.5 KiB
Go
387 lines
9.5 KiB
Go
package nakama
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/url"
|
|
"seanime/internal/constants"
|
|
"seanime/internal/events"
|
|
"seanime/internal/util"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// connectToHost establishes a connection to the Nakama host
|
|
func (m *Manager) connectToHost() {
|
|
if m.settings == nil || !m.settings.Enabled || m.settings.RemoteServerURL == "" || m.settings.RemoteServerPassword == "" {
|
|
return
|
|
}
|
|
|
|
m.logger.Info().Str("url", m.settings.RemoteServerURL).Msg("nakama: Connecting to host")
|
|
|
|
// Cancel any existing connection attempts
|
|
m.hostMu.Lock()
|
|
if m.hostConnectionCancel != nil {
|
|
m.hostConnectionCancel()
|
|
}
|
|
|
|
// Create new context for this connection attempt
|
|
m.hostConnectionCtx, m.hostConnectionCancel = context.WithCancel(m.ctx)
|
|
|
|
// Prevent multiple concurrent connection attempts
|
|
if m.reconnecting {
|
|
m.hostMu.Unlock()
|
|
return
|
|
}
|
|
m.reconnecting = true
|
|
m.hostMu.Unlock()
|
|
|
|
go m.connectToHostAsync()
|
|
}
|
|
|
|
// disconnectFromHost disconnects from the Nakama host
|
|
func (m *Manager) disconnectFromHost() {
|
|
m.hostMu.Lock()
|
|
defer m.hostMu.Unlock()
|
|
|
|
// Cancel any ongoing connection attempts
|
|
if m.hostConnectionCancel != nil {
|
|
m.hostConnectionCancel()
|
|
m.hostConnectionCancel = nil
|
|
}
|
|
|
|
if m.hostConnection != nil {
|
|
m.logger.Info().Msg("nakama: Disconnecting from host")
|
|
|
|
// Cancel any reconnection timer
|
|
if m.hostConnection.reconnectTimer != nil {
|
|
m.hostConnection.reconnectTimer.Stop()
|
|
}
|
|
|
|
m.hostConnection.Close()
|
|
m.hostConnection = nil
|
|
|
|
// Send event to client about disconnection
|
|
m.wsEventManager.SendEvent(events.NakamaHostDisconnected, map[string]interface{}{
|
|
"connected": false,
|
|
})
|
|
}
|
|
|
|
// Reset reconnecting flag
|
|
m.reconnecting = false
|
|
}
|
|
|
|
// connectToHostAsync handles the actual connection logic with retries
|
|
func (m *Manager) connectToHostAsync() {
|
|
defer func() {
|
|
m.hostMu.Lock()
|
|
m.reconnecting = false
|
|
m.hostMu.Unlock()
|
|
}()
|
|
|
|
if m.settings == nil || !m.settings.Enabled || m.settings.RemoteServerURL == "" || m.settings.RemoteServerPassword == "" {
|
|
return
|
|
}
|
|
|
|
// Get the connection context
|
|
m.hostMu.RLock()
|
|
connCtx := m.hostConnectionCtx
|
|
m.hostMu.RUnlock()
|
|
|
|
if connCtx == nil {
|
|
return
|
|
}
|
|
|
|
maxRetries := 5
|
|
retryDelay := 5 * time.Second
|
|
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
select {
|
|
case <-connCtx.Done():
|
|
m.logger.Info().Msg("nakama: Connection attempt cancelled")
|
|
return
|
|
case <-m.ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
if err := m.attemptHostConnection(connCtx); err != nil {
|
|
m.logger.Error().Err(err).Int("attempt", attempt+1).Msg("nakama: Failed to connect to host")
|
|
|
|
if attempt < maxRetries-1 {
|
|
select {
|
|
case <-connCtx.Done():
|
|
m.logger.Info().Msg("nakama: Connection attempt cancelled")
|
|
return
|
|
case <-m.ctx.Done():
|
|
return
|
|
case <-time.After(retryDelay):
|
|
retryDelay *= 2 // Exponential backoff
|
|
continue
|
|
}
|
|
}
|
|
} else {
|
|
// Success
|
|
m.logger.Info().Msg("nakama: Successfully connected to host")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Only log error if not cancelled
|
|
select {
|
|
case <-connCtx.Done():
|
|
m.logger.Info().Msg("nakama: Connection attempts cancelled")
|
|
default:
|
|
m.logger.Error().Msg("nakama: Failed to connect to host after all retries")
|
|
m.wsEventManager.SendEvent(events.ErrorToast, "Failed to connect to Nakama host after multiple attempts.")
|
|
}
|
|
}
|
|
|
|
// attemptHostConnection makes a single connection attempt to the host
|
|
func (m *Manager) attemptHostConnection(connCtx context.Context) error {
|
|
// Parse URL
|
|
u, err := url.Parse(m.settings.RemoteServerURL)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Convert HTTP to WebSocket scheme
|
|
switch u.Scheme {
|
|
case "http":
|
|
u.Scheme = "ws"
|
|
case "https":
|
|
u.Scheme = "wss"
|
|
}
|
|
|
|
// Add Nakama WebSocket path
|
|
if !strings.HasSuffix(u.Path, "/") {
|
|
u.Path += "/"
|
|
}
|
|
u.Path += "api/v1/nakama/ws"
|
|
|
|
// Generate UUID for this peer instance
|
|
peerID := uuid.New().String()
|
|
|
|
username := m.username
|
|
// Generate a random username if username is not set
|
|
if username == "" {
|
|
username = "Peer_" + util.RandomStringWithAlphabet(8, "bcdefhijklmnopqrstuvwxyz0123456789")
|
|
}
|
|
|
|
// Set up headers for authentication
|
|
headers := http.Header{}
|
|
headers.Set("X-Seanime-Nakama-Token", m.settings.RemoteServerPassword)
|
|
headers.Set("X-Seanime-Nakama-Username", username)
|
|
headers.Set("X-Seanime-Nakama-Server-Version", constants.Version)
|
|
headers.Set("X-Seanime-Nakama-Peer-Id", peerID)
|
|
|
|
// Create a dialer with the connection context
|
|
dialer := websocket.Dialer{
|
|
HandshakeTimeout: 10 * time.Second,
|
|
}
|
|
|
|
// Connect with context
|
|
conn, _, err := dialer.DialContext(connCtx, u.String(), headers)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
hostConn := &HostConnection{
|
|
URL: u.String(),
|
|
Conn: conn,
|
|
Authenticated: false,
|
|
LastPing: time.Now(),
|
|
PeerId: peerID, // Store our generated PeerID
|
|
}
|
|
|
|
// Authenticate
|
|
authMessage := &Message{
|
|
Type: MessageTypeAuth,
|
|
Payload: AuthPayload{
|
|
Password: m.settings.RemoteServerPassword,
|
|
PeerId: peerID, // Include PeerID in auth payload
|
|
},
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
if err := hostConn.SendMessage(authMessage); err != nil {
|
|
_ = conn.Close()
|
|
return err
|
|
}
|
|
|
|
// Wait for auth response with timeout
|
|
_ = conn.SetReadDeadline(time.Now().Add(10 * time.Second))
|
|
var authResponse Message
|
|
if err := conn.ReadJSON(&authResponse); err != nil {
|
|
_ = conn.Close()
|
|
return err
|
|
}
|
|
|
|
if authResponse.Type != MessageTypeAuthReply {
|
|
_ = conn.Close()
|
|
return errors.New("unexpected auth response type")
|
|
}
|
|
|
|
// Parse auth response
|
|
authReplyData, err := json.Marshal(authResponse.Payload)
|
|
if err != nil {
|
|
_ = conn.Close()
|
|
return err
|
|
}
|
|
|
|
var authReply AuthReplyPayload
|
|
if err := json.Unmarshal(authReplyData, &authReply); err != nil {
|
|
_ = conn.Close()
|
|
return err
|
|
}
|
|
|
|
if !authReply.Success {
|
|
_ = conn.Close()
|
|
return errors.New("authentication failed: " + authReply.Message)
|
|
}
|
|
|
|
// Verify that the host echoed back our PeerID
|
|
if authReply.PeerId != peerID {
|
|
m.logger.Warn().Str("expectedPeerID", peerID).Str("receivedPeerID", authReply.PeerId).Msg("nakama: Host returned different PeerID")
|
|
}
|
|
|
|
hostConn.Username = authReply.Username
|
|
if hostConn.Username == "" {
|
|
hostConn.Username = "Host_" + util.RandomStringWithAlphabet(8, "bcdefhijklmnopqrstuvwxyz0123456789")
|
|
}
|
|
hostConn.Authenticated = true
|
|
|
|
// Set the connection and cancel any existing reconnection timer
|
|
m.hostMu.Lock()
|
|
if m.hostConnection != nil && m.hostConnection.reconnectTimer != nil {
|
|
m.hostConnection.reconnectTimer.Stop()
|
|
}
|
|
m.hostConnection = hostConn
|
|
m.hostMu.Unlock()
|
|
|
|
// Send event to client about successful connection
|
|
m.wsEventManager.SendEvent(events.NakamaHostConnected, map[string]interface{}{
|
|
"connected": true,
|
|
"authenticated": true,
|
|
"url": hostConn.URL,
|
|
"peerID": peerID, // Include our PeerID in the event
|
|
})
|
|
|
|
// Start handling the connection
|
|
go m.handleHostConnection(hostConn)
|
|
|
|
// Start client ping routine
|
|
go m.clientPingRoutine()
|
|
|
|
return nil
|
|
}
|
|
|
|
// handleHostConnection handles messages from the host
|
|
func (m *Manager) handleHostConnection(hostConn *HostConnection) {
|
|
defer func() {
|
|
m.logger.Info().Msg("nakama: Host connection closed")
|
|
|
|
m.hostMu.Lock()
|
|
if m.hostConnection == hostConn {
|
|
m.hostConnection = nil
|
|
}
|
|
m.hostMu.Unlock()
|
|
|
|
// Send event to client about disconnection
|
|
m.wsEventManager.SendEvent(events.NakamaHostDisconnected, map[string]interface{}{
|
|
"connected": false,
|
|
})
|
|
|
|
// Attempt reconnection after a delay if settings are still valid and not already reconnecting
|
|
m.hostMu.Lock()
|
|
shouldReconnect := m.settings != nil && m.settings.RemoteServerURL != "" && m.settings.RemoteServerPassword != "" && !m.reconnecting
|
|
if shouldReconnect {
|
|
m.reconnecting = true
|
|
hostConn.reconnectTimer = time.AfterFunc(10*time.Second, func() {
|
|
m.connectToHostAsync()
|
|
})
|
|
}
|
|
m.hostMu.Unlock()
|
|
}()
|
|
|
|
// Set up ping/pong handler
|
|
hostConn.Conn.SetPongHandler(func(appData string) error {
|
|
hostConn.LastPing = time.Now()
|
|
return nil
|
|
})
|
|
|
|
// Set read deadline
|
|
_ = hostConn.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
|
|
for {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
return
|
|
default:
|
|
var message Message
|
|
err := hostConn.Conn.ReadJSON(&message)
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
|
|
m.logger.Error().Err(err).Msg("nakama: Unexpected close error from host")
|
|
}
|
|
return
|
|
}
|
|
|
|
// Handle the message
|
|
if err := m.handleMessage(&message, "host"); err != nil {
|
|
m.logger.Error().Err(err).Str("messageType", string(message.Type)).Msg("nakama: Failed to handle message from host")
|
|
}
|
|
|
|
// Reset read deadline
|
|
_ = hostConn.Conn.SetReadDeadline(time.Now().Add(60 * time.Second))
|
|
}
|
|
}
|
|
}
|
|
|
|
// clientPingRoutine sends ping messages to the host
|
|
func (m *Manager) clientPingRoutine() {
|
|
ticker := time.NewTicker(30 * time.Second)
|
|
defer ticker.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-m.ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
m.hostMu.RLock()
|
|
if m.hostConnection == nil || !m.hostConnection.Authenticated {
|
|
m.hostMu.RUnlock()
|
|
return
|
|
}
|
|
|
|
// Check if host is still alive
|
|
if time.Since(m.hostConnection.LastPing) > 90*time.Second {
|
|
m.logger.Warn().Msg("nakama: Host connection timeout")
|
|
m.hostConnection.Close()
|
|
m.hostMu.RUnlock()
|
|
return
|
|
}
|
|
|
|
// Send ping
|
|
message := &Message{
|
|
Type: MessageTypePing,
|
|
Payload: nil,
|
|
Timestamp: time.Now(),
|
|
}
|
|
|
|
if err := m.hostConnection.SendMessage(message); err != nil {
|
|
m.logger.Error().Err(err).Msg("nakama: Failed to send ping to host")
|
|
m.hostConnection.Close()
|
|
m.hostMu.RUnlock()
|
|
return
|
|
}
|
|
m.hostMu.RUnlock()
|
|
}
|
|
}
|
|
}
|