1184 lines
28 KiB
Go
1184 lines
28 KiB
Go
package gatewayruntime
|
|
|
|
import (
|
|
"crypto/ed25519"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
|
|
"xworkmate-bridge/internal/shared"
|
|
)
|
|
|
|
type remoteResponse struct {
|
|
Type string `json:"type"`
|
|
ID string `json:"id"`
|
|
OK bool `json:"ok"`
|
|
Payload any `json:"payload"`
|
|
Error map[string]any `json:"error"`
|
|
}
|
|
|
|
type runtimeSnapshot struct {
|
|
Status string
|
|
Mode string
|
|
StatusText string
|
|
ServerName string
|
|
RemoteAddress string
|
|
MainSessionKey string
|
|
LastError string
|
|
LastErrorCode string
|
|
LastErrorDetailCode string
|
|
LastConnectedAtMs int64
|
|
DeviceID string
|
|
AuthRole string
|
|
AuthScopes []string
|
|
ConnectAuthMode string
|
|
ConnectAuthFields []string
|
|
ConnectAuthSources []string
|
|
HasSharedAuth bool
|
|
HasDeviceToken bool
|
|
HealthPayload map[string]any
|
|
StatusPayload map[string]any
|
|
}
|
|
|
|
func (s runtimeSnapshot) Map() map[string]any {
|
|
payload := map[string]any{
|
|
"status": s.Status,
|
|
"mode": s.Mode,
|
|
"statusText": s.StatusText,
|
|
"authScopes": append([]string(nil), s.AuthScopes...),
|
|
"connectAuthFields": append([]string(nil), s.ConnectAuthFields...),
|
|
"connectAuthSources": append([]string(nil), s.ConnectAuthSources...),
|
|
"hasSharedAuth": s.HasSharedAuth,
|
|
"hasDeviceToken": s.HasDeviceToken,
|
|
}
|
|
if s.ServerName != "" {
|
|
payload["serverName"] = s.ServerName
|
|
}
|
|
if s.RemoteAddress != "" {
|
|
payload["remoteAddress"] = s.RemoteAddress
|
|
}
|
|
if s.MainSessionKey != "" {
|
|
payload["mainSessionKey"] = s.MainSessionKey
|
|
}
|
|
if s.LastError != "" {
|
|
payload["lastError"] = s.LastError
|
|
}
|
|
if s.LastErrorCode != "" {
|
|
payload["lastErrorCode"] = s.LastErrorCode
|
|
}
|
|
if s.LastErrorDetailCode != "" {
|
|
payload["lastErrorDetailCode"] = s.LastErrorDetailCode
|
|
}
|
|
if s.LastConnectedAtMs > 0 {
|
|
payload["lastConnectedAtMs"] = s.LastConnectedAtMs
|
|
}
|
|
if s.DeviceID != "" {
|
|
payload["deviceId"] = s.DeviceID
|
|
}
|
|
if s.AuthRole != "" {
|
|
payload["authRole"] = s.AuthRole
|
|
}
|
|
if s.ConnectAuthMode != "" {
|
|
payload["connectAuthMode"] = s.ConnectAuthMode
|
|
}
|
|
if len(s.HealthPayload) > 0 {
|
|
payload["healthPayload"] = s.HealthPayload
|
|
}
|
|
if len(s.StatusPayload) > 0 {
|
|
payload["statusPayload"] = s.StatusPayload
|
|
}
|
|
return payload
|
|
}
|
|
|
|
type Manager struct {
|
|
mu sync.Mutex
|
|
|
|
sessions map[string]*session
|
|
|
|
ReconnectDelay time.Duration
|
|
ConnectTimeout time.Duration
|
|
ChallengeTimeout time.Duration
|
|
}
|
|
|
|
func NewManager() *Manager {
|
|
return &Manager{
|
|
sessions: make(map[string]*session),
|
|
ReconnectDelay: defaultReconnectDelay,
|
|
ConnectTimeout: defaultConnectTimeout,
|
|
ChallengeTimeout: defaultChallengeWait,
|
|
}
|
|
}
|
|
|
|
func (m *Manager) Connect(
|
|
request ConnectRequest,
|
|
notify func(map[string]any),
|
|
) ConnectResult {
|
|
runtimeID := strings.TrimSpace(request.RuntimeID)
|
|
if runtimeID == "" {
|
|
return ConnectResult{
|
|
OK: false,
|
|
Error: (&GatewayError{
|
|
Message: "runtimeId is required",
|
|
Code: "INVALID_RUNTIME_ID",
|
|
}).Map(),
|
|
}
|
|
}
|
|
|
|
m.mu.Lock()
|
|
current := m.sessions[runtimeID]
|
|
if current == nil {
|
|
current = newSession(m, runtimeID)
|
|
m.sessions[runtimeID] = current
|
|
}
|
|
m.mu.Unlock()
|
|
|
|
current.configure(request, notify)
|
|
return current.connect()
|
|
}
|
|
|
|
func (m *Manager) Request(
|
|
runtimeID string,
|
|
method string,
|
|
params map[string]any,
|
|
timeout time.Duration,
|
|
notify func(map[string]any),
|
|
) RequestResult {
|
|
current := m.lookup(runtimeID)
|
|
if current == nil {
|
|
return RequestResult{
|
|
OK: false,
|
|
Error: (&GatewayError{
|
|
Message: "gateway not connected",
|
|
Code: "OFFLINE",
|
|
}).Map(),
|
|
}
|
|
}
|
|
current.setNotify(notify)
|
|
return current.request(method, params, timeout)
|
|
}
|
|
|
|
func (m *Manager) RequestByMode(
|
|
mode string,
|
|
method string,
|
|
params map[string]any,
|
|
timeout time.Duration,
|
|
notify func(map[string]any),
|
|
) RequestResult {
|
|
current := m.lookupConnectedByMode(mode)
|
|
if current == nil {
|
|
return RequestResult{
|
|
OK: false,
|
|
Error: (&GatewayError{
|
|
Message: "gateway not connected",
|
|
Code: "OFFLINE",
|
|
}).Map(),
|
|
}
|
|
}
|
|
current.setNotify(notify)
|
|
return current.request(method, params, timeout)
|
|
}
|
|
|
|
func (m *Manager) Disconnect(runtimeID string, notify func(map[string]any)) {
|
|
current := m.lookup(runtimeID)
|
|
if current == nil {
|
|
return
|
|
}
|
|
current.setNotify(notify)
|
|
current.disconnect()
|
|
}
|
|
|
|
func (m *Manager) lookup(runtimeID string) *session {
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
return m.sessions[strings.TrimSpace(runtimeID)]
|
|
}
|
|
|
|
func (m *Manager) lookupConnectedByMode(mode string) *session {
|
|
normalizedMode := strings.TrimSpace(mode)
|
|
m.mu.Lock()
|
|
defer m.mu.Unlock()
|
|
for _, current := range m.sessions {
|
|
if current == nil {
|
|
continue
|
|
}
|
|
current.mu.Lock()
|
|
connected := current.snapshot.Status == "connected"
|
|
currentMode := current.snapshot.Mode
|
|
current.mu.Unlock()
|
|
if connected && strings.TrimSpace(currentMode) == normalizedMode {
|
|
return current
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type session struct {
|
|
manager *Manager
|
|
runtimeID string
|
|
|
|
mu sync.Mutex
|
|
writeMu sync.Mutex
|
|
notify func(map[string]any)
|
|
config ConnectRequest
|
|
snapshot runtimeSnapshot
|
|
conn *websocket.Conn
|
|
pending map[string]chan remoteResponse
|
|
requestSeq int64
|
|
reconnectTimer *time.Timer
|
|
manualDisconnect bool
|
|
suppressReconnect bool
|
|
closed bool
|
|
challengeCh chan string
|
|
}
|
|
|
|
func newSession(manager *Manager, runtimeID string) *session {
|
|
return &session{
|
|
manager: manager,
|
|
runtimeID: runtimeID,
|
|
pending: make(map[string]chan remoteResponse),
|
|
}
|
|
}
|
|
|
|
func (s *session) configure(request ConnectRequest, notify func(map[string]any)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.notify = notify
|
|
s.config = request
|
|
s.manualDisconnect = false
|
|
s.suppressReconnect = false
|
|
s.closed = false
|
|
s.stopReconnectLocked()
|
|
s.snapshot = runtimeSnapshot{
|
|
Status: "offline",
|
|
Mode: request.Mode,
|
|
StatusText: "Offline",
|
|
DeviceID: request.Identity.DeviceID,
|
|
ConnectAuthMode: request.ConnectAuthMode,
|
|
ConnectAuthFields: append([]string(nil), request.ConnectAuthFields...),
|
|
ConnectAuthSources: append([]string(nil), request.ConnectAuthSources...),
|
|
HasSharedAuth: request.HasSharedAuth,
|
|
HasDeviceToken: request.HasDeviceToken,
|
|
}
|
|
}
|
|
|
|
func (s *session) setNotify(notify func(map[string]any)) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.notify = notify
|
|
}
|
|
|
|
func (s *session) connect() ConnectResult {
|
|
s.appendLog(
|
|
"info",
|
|
"connect",
|
|
fmt.Sprintf(
|
|
"attempt %s:%d tls:%t | auth: %s",
|
|
s.config.Endpoint.Host,
|
|
s.config.Endpoint.Port,
|
|
s.config.Endpoint.TLS,
|
|
formatConnectAuthSummary(
|
|
s.config.ConnectAuthMode,
|
|
s.config.ConnectAuthFields,
|
|
s.config.ConnectAuthSources,
|
|
),
|
|
),
|
|
)
|
|
s.updateSnapshot(func(snapshot *runtimeSnapshot) {
|
|
snapshot.Status = "connecting"
|
|
snapshot.StatusText = "Connecting…"
|
|
snapshot.RemoteAddress = fmt.Sprintf(
|
|
"%s:%d",
|
|
s.config.Endpoint.Host,
|
|
s.config.Endpoint.Port,
|
|
)
|
|
snapshot.LastError = ""
|
|
snapshot.LastErrorCode = ""
|
|
snapshot.LastErrorDetailCode = ""
|
|
})
|
|
|
|
result, gatewayErr := s.connectAttempt()
|
|
if gatewayErr == nil {
|
|
return result
|
|
}
|
|
s.handleConnectFailure(gatewayErr)
|
|
return ConnectResult{
|
|
OK: false,
|
|
Snapshot: s.snapshotMap(),
|
|
Error: gatewayErr.Map(),
|
|
}
|
|
}
|
|
|
|
func (s *session) connectAttempt() (ConnectResult, *GatewayError) {
|
|
url := fmt.Sprintf(
|
|
"%s://%s:%d",
|
|
resolveRemoteScheme(s.config.Endpoint.TLS),
|
|
s.config.Endpoint.Host,
|
|
s.config.Endpoint.Port,
|
|
)
|
|
dialer := websocket.Dialer{
|
|
HandshakeTimeout: s.manager.ConnectTimeout,
|
|
Proxy: http.ProxyFromEnvironment,
|
|
}
|
|
conn, _, err := dialer.Dial(url, nil)
|
|
if err != nil {
|
|
return ConnectResult{}, &GatewayError{
|
|
Message: err.Error(),
|
|
Code: "SOCKET_FAILURE",
|
|
}
|
|
}
|
|
|
|
challengeCh := make(chan string, 1)
|
|
s.mu.Lock()
|
|
s.conn = conn
|
|
s.challengeCh = challengeCh
|
|
s.mu.Unlock()
|
|
go s.readLoop(conn, challengeCh)
|
|
|
|
var nonce string
|
|
select {
|
|
case nonce = <-challengeCh:
|
|
case <-time.After(s.manager.ChallengeTimeout):
|
|
s.closeConn(conn)
|
|
return ConnectResult{}, &GatewayError{
|
|
Message: "connect challenge timeout",
|
|
Code: "CONNECT_CHALLENGE_TIMEOUT",
|
|
}
|
|
}
|
|
|
|
params, gatewayErr := buildConnectParams(s.config, nonce)
|
|
if gatewayErr != nil {
|
|
s.closeConn(conn)
|
|
return ConnectResult{}, gatewayErr
|
|
}
|
|
requestResult := s.requestRemote("connect", params, 12*time.Second, false)
|
|
if !requestResult.OK {
|
|
s.closeConn(conn)
|
|
return ConnectResult{}, mapToGatewayError(requestResult.Error, "connect failed")
|
|
}
|
|
|
|
payload, _ := requestResult.Payload.(map[string]any)
|
|
auth := asMap(payload["auth"])
|
|
server := asMap(payload["server"])
|
|
snapshotPayload := asMap(payload["snapshot"])
|
|
sessionDefaults := asMap(snapshotPayload["sessionDefaults"])
|
|
returnedDeviceToken := strings.TrimSpace(stringValue(auth["deviceToken"]))
|
|
if returnedDeviceToken != "" {
|
|
s.mu.Lock()
|
|
s.config.Auth.DeviceToken = returnedDeviceToken
|
|
s.mu.Unlock()
|
|
}
|
|
negotiatedScopes := stringSlice(auth["scopes"])
|
|
negotiatedRole := strings.TrimSpace(stringValue(auth["role"]))
|
|
if negotiatedRole == "" {
|
|
negotiatedRole = "operator"
|
|
}
|
|
s.updateSnapshot(func(snapshot *runtimeSnapshot) {
|
|
snapshot.Status = "connected"
|
|
snapshot.StatusText = "Connected"
|
|
snapshot.ServerName = strings.TrimSpace(stringValue(server["host"]))
|
|
snapshot.RemoteAddress = fmt.Sprintf(
|
|
"%s:%d",
|
|
s.config.Endpoint.Host,
|
|
s.config.Endpoint.Port,
|
|
)
|
|
snapshot.MainSessionKey = strings.TrimSpace(
|
|
stringValue(sessionDefaults["mainSessionKey"]),
|
|
)
|
|
if snapshot.MainSessionKey == "" {
|
|
snapshot.MainSessionKey = "main"
|
|
}
|
|
snapshot.LastConnectedAtMs = time.Now().UnixMilli()
|
|
snapshot.AuthRole = negotiatedRole
|
|
snapshot.AuthScopes = negotiatedScopes
|
|
snapshot.HasDeviceToken =
|
|
returnedDeviceToken != "" || s.config.Auth.DeviceToken != ""
|
|
snapshot.LastError = ""
|
|
snapshot.LastErrorCode = ""
|
|
snapshot.LastErrorDetailCode = ""
|
|
})
|
|
s.appendLog(
|
|
"info",
|
|
"connect",
|
|
fmt.Sprintf(
|
|
"connected %s:%d | role: %s | scopes: %d",
|
|
s.config.Endpoint.Host,
|
|
s.config.Endpoint.Port,
|
|
negotiatedRole,
|
|
len(negotiatedScopes),
|
|
),
|
|
)
|
|
return ConnectResult{
|
|
OK: true,
|
|
Snapshot: s.snapshotMap(),
|
|
Auth: auth,
|
|
ReturnedDeviceToken: returnedDeviceToken,
|
|
}, nil
|
|
}
|
|
|
|
func (s *session) request(
|
|
method string,
|
|
params map[string]any,
|
|
timeout time.Duration,
|
|
) RequestResult {
|
|
return s.requestRemote(method, params, timeout, true)
|
|
}
|
|
|
|
func (s *session) requestRemote(
|
|
method string,
|
|
params map[string]any,
|
|
timeout time.Duration,
|
|
requireConnected bool,
|
|
) RequestResult {
|
|
if timeout <= 0 {
|
|
timeout = defaultRequestTimeout
|
|
}
|
|
|
|
s.mu.Lock()
|
|
conn := s.conn
|
|
connected := s.snapshot.Status == "connected"
|
|
if conn == nil || (requireConnected && !connected) {
|
|
s.mu.Unlock()
|
|
s.appendLog("warn", "rpc", fmt.Sprintf("blocked request %s | offline", method))
|
|
return RequestResult{
|
|
OK: false,
|
|
Error: (&GatewayError{
|
|
Message: "gateway not connected",
|
|
Code: "OFFLINE",
|
|
}).Map(),
|
|
}
|
|
}
|
|
requestID := fmt.Sprintf("%d-%d", time.Now().UnixMicro(), s.requestSeq)
|
|
s.requestSeq++
|
|
responseCh := make(chan remoteResponse, 1)
|
|
s.pending[requestID] = responseCh
|
|
s.mu.Unlock()
|
|
|
|
frame := map[string]any{
|
|
"type": "req",
|
|
"id": requestID,
|
|
"method": method,
|
|
}
|
|
if len(params) > 0 {
|
|
frame["params"] = params
|
|
}
|
|
|
|
s.writeMu.Lock()
|
|
writeErr := conn.WriteJSON(frame)
|
|
s.writeMu.Unlock()
|
|
if writeErr != nil {
|
|
s.mu.Lock()
|
|
delete(s.pending, requestID)
|
|
s.mu.Unlock()
|
|
return RequestResult{
|
|
OK: false,
|
|
Error: (&GatewayError{
|
|
Message: writeErr.Error(),
|
|
Code: "SOCKET_FAILURE",
|
|
}).Map(),
|
|
}
|
|
}
|
|
|
|
select {
|
|
case response := <-responseCh:
|
|
s.mu.Lock()
|
|
delete(s.pending, requestID)
|
|
s.mu.Unlock()
|
|
if !response.OK {
|
|
gatewayErr := parseRemoteError(response.Error)
|
|
if !shouldAutoReconnectForCodes(
|
|
gatewayErr.Code,
|
|
gatewayErr.DetailCode(),
|
|
) {
|
|
s.mu.Lock()
|
|
s.suppressReconnect = true
|
|
s.mu.Unlock()
|
|
}
|
|
s.appendLog(
|
|
"error",
|
|
"rpc",
|
|
fmt.Sprintf(
|
|
"request failed | code: %s | detail: %s | message: %s",
|
|
fallbackText(gatewayErr.Code, "unknown"),
|
|
fallbackText(gatewayErr.DetailCode(), "none"),
|
|
fallbackText(gatewayErr.Message, "gateway request failed"),
|
|
),
|
|
)
|
|
return RequestResult{
|
|
OK: false,
|
|
Error: gatewayErr.Map(),
|
|
}
|
|
}
|
|
return RequestResult{
|
|
OK: true,
|
|
Payload: response.Payload,
|
|
}
|
|
case <-time.After(timeout):
|
|
s.mu.Lock()
|
|
delete(s.pending, requestID)
|
|
s.mu.Unlock()
|
|
return RequestResult{
|
|
OK: false,
|
|
Error: (&GatewayError{
|
|
Message: method + " request timeout",
|
|
Code: "RPC_TIMEOUT",
|
|
}).Map(),
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *session) disconnect() {
|
|
s.mu.Lock()
|
|
s.manualDisconnect = true
|
|
s.stopReconnectLocked()
|
|
conn := s.conn
|
|
s.conn = nil
|
|
pending := s.takePendingLocked()
|
|
s.snapshot = runtimeSnapshot{
|
|
Status: "offline",
|
|
Mode: s.snapshot.Mode,
|
|
StatusText: "Offline",
|
|
DeviceID: s.snapshot.DeviceID,
|
|
AuthRole: s.snapshot.AuthRole,
|
|
AuthScopes: append([]string(nil), s.snapshot.AuthScopes...),
|
|
ConnectAuthMode: s.snapshot.ConnectAuthMode,
|
|
ConnectAuthFields: append([]string(nil), s.snapshot.ConnectAuthFields...),
|
|
ConnectAuthSources: append([]string(nil), s.snapshot.ConnectAuthSources...),
|
|
HasSharedAuth: s.snapshot.HasSharedAuth,
|
|
HasDeviceToken: s.snapshot.HasDeviceToken,
|
|
}
|
|
s.mu.Unlock()
|
|
|
|
s.appendLog("info", "connect", "manual disconnect")
|
|
for _, ch := range pending {
|
|
ch <- remoteResponse{
|
|
OK: false,
|
|
Error: (&GatewayError{
|
|
Message: "socket reset",
|
|
Code: "SOCKET_RESET",
|
|
}).Map(),
|
|
}
|
|
}
|
|
s.emitSnapshot()
|
|
if conn != nil {
|
|
_ = conn.Close()
|
|
}
|
|
}
|
|
|
|
func (s *session) readLoop(conn *websocket.Conn, challengeCh chan string) {
|
|
for {
|
|
_, payload, err := conn.ReadMessage()
|
|
if err != nil {
|
|
s.onConnLost(conn, err)
|
|
return
|
|
}
|
|
var decoded map[string]any
|
|
if err := json.Unmarshal(payload, &decoded); err != nil {
|
|
continue
|
|
}
|
|
switch strings.TrimSpace(stringValue(decoded["type"])) {
|
|
case "event":
|
|
event := strings.TrimSpace(stringValue(decoded["event"]))
|
|
body := asMap(decoded["payload"])
|
|
if event == "connect.challenge" {
|
|
select {
|
|
case challengeCh <- strings.TrimSpace(stringValue(body["nonce"])):
|
|
default:
|
|
}
|
|
s.appendLog("debug", "connect", "challenge received")
|
|
continue
|
|
}
|
|
s.handleEvent(event, decoded, body)
|
|
case "res":
|
|
response := remoteResponse{
|
|
Type: "res",
|
|
ID: strings.TrimSpace(stringValue(decoded["id"])),
|
|
OK: boolValue(decoded["ok"]),
|
|
Payload: decoded["payload"],
|
|
Error: asMap(decoded["error"]),
|
|
}
|
|
s.mu.Lock()
|
|
responseCh := s.pending[response.ID]
|
|
s.mu.Unlock()
|
|
if responseCh != nil {
|
|
responseCh <- response
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *session) handleEvent(
|
|
event string,
|
|
decoded map[string]any,
|
|
payload map[string]any,
|
|
) {
|
|
switch event {
|
|
case "health":
|
|
s.updateSnapshot(func(snapshot *runtimeSnapshot) {
|
|
snapshot.HealthPayload = payload
|
|
})
|
|
s.appendLog("debug", "health", "push health update")
|
|
case "device.pair.requested", "device.pair.resolved":
|
|
s.appendLog(
|
|
"info",
|
|
"pairing",
|
|
fmt.Sprintf(
|
|
"%s | request: %s | device: %s",
|
|
event,
|
|
fallbackText(strings.TrimSpace(stringValue(payload["requestId"])), "unknown"),
|
|
fallbackText(strings.TrimSpace(stringValue(payload["deviceId"])), "unknown"),
|
|
),
|
|
)
|
|
case "seqGap":
|
|
s.appendLog("warn", "sync", "sequence gap detected")
|
|
}
|
|
if normalized := normalizeChatRunEvent(event, payload); len(normalized) > 0 {
|
|
s.emitNotification(
|
|
"xworkmate.gateway.push",
|
|
map[string]any{
|
|
"runtimeId": s.runtimeID,
|
|
"event": map[string]any{
|
|
"event": "chat.run",
|
|
"payload": normalized,
|
|
"sequence": intValue(decoded["seq"]),
|
|
},
|
|
},
|
|
)
|
|
}
|
|
s.emitNotification(
|
|
"xworkmate.gateway.push",
|
|
map[string]any{
|
|
"runtimeId": s.runtimeID,
|
|
"event": map[string]any{
|
|
"event": event,
|
|
"payload": payload,
|
|
"sequence": intValue(decoded["seq"]),
|
|
},
|
|
},
|
|
)
|
|
}
|
|
|
|
func (s *session) onConnLost(conn *websocket.Conn, err error) {
|
|
s.mu.Lock()
|
|
if s.conn != conn {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.conn = nil
|
|
pending := s.takePendingLocked()
|
|
manualDisconnect := s.manualDisconnect
|
|
suppressReconnect := s.suppressReconnect
|
|
closed := s.closed
|
|
s.mu.Unlock()
|
|
|
|
for _, ch := range pending {
|
|
ch <- remoteResponse{
|
|
OK: false,
|
|
Error: (&GatewayError{
|
|
Message: "socket closed",
|
|
Code: "SOCKET_CLOSED",
|
|
}).Map(),
|
|
}
|
|
}
|
|
if manualDisconnect || suppressReconnect || closed {
|
|
s.appendLog(
|
|
"warn",
|
|
"socket",
|
|
fmt.Sprintf(
|
|
"closed without reconnect | manual: %t | suppressed: %t",
|
|
manualDisconnect,
|
|
suppressReconnect,
|
|
),
|
|
)
|
|
return
|
|
}
|
|
s.appendLog("warn", "socket", "closed by gateway")
|
|
s.updateSnapshot(func(snapshot *runtimeSnapshot) {
|
|
snapshot.Status = "error"
|
|
snapshot.StatusText = "Disconnected"
|
|
snapshot.LastError = "Gateway connection closed"
|
|
snapshot.LastErrorCode = "SOCKET_CLOSED"
|
|
snapshot.LastErrorDetailCode = ""
|
|
})
|
|
s.scheduleReconnect()
|
|
}
|
|
|
|
func (s *session) handleConnectFailure(err *GatewayError) {
|
|
if !shouldAutoReconnectForCodes(err.Code, err.DetailCode()) {
|
|
s.mu.Lock()
|
|
s.suppressReconnect = true
|
|
s.mu.Unlock()
|
|
s.appendLog(
|
|
"warn",
|
|
"socket",
|
|
fmt.Sprintf(
|
|
"auto reconnect suppressed | code: %s | detail: %s",
|
|
fallbackText(err.Code, "unknown"),
|
|
fallbackText(err.DetailCode(), "none"),
|
|
),
|
|
)
|
|
} else {
|
|
s.appendLog(
|
|
"warn",
|
|
"socket",
|
|
fmt.Sprintf(
|
|
"scheduling reconnect in 2s | code: %s",
|
|
fallbackText(err.Code, "unknown"),
|
|
),
|
|
)
|
|
s.scheduleReconnect()
|
|
}
|
|
s.appendLog(
|
|
"error",
|
|
"connect",
|
|
fmt.Sprintf(
|
|
"failed %s:%d | code: %s | detail: %s | message: %s",
|
|
s.config.Endpoint.Host,
|
|
s.config.Endpoint.Port,
|
|
fallbackText(err.Code, "unknown"),
|
|
fallbackText(err.DetailCode(), "none"),
|
|
err.Message,
|
|
),
|
|
)
|
|
s.updateSnapshot(func(snapshot *runtimeSnapshot) {
|
|
snapshot.Status = "error"
|
|
snapshot.StatusText = "Connection failed"
|
|
snapshot.LastError = err.Message
|
|
snapshot.LastErrorCode = err.Code
|
|
snapshot.LastErrorDetailCode = err.DetailCode()
|
|
snapshot.HasDeviceToken = s.config.Auth.DeviceToken != ""
|
|
})
|
|
}
|
|
|
|
func (s *session) scheduleReconnect() {
|
|
s.mu.Lock()
|
|
if s.manualDisconnect || s.suppressReconnect || s.closed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.stopReconnectLocked()
|
|
delay := s.manager.ReconnectDelay
|
|
if delay <= 0 {
|
|
delay = defaultReconnectDelay
|
|
}
|
|
s.reconnectTimer = time.AfterFunc(delay, func() {
|
|
s.appendLog(
|
|
"info",
|
|
"socket",
|
|
fmt.Sprintf(
|
|
"reconnect firing | host: %s | port: %d",
|
|
resolveReconnectHostLabel(s.config.Endpoint.Host),
|
|
s.config.Endpoint.Port,
|
|
),
|
|
)
|
|
if _, err := s.connectAttempt(); err != nil {
|
|
s.handleConnectFailure(err)
|
|
}
|
|
})
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
func (s *session) stopReconnectLocked() {
|
|
if s.reconnectTimer != nil {
|
|
s.reconnectTimer.Stop()
|
|
s.reconnectTimer = nil
|
|
}
|
|
}
|
|
|
|
func (s *session) closeConn(conn *websocket.Conn) {
|
|
if conn != nil {
|
|
_ = conn.Close()
|
|
}
|
|
}
|
|
|
|
func (s *session) takePendingLocked() map[string]chan remoteResponse {
|
|
pending := s.pending
|
|
s.pending = make(map[string]chan remoteResponse)
|
|
return pending
|
|
}
|
|
|
|
func (s *session) updateSnapshot(update func(snapshot *runtimeSnapshot)) {
|
|
s.mu.Lock()
|
|
update(&s.snapshot)
|
|
s.mu.Unlock()
|
|
s.emitSnapshot()
|
|
}
|
|
|
|
func (s *session) snapshotMap() map[string]any {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.snapshot.Map()
|
|
}
|
|
|
|
func (s *session) emitSnapshot() {
|
|
s.emitNotification(
|
|
"xworkmate.gateway.snapshot",
|
|
map[string]any{
|
|
"runtimeId": s.runtimeID,
|
|
"snapshot": s.snapshotMap(),
|
|
},
|
|
)
|
|
}
|
|
|
|
func (s *session) appendLog(level string, category string, message string) {
|
|
entry := map[string]any{
|
|
"timestampMs": time.Now().UnixMilli(),
|
|
"level": level,
|
|
"category": category,
|
|
"message": message,
|
|
}
|
|
s.emitNotification(
|
|
"xworkmate.gateway.log",
|
|
map[string]any{
|
|
"runtimeId": s.runtimeID,
|
|
"log": entry,
|
|
},
|
|
)
|
|
}
|
|
|
|
func (s *session) emitNotification(method string, params map[string]any) {
|
|
s.mu.Lock()
|
|
notify := s.notify
|
|
s.mu.Unlock()
|
|
if notify == nil {
|
|
return
|
|
}
|
|
notify(shared.NotificationEnvelope(method, params))
|
|
}
|
|
|
|
func buildConnectParams(
|
|
request ConnectRequest,
|
|
nonce string,
|
|
) (map[string]any, *GatewayError) {
|
|
signedAt := time.Now().UnixMilli()
|
|
signaturePayload := buildDeviceAuthPayloadV3(
|
|
request.Identity.DeviceID,
|
|
request.ClientID,
|
|
"ui",
|
|
"operator",
|
|
defaultOperatorScopes,
|
|
signedAt,
|
|
firstNonEmpty(request.Auth.Token, request.Auth.DeviceToken),
|
|
nonce,
|
|
request.DeviceInfo.PlatformLabel(),
|
|
request.DeviceInfo.DeviceFamily,
|
|
)
|
|
signature, err := signPayload(
|
|
request.Identity.PrivateKeyBase64URL,
|
|
signaturePayload,
|
|
)
|
|
if err != nil {
|
|
return nil, &GatewayError{
|
|
Message: err.Error(),
|
|
Code: "DEVICE_IDENTITY_SIGN_FAILED",
|
|
}
|
|
}
|
|
|
|
result := map[string]any{
|
|
"minProtocol": defaultProtocolVersion,
|
|
"maxProtocol": defaultProtocolVersion,
|
|
"client": map[string]any{
|
|
"id": request.ClientID,
|
|
"displayName": strings.TrimSpace(request.PackageInfo.AppName) + " " + strings.TrimSpace(request.DeviceInfo.DeviceFamily),
|
|
"version": request.PackageInfo.Version,
|
|
"platform": request.DeviceInfo.PlatformLabel(),
|
|
"deviceFamily": request.DeviceInfo.DeviceFamily,
|
|
"modelIdentifier": request.DeviceInfo.ModelIdentifier,
|
|
"mode": "ui",
|
|
"instanceId": request.ClientID + "-" + trimPrefix(request.Identity.DeviceID, 8),
|
|
},
|
|
"caps": []string{"tool-events"},
|
|
"commands": []string{},
|
|
"permissions": map[string]bool{},
|
|
"role": "operator",
|
|
"scopes": append([]string(nil), defaultOperatorScopes...),
|
|
"locale": request.Locale,
|
|
"userAgent": request.UserAgent,
|
|
"device": map[string]any{
|
|
"id": request.Identity.DeviceID,
|
|
"publicKey": request.Identity.PublicKeyBase64URL,
|
|
"signature": signature,
|
|
"signedAt": signedAt,
|
|
"nonce": nonce,
|
|
},
|
|
}
|
|
if request.Auth.Token != "" || request.Auth.DeviceToken != "" || request.Auth.Password != "" {
|
|
auth := map[string]any{}
|
|
if request.Auth.Token != "" {
|
|
auth["token"] = request.Auth.Token
|
|
}
|
|
if request.Auth.DeviceToken != "" {
|
|
auth["deviceToken"] = request.Auth.DeviceToken
|
|
}
|
|
if request.Auth.Password != "" {
|
|
auth["password"] = request.Auth.Password
|
|
}
|
|
result["auth"] = auth
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func signPayload(privateKeyBase64URL string, payload string) (string, error) {
|
|
privateKeyBytes, err := decodeBase64URL(privateKeyBase64URL)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var privateKey ed25519.PrivateKey
|
|
switch len(privateKeyBytes) {
|
|
case ed25519.PrivateKeySize:
|
|
privateKey = ed25519.PrivateKey(privateKeyBytes)
|
|
case ed25519.SeedSize:
|
|
privateKey = ed25519.NewKeyFromSeed(privateKeyBytes)
|
|
default:
|
|
return "", fmt.Errorf("unsupported Ed25519 private key length: %d", len(privateKeyBytes))
|
|
}
|
|
signature := ed25519.Sign(privateKey, []byte(payload))
|
|
return encodeBase64URL(signature), nil
|
|
}
|
|
|
|
func buildDeviceAuthPayloadV3(
|
|
deviceID string,
|
|
clientID string,
|
|
clientMode string,
|
|
role string,
|
|
scopes []string,
|
|
signedAt int64,
|
|
token string,
|
|
nonce string,
|
|
platform string,
|
|
deviceFamily string,
|
|
) string {
|
|
parts := []string{
|
|
"v3",
|
|
deviceID,
|
|
clientID,
|
|
clientMode,
|
|
role,
|
|
strings.Join(scopes, ","),
|
|
fmt.Sprintf("%d", signedAt),
|
|
token,
|
|
nonce,
|
|
normalizeMetadataForAuth(platform),
|
|
normalizeMetadataForAuth(deviceFamily),
|
|
}
|
|
return strings.Join(parts, "|")
|
|
}
|
|
|
|
func normalizeMetadataForAuth(value string) string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
var builder strings.Builder
|
|
builder.Grow(len(trimmed))
|
|
for _, r := range trimmed {
|
|
if r >= 'A' && r <= 'Z' {
|
|
builder.WriteRune(r + 32)
|
|
continue
|
|
}
|
|
builder.WriteRune(r)
|
|
}
|
|
return builder.String()
|
|
}
|
|
|
|
func shouldAutoReconnectForCodes(code string, detailCode string) bool {
|
|
resolvedCode := strings.ToUpper(strings.TrimSpace(code))
|
|
resolvedDetail := strings.ToUpper(strings.TrimSpace(detailCode))
|
|
nonRetryableCodes := map[string]bool{
|
|
"INVALID_REQUEST": true,
|
|
"UNAUTHORIZED": true,
|
|
"NOT_PAIRED": true,
|
|
"AUTH_REQUIRED": true,
|
|
}
|
|
nonRetryableDetailCodes := map[string]bool{
|
|
"AUTH_REQUIRED": true,
|
|
"AUTH_UNAUTHORIZED": true,
|
|
"AUTH_TOKEN_MISSING": true,
|
|
"AUTH_TOKEN_MISMATCH": true,
|
|
"AUTH_PASSWORD_MISSING": true,
|
|
"AUTH_PASSWORD_MISMATCH": true,
|
|
"AUTH_DEVICE_TOKEN_MISMATCH": true,
|
|
"PAIRING_REQUIRED": true,
|
|
"DEVICE_IDENTITY_REQUIRED": true,
|
|
"CONTROL_UI_DEVICE_IDENTITY_REQUIRED": true,
|
|
}
|
|
if nonRetryableCodes[resolvedCode] {
|
|
return false
|
|
}
|
|
if nonRetryableDetailCodes[resolvedDetail] {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func parseRemoteError(errorPayload map[string]any) *GatewayError {
|
|
return &GatewayError{
|
|
Message: fallbackText(strings.TrimSpace(stringValue(errorPayload["message"])), "gateway request failed"),
|
|
Code: strings.TrimSpace(stringValue(errorPayload["code"])),
|
|
Details: asMap(errorPayload["details"]),
|
|
}
|
|
}
|
|
|
|
func mapToGatewayError(errorPayload map[string]any, fallback string) *GatewayError {
|
|
if len(errorPayload) == 0 {
|
|
return &GatewayError{Message: fallback}
|
|
}
|
|
return &GatewayError{
|
|
Message: fallbackText(strings.TrimSpace(stringValue(errorPayload["message"])), fallback),
|
|
Code: strings.TrimSpace(stringValue(errorPayload["code"])),
|
|
Details: asMap(errorPayload["details"]),
|
|
}
|
|
}
|
|
|
|
func resolveRemoteScheme(tls bool) string {
|
|
if tls {
|
|
return "wss"
|
|
}
|
|
return "ws"
|
|
}
|
|
|
|
func resolveReconnectHostLabel(host string) string {
|
|
host = strings.TrimSpace(host)
|
|
if host == "" {
|
|
return "setup-code"
|
|
}
|
|
return host
|
|
}
|
|
|
|
func formatConnectAuthSummary(mode string, fields []string, sources []string) string {
|
|
resolvedFields := "none"
|
|
if len(fields) > 0 {
|
|
resolvedFields = strings.Join(fields, ", ")
|
|
}
|
|
resolvedSources := "none"
|
|
if len(sources) > 0 {
|
|
resolvedSources = strings.Join(sources, " · ")
|
|
}
|
|
return strings.TrimSpace(mode) + " | fields: " + resolvedFields + " | sources: " + resolvedSources
|
|
}
|
|
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed != "" {
|
|
return trimmed
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func trimPrefix(value string, max int) string {
|
|
if max <= 0 || len(value) <= max {
|
|
return value
|
|
}
|
|
return value[:max]
|
|
}
|
|
|
|
func fallbackText(value string, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
func asMap(value any) map[string]any {
|
|
if value == nil {
|
|
return map[string]any{}
|
|
}
|
|
if typed, ok := value.(map[string]any); ok {
|
|
return typed
|
|
}
|
|
if typed, ok := value.(map[string]interface{}); ok {
|
|
return typed
|
|
}
|
|
return map[string]any{}
|
|
}
|
|
|
|
func stringValue(value any) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed
|
|
default:
|
|
return fmt.Sprint(typed)
|
|
}
|
|
}
|
|
|
|
func boolValue(value any) bool {
|
|
switch typed := value.(type) {
|
|
case bool:
|
|
return typed
|
|
case float64:
|
|
return typed != 0
|
|
case int:
|
|
return typed != 0
|
|
case string:
|
|
trimmed := strings.ToLower(strings.TrimSpace(typed))
|
|
return trimmed == "true" || trimmed == "1" || trimmed == "yes"
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func intValue(value any) int {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return typed
|
|
case int64:
|
|
return int(typed)
|
|
case float64:
|
|
return int(typed)
|
|
case json.Number:
|
|
resolved, _ := typed.Int64()
|
|
return int(resolved)
|
|
case string:
|
|
var parsed int
|
|
_, _ = fmt.Sscanf(strings.TrimSpace(typed), "%d", &parsed)
|
|
return parsed
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func stringSlice(value any) []string {
|
|
list, ok := value.([]any)
|
|
if !ok {
|
|
if typed, ok := value.([]string); ok {
|
|
return append([]string(nil), typed...)
|
|
}
|
|
return nil
|
|
}
|
|
result := make([]string, 0, len(list))
|
|
for _, item := range list {
|
|
text := strings.TrimSpace(stringValue(item))
|
|
if text == "" {
|
|
continue
|
|
}
|
|
result = append(result, text)
|
|
}
|
|
return result
|
|
}
|
|
|
|
func decodeBase64URL(value string) ([]byte, error) {
|
|
normalized := strings.ReplaceAll(value, "-", "+")
|
|
normalized = strings.ReplaceAll(normalized, "_", "/")
|
|
switch len(normalized) % 4 {
|
|
case 2:
|
|
normalized += "=="
|
|
case 3:
|
|
normalized += "="
|
|
}
|
|
return base64.StdEncoding.DecodeString(normalized)
|
|
}
|
|
|
|
func encodeBase64URL(value []byte) string {
|
|
return strings.TrimRight(base64.URLEncoding.EncodeToString(value), "=")
|
|
}
|