package main import ( "bufio" "bytes" "crypto/rand" "crypto/sha1" "crypto/tls" "encoding/base64" "encoding/binary" "encoding/json" "errors" "flag" "fmt" "io" "net" "net/http" "net/url" "os" "path/filepath" "runtime" "strings" "sync" "time" ) const ( agentVersion = "1.1.0" protocolVersion = 1 websocketGUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" heartbeatInterval = 8 * time.Second ) type Config struct { ServerURL string `json:"server_url"` VmixURL string `json:"vmix_url"` DeviceID string `json:"device_id"` DeviceSecret string `json:"device_secret"` DeviceName string `json:"device_name"` } 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 Agent struct { cfg Config mu sync.Mutex assignmentID string matchID string pairedLogin string active bool } type wsConn struct { conn net.Conn r *bufio.Reader mu sync.Mutex } func main() { cfgPath, err := configPath() if err != nil { fatal(err) } cfg, err := loadConfig(cfgPath) if err != nil { fatal(err) } server := flag.String("server", "", "Hockey server URL") vmix := flag.String("vmix", "", "vMix API URL") name := flag.String("name", "", "Device display name") resetID := flag.Bool("reset-device", false, "Generate a new Device ID and secret") flag.Parse() changed := false if *server != "" { cfg.ServerURL = strings.TrimRight(strings.TrimSpace(*server), "/") changed = true } if *vmix != "" { cfg.VmixURL = strings.TrimSpace(*vmix) changed = true } if *name != "" { cfg.DeviceName = strings.TrimSpace(*name) changed = true } if *resetID { cfg.DeviceID = newUUID() cfg.DeviceSecret = randomToken(40) changed = true } if changed { if err := saveConfig(cfgPath, cfg); err != nil { fatal(err) } } fmt.Println("============================================================") fmt.Println(" HOCKEY vMix AGENT") fmt.Printf(" Version: %s | Protocol: %d\n", agentVersion, protocolVersion) fmt.Printf(" Device ID: %s\n", cfg.DeviceID) fmt.Printf(" Name: %s\n", cfg.DeviceName) fmt.Printf(" Server: %s\n", cfg.ServerURL) fmt.Printf(" vMix API: %s\n", cfg.VmixURL) fmt.Printf(" Config: %s\n", cfgPath) fmt.Println("============================================================") fmt.Println("После подключения откройте web-интерфейс → vMix Agent → Прикрепить.") fmt.Println() agent := &Agent{cfg: cfg} backoff := time.Second for { if err := agent.runConnection(); err != nil { fmt.Printf("[%s] SERVER OFFLINE: %v\n", stamp(), err) } time.Sleep(backoff) if backoff < 15*time.Second { backoff *= 2 } if backoff > 15*time.Second { backoff = 15 * time.Second } } } func (a *Agent) runConnection() error { target, err := websocketURL(a.cfg.ServerURL) if err != nil { return err } fmt.Printf("[%s] Connecting %s ...\n", stamp(), target) ws, err := dialWebSocket(target, 7*time.Second) if err != nil { return err } defer ws.Close() fmt.Printf("[%s] SERVER ONLINE\n", stamp()) hello := map[string]any{ "type": "hello", "protocol": protocolVersion, "device_id": a.cfg.DeviceID, "device_secret": a.cfg.DeviceSecret, "device_name": a.cfg.DeviceName, "hostname": hostname(), "agent_version": agentVersion, "platform": runtime.GOOS + "/" + runtime.GOARCH, "vmix": probeVmix(a.cfg.VmixURL, nil), } if err := ws.WriteJSON(hello); err != nil { return err } lastHeartbeat := time.Time{} for { if time.Since(lastHeartbeat) >= heartbeatInterval { vmix := probeVmix(a.cfg.VmixURL, nil) a.printVmix(vmix) a.mu.Lock() assignmentID, matchID := a.assignmentID, a.matchID a.mu.Unlock() _ = ws.WriteJSON(map[string]any{ "type": "heartbeat", "device_id": a.cfg.DeviceID, "assignment_id": assignmentID, "match_id": matchID, "vmix": vmix, "error": vmix.Error, }) lastHeartbeat = time.Now() } _ = ws.conn.SetReadDeadline(time.Now().Add(1200 * time.Millisecond)) payload, err := ws.ReadText() if err != nil { if ne, ok := err.(net.Error); ok && ne.Timeout() { continue } return err } var msg map[string]any if err := json.Unmarshal(payload, &msg); err != nil { continue } if err := a.handleMessage(ws, msg); err != nil { fmt.Printf("[%s] MESSAGE ERROR: %v\n", stamp(), err) } } } func (a *Agent) handleMessage(ws *wsConn, msg map[string]any) error { typ := stringValue(msg["type"]) switch typ { case "hello.ok": fmt.Printf("[%s] Device registered on server\n", stamp()) 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.pairedLogin = login a.active = active a.mu.Unlock() suffix := "" if active { suffix = " (ACTIVE)" } fmt.Printf("[%s] PAIRED: %s%s\n", stamp(), login, suffix) case "pairing.revoked": a.mu.Lock() a.pairedLogin = "" a.active = false a.assignmentID = "" a.matchID = "" a.mu.Unlock() fmt.Printf("[%s] Pairing revoked\n", stamp()) case "device.activated": a.mu.Lock() a.active = true a.mu.Unlock() fmt.Printf("[%s] Device activated for broadcast\n", stamp()) case "device.deactivated": a.mu.Lock() a.active = false a.assignmentID = "" a.matchID = "" a.mu.Unlock() fmt.Printf("[%s] Device deactivated; match assignment cleared\n", stamp()) case "match.assign": assignmentID := stringValue(msg["assignment_id"]) matchID := stringValue(msg["game_id"]) a.mu.Lock() a.assignmentID = assignmentID a.matchID = matchID a.mu.Unlock() fmt.Printf("[%s] MATCH ASSIGNED: %s | assignment %s\n", stamp(), matchID, short(assignmentID)) vmix := probeVmix(a.cfg.VmixURL, nil) 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": vmix := probeVmix(a.cfg.VmixURL, nil) a.printVmix(vmix) 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 "heartbeat.ack": return nil case "server.error": fmt.Printf("[%s] SERVER ERROR: %s\n", stamp(), 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.Lock() currentAssignment, currentMatch := a.assignmentID, a.matchID a.mu.Unlock() 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 text != "" { params.Set(key, text) } } fmt.Printf("[%s] vMix COMMAND: %s | Input=%s | SelectedName=%s\n", stamp(), function, params.Get("Input"), params.Get("SelectedName")) vmix := probeVmix(a.cfg.VmixURL, params) a.printVmix(vmix) if vmix.Connected { fmt.Printf("[%s] vMix COMMAND OK: %s | response=%s\n", stamp(), function, short(vmix.Response)) } 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) printVmix(status VmixStatus) { if status.Connected { suffix := "" if status.Version != "" { suffix = " " + status.Version } fmt.Printf("[%s] vMix CONNECTED%s\n", stamp(), suffix) } else if status.Error != "" { fmt.Printf("[%s] vMix NO CONNECTION: %s\n", stamp(), status.Error) } } func probeVmix(base string, params url.Values) VmixStatus { result := VmixStatus{URL: base} target := strings.TrimSpace(base) if params != nil && len(params) > 0 { sep := "?" if strings.Contains(target, "?") { sep = "&" } target += sep + params.Encode() } client := &http.Client{Timeout: 3 * time.Second} resp, err := client.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 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 } key := randomBase64(16) path := u.EscapedPath() if path == "" { path = "/" } if u.RawQuery != "" { path += "?" + u.RawQuery } hostHeader := u.Host 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, hostHeader, 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") } 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() 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" } 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 } func fatal(err error) { fmt.Fprintln(os.Stderr, "FATAL:", err); os.Exit(1) }