package main import ( "bufio" "bytes" "crypto/rand" "crypto/sha1" "crypto/sha256" "crypto/tls" "encoding/base64" "encoding/binary" "encoding/json" "encoding/xml" "errors" "fmt" "io" "net" "net/http" "net/url" "os" "path/filepath" "runtime" "sort" "strings" "sync" "time" ) const ( agentVersion = "1.5.0" protocolVersion = 1 websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" heartbeatInterval = 8 * time.Second overlayPollInterval = 400 * time.Millisecond overlayResendInterval = 2 * time.Second ) type Config struct { ServerURL string `json:"server_url"` FallbackServerURL string `json:"fallback_server_url,omitempty"` VmixURL string `json:"vmix_url"` DeviceID string `json:"device_id"` DeviceSecret string `json:"device_secret"` DeviceName string `json:"device_name"` StartMinimized bool `json:"start_minimized"` RunWithWindows bool `json:"run_with_windows"` } type VmixStatus struct { Connected bool `json:"connected"` Version string `json:"version,omitempty"` URL string `json:"url"` Error string `json:"error,omitempty"` Response string `json:"response,omitempty"` } type VmixFieldInventory struct { Name string `json:"name"` Type string `json:"type"` Index string `json:"index,omitempty"` } type VmixInputInventory struct { Key string `json:"key,omitempty"` Number string `json:"number,omitempty"` Title string `json:"title"` Type string `json:"type,omitempty"` Fields []VmixFieldInventory `json:"fields"` } type VmixInventory struct { Fingerprint string `json:"fingerprint"` Inputs []VmixInputInventory `json:"inputs"` InputCount int `json:"input_count"` FieldCount int `json:"field_count"` VmixVersion string `json:"vmix_version,omitempty"` } type vmixAPIXML struct { Version string `xml:"version"` Inputs []vmixInputXML `xml:"inputs>input"` Overlays []vmixOverlayXML `xml:"overlays>overlay"` } type vmixOverlayXML struct { Number string `xml:"number,attr"` Key string `xml:"key,attr"` Value string `xml:",chardata"` } type VmixOverlayTally struct { Input string `json:"input"` InputNumber string `json:"input_number,omitempty"` InputKey string `json:"input_key,omitempty"` InputTitle string `json:"input_title,omitempty"` } type vmixInputXML struct { Key string `xml:"key,attr"` Number string `xml:"number,attr"` Type string `xml:"type,attr"` Title string `xml:"title,attr"` Children []vmixFieldXML `xml:",any"` } type vmixFieldXML struct { XMLName xml.Name Index string `xml:"index,attr"` Name string `xml:"name,attr"` } type AppState struct { ServerConnected bool ServerConnecting bool ServerError string Vmix VmixStatus Paired bool PairedLogin string Active bool AssignmentID string MatchID string MappingName string MappingVersion string LastCommand string LastCommandOK bool LastCommandAt time.Time LastCommandDetail string LastServerAt time.Time ConnectedServer string } type Agent struct { mu sync.RWMutex cfg Config cfgPath string state AppState logs []string logFilePath string fileLog chan string currentWS *wsConn inventory VmixInventory stop chan struct{} reconnect chan struct{} } type wsConn struct { conn net.Conn r *bufio.Reader mu sync.Mutex } func NewAgent(cfgPath string, cfg Config) *Agent { return &Agent{ cfgPath: cfgPath, cfg: cfg, stop: make(chan struct{}), reconnect: make(chan struct{}, 1), logs: make([]string, 0, 150), logFilePath: filepath.Join(filepath.Dir(cfgPath), "agent.log"), fileLog: make(chan string, 256), state: AppState{Vmix: VmixStatus{URL: cfg.VmixURL}}, } } func (a *Agent) Start() { go a.fileLogLoop() a.logf("Agent %s started", agentVersion) a.logf("Device: %s (%s)", a.cfg.DeviceName, short(a.cfg.DeviceID)) go a.vmixMonitor() go a.vmixOverlayMonitor() go a.connectionLoop() } func (a *Agent) Stop() { select { case <-a.stop: return default: close(a.stop) } a.mu.Lock() ws := a.currentWS a.currentWS = nil a.mu.Unlock() // Never perform socket I/O while holding the Agent mutex. A slow or stuck // network close must not block GUI reads such as Config()/Snapshot(). if ws != nil { _ = ws.Close() } } func (a *Agent) Config() Config { a.mu.RLock() defer a.mu.RUnlock() return a.cfg } func (a *Agent) UpdateConfig(cfg Config) error { a.mu.Lock() // Device identity is intentionally immutable from the GUI. cfg.DeviceID = a.cfg.DeviceID cfg.DeviceSecret = a.cfg.DeviceSecret if strings.TrimSpace(cfg.DeviceName) == "" { cfg.DeviceName = hostname() } cfg.ServerURL = normalizeServerBase(cfg.ServerURL) cfg.FallbackServerURL = normalizeServerBase(cfg.FallbackServerURL) cfg.VmixURL = strings.TrimSpace(cfg.VmixURL) a.cfg = cfg a.mu.Unlock() if err := saveConfig(a.cfgPath, cfg); err != nil { return err } a.logf("Settings saved") select { case a.reconnect <- struct{}{}: default: } a.mu.RLock() ws := a.currentWS a.mu.RUnlock() // Closing a WebSocket can occasionally take longer than expected on a bad // network. Do it outside the mutex so the Windows UI never waits on it. if ws != nil { _ = ws.Close() } return nil } func (a *Agent) Snapshot() AppState { a.mu.RLock() defer a.mu.RUnlock() return a.state } func (a *Agent) Logs() string { a.mu.RLock() defer a.mu.RUnlock() return strings.Join(a.logs, "\r\n") } func (a *Agent) logf(format string, args ...any) { line := fmt.Sprintf("[%s] %s", stamp(), fmt.Sprintf(format, args...)) a.mu.Lock() a.logs = append(a.logs, line) if len(a.logs) > 150 { a.logs = append([]string(nil), a.logs[len(a.logs)-150:]...) } a.mu.Unlock() // File logging is best-effort and never allowed to block the Agent. This is // especially useful when the GUI itself becomes unresponsive: agent.log can // still show the last network/vMix event before the problem. select { case a.fileLog <- line: default: } } func (a *Agent) fileLogLoop() { path := a.logFilePath if stat, err := os.Stat(path); err == nil && stat.Size() > 2*1024*1024 { _ = os.Remove(path + ".1") _ = os.Rename(path, path+".1") } f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return } defer f.Close() for { select { case <-a.stop: return case line := <-a.fileLog: _, _ = io.WriteString(f, time.Now().Format("2006-01-02 ")+line+"\r\n") } } } func (a *Agent) vmixMonitor() { ticker := time.NewTicker(3 * time.Second) defer ticker.Stop() for { a.mu.RLock() vmixURL := a.cfg.VmixURL a.mu.RUnlock() status, inventory := scanVmix(vmixURL) a.mu.Lock() previous := a.state.Vmix.Connected previousErr := a.state.Vmix.Error previousFingerprint := a.inventory.Fingerprint a.state.Vmix = status a.inventory = inventory ws := a.currentWS a.mu.Unlock() if status.Connected && inventory.Fingerprint != "" && inventory.Fingerprint != previousFingerprint && ws != nil { if err := ws.WriteJSON(map[string]any{"type": "vmix.inventory", "device_id": a.cfg.DeviceID, "inventory": inventory, "vmix": status}); err == nil { a.logf("vMix structure sent: %d Inputs / %d fields", inventory.InputCount, inventory.FieldCount) } } if status.Connected && !previous { a.logf("vMix connected%s", suffixVersion(status.Version)) } if !status.Connected && (previous || status.Error != previousErr) { a.logf("vMix not found: %s", status.Error) } select { case <-a.stop: return case <-ticker.C: } } } func (a *Agent) vmixOverlayMonitor() { ticker := time.NewTicker(overlayPollInterval) defer ticker.Stop() lastHash := "" var lastWS *wsConn lastSentAt := time.Time{} for { a.mu.RLock() vmixURL := a.cfg.VmixURL ws := a.currentWS a.mu.RUnlock() overlays, connected := scanVmixOverlayTally(vmixURL) encoded, _ := json.Marshal(overlays) stateHash := fmt.Sprintf("%t:%s", connected, string(encoded)) force := ws != nil && ws != lastWS periodic := ws != nil && (lastSentAt.IsZero() || time.Since(lastSentAt) >= overlayResendInterval) changed := stateHash != lastHash if ws != nil && (force || periodic || changed) { payload := map[string]any{ "type": "vmix.overlay_state", "device_id": a.cfg.DeviceID, "vmix_connected": connected, "overlays": overlays, "observed_at": time.Now().UTC().Format(time.RFC3339Nano), } if err := ws.WriteJSON(payload); err == nil { lastHash = stateHash lastWS = ws lastSentAt = time.Now() } } if ws == nil { lastWS = nil } select { case <-a.stop: return case <-ticker.C: } } } func scanVmixOverlayTally(base string) (map[string]VmixOverlayTally, bool) { result := map[string]VmixOverlayTally{} target := strings.TrimSpace(base) if target == "" { return result, false } resp, err := vmixHTTPClient.Get(target) if err != nil { return result, false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return result, false } body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024)) if err != nil { return result, false } var api vmixAPIXML if err := xml.Unmarshal(body, &api); err != nil { return result, true } byNumber := map[string]vmixInputXML{} byKey := map[string]vmixInputXML{} for _, input := range api.Inputs { number := strings.TrimSpace(input.Number) key := strings.TrimSpace(input.Key) if number != "" { byNumber[number] = input } if key != "" { byKey[strings.ToLower(key)] = input } } for _, overlay := range api.Overlays { layer := strings.TrimSpace(overlay.Number) if layer != "1" && layer != "2" && layer != "3" && layer != "4" { continue } raw := strings.TrimSpace(overlay.Value) rawKey := strings.TrimSpace(overlay.Key) if raw == "" && rawKey == "" { continue } var matched vmixInputXML found := false if raw != "" { if item, ok := byNumber[raw]; ok { matched, found = item, true } else if item, ok := byKey[strings.ToLower(raw)]; ok { matched, found = item, true } } if !found && rawKey != "" { if item, ok := byKey[strings.ToLower(rawKey)]; ok { matched, found = item, true } } entry := VmixOverlayTally{Input: raw} if entry.Input == "" { entry.Input = rawKey } if found { entry.InputNumber = strings.TrimSpace(matched.Number) entry.InputKey = strings.TrimSpace(matched.Key) entry.InputTitle = strings.TrimSpace(matched.Title) if entry.Input == "" { entry.Input = entry.InputNumber } } else { entry.InputKey = rawKey if _, ok := byNumber[raw]; ok || raw != "" { entry.InputNumber = raw } } result[layer] = entry } return result, true } func suffixVersion(v string) string { if v == "" { return "" } return " · " + v } func (a *Agent) connectionLoop() { backoff := time.Second outer: for { select { case <-a.stop: return default: } a.mu.RLock() cfg := a.cfg a.mu.RUnlock() candidates := serverCandidates(cfg) if len(candidates) == 0 { candidates = []string{"http://127.0.0.1:8000"} } var lastErr error for _, serverBase := range candidates { a.mu.Lock() a.state.ServerConnecting = true a.state.ServerError = "" a.mu.Unlock() connectionStarted := time.Now() err := a.runConnection(serverBase) if time.Since(connectionStarted) >= 5*time.Second { backoff = time.Second } a.mu.Lock() a.state.ServerConnected = false a.state.ServerConnecting = false a.state.ConnectedServer = "" if err != nil { a.state.ServerError = err.Error() } a.currentWS = nil a.mu.Unlock() if err == nil { lastErr = nil break } lastErr = err a.logf("Server unavailable (%s): %v", serverBase, err) select { case <-a.stop: return case <-a.reconnect: backoff = time.Second continue outer default: } } if lastErr != nil { a.logf("All configured Hockey servers are offline") } timer := time.NewTimer(backoff) select { case <-a.stop: timer.Stop() return case <-a.reconnect: timer.Stop() backoff = time.Second continue case <-timer.C: } if backoff < 15*time.Second { backoff *= 2 } if backoff > 15*time.Second { backoff = 15 * time.Second } } } func (a *Agent) runConnection(serverBase string) error { a.mu.RLock() cfg := a.cfg a.mu.RUnlock() target, err := websocketURL(serverBase) if err != nil { return err } a.logf("Connecting to %s", target) ws, err := dialWebSocket(target, 7*time.Second) if err != nil { return err } defer ws.Close() a.mu.Lock() a.currentWS = ws a.state.ServerConnected = true a.state.ServerConnecting = false a.state.ServerError = "" a.state.LastServerAt = time.Now() a.state.ConnectedServer = serverBase a.mu.Unlock() a.logf("Server connected: %s", serverBase) vmix, inventory := scanVmix(cfg.VmixURL) a.mu.Lock() a.state.Vmix = vmix a.inventory = inventory a.mu.Unlock() hello := map[string]any{ "type": "hello", "protocol": protocolVersion, "device_id": cfg.DeviceID, "device_secret": cfg.DeviceSecret, "device_name": cfg.DeviceName, "hostname": hostname(), "agent_version": agentVersion, "platform": runtime.GOOS + "/" + runtime.GOARCH, "vmix": vmix, } if err := ws.WriteJSON(hello); err != nil { return err } if inventory.Fingerprint != "" { if err := ws.WriteJSON(map[string]any{"type": "vmix.inventory", "device_id": cfg.DeviceID, "inventory": inventory, "vmix": vmix}); err != nil { return err } } // Heartbeats are written from their own goroutine. The old implementation // used a 1.2 s read timeout merely to get a chance to send heartbeats. If a // WebSocket frame arrived slowly, that timeout could fire after part of the // frame had already been consumed, desynchronising the stream. From then on // the agent could randomly disconnect or appear to hang. A dedicated writer // removes that failure mode completely. heartbeatStop := make(chan struct{}) heartbeatErr := make(chan error, 1) defer close(heartbeatStop) sendHeartbeat := func() error { a.mu.RLock() currentVmix := a.state.Vmix assignmentID, matchID := a.state.AssignmentID, a.state.MatchID a.mu.RUnlock() return ws.WriteJSON(map[string]any{ "type": "heartbeat", "device_id": cfg.DeviceID, "assignment_id": assignmentID, "match_id": matchID, "vmix": currentVmix, "error": currentVmix.Error, }) } // Send one immediately so the server sees fresh status without waiting. if err := sendHeartbeat(); err != nil { return err } go func() { ticker := time.NewTicker(heartbeatInterval) defer ticker.Stop() for { select { case <-heartbeatStop: return case <-a.stop: return case <-ticker.C: if err := sendHeartbeat(); err != nil { select { case heartbeatErr <- err: default: } _ = ws.Close() // unblock the reader immediately return } } } }() // The server answers every heartbeat. If no complete server frame is // received for 30 seconds, treat the TCP connection as stale and reconnect. // A timeout always discards this socket; we never continue after partially // consuming a frame. for { _ = ws.conn.SetReadDeadline(time.Now().Add(30 * time.Second)) payload, err := ws.ReadText() if err != nil { select { case hbErr := <-heartbeatErr: return fmt.Errorf("heartbeat write failed: %w", hbErr) default: } if ne, ok := err.(net.Error); ok && ne.Timeout() { return errors.New("server watchdog timeout: no data for 30s") } select { case <-a.stop: return nil default: } return err } var msg map[string]any if json.Unmarshal(payload, &msg) != nil { continue } a.mu.Lock() a.state.LastServerAt = time.Now() a.mu.Unlock() if err := a.handleMessage(ws, msg); err != nil { a.logf("Message error: %v", err) } } } func (a *Agent) handleMessage(ws *wsConn, msg map[string]any) error { typ := stringValue(msg["type"]) switch typ { case "hello.ok": a.logf("Device registered on server") if dev, ok := msg["device"].(map[string]any); ok { a.mu.Lock() a.state.Paired = boolValue(dev["paired"]) a.state.PairedLogin = stringValue(dev["owner"]) a.state.Active = boolValue(dev["active_for_account"]) a.state.MatchID = stringValue(dev["current_match_id"]) a.state.AssignmentID = stringValue(dev["assignment_id"]) a.mu.Unlock() } case "hello.error": return errors.New(stringValue(msg["detail"])) case "pairing.confirmed": account, _ := msg["account"].(map[string]any) login := stringValue(account["login"]) active := boolValue(msg["active_for_account"]) a.mu.Lock() a.state.Paired = true a.state.PairedLogin = login a.state.Active = active a.mu.Unlock() a.logf("Paired to account: %s", login) case "pairing.revoked": a.mu.Lock() a.state.Paired = false a.state.PairedLogin = "" a.state.Active = false a.state.AssignmentID = "" a.state.MatchID = "" a.mu.Unlock() a.logf("Pairing revoked") case "device.activated": a.mu.Lock() a.state.Active = true a.mu.Unlock() a.logf("Device activated for broadcast") case "device.deactivated": a.mu.Lock() a.state.Active = false a.state.AssignmentID = "" a.state.MatchID = "" a.mu.Unlock() a.logf("Device moved to standby") case "match.assign": assignmentID := stringValue(msg["assignment_id"]) matchID := stringValue(msg["game_id"]) a.mu.Lock() old := a.state.MatchID a.state.AssignmentID = assignmentID a.state.MatchID = matchID a.mu.Unlock() if old != "" && old != matchID { a.logf("Match switched: %s → %s", old, matchID) } else { a.logf("Match assigned: %s", matchID) } a.mu.RLock() vmix := a.state.Vmix a.mu.RUnlock() return ws.WriteJSON(map[string]any{"type": "match.accepted", "device_id": a.cfg.DeviceID, "assignment_id": assignmentID, "match_id": matchID, "vmix": vmix}) case "vmix.probe": a.mu.RLock() vmix := a.state.Vmix a.mu.RUnlock() return ws.WriteJSON(map[string]any{"type": "vmix.status", "request_id": stringValue(msg["request_id"]), "vmix": vmix, "error": vmix.Error}) case "vmix.command": return a.handleVmixCommand(ws, msg) case "vmix.batch": return a.handleVmixBatch(ws, msg) case "heartbeat.ack": return nil case "mapping.assigned": a.mu.Lock() a.state.MappingName = stringValue(msg["name"]) a.state.MappingVersion = stringValue(msg["version"]) a.mu.Unlock() a.logf("Mapping assigned: %s", stringValue(msg["name"])) case "mapping.missing": a.mu.Lock() a.state.MappingName = "" a.state.MappingVersion = "" a.mu.Unlock() a.logf("Mapping not configured for current vMix project") case "server.error": a.logf("Server error: %s", stringValue(msg["detail"])) } return nil } func (a *Agent) handleVmixCommand(ws *wsConn, msg map[string]any) error { requestID := stringValue(msg["request_id"]) incomingAssignment := stringValue(msg["assignment_id"]) incomingMatch := stringValue(msg["match_id"]) a.mu.RLock() currentAssignment, currentMatch := a.state.AssignmentID, a.state.MatchID vmixURL := a.cfg.VmixURL a.mu.RUnlock() if incomingAssignment != "" && incomingAssignment != currentAssignment { return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "assignment_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch}) } if incomingMatch != "" && incomingMatch != currentMatch { return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "match_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch}) } rawCommand, _ := msg["command"].(map[string]any) function := stringValue(rawCommand["Function"]) if function == "" { function = stringValue(rawCommand["function"]) } if function == "" { return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": false, "reason": "missing_function"}) } params := url.Values{} params.Set("Function", function) for key, value := range rawCommand { if strings.EqualFold(key, "function") || value == nil { continue } text := stringValue(value) if strings.EqualFold(key, "Value") || text != "" { params.Set(key, text) } } detail := function if input := params.Get("Input"); input != "" { detail += " · " + input } if field := params.Get("SelectedName"); field != "" { detail += " · " + field } a.logf("vMix command: %s", detail) vmix := probeVmix(vmixURL, params) a.mu.Lock() a.state.Vmix = vmix a.state.LastCommand = detail a.state.LastCommandOK = vmix.Connected a.state.LastCommandAt = time.Now() a.state.LastCommandDetail = vmix.Error a.mu.Unlock() if vmix.Connected { a.logf("Command OK: %s", function) } else { a.logf("Command failed: %s", vmix.Error) } return ws.WriteJSON(map[string]any{"type": "command.ack", "request_id": requestID, "ok": vmix.Connected, "reason": vmix.Error, "assignment_id": currentAssignment, "match_id": currentMatch, "vmix": vmix, "error": vmix.Error}) } func (a *Agent) handleVmixBatch(ws *wsConn, msg map[string]any) error { requestID := stringValue(msg["request_id"]) incomingAssignment := stringValue(msg["assignment_id"]) incomingMatch := stringValue(msg["match_id"]) a.mu.RLock() currentAssignment, currentMatch := a.state.AssignmentID, a.state.MatchID vmixURL := a.cfg.VmixURL a.mu.RUnlock() if incomingAssignment != "" && incomingAssignment != currentAssignment { return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "assignment_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}}) } if incomingMatch != "" && incomingMatch != currentMatch { return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "match_mismatch", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}}) } rawCommands, _ := msg["commands"].([]any) if len(rawCommands) == 0 { return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": true, "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}, "applied": 0}) } if len(rawCommands) > 500 { return ws.WriteJSON(map[string]any{"type": "command.batch.ack", "request_id": requestID, "ok": false, "reason": "too_many_commands", "assignment_id": currentAssignment, "match_id": currentMatch, "results": []any{}}) } type batchResult struct { OK bool Reason string Detail string Function string Vmix VmixStatus } results := make([]batchResult, len(rawCommands)) jobs := make(chan int) workers := 6 if len(rawCommands) < workers { workers = len(rawCommands) } var wg sync.WaitGroup wg.Add(workers) for w := 0; w < workers; w++ { go func() { defer wg.Done() for index := range jobs { rawCommand, _ := rawCommands[index].(map[string]any) function := stringValue(rawCommand["Function"]) if function == "" { function = stringValue(rawCommand["function"]) } if function == "" { results[index] = batchResult{OK: false, Reason: "missing_function"} continue } params := url.Values{} params.Set("Function", function) for key, value := range rawCommand { if strings.EqualFold(key, "function") || value == nil { continue } text := stringValue(value) if strings.EqualFold(key, "Value") || text != "" { params.Set(key, text) } } detail := function if input := params.Get("Input"); input != "" { detail += " · " + input } if field := params.Get("SelectedName"); field != "" { detail += " · " + field } vmix := probeVmix(vmixURL, params) results[index] = batchResult{OK: vmix.Connected, Reason: vmix.Error, Detail: detail, Function: function, Vmix: vmix} } }() } for index := range rawCommands { jobs <- index } close(jobs) wg.Wait() ackResults := make([]map[string]any, len(results)) applied := 0 failed := 0 lastDetail := "" lastStatus := VmixStatus{URL: vmixURL} for index, item := range results { ackResults[index] = map[string]any{"ok": item.OK, "reason": item.Reason} if item.OK { applied++ } else { failed++ } if item.Detail != "" { lastDetail = item.Detail lastStatus = item.Vmix } } a.mu.Lock() if lastDetail != "" { a.state.Vmix = lastStatus a.state.LastCommand = fmt.Sprintf("Batch %d · %s", len(results), lastDetail) a.state.LastCommandOK = failed == 0 a.state.LastCommandAt = time.Now() if failed > 0 { a.state.LastCommandDetail = fmt.Sprintf("%d команд с ошибкой", failed) } else { a.state.LastCommandDetail = "" } } a.mu.Unlock() a.logf("vMix batch: %d commands · OK %d · errors %d", len(results), applied, failed) return ws.WriteJSON(map[string]any{ "type": "command.batch.ack", "request_id": requestID, "ok": failed == 0, "assignment_id": currentAssignment, "match_id": currentMatch, "applied": applied, "failed": failed, "results": ackResults, }) } func scanVmix(base string) (VmixStatus, VmixInventory) { status := VmixStatus{URL: base} inventory := VmixInventory{Inputs: []VmixInputInventory{}} target := strings.TrimSpace(base) if target == "" { status.Error = "vMix URL is empty" return status, inventory } client := &http.Client{Timeout: 4 * time.Second} resp, err := client.Get(target) if err != nil { status.Error = err.Error() return status, inventory } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { status.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) return status, inventory } body, err := io.ReadAll(io.LimitReader(resp.Body, 8*1024*1024)) if err != nil { status.Error = err.Error() return status, inventory } status.Connected = true var api vmixAPIXML if err := xml.Unmarshal(body, &api); err != nil { status.Error = "vMix XML parse: " + err.Error() status.Connected = true return status, inventory } status.Version = strings.TrimSpace(api.Version) inventory.VmixVersion = status.Version fingerprintLines := make([]string, 0, len(api.Inputs)) for _, raw := range api.Inputs { item := VmixInputInventory{ Key: strings.TrimSpace(raw.Key), Number: strings.TrimSpace(raw.Number), Title: strings.TrimSpace(raw.Title), Type: strings.TrimSpace(raw.Type), Fields: []VmixFieldInventory{}, } fieldLines := make([]string, 0, len(raw.Children)) for _, child := range raw.Children { name := strings.TrimSpace(child.Name) if name == "" { continue } fieldType := strings.ToLower(strings.TrimSpace(child.XMLName.Local)) if fieldType == "" { fieldType = "field" } item.Fields = append(item.Fields, VmixFieldInventory{Name: name, Type: fieldType, Index: strings.TrimSpace(child.Index)}) fieldLines = append(fieldLines, fieldType+":"+name) } sort.Slice(item.Fields, func(i, j int) bool { if item.Fields[i].Type == item.Fields[j].Type { return item.Fields[i].Name < item.Fields[j].Name } return item.Fields[i].Type < item.Fields[j].Type }) sort.Strings(fieldLines) inventory.FieldCount += len(item.Fields) inventory.Inputs = append(inventory.Inputs, item) fingerprintLines = append(fingerprintLines, item.Type+"|"+item.Title+"|"+strings.Join(fieldLines, ",")) } sort.Slice(inventory.Inputs, func(i, j int) bool { if inventory.Inputs[i].Title == inventory.Inputs[j].Title { return inventory.Inputs[i].Number < inventory.Inputs[j].Number } return inventory.Inputs[i].Title < inventory.Inputs[j].Title }) sort.Strings(fingerprintLines) inventory.InputCount = len(inventory.Inputs) hash := sha256.Sum256([]byte(strings.Join(fingerprintLines, "\n"))) inventory.Fingerprint = fmt.Sprintf("%x", hash[:]) return status, inventory } var vmixHTTPClient = &http.Client{ Timeout: 3 * time.Second, Transport: &http.Transport{ MaxIdleConns: 32, MaxIdleConnsPerHost: 16, IdleConnTimeout: 90 * time.Second, }, } func probeVmix(base string, params url.Values) VmixStatus { result := VmixStatus{URL: base} target := strings.TrimSpace(base) if target == "" { result.Error = "vMix URL is empty" return result } if params != nil && len(params) > 0 { sep := "?" if strings.Contains(target, "?") { sep = "&" } target += sep + params.Encode() } resp, err := vmixHTTPClient.Get(target) if err != nil { result.Error = err.Error() return result } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { result.Error = fmt.Sprintf("HTTP %d", resp.StatusCode) return result } body, _ := io.ReadAll(io.LimitReader(resp.Body, 64*1024)) result.Connected = true text := string(body) if params != nil && len(params) > 0 { responseText := strings.TrimSpace(text) if len(responseText) > 1000 { responseText = responseText[:1000] } result.Response = responseText } if i := strings.Index(text, ""); i >= 0 { rest := text[i+len(""):] if j := strings.Index(rest, ""); j >= 0 { result.Version = strings.TrimSpace(rest[:j]) } } return result } func normalizeServerBase(raw string) string { raw = strings.TrimSpace(raw) if raw == "" { return "" } if !strings.Contains(raw, "://") { raw = "http://" + raw } return strings.TrimRight(raw, "/") } // serverCandidates makes deployment independent from localhost. // Priority: // 1. HOCKEY_SERVER_URL environment variable // 2. server_url.txt next to agent.exe (handy for server migration/deployment) // 3. primary URL saved in agent_config.json // 4. optional fallback URL saved in agent_config.json func serverCandidates(cfg Config) []string { values := make([]string, 0, 4) if env := normalizeServerBase(os.Getenv("HOCKEY_SERVER_URL")); env != "" { values = append(values, env) } if exe, err := os.Executable(); err == nil { path := filepath.Join(filepath.Dir(exe), "server_url.txt") if data, err := os.ReadFile(path); err == nil { if v := normalizeServerBase(strings.TrimSpace(string(data))); v != "" { values = append(values, v) } } } values = append(values, normalizeServerBase(cfg.ServerURL), normalizeServerBase(cfg.FallbackServerURL)) seen := map[string]bool{} out := make([]string, 0, len(values)) for _, value := range values { if value == "" || seen[value] { continue } seen[value] = true out = append(out, value) } return out } func websocketURL(server string) (string, error) { raw := strings.TrimRight(strings.TrimSpace(server), "/") if raw == "" { return "", errors.New("server_url is empty") } u, err := url.Parse(raw) if err != nil { return "", err } switch strings.ToLower(u.Scheme) { case "http": u.Scheme = "ws" case "https": u.Scheme = "wss" case "ws", "wss": default: if !strings.Contains(raw, "://") { return websocketURL("http://" + raw) } return "", fmt.Errorf("unsupported server scheme: %s", u.Scheme) } u.Path = strings.TrimRight(u.Path, "/") + "/ws/hockey-agent" u.RawQuery = "" return u.String(), nil } func dialWebSocket(target string, timeout time.Duration) (*wsConn, error) { u, err := url.Parse(target) if err != nil { return nil, err } host := u.Hostname() port := u.Port() if port == "" { if u.Scheme == "wss" { port = "443" } else { port = "80" } } address := net.JoinHostPort(host, port) d := net.Dialer{Timeout: timeout} var conn net.Conn if u.Scheme == "wss" { conn, err = tls.DialWithDialer(&d, "tcp", address, &tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}) } else { conn, err = d.Dial("tcp", address) } if err != nil { return nil, err } // Bound the entire HTTP Upgrade handshake. Without this, a peer that // accepts TCP but never completes the response can stall a connection attempt. _ = conn.SetDeadline(time.Now().Add(timeout)) key := randomBase64(16) path := u.EscapedPath() if path == "" { path = "/" } if u.RawQuery != "" { path += "?" + u.RawQuery } req := fmt.Sprintf("GET %s HTTP/1.1\r\nHost: %s\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: %s\r\nSec-WebSocket-Version: 13\r\nUser-Agent: Hockey-vMix-Agent/%s\r\n\r\n", path, u.Host, key, agentVersion) if _, err := io.WriteString(conn, req); err != nil { conn.Close() return nil, err } reader := bufio.NewReader(conn) statusLine, err := reader.ReadString('\n') if err != nil { conn.Close() return nil, err } if !strings.Contains(statusLine, " 101 ") { conn.Close() return nil, fmt.Errorf("websocket upgrade failed: %s", strings.TrimSpace(statusLine)) } headers := map[string]string{} for { line, err := reader.ReadString('\n') if err != nil { conn.Close() return nil, err } line = strings.TrimRight(line, "\r\n") if line == "" { break } if i := strings.Index(line, ":"); i > 0 { headers[strings.ToLower(strings.TrimSpace(line[:i]))] = strings.TrimSpace(line[i+1:]) } } sum := sha1.Sum([]byte(key + websocketGUID)) expected := base64.StdEncoding.EncodeToString(sum[:]) if headers["sec-websocket-accept"] != expected { conn.Close() return nil, errors.New("invalid Sec-WebSocket-Accept") } _ = conn.SetDeadline(time.Time{}) return &wsConn{conn: conn, r: reader}, nil } func (w *wsConn) Close() error { return w.conn.Close() } func (w *wsConn) WriteJSON(value any) error { payload, err := json.Marshal(value) if err != nil { return err } return w.writeFrame(0x1, payload) } func (w *wsConn) writeFrame(opcode byte, payload []byte) error { w.mu.Lock() defer w.mu.Unlock() // Never let a dead network block the WebSocket writer forever. _ = w.conn.SetWriteDeadline(time.Now().Add(5 * time.Second)) defer w.conn.SetWriteDeadline(time.Time{}) var header bytes.Buffer header.WriteByte(0x80 | opcode) n := len(payload) switch { case n < 126: header.WriteByte(0x80 | byte(n)) case n <= 65535: header.WriteByte(0x80 | 126) _ = binary.Write(&header, binary.BigEndian, uint16(n)) default: header.WriteByte(0x80 | 127) _ = binary.Write(&header, binary.BigEndian, uint64(n)) } mask := make([]byte, 4) if _, err := rand.Read(mask); err != nil { return err } header.Write(mask) masked := make([]byte, n) for i := range payload { masked[i] = payload[i] ^ mask[i%4] } if _, err := w.conn.Write(header.Bytes()); err != nil { return err } _, err := w.conn.Write(masked) return err } func (w *wsConn) ReadText() ([]byte, error) { for { first, err := w.r.ReadByte() if err != nil { return nil, err } second, err := w.r.ReadByte() if err != nil { return nil, err } fin := first&0x80 != 0 opcode := first & 0x0F masked := second&0x80 != 0 length := uint64(second & 0x7F) if length == 126 { var n uint16 if err := binary.Read(w.r, binary.BigEndian, &n); err != nil { return nil, err } length = uint64(n) } else if length == 127 { if err := binary.Read(w.r, binary.BigEndian, &length); err != nil { return nil, err } } if length > 16*1024*1024 { return nil, errors.New("websocket frame too large") } var mask [4]byte if masked { if _, err := io.ReadFull(w.r, mask[:]); err != nil { return nil, err } } payload := make([]byte, int(length)) if _, err := io.ReadFull(w.r, payload); err != nil { return nil, err } if masked { for i := range payload { payload[i] ^= mask[i%4] } } switch opcode { case 0x1: if !fin { return nil, errors.New("fragmented text frames are not supported") } return payload, nil case 0x8: return nil, io.EOF case 0x9: _ = w.writeFrame(0xA, payload) case 0xA: continue default: continue } } } func configPath() (string, error) { exe, err := os.Executable() if err != nil { return "", err } return filepath.Join(filepath.Dir(exe), "agent_config.json"), nil } func loadConfig(path string) (Config, error) { cfg := Config{ServerURL: "http://127.0.0.1:8000", VmixURL: "http://127.0.0.1:8088/api/", DeviceID: newUUID(), DeviceSecret: randomToken(40), DeviceName: hostname()} if data, err := os.ReadFile(path); err == nil { _ = json.Unmarshal(data, &cfg) } if cfg.DeviceID == "" { cfg.DeviceID = newUUID() } if cfg.DeviceSecret == "" { cfg.DeviceSecret = randomToken(40) } if cfg.DeviceName == "" { cfg.DeviceName = hostname() } if cfg.ServerURL == "" { cfg.ServerURL = "http://127.0.0.1:8000" } cfg.ServerURL = normalizeServerBase(cfg.ServerURL) cfg.FallbackServerURL = normalizeServerBase(cfg.FallbackServerURL) if cfg.VmixURL == "" { cfg.VmixURL = "http://127.0.0.1:8088/api/" } if err := saveConfig(path, cfg); err != nil { return cfg, err } return cfg, nil } func saveConfig(path string, cfg Config) error { data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } return os.WriteFile(path, data, 0600) } func newUUID() string { b := make([]byte, 16) _, _ = rand.Read(b) b[6] = (b[6] & 0x0f) | 0x40 b[8] = (b[8] & 0x3f) | 0x80 return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]) } func randomBase64(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return base64.StdEncoding.EncodeToString(b) } func randomToken(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return base64.RawURLEncoding.EncodeToString(b) } func hostname() string { h, _ := os.Hostname() if h == "" { return "HOCKEY-GFX" } return h } func stamp() string { return time.Now().Format("15:04:05") } func short(v string) string { if len(v) > 12 { return v[:12] + "..." } return v } func stringValue(v any) string { if v == nil { return "" } if s, ok := v.(string); ok { return s } return fmt.Sprint(v) } func boolValue(v any) bool { b, _ := v.(bool); return b }