diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 95a4ec4..802a578 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -28,9 +28,14 @@ jobs: filters: | server: - 'Overlord-Server/**' + - 'Overlord-Client/cmd/agent/wire/wire_contract_generated.go' + - 'protocol/**' + - 'scripts/generate-wire-protocol.ts' - '.github/workflows/tests.yml' client: - 'Overlord-Client/**' + - 'protocol/**' + - 'scripts/generate-wire-protocol.ts' - 'go.work' - 'go.work.sum' @@ -60,6 +65,9 @@ jobs: - name: Typecheck server run: bun run typecheck + - name: Verify generated wire protocol + run: bun run protocol:check + - name: Run server tests run: bun run test diff --git a/Overlord-Client/cmd/agent/config/config.go b/Overlord-Client/cmd/agent/config/config.go index 46526e4..f75f913 100644 --- a/Overlord-Client/cmd/agent/config/config.go +++ b/Overlord-Client/cmd/agent/config/config.go @@ -17,7 +17,7 @@ import ( "time" ) -var AgentVersion = "2.6.1" +var AgentVersion = "3.0.0" var DefaultPersistence = "false" var DefaultServerURL = "wss://127.0.0.1:5173" diff --git a/Overlord-Client/cmd/agent/handlers/command.go b/Overlord-Client/cmd/agent/handlers/command.go index 8abe39c..534d754 100644 --- a/Overlord-Client/cmd/agent/handlers/command.go +++ b/Overlord-Client/cmd/agent/handlers/command.go @@ -587,7 +587,8 @@ func extractCaptureDLLBytes(payload map[string]interface{}) []byte { func HandleCommand(ctx context.Context, env *runtime.Env, envelope map[string]interface{}) error { //garble:controlflow block_splits=10 junk_jumps=10 flatten_passes=2 cmdID, _ := envelope["id"].(string) - action, _ := envelope["commandType"].(string) + actionValue, _ := envelope["commandType"].(string) + action := wire.CommandType(actionValue) switch action { case "screenshot": diff --git a/Overlord-Client/cmd/agent/handlers/dispatcher.go b/Overlord-Client/cmd/agent/handlers/dispatcher.go index 31244f2..c169388 100644 --- a/Overlord-Client/cmd/agent/handlers/dispatcher.go +++ b/Overlord-Client/cmd/agent/handlers/dispatcher.go @@ -8,6 +8,7 @@ import ( "overlord-client/cmd/agent/capture" "overlord-client/cmd/agent/runtime" + "overlord-client/cmd/agent/wire" ) type Dispatcher struct { @@ -31,6 +32,10 @@ func (d *Dispatcher) Dispatch(ctx context.Context, envelope map[string]interface log.Printf("dispatcher: missing type (keys=%v)", maps.Keys(envelope)) return nil } + if !wire.IsServerToAgentMessageType(msgType) { + log.Printf("dispatcher: unknown message type=%v", msgType) + return nil + } switch msgType { case "hello_ack": return HandleHelloAck(ctx, d.Env, envelope) @@ -43,6 +48,40 @@ func (d *Dispatcher) Dispatch(ctx context.Context, envelope map[string]interface return nil case "command": cmdType, _ := envelope["commandType"].(string) + if !wire.IsCommandType(cmdType) { + log.Printf("dispatcher: unknown command type=%s", cmdType) + return nil + } + commandVersion := toInt(envelope["commandVersion"]) + if commandVersion <= 0 { + commandVersion = 1 + } + envelope["commandVersion"] = commandVersion + if !wire.IsSupportedCommandVersion(cmdType, commandVersion) { + cmdID, _ := envelope["id"].(string) + command := wire.CommandType(cmdType) + supported := wire.CommandVersionSupport[command] + log.Printf( + "dispatcher: unsupported command version type=%s version=%d supported=%d-%d", + cmdType, + commandVersion, + supported.Min, + supported.Max, + ) + if d.Env == nil || d.Env.Conn == nil { + return nil + } + return wire.WriteMsg(ctx, d.Env.Conn, wire.CommandResult{ + Type: "command_result", + CommandID: cmdID, + CommandType: command, + CommandVersion: commandVersion, + OK: false, + Message: fmt.Sprintf("unsupported %s command version %d", cmdType, commandVersion), + ErrorCode: "unsupported_command_version", + SupportedVersions: &supported, + }) + } if !isInputCommand(cmdType) { log.Printf("dispatcher: handling command type=%s", cmdType) } @@ -62,7 +101,7 @@ func (d *Dispatcher) Dispatch(ctx context.Context, envelope map[string]interface } return nil default: - log.Printf("dispatcher: unknown message type=%v", msgType) + log.Printf("dispatcher: unhandled contract message type=%v", msgType) return nil } } diff --git a/Overlord-Client/cmd/agent/handlers/hello_ack.go b/Overlord-Client/cmd/agent/handlers/hello_ack.go index 34c1d8d..4c0f7f9 100644 --- a/Overlord-Client/cmd/agent/handlers/hello_ack.go +++ b/Overlord-Client/cmd/agent/handlers/hello_ack.go @@ -6,6 +6,7 @@ import ( "strings" "overlord-client/cmd/agent/runtime" + "overlord-client/cmd/agent/wire" ) func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]interface{}) error { @@ -13,6 +14,9 @@ func HandleHelloAck(_ context.Context, env *runtime.Env, envelope map[string]int if env == nil || envelope == nil { return nil } + if version := toInt(envelope["protocolVersion"]); version > 0 { + log.Printf("hello ack: negotiated wire protocol v%d (agent v%d)", version, wire.WireProtocolVersion) + } var keywords []string minInterval := 0 diff --git a/Overlord-Client/cmd/agent/session.go b/Overlord-Client/cmd/agent/session.go index c0181b5..e6d71b3 100644 --- a/Overlord-Client/cmd/agent/session.go +++ b/Overlord-Client/cmd/agent/session.go @@ -634,21 +634,23 @@ func runSession(ctx context.Context, cancel context.CancelFunc, conn *websocket. } hello := wire.Hello{ - Type: "hello", - ID: cfg.ID, - HWID: cfg.HWID, - Host: rt.Hostname(), - OS: osVal, - Arch: archVal, - Version: cfg.Version, - User: rt.CurrentUser(), - Monitors: capture.MonitorCount(), - MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()), - Country: cfg.Country, - BuildTag: cfg.BuildTag, - PublicKey: publicKeyB64, - Signature: signatureB64, - InMemory: isRunningInMemory(), + Type: "hello", + ID: cfg.ID, + HWID: cfg.HWID, + Host: rt.Hostname(), + OS: osVal, + Arch: archVal, + Version: cfg.Version, + User: rt.CurrentUser(), + Monitors: capture.MonitorCount(), + MonitorInfo: toWireMonitorInfo(capture.MonitorInfos()), + Country: cfg.Country, + BuildTag: cfg.BuildTag, + PublicKey: publicKeyB64, + Signature: signatureB64, + InMemory: isRunningInMemory(), + ProtocolVersion: wire.WireProtocolVersion, + CommandVersions: wire.SupportedCommandVersionRanges(), } hw := sysinfo.Collect() diff --git a/Overlord-Client/cmd/agent/wire/protocol.go b/Overlord-Client/cmd/agent/wire/protocol.go index afa2631..1227e7d 100644 --- a/Overlord-Client/cmd/agent/wire/protocol.go +++ b/Overlord-Client/cmd/agent/wire/protocol.go @@ -1,33 +1,35 @@ package wire type Hello struct { - Type string `msgpack:"type"` - ID string `msgpack:"id"` - HWID string `msgpack:"hwid"` - Host string `msgpack:"host"` - OS string `msgpack:"os"` - Arch string `msgpack:"arch"` - HostArch string `msgpack:"hostArch,omitempty"` - Version string `msgpack:"version"` - User string `msgpack:"user"` - Monitors int `msgpack:"monitors"` - MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"` - Country string `msgpack:"country,omitempty"` - BuildTag string `msgpack:"buildTag,omitempty"` - PublicKey string `msgpack:"publicKey,omitempty"` - Signature string `msgpack:"signature,omitempty"` - InMemory bool `msgpack:"inMemory,omitempty"` - CPU string `msgpack:"cpu,omitempty"` - GPU string `msgpack:"gpu,omitempty"` - RAM string `msgpack:"ram,omitempty"` - BatteryPercent *int `msgpack:"batteryPercent,omitempty"` - BatteryCharging *bool `msgpack:"batteryCharging,omitempty"` - IsAdmin bool `msgpack:"isAdmin,omitempty"` - Elevation string `msgpack:"elevation,omitempty"` - Permissions map[string]bool `msgpack:"permissions,omitempty"` - PublicIP string `msgpack:"publicIP,omitempty"` - LastCrashReason string `msgpack:"lastCrashReason,omitempty"` - LastCrashDetail string `msgpack:"lastCrashDetail,omitempty"` + Type string `msgpack:"type"` + ID string `msgpack:"id"` + HWID string `msgpack:"hwid"` + Host string `msgpack:"host"` + OS string `msgpack:"os"` + Arch string `msgpack:"arch"` + HostArch string `msgpack:"hostArch,omitempty"` + Version string `msgpack:"version"` + User string `msgpack:"user"` + Monitors int `msgpack:"monitors"` + MonitorInfo []MonitorInfo `msgpack:"monitorInfo,omitempty"` + Country string `msgpack:"country,omitempty"` + BuildTag string `msgpack:"buildTag,omitempty"` + PublicKey string `msgpack:"publicKey,omitempty"` + Signature string `msgpack:"signature,omitempty"` + InMemory bool `msgpack:"inMemory,omitempty"` + CPU string `msgpack:"cpu,omitempty"` + GPU string `msgpack:"gpu,omitempty"` + RAM string `msgpack:"ram,omitempty"` + BatteryPercent *int `msgpack:"batteryPercent,omitempty"` + BatteryCharging *bool `msgpack:"batteryCharging,omitempty"` + IsAdmin bool `msgpack:"isAdmin,omitempty"` + Elevation string `msgpack:"elevation,omitempty"` + Permissions map[string]bool `msgpack:"permissions,omitempty"` + PublicIP string `msgpack:"publicIP,omitempty"` + LastCrashReason string `msgpack:"lastCrashReason,omitempty"` + LastCrashDetail string `msgpack:"lastCrashDetail,omitempty"` + ProtocolVersion int `msgpack:"protocolVersion,omitempty"` + CommandVersions map[string]CommandVersionRange `msgpack:"commandVersions,omitempty"` } type EnrollmentChallenge struct { @@ -56,17 +58,22 @@ type Pong struct { } type Command struct { - Type string `msgpack:"type"` - CommandType string `msgpack:"commandType"` - Payload interface{} `msgpack:"payload,omitempty"` - ID string `msgpack:"id,omitempty"` + Type string `msgpack:"type"` + CommandType CommandType `msgpack:"commandType"` + CommandVersion int `msgpack:"commandVersion,omitempty"` + Payload interface{} `msgpack:"payload,omitempty"` + ID string `msgpack:"id,omitempty"` } type CommandResult struct { - Type string `msgpack:"type"` - CommandID string `msgpack:"commandId,omitempty"` - OK bool `msgpack:"ok"` - Message string `msgpack:"message,omitempty"` + Type string `msgpack:"type"` + CommandID string `msgpack:"commandId,omitempty"` + CommandType CommandType `msgpack:"commandType,omitempty"` + CommandVersion int `msgpack:"commandVersion,omitempty"` + OK bool `msgpack:"ok"` + Message string `msgpack:"message,omitempty"` + ErrorCode string `msgpack:"errorCode,omitempty"` + SupportedVersions *CommandVersionRange `msgpack:"supportedVersions,omitempty"` } type DesktopEncoderProfile struct { diff --git a/Overlord-Client/cmd/agent/wire/protocol_test.go b/Overlord-Client/cmd/agent/wire/protocol_test.go index 302f6e1..33871f9 100644 --- a/Overlord-Client/cmd/agent/wire/protocol_test.go +++ b/Overlord-Client/cmd/agent/wire/protocol_test.go @@ -206,10 +206,11 @@ func TestPingPongMarshaling(t *testing.T) { func TestCommandMarshaling(t *testing.T) { cmd := Command{ - Type: "command", - CommandType: "test_command", - ID: "cmd-123", - Payload: map[string]interface{}{"key": "value"}, + Type: "command", + CommandType: "process_list", + CommandVersion: 1, + ID: "cmd-123", + Payload: map[string]interface{}{"key": "value"}, } data, err := msgpack.Marshal(cmd) @@ -228,6 +229,9 @@ func TestCommandMarshaling(t *testing.T) { if decoded.ID != cmd.ID { t.Errorf("ID mismatch: got %s, want %s", decoded.ID, cmd.ID) } + if decoded.CommandVersion != cmd.CommandVersion { + t.Errorf("CommandVersion mismatch: got %d, want %d", decoded.CommandVersion, cmd.CommandVersion) + } } func TestCommandResultMarshaling(t *testing.T) { diff --git a/Overlord-Client/cmd/agent/wire/wire_contract_generated.go b/Overlord-Client/cmd/agent/wire/wire_contract_generated.go new file mode 100644 index 0000000..74570c5 --- /dev/null +++ b/Overlord-Client/cmd/agent/wire/wire_contract_generated.go @@ -0,0 +1,612 @@ +// Code generated by scripts/generate-wire-protocol.ts; DO NOT EDIT. + +package wire + +const ( + WireSchemaVersion = 1 + WireProtocolVersion = 1 +) + +type MessageType string + +const ( + MessageBackstageBrowserCheckResult MessageType = "backstage_browser_check_result" + MessageBackstageBrowserLaunchStatus MessageType = "backstage_browser_launch_status" + MessageBackstageCloneProgress MessageType = "backstage_clone_progress" + MessageBackstageDxgiStatus MessageType = "backstage_dxgi_status" + MessageBackstageInstalledAppsResult MessageType = "backstage_installed_apps_result" + MessageBackstageLookupResult MessageType = "backstage_lookup_result" + MessageBackstageWindowListResult MessageType = "backstage_window_list_result" + MessageClientLogsResult MessageType = "client_logs_result" + MessageClipboardContent MessageType = "clipboard_content" + MessageCommand MessageType = "command" + MessageCommandAbort MessageType = "command_abort" + MessageCommandProgress MessageType = "command_progress" + MessageCommandResult MessageType = "command_result" + MessageConsoleOutput MessageType = "console_output" + MessageDesktopAudioUplink MessageType = "desktop_audio_uplink" + MessageDesktopCursor MessageType = "desktop_cursor" + MessageDesktopEncoderCapabilities MessageType = "desktop_encoder_capabilities" + MessageDesktopStreamStats MessageType = "desktop_stream_stats" + MessageDisconnectInfo MessageType = "disconnect_info" + MessageEnrollmentChallenge MessageType = "enrollment_challenge" + MessageEnrollmentStatus MessageType = "enrollment_status" + MessageFileDirsizeResult MessageType = "file_dirsize_result" + MessageFileDownload MessageType = "file_download" + MessageFileHashResult MessageType = "file_hash_result" + MessageFileIconResult MessageType = "file_icon_result" + MessageFileListResult MessageType = "file_list_result" + MessageFilePeekResult MessageType = "file_peek_result" + MessageFileReadResult MessageType = "file_read_result" + MessageFileSearchResult MessageType = "file_search_result" + MessageFileThumbResult MessageType = "file_thumb_result" + MessageFileUploadResult MessageType = "file_upload_result" + MessageFrame MessageType = "frame" + MessageFrameAck MessageType = "frame_ack" + MessageHello MessageType = "hello" + MessageHelloAck MessageType = "hello_ack" + MessageKeylogClearResult MessageType = "keylog_clear_result" + MessageKeylogDeleteResult MessageType = "keylog_delete_result" + MessageKeylogFileContent MessageType = "keylog_file_content" + MessageKeylogFileList MessageType = "keylog_file_list" + MessageKeylogPermissionResult MessageType = "keylog_permission_result" + MessageNotification MessageType = "notification" + MessageNotificationConfig MessageType = "notification_config" + MessagePing MessageType = "ping" + MessagePluginEvent MessageType = "plugin_event" + MessagePong MessageType = "pong" + MessageProcessIconResult MessageType = "process_icon_result" + MessageProcessListResult MessageType = "process_list_result" + MessageProxyClose MessageType = "proxy_close" + MessageProxyData MessageType = "proxy_data" + MessageScreenshotResult MessageType = "screenshot_result" + MessageScriptResult MessageType = "script_result" + MessageStatus MessageType = "status" + MessageVoiceUplink MessageType = "voice_uplink" + MessageWebcamDevices MessageType = "webcam_devices" + MessageWebrtcP2pAnswer MessageType = "webrtc_p2p_answer" + MessageWebrtcP2pIce MessageType = "webrtc_p2p_ice" +) + +type CommandType string + +const ( + CommandAgentUpdate CommandType = "agent_update" + CommandBackstageBrowserCheck CommandType = "backstage_browser_check" + CommandBackstageEnableCursor CommandType = "backstage_enable_cursor" + CommandBackstageEnableDxgi CommandType = "backstage_enable_dxgi" + CommandBackstageEnableKeyboard CommandType = "backstage_enable_keyboard" + CommandBackstageEnableMouse CommandType = "backstage_enable_mouse" + CommandBackstageEnableUia CommandType = "backstage_enable_uia" + CommandBackstageInstalledApps CommandType = "backstage_installed_apps" + CommandBackstageKeyDown CommandType = "backstage_key_down" + CommandBackstageKeyUp CommandType = "backstage_key_up" + CommandBackstageKillAll CommandType = "backstage_kill_all" + CommandBackstageLookup CommandType = "backstage_lookup" + CommandBackstageMouseDown CommandType = "backstage_mouse_down" + CommandBackstageMouseMove CommandType = "backstage_mouse_move" + CommandBackstageMouseUp CommandType = "backstage_mouse_up" + CommandBackstageMouseWheel CommandType = "backstage_mouse_wheel" + CommandBackstageRequestKeyframe CommandType = "backstage_request_keyframe" + CommandBackstageSelectDisplay CommandType = "backstage_select_display" + CommandBackstageSetFps CommandType = "backstage_set_fps" + CommandBackstageSetQuality CommandType = "backstage_set_quality" + CommandBackstageSetResolution CommandType = "backstage_set_resolution" + CommandBackstageStart CommandType = "backstage_start" + CommandBackstageStartBrowserInjected CommandType = "backstage_start_browser_injected" + CommandBackstageStartChromeInjected CommandType = "backstage_start_chrome_injected" + CommandBackstageStartProcess CommandType = "backstage_start_process" + CommandBackstageStartProcessInjected CommandType = "backstage_start_process_injected" + CommandBackstageStop CommandType = "backstage_stop" + CommandBackstageWindowList CommandType = "backstage_window_list" + CommandClientLogsRequest CommandType = "client_logs_request" + CommandClipboardSet CommandType = "clipboard_set" + CommandClipboardSyncStart CommandType = "clipboard_sync_start" + CommandClipboardSyncStop CommandType = "clipboard_sync_stop" + CommandConsoleInput CommandType = "console_input" + CommandConsoleResize CommandType = "console_resize" + CommandConsoleStart CommandType = "console_start" + CommandConsoleStop CommandType = "console_stop" + CommandDarwinRequestPermissions CommandType = "darwin_request_permissions" + CommandDesktopAudioStart CommandType = "desktop_audio_start" + CommandDesktopAudioStop CommandType = "desktop_audio_stop" + CommandDesktopEnableCursor CommandType = "desktop_enable_cursor" + CommandDesktopEnableKeyboard CommandType = "desktop_enable_keyboard" + CommandDesktopEnableMouse CommandType = "desktop_enable_mouse" + CommandDesktopEncoderCapabilities CommandType = "desktop_encoder_capabilities" + CommandDesktopKeyDown CommandType = "desktop_key_down" + CommandDesktopKeyUp CommandType = "desktop_key_up" + CommandDesktopMouseDown CommandType = "desktop_mouse_down" + CommandDesktopMouseMove CommandType = "desktop_mouse_move" + CommandDesktopMouseUp CommandType = "desktop_mouse_up" + CommandDesktopMouseWheel CommandType = "desktop_mouse_wheel" + CommandDesktopRequestKeyframe CommandType = "desktop_request_keyframe" + CommandDesktopSelectDisplay CommandType = "desktop_select_display" + CommandDesktopSetBitrate CommandType = "desktop_set_bitrate" + CommandDesktopSetDuplication CommandType = "desktop_set_duplication" + CommandDesktopSetFps CommandType = "desktop_set_fps" + CommandDesktopSetProfile CommandType = "desktop_set_profile" + CommandDesktopSetQuality CommandType = "desktop_set_quality" + CommandDesktopSetResolution CommandType = "desktop_set_resolution" + CommandDesktopStart CommandType = "desktop_start" + CommandDesktopStop CommandType = "desktop_stop" + CommandDesktopText CommandType = "desktop_text" + CommandDisconnect CommandType = "disconnect" + CommandElevate CommandType = "elevate" + CommandFileChmod CommandType = "file_chmod" + CommandFileCopy CommandType = "file_copy" + CommandFileDelete CommandType = "file_delete" + CommandFileDirsize CommandType = "file_dirsize" + CommandFileDownload CommandType = "file_download" + CommandFileExecute CommandType = "file_execute" + CommandFileHash CommandType = "file_hash" + CommandFileIcon CommandType = "file_icon" + CommandFileList CommandType = "file_list" + CommandFileMkdir CommandType = "file_mkdir" + CommandFileMove CommandType = "file_move" + CommandFilePeek CommandType = "file_peek" + CommandFileRead CommandType = "file_read" + CommandFileRequestAccess CommandType = "file_request_access" + CommandFileSearch CommandType = "file_search" + CommandFileThumb CommandType = "file_thumb" + CommandFileUpload CommandType = "file_upload" + CommandFileUploadHttp CommandType = "file_upload_http" + CommandFileWrite CommandType = "file_write" + CommandFileZip CommandType = "file_zip" + CommandKeylogClearAll CommandType = "keylog_clear_all" + CommandKeylogDelete CommandType = "keylog_delete" + CommandKeylogList CommandType = "keylog_list" + CommandKeylogRequestPermission CommandType = "keylog_request_permission" + CommandKeylogRetrieve CommandType = "keylog_retrieve" + CommandPluginLoad CommandType = "plugin_load" + CommandPluginLoadHttp CommandType = "plugin_load_http" + CommandPluginUnload CommandType = "plugin_unload" + CommandPrivacyStart CommandType = "privacy_start" + CommandPrivacyStatus CommandType = "privacy_status" + CommandPrivacyStop CommandType = "privacy_stop" + CommandProcessIcon CommandType = "process_icon" + CommandProcessKill CommandType = "process_kill" + CommandProcessList CommandType = "process_list" + CommandProcessResume CommandType = "process_resume" + CommandProcessSuspend CommandType = "process_suspend" + CommandProxyClose CommandType = "proxy_close" + CommandProxyConnect CommandType = "proxy_connect" + CommandProxyData CommandType = "proxy_data" + CommandReconnect CommandType = "reconnect" + CommandScreenshot CommandType = "screenshot" + CommandScriptExec CommandType = "script_exec" + CommandSilentExec CommandType = "silent_exec" + CommandUninstall CommandType = "uninstall" + CommandVirtualEnableCursor CommandType = "virtual_enable_cursor" + CommandVirtualEnableKeyboard CommandType = "virtual_enable_keyboard" + CommandVirtualEnableMouse CommandType = "virtual_enable_mouse" + CommandVirtualKeyDown CommandType = "virtual_key_down" + CommandVirtualKeyUp CommandType = "virtual_key_up" + CommandVirtualKillAll CommandType = "virtual_kill_all" + CommandVirtualMouseDown CommandType = "virtual_mouse_down" + CommandVirtualMouseMove CommandType = "virtual_mouse_move" + CommandVirtualMouseUp CommandType = "virtual_mouse_up" + CommandVirtualMouseWheel CommandType = "virtual_mouse_wheel" + CommandVirtualRequestKeyframe CommandType = "virtual_request_keyframe" + CommandVirtualSetFps CommandType = "virtual_set_fps" + CommandVirtualSetQuality CommandType = "virtual_set_quality" + CommandVirtualStart CommandType = "virtual_start" + CommandVirtualStartProcess CommandType = "virtual_start_process" + CommandVirtualStop CommandType = "virtual_stop" + CommandVirtualWindowList CommandType = "virtual_window_list" + CommandVoiceCapabilities CommandType = "voice_capabilities" + CommandVoiceDownlink CommandType = "voice_downlink" + CommandVoiceSessionStart CommandType = "voice_session_start" + CommandVoiceSessionStop CommandType = "voice_session_stop" + CommandWebcamList CommandType = "webcam_list" + CommandWebcamSelect CommandType = "webcam_select" + CommandWebcamSetFps CommandType = "webcam_set_fps" + CommandWebcamSetQuality CommandType = "webcam_set_quality" + CommandWebcamStart CommandType = "webcam_start" + CommandWebcamStop CommandType = "webcam_stop" + CommandWebrtcP2pIce CommandType = "webrtc_p2p_ice" + CommandWebrtcP2pOffer CommandType = "webrtc_p2p_offer" + CommandWebrtcP2pStop CommandType = "webrtc_p2p_stop" + CommandWebrtcPublish CommandType = "webrtc_publish" + CommandWebrtcStop CommandType = "webrtc_stop" + CommandWinreInstall CommandType = "winre_install" + CommandWinreProbe CommandType = "winre_probe" + CommandWinreUninstall CommandType = "winre_uninstall" +) + +var AgentToServerMessageTypes = [...]MessageType{ + MessageBackstageBrowserCheckResult, + MessageBackstageBrowserLaunchStatus, + MessageBackstageCloneProgress, + MessageBackstageDxgiStatus, + MessageBackstageInstalledAppsResult, + MessageBackstageLookupResult, + MessageBackstageWindowListResult, + MessageClientLogsResult, + MessageClipboardContent, + MessageCommandProgress, + MessageCommandResult, + MessageConsoleOutput, + MessageDesktopAudioUplink, + MessageDesktopCursor, + MessageDesktopEncoderCapabilities, + MessageDesktopStreamStats, + MessageDisconnectInfo, + MessageFileDirsizeResult, + MessageFileDownload, + MessageFileHashResult, + MessageFileIconResult, + MessageFileListResult, + MessageFilePeekResult, + MessageFileReadResult, + MessageFileSearchResult, + MessageFileThumbResult, + MessageFileUploadResult, + MessageFrame, + MessageHello, + MessageKeylogClearResult, + MessageKeylogDeleteResult, + MessageKeylogFileContent, + MessageKeylogFileList, + MessageKeylogPermissionResult, + MessageNotification, + MessagePing, + MessagePluginEvent, + MessagePong, + MessageProcessIconResult, + MessageProcessListResult, + MessageProxyClose, + MessageProxyData, + MessageScreenshotResult, + MessageScriptResult, + MessageStatus, + MessageVoiceUplink, + MessageWebcamDevices, + MessageWebrtcP2pAnswer, + MessageWebrtcP2pIce, +} + +var ServerToAgentMessageTypes = [...]MessageType{ + MessageCommand, + MessageCommandAbort, + MessageEnrollmentChallenge, + MessageEnrollmentStatus, + MessageFrameAck, + MessageHelloAck, + MessageNotificationConfig, + MessagePing, + MessagePluginEvent, + MessagePong, +} + +var CommandTypes = [...]CommandType{ + CommandAgentUpdate, + CommandBackstageBrowserCheck, + CommandBackstageEnableCursor, + CommandBackstageEnableDxgi, + CommandBackstageEnableKeyboard, + CommandBackstageEnableMouse, + CommandBackstageEnableUia, + CommandBackstageInstalledApps, + CommandBackstageKeyDown, + CommandBackstageKeyUp, + CommandBackstageKillAll, + CommandBackstageLookup, + CommandBackstageMouseDown, + CommandBackstageMouseMove, + CommandBackstageMouseUp, + CommandBackstageMouseWheel, + CommandBackstageRequestKeyframe, + CommandBackstageSelectDisplay, + CommandBackstageSetFps, + CommandBackstageSetQuality, + CommandBackstageSetResolution, + CommandBackstageStart, + CommandBackstageStartBrowserInjected, + CommandBackstageStartChromeInjected, + CommandBackstageStartProcess, + CommandBackstageStartProcessInjected, + CommandBackstageStop, + CommandBackstageWindowList, + CommandClientLogsRequest, + CommandClipboardSet, + CommandClipboardSyncStart, + CommandClipboardSyncStop, + CommandConsoleInput, + CommandConsoleResize, + CommandConsoleStart, + CommandConsoleStop, + CommandDarwinRequestPermissions, + CommandDesktopAudioStart, + CommandDesktopAudioStop, + CommandDesktopEnableCursor, + CommandDesktopEnableKeyboard, + CommandDesktopEnableMouse, + CommandDesktopEncoderCapabilities, + CommandDesktopKeyDown, + CommandDesktopKeyUp, + CommandDesktopMouseDown, + CommandDesktopMouseMove, + CommandDesktopMouseUp, + CommandDesktopMouseWheel, + CommandDesktopRequestKeyframe, + CommandDesktopSelectDisplay, + CommandDesktopSetBitrate, + CommandDesktopSetDuplication, + CommandDesktopSetFps, + CommandDesktopSetProfile, + CommandDesktopSetQuality, + CommandDesktopSetResolution, + CommandDesktopStart, + CommandDesktopStop, + CommandDesktopText, + CommandDisconnect, + CommandElevate, + CommandFileChmod, + CommandFileCopy, + CommandFileDelete, + CommandFileDirsize, + CommandFileDownload, + CommandFileExecute, + CommandFileHash, + CommandFileIcon, + CommandFileList, + CommandFileMkdir, + CommandFileMove, + CommandFilePeek, + CommandFileRead, + CommandFileRequestAccess, + CommandFileSearch, + CommandFileThumb, + CommandFileUpload, + CommandFileUploadHttp, + CommandFileWrite, + CommandFileZip, + CommandKeylogClearAll, + CommandKeylogDelete, + CommandKeylogList, + CommandKeylogRequestPermission, + CommandKeylogRetrieve, + CommandPluginLoad, + CommandPluginLoadHttp, + CommandPluginUnload, + CommandPrivacyStart, + CommandPrivacyStatus, + CommandPrivacyStop, + CommandProcessIcon, + CommandProcessKill, + CommandProcessList, + CommandProcessResume, + CommandProcessSuspend, + CommandProxyClose, + CommandProxyConnect, + CommandProxyData, + CommandReconnect, + CommandScreenshot, + CommandScriptExec, + CommandSilentExec, + CommandUninstall, + CommandVirtualEnableCursor, + CommandVirtualEnableKeyboard, + CommandVirtualEnableMouse, + CommandVirtualKeyDown, + CommandVirtualKeyUp, + CommandVirtualKillAll, + CommandVirtualMouseDown, + CommandVirtualMouseMove, + CommandVirtualMouseUp, + CommandVirtualMouseWheel, + CommandVirtualRequestKeyframe, + CommandVirtualSetFps, + CommandVirtualSetQuality, + CommandVirtualStart, + CommandVirtualStartProcess, + CommandVirtualStop, + CommandVirtualWindowList, + CommandVoiceCapabilities, + CommandVoiceDownlink, + CommandVoiceSessionStart, + CommandVoiceSessionStop, + CommandWebcamList, + CommandWebcamSelect, + CommandWebcamSetFps, + CommandWebcamSetQuality, + CommandWebcamStart, + CommandWebcamStop, + CommandWebrtcP2pIce, + CommandWebrtcP2pOffer, + CommandWebrtcP2pStop, + CommandWebrtcPublish, + CommandWebrtcStop, + CommandWinreInstall, + CommandWinreProbe, + CommandWinreUninstall, +} + +type CommandVersionRange struct { + Min int `msgpack:"min" json:"min"` + Max int `msgpack:"max" json:"max"` +} + +var CommandVersionSupport = map[CommandType]CommandVersionRange{ + CommandAgentUpdate: {Min: 1, Max: 1}, + CommandBackstageBrowserCheck: {Min: 1, Max: 1}, + CommandBackstageEnableCursor: {Min: 1, Max: 1}, + CommandBackstageEnableDxgi: {Min: 1, Max: 1}, + CommandBackstageEnableKeyboard: {Min: 1, Max: 1}, + CommandBackstageEnableMouse: {Min: 1, Max: 1}, + CommandBackstageEnableUia: {Min: 1, Max: 1}, + CommandBackstageInstalledApps: {Min: 1, Max: 1}, + CommandBackstageKeyDown: {Min: 1, Max: 1}, + CommandBackstageKeyUp: {Min: 1, Max: 1}, + CommandBackstageKillAll: {Min: 1, Max: 1}, + CommandBackstageLookup: {Min: 1, Max: 1}, + CommandBackstageMouseDown: {Min: 1, Max: 1}, + CommandBackstageMouseMove: {Min: 1, Max: 1}, + CommandBackstageMouseUp: {Min: 1, Max: 1}, + CommandBackstageMouseWheel: {Min: 1, Max: 1}, + CommandBackstageRequestKeyframe: {Min: 1, Max: 1}, + CommandBackstageSelectDisplay: {Min: 1, Max: 1}, + CommandBackstageSetFps: {Min: 1, Max: 1}, + CommandBackstageSetQuality: {Min: 1, Max: 1}, + CommandBackstageSetResolution: {Min: 1, Max: 1}, + CommandBackstageStart: {Min: 1, Max: 1}, + CommandBackstageStartBrowserInjected: {Min: 1, Max: 1}, + CommandBackstageStartChromeInjected: {Min: 1, Max: 1}, + CommandBackstageStartProcess: {Min: 1, Max: 1}, + CommandBackstageStartProcessInjected: {Min: 1, Max: 1}, + CommandBackstageStop: {Min: 1, Max: 1}, + CommandBackstageWindowList: {Min: 1, Max: 1}, + CommandClientLogsRequest: {Min: 1, Max: 1}, + CommandClipboardSet: {Min: 1, Max: 1}, + CommandClipboardSyncStart: {Min: 1, Max: 1}, + CommandClipboardSyncStop: {Min: 1, Max: 1}, + CommandConsoleInput: {Min: 1, Max: 1}, + CommandConsoleResize: {Min: 1, Max: 1}, + CommandConsoleStart: {Min: 1, Max: 1}, + CommandConsoleStop: {Min: 1, Max: 1}, + CommandDarwinRequestPermissions: {Min: 1, Max: 1}, + CommandDesktopAudioStart: {Min: 1, Max: 1}, + CommandDesktopAudioStop: {Min: 1, Max: 1}, + CommandDesktopEnableCursor: {Min: 1, Max: 1}, + CommandDesktopEnableKeyboard: {Min: 1, Max: 1}, + CommandDesktopEnableMouse: {Min: 1, Max: 1}, + CommandDesktopEncoderCapabilities: {Min: 1, Max: 1}, + CommandDesktopKeyDown: {Min: 1, Max: 1}, + CommandDesktopKeyUp: {Min: 1, Max: 1}, + CommandDesktopMouseDown: {Min: 1, Max: 1}, + CommandDesktopMouseMove: {Min: 1, Max: 1}, + CommandDesktopMouseUp: {Min: 1, Max: 1}, + CommandDesktopMouseWheel: {Min: 1, Max: 1}, + CommandDesktopRequestKeyframe: {Min: 1, Max: 1}, + CommandDesktopSelectDisplay: {Min: 1, Max: 1}, + CommandDesktopSetBitrate: {Min: 1, Max: 1}, + CommandDesktopSetDuplication: {Min: 1, Max: 1}, + CommandDesktopSetFps: {Min: 1, Max: 1}, + CommandDesktopSetProfile: {Min: 1, Max: 1}, + CommandDesktopSetQuality: {Min: 1, Max: 1}, + CommandDesktopSetResolution: {Min: 1, Max: 1}, + CommandDesktopStart: {Min: 1, Max: 1}, + CommandDesktopStop: {Min: 1, Max: 1}, + CommandDesktopText: {Min: 1, Max: 1}, + CommandDisconnect: {Min: 1, Max: 1}, + CommandElevate: {Min: 1, Max: 1}, + CommandFileChmod: {Min: 1, Max: 1}, + CommandFileCopy: {Min: 1, Max: 1}, + CommandFileDelete: {Min: 1, Max: 1}, + CommandFileDirsize: {Min: 1, Max: 1}, + CommandFileDownload: {Min: 1, Max: 1}, + CommandFileExecute: {Min: 1, Max: 1}, + CommandFileHash: {Min: 1, Max: 1}, + CommandFileIcon: {Min: 1, Max: 1}, + CommandFileList: {Min: 1, Max: 1}, + CommandFileMkdir: {Min: 1, Max: 1}, + CommandFileMove: {Min: 1, Max: 1}, + CommandFilePeek: {Min: 1, Max: 1}, + CommandFileRead: {Min: 1, Max: 1}, + CommandFileRequestAccess: {Min: 1, Max: 1}, + CommandFileSearch: {Min: 1, Max: 1}, + CommandFileThumb: {Min: 1, Max: 1}, + CommandFileUpload: {Min: 1, Max: 1}, + CommandFileUploadHttp: {Min: 1, Max: 1}, + CommandFileWrite: {Min: 1, Max: 1}, + CommandFileZip: {Min: 1, Max: 1}, + CommandKeylogClearAll: {Min: 1, Max: 1}, + CommandKeylogDelete: {Min: 1, Max: 1}, + CommandKeylogList: {Min: 1, Max: 1}, + CommandKeylogRequestPermission: {Min: 1, Max: 1}, + CommandKeylogRetrieve: {Min: 1, Max: 1}, + CommandPluginLoad: {Min: 1, Max: 1}, + CommandPluginLoadHttp: {Min: 1, Max: 1}, + CommandPluginUnload: {Min: 1, Max: 1}, + CommandPrivacyStart: {Min: 1, Max: 1}, + CommandPrivacyStatus: {Min: 1, Max: 1}, + CommandPrivacyStop: {Min: 1, Max: 1}, + CommandProcessIcon: {Min: 1, Max: 1}, + CommandProcessKill: {Min: 1, Max: 1}, + CommandProcessList: {Min: 1, Max: 1}, + CommandProcessResume: {Min: 1, Max: 1}, + CommandProcessSuspend: {Min: 1, Max: 1}, + CommandProxyClose: {Min: 1, Max: 1}, + CommandProxyConnect: {Min: 1, Max: 1}, + CommandProxyData: {Min: 1, Max: 1}, + CommandReconnect: {Min: 1, Max: 1}, + CommandScreenshot: {Min: 1, Max: 1}, + CommandScriptExec: {Min: 1, Max: 1}, + CommandSilentExec: {Min: 1, Max: 1}, + CommandUninstall: {Min: 1, Max: 1}, + CommandVirtualEnableCursor: {Min: 1, Max: 1}, + CommandVirtualEnableKeyboard: {Min: 1, Max: 1}, + CommandVirtualEnableMouse: {Min: 1, Max: 1}, + CommandVirtualKeyDown: {Min: 1, Max: 1}, + CommandVirtualKeyUp: {Min: 1, Max: 1}, + CommandVirtualKillAll: {Min: 1, Max: 1}, + CommandVirtualMouseDown: {Min: 1, Max: 1}, + CommandVirtualMouseMove: {Min: 1, Max: 1}, + CommandVirtualMouseUp: {Min: 1, Max: 1}, + CommandVirtualMouseWheel: {Min: 1, Max: 1}, + CommandVirtualRequestKeyframe: {Min: 1, Max: 1}, + CommandVirtualSetFps: {Min: 1, Max: 1}, + CommandVirtualSetQuality: {Min: 1, Max: 1}, + CommandVirtualStart: {Min: 1, Max: 1}, + CommandVirtualStartProcess: {Min: 1, Max: 1}, + CommandVirtualStop: {Min: 1, Max: 1}, + CommandVirtualWindowList: {Min: 1, Max: 1}, + CommandVoiceCapabilities: {Min: 1, Max: 1}, + CommandVoiceDownlink: {Min: 1, Max: 1}, + CommandVoiceSessionStart: {Min: 1, Max: 1}, + CommandVoiceSessionStop: {Min: 1, Max: 1}, + CommandWebcamList: {Min: 1, Max: 1}, + CommandWebcamSelect: {Min: 1, Max: 1}, + CommandWebcamSetFps: {Min: 1, Max: 1}, + CommandWebcamSetQuality: {Min: 1, Max: 1}, + CommandWebcamStart: {Min: 1, Max: 1}, + CommandWebcamStop: {Min: 1, Max: 1}, + CommandWebrtcP2pIce: {Min: 1, Max: 1}, + CommandWebrtcP2pOffer: {Min: 1, Max: 1}, + CommandWebrtcP2pStop: {Min: 1, Max: 1}, + CommandWebrtcPublish: {Min: 1, Max: 1}, + CommandWebrtcStop: {Min: 1, Max: 1}, + CommandWinreInstall: {Min: 1, Max: 1}, + CommandWinreProbe: {Min: 1, Max: 1}, + CommandWinreUninstall: {Min: 1, Max: 1}, +} + +var commandTypeSet = func() map[CommandType]struct{} { + values := make(map[CommandType]struct{}, len(CommandTypes)) + for _, value := range CommandTypes { + values[value] = struct{}{} + } + return values +}() + +var serverToAgentMessageTypeSet = func() map[MessageType]struct{} { + values := make(map[MessageType]struct{}, len(ServerToAgentMessageTypes)) + for _, value := range ServerToAgentMessageTypes { + values[value] = struct{}{} + } + return values +}() + +func IsCommandType(value string) bool { + _, ok := commandTypeSet[CommandType(value)] + return ok +} + +func IsSupportedCommandVersion(command string, version int) bool { + rangeValue, ok := CommandVersionSupport[CommandType(command)] + return ok && version >= rangeValue.Min && version <= rangeValue.Max +} + +func SupportedCommandVersionRanges() map[string]CommandVersionRange { + values := make(map[string]CommandVersionRange, len(CommandVersionSupport)) + for command, rangeValue := range CommandVersionSupport { + values[string(command)] = rangeValue + } + return values +} + +func IsServerToAgentMessageType(value string) bool { + _, ok := serverToAgentMessageTypeSet[MessageType(value)] + return ok +} diff --git a/Overlord-Client/cmd/agent/wire/wire_contract_test.go b/Overlord-Client/cmd/agent/wire/wire_contract_test.go new file mode 100644 index 0000000..7da6587 --- /dev/null +++ b/Overlord-Client/cmd/agent/wire/wire_contract_test.go @@ -0,0 +1,45 @@ +package wire + +import "testing" + +func TestGeneratedWireContract(t *testing.T) { + if WireProtocolVersion != 1 { + t.Fatalf("unexpected wire protocol version: %d", WireProtocolVersion) + } + if len(CommandTypes) != 141 { + t.Fatalf("unexpected command count: %d", len(CommandTypes)) + } + if len(CommandVersionSupport) != len(CommandTypes) { + t.Fatalf("command version catalog has %d entries, want %d", len(CommandVersionSupport), len(CommandTypes)) + } + for _, command := range CommandTypes { + versionRange, ok := CommandVersionSupport[command] + if !ok { + t.Fatalf("missing version range for %s", command) + } + if versionRange.Min != 1 || versionRange.Max != 1 { + t.Fatalf("unexpected version range for %s: %+v", command, versionRange) + } + if !IsSupportedCommandVersion(string(command), 1) { + t.Fatalf("%s must support v1", command) + } + if IsSupportedCommandVersion(string(command), 2) { + t.Fatalf("%s must not support undeclared v2", command) + } + } + if !IsCommandType("desktop_start") { + t.Fatal("desktop_start must be a known command") + } + if !IsCommandType("virtual_window_list") { + t.Fatal("virtual_window_list must be a known command") + } + if IsCommandType("not_a_command") { + t.Fatal("unknown command was accepted") + } + if !IsServerToAgentMessageType("hello_ack") { + t.Fatal("hello_ack must be a known server message") + } + if IsServerToAgentMessageType("console_output") { + t.Fatal("agent-only message was accepted as a server message") + } +} diff --git a/Overlord-Server/package.json b/Overlord-Server/package.json index 34d1d13..2cb9f5f 100644 --- a/Overlord-Server/package.json +++ b/Overlord-Server/package.json @@ -1,6 +1,6 @@ { "name": "overlord-server", - "version": "2.6.1", + "version": "3.0.0", "private": true, "type": "module", "scripts": { @@ -20,6 +20,8 @@ "watch:css": "tailwindcss -i ./src/styles.css -o ./public/assets/tailwind.css --watch=always", "minify": "bun run scripts/minify-public.ts", "bench:client-db": "bun run scripts/client-db-stress.ts", + "protocol:generate": "bun ../scripts/generate-wire-protocol.ts", + "protocol:check": "bun ../scripts/generate-wire-protocol.ts --check", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/Overlord-Server/src/command-compatibility.test.ts b/Overlord-Server/src/command-compatibility.test.ts new file mode 100644 index 0000000..7a0fe7b --- /dev/null +++ b/Overlord-Server/src/command-compatibility.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, test } from "bun:test"; +import { + getCommandCompatibility, + getNegotiatedCommandVersions, + requireCommandVersion, + sanitizeCommandVersionRanges, + versionCommandForClient, +} from "./command-compatibility"; + +describe("command version negotiation", () => { + test("sanitizes advertised ranges and ignores unknown commands", () => { + expect(sanitizeCommandVersionRanges({ + process_list: { min: 1, max: 3 }, + desktop_start: { min: 3, max: 2 }, + not_a_command: { min: 1, max: 1 }, + })).toEqual({ + process_list: { min: 1, max: 3 }, + }); + }); + + test("treats pre-3.0 agents as v1 for backward compatibility", () => { + expect(getCommandCompatibility({}, "process_list")).toEqual({ + supported: true, + version: 1, + server: { min: 1, max: 1 }, + agent: { min: 1, max: 1 }, + legacyAssumption: true, + }); + }); + + test("selects the highest shared version", () => { + const client = { + commandVersions: { + process_list: { min: 1, max: 3 }, + }, + }; + expect(requireCommandVersion(client, "process_list")).toBe(1); + expect(getNegotiatedCommandVersions(client)).toEqual({ process_list: 1 }); + expect(versionCommandForClient(client, { + type: "command", + commandType: "process_list", + id: "command-123", + })).toMatchObject({ + commandType: "process_list", + commandVersion: 1, + }); + }); + + test("strictly reports missing commands and non-overlapping versions", () => { + expect(getCommandCompatibility( + { commandVersions: {} }, + "process_list", + )).toMatchObject({ + supported: false, + reason: "agent_missing_command", + }); + + const incompatible = { + commandVersions: { + process_list: { min: 2, max: 3 }, + }, + }; + expect(getCommandCompatibility(incompatible, "process_list")).toMatchObject({ + supported: false, + reason: "no_common_version", + }); + expect(() => requireCommandVersion(incompatible, "process_list")).toThrow( + "no shared version", + ); + }); +}); diff --git a/Overlord-Server/src/command-compatibility.ts b/Overlord-Server/src/command-compatibility.ts new file mode 100644 index 0000000..b5b87c4 --- /dev/null +++ b/Overlord-Server/src/command-compatibility.ts @@ -0,0 +1,120 @@ +import { + COMMAND_TYPES, + COMMAND_VERSION_SUPPORT, + isCommandType, + type CommandType, + type CommandVersionRange, +} from "./generated/wire-contract"; +import type { ClientInfo } from "./types"; +import type { Command } from "./protocol"; + +export type CommandCompatibility = { + supported: boolean; + version?: number; + server: CommandVersionRange; + agent?: CommandVersionRange; + reason?: "agent_missing_command" | "no_common_version"; + legacyAssumption?: boolean; +}; + +function cleanPositiveInt(value: unknown): number | undefined { + if (typeof value !== "number" || !Number.isFinite(value)) return undefined; + const result = Math.floor(value); + return result > 0 && result <= 65_535 ? result : undefined; +} + +export function sanitizeCommandVersionRanges( + value: unknown, +): Partial> | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const result: Partial> = {}; + let count = 0; + for (const [name, rawRange] of Object.entries(value as Record)) { + if (count >= COMMAND_TYPES.length || !isCommandType(name)) continue; + if (!rawRange || typeof rawRange !== "object" || Array.isArray(rawRange)) continue; + const min = cleanPositiveInt((rawRange as any).min); + const max = cleanPositiveInt((rawRange as any).max); + if (min === undefined || max === undefined || min > max) continue; + result[name] = { min, max }; + count += 1; + } + return result; +} + +export function getCommandCompatibility( + client: Pick, + command: CommandType, +): CommandCompatibility { + const server = COMMAND_VERSION_SUPPORT[command]; + const advertised = client.commandVersions; + + // Pre-3.0 agents did not advertise a catalog. They remain v1 best-effort. + if (!advertised) { + const supported = server.min <= 1 && server.max >= 1; + return supported + ? { supported: true, version: 1, server, agent: { min: 1, max: 1 }, legacyAssumption: true } + : { supported: false, server, agent: { min: 1, max: 1 }, reason: "no_common_version", legacyAssumption: true }; + } + + const agent = advertised[command]; + if (!agent) { + return { supported: false, server, reason: "agent_missing_command" }; + } + + const min = Math.max(server.min, agent.min); + const max = Math.min(server.max, agent.max); + if (min > max) { + return { supported: false, server, agent, reason: "no_common_version" }; + } + return { supported: true, version: max, server, agent }; +} + +export function getCommandCompatibilityCatalog( + client: Pick, +): Record { + return Object.fromEntries( + COMMAND_TYPES.map((command) => [command, getCommandCompatibility(client, command)]), + ) as Record; +} + +export function getNegotiatedCommandVersions( + client: Pick, +): Partial> { + const result: Partial> = {}; + for (const command of COMMAND_TYPES) { + const compatibility = getCommandCompatibility(client, command); + if (compatibility.supported && compatibility.version !== undefined) { + result[command] = compatibility.version; + } + } + return result; +} + +export function requireCommandVersion( + client: Pick, + command: CommandType, +): number { + const compatibility = getCommandCompatibility(client, command); + if (!compatibility.supported || compatibility.version === undefined) { + const reason = compatibility.reason === "agent_missing_command" + ? "agent does not advertise this command" + : `no shared version (server ${compatibility.server.min}-${compatibility.server.max}, agent ${compatibility.agent?.min ?? "none"}-${compatibility.agent?.max ?? "none"})`; + throw new Error(`Command ${command} is unsupported: ${reason}`); + } + return compatibility.version; +} + +/** + * Use this at target-aware send boundaries. It fails before transmission when + * the connected agent does not support the command and pins the negotiated + * version into the envelope. + */ +export function versionCommandForClient( + client: Pick, + command: Command, +): Command { + return { + ...command, + commandVersion: requireCommandVersion(client, command.commandType), + }; +} diff --git a/Overlord-Server/src/generated/wire-contract.ts b/Overlord-Server/src/generated/wire-contract.ts new file mode 100644 index 0000000..129f7be --- /dev/null +++ b/Overlord-Server/src/generated/wire-contract.ts @@ -0,0 +1,879 @@ +// Code generated by scripts/generate-wire-protocol.ts; DO NOT EDIT. + +export const WIRE_SCHEMA_VERSION = 1 as const; +export const WIRE_PROTOCOL_VERSION = 1 as const; + +export const WIRE_MESSAGE_TYPES = [ + "backstage_browser_check_result", + "backstage_browser_launch_status", + "backstage_clone_progress", + "backstage_dxgi_status", + "backstage_installed_apps_result", + "backstage_lookup_result", + "backstage_window_list_result", + "client_logs_result", + "clipboard_content", + "command", + "command_abort", + "command_progress", + "command_result", + "console_output", + "desktop_audio_uplink", + "desktop_cursor", + "desktop_encoder_capabilities", + "desktop_stream_stats", + "disconnect_info", + "enrollment_challenge", + "enrollment_status", + "file_dirsize_result", + "file_download", + "file_hash_result", + "file_icon_result", + "file_list_result", + "file_peek_result", + "file_read_result", + "file_search_result", + "file_thumb_result", + "file_upload_result", + "frame", + "frame_ack", + "hello", + "hello_ack", + "keylog_clear_result", + "keylog_delete_result", + "keylog_file_content", + "keylog_file_list", + "keylog_permission_result", + "notification", + "notification_config", + "ping", + "plugin_event", + "pong", + "process_icon_result", + "process_list_result", + "proxy_close", + "proxy_data", + "screenshot_result", + "script_result", + "status", + "voice_uplink", + "webcam_devices", + "webrtc_p2p_answer", + "webrtc_p2p_ice" +] as const; + +export type MessageKind = (typeof WIRE_MESSAGE_TYPES)[number]; + +export const SERVER_TO_AGENT_MESSAGE_TYPES = [ + "command", + "command_abort", + "enrollment_challenge", + "enrollment_status", + "frame_ack", + "hello_ack", + "notification_config", + "ping", + "plugin_event", + "pong" +] as const; + +export type ServerToAgentMessageType = (typeof SERVER_TO_AGENT_MESSAGE_TYPES)[number]; + +export const AGENT_TO_SERVER_MESSAGE_TYPES = [ + "backstage_browser_check_result", + "backstage_browser_launch_status", + "backstage_clone_progress", + "backstage_dxgi_status", + "backstage_installed_apps_result", + "backstage_lookup_result", + "backstage_window_list_result", + "client_logs_result", + "clipboard_content", + "command_progress", + "command_result", + "console_output", + "desktop_audio_uplink", + "desktop_cursor", + "desktop_encoder_capabilities", + "desktop_stream_stats", + "disconnect_info", + "file_dirsize_result", + "file_download", + "file_hash_result", + "file_icon_result", + "file_list_result", + "file_peek_result", + "file_read_result", + "file_search_result", + "file_thumb_result", + "file_upload_result", + "frame", + "hello", + "keylog_clear_result", + "keylog_delete_result", + "keylog_file_content", + "keylog_file_list", + "keylog_permission_result", + "notification", + "ping", + "plugin_event", + "pong", + "process_icon_result", + "process_list_result", + "proxy_close", + "proxy_data", + "screenshot_result", + "script_result", + "status", + "voice_uplink", + "webcam_devices", + "webrtc_p2p_answer", + "webrtc_p2p_ice" +] as const; + +export type AgentToServerMessageType = (typeof AGENT_TO_SERVER_MESSAGE_TYPES)[number]; + +export const COMMAND_TYPES = [ + "agent_update", + "backstage_browser_check", + "backstage_enable_cursor", + "backstage_enable_dxgi", + "backstage_enable_keyboard", + "backstage_enable_mouse", + "backstage_enable_uia", + "backstage_installed_apps", + "backstage_key_down", + "backstage_key_up", + "backstage_kill_all", + "backstage_lookup", + "backstage_mouse_down", + "backstage_mouse_move", + "backstage_mouse_up", + "backstage_mouse_wheel", + "backstage_request_keyframe", + "backstage_select_display", + "backstage_set_fps", + "backstage_set_quality", + "backstage_set_resolution", + "backstage_start", + "backstage_start_browser_injected", + "backstage_start_chrome_injected", + "backstage_start_process", + "backstage_start_process_injected", + "backstage_stop", + "backstage_window_list", + "client_logs_request", + "clipboard_set", + "clipboard_sync_start", + "clipboard_sync_stop", + "console_input", + "console_resize", + "console_start", + "console_stop", + "darwin_request_permissions", + "desktop_audio_start", + "desktop_audio_stop", + "desktop_enable_cursor", + "desktop_enable_keyboard", + "desktop_enable_mouse", + "desktop_encoder_capabilities", + "desktop_key_down", + "desktop_key_up", + "desktop_mouse_down", + "desktop_mouse_move", + "desktop_mouse_up", + "desktop_mouse_wheel", + "desktop_request_keyframe", + "desktop_select_display", + "desktop_set_bitrate", + "desktop_set_duplication", + "desktop_set_fps", + "desktop_set_profile", + "desktop_set_quality", + "desktop_set_resolution", + "desktop_start", + "desktop_stop", + "desktop_text", + "disconnect", + "elevate", + "file_chmod", + "file_copy", + "file_delete", + "file_dirsize", + "file_download", + "file_execute", + "file_hash", + "file_icon", + "file_list", + "file_mkdir", + "file_move", + "file_peek", + "file_read", + "file_request_access", + "file_search", + "file_thumb", + "file_upload", + "file_upload_http", + "file_write", + "file_zip", + "keylog_clear_all", + "keylog_delete", + "keylog_list", + "keylog_request_permission", + "keylog_retrieve", + "plugin_load", + "plugin_load_http", + "plugin_unload", + "privacy_start", + "privacy_status", + "privacy_stop", + "process_icon", + "process_kill", + "process_list", + "process_resume", + "process_suspend", + "proxy_close", + "proxy_connect", + "proxy_data", + "reconnect", + "screenshot", + "script_exec", + "silent_exec", + "uninstall", + "virtual_enable_cursor", + "virtual_enable_keyboard", + "virtual_enable_mouse", + "virtual_key_down", + "virtual_key_up", + "virtual_kill_all", + "virtual_mouse_down", + "virtual_mouse_move", + "virtual_mouse_up", + "virtual_mouse_wheel", + "virtual_request_keyframe", + "virtual_set_fps", + "virtual_set_quality", + "virtual_start", + "virtual_start_process", + "virtual_stop", + "virtual_window_list", + "voice_capabilities", + "voice_downlink", + "voice_session_start", + "voice_session_stop", + "webcam_list", + "webcam_select", + "webcam_set_fps", + "webcam_set_quality", + "webcam_start", + "webcam_stop", + "webrtc_p2p_ice", + "webrtc_p2p_offer", + "webrtc_p2p_stop", + "webrtc_publish", + "webrtc_stop", + "winre_install", + "winre_probe", + "winre_uninstall" +] as const; + +export type CommandType = (typeof COMMAND_TYPES)[number]; + +export type CommandVersionRange = Readonly<{ min: number; max: number }>; +export const COMMAND_VERSION_SUPPORT = { + "agent_update": { + "min": 1, + "max": 1 + }, + "backstage_browser_check": { + "min": 1, + "max": 1 + }, + "backstage_enable_cursor": { + "min": 1, + "max": 1 + }, + "backstage_enable_dxgi": { + "min": 1, + "max": 1 + }, + "backstage_enable_keyboard": { + "min": 1, + "max": 1 + }, + "backstage_enable_mouse": { + "min": 1, + "max": 1 + }, + "backstage_enable_uia": { + "min": 1, + "max": 1 + }, + "backstage_installed_apps": { + "min": 1, + "max": 1 + }, + "backstage_key_down": { + "min": 1, + "max": 1 + }, + "backstage_key_up": { + "min": 1, + "max": 1 + }, + "backstage_kill_all": { + "min": 1, + "max": 1 + }, + "backstage_lookup": { + "min": 1, + "max": 1 + }, + "backstage_mouse_down": { + "min": 1, + "max": 1 + }, + "backstage_mouse_move": { + "min": 1, + "max": 1 + }, + "backstage_mouse_up": { + "min": 1, + "max": 1 + }, + "backstage_mouse_wheel": { + "min": 1, + "max": 1 + }, + "backstage_request_keyframe": { + "min": 1, + "max": 1 + }, + "backstage_select_display": { + "min": 1, + "max": 1 + }, + "backstage_set_fps": { + "min": 1, + "max": 1 + }, + "backstage_set_quality": { + "min": 1, + "max": 1 + }, + "backstage_set_resolution": { + "min": 1, + "max": 1 + }, + "backstage_start": { + "min": 1, + "max": 1 + }, + "backstage_start_browser_injected": { + "min": 1, + "max": 1 + }, + "backstage_start_chrome_injected": { + "min": 1, + "max": 1 + }, + "backstage_start_process": { + "min": 1, + "max": 1 + }, + "backstage_start_process_injected": { + "min": 1, + "max": 1 + }, + "backstage_stop": { + "min": 1, + "max": 1 + }, + "backstage_window_list": { + "min": 1, + "max": 1 + }, + "client_logs_request": { + "min": 1, + "max": 1 + }, + "clipboard_set": { + "min": 1, + "max": 1 + }, + "clipboard_sync_start": { + "min": 1, + "max": 1 + }, + "clipboard_sync_stop": { + "min": 1, + "max": 1 + }, + "console_input": { + "min": 1, + "max": 1 + }, + "console_resize": { + "min": 1, + "max": 1 + }, + "console_start": { + "min": 1, + "max": 1 + }, + "console_stop": { + "min": 1, + "max": 1 + }, + "darwin_request_permissions": { + "min": 1, + "max": 1 + }, + "desktop_audio_start": { + "min": 1, + "max": 1 + }, + "desktop_audio_stop": { + "min": 1, + "max": 1 + }, + "desktop_enable_cursor": { + "min": 1, + "max": 1 + }, + "desktop_enable_keyboard": { + "min": 1, + "max": 1 + }, + "desktop_enable_mouse": { + "min": 1, + "max": 1 + }, + "desktop_encoder_capabilities": { + "min": 1, + "max": 1 + }, + "desktop_key_down": { + "min": 1, + "max": 1 + }, + "desktop_key_up": { + "min": 1, + "max": 1 + }, + "desktop_mouse_down": { + "min": 1, + "max": 1 + }, + "desktop_mouse_move": { + "min": 1, + "max": 1 + }, + "desktop_mouse_up": { + "min": 1, + "max": 1 + }, + "desktop_mouse_wheel": { + "min": 1, + "max": 1 + }, + "desktop_request_keyframe": { + "min": 1, + "max": 1 + }, + "desktop_select_display": { + "min": 1, + "max": 1 + }, + "desktop_set_bitrate": { + "min": 1, + "max": 1 + }, + "desktop_set_duplication": { + "min": 1, + "max": 1 + }, + "desktop_set_fps": { + "min": 1, + "max": 1 + }, + "desktop_set_profile": { + "min": 1, + "max": 1 + }, + "desktop_set_quality": { + "min": 1, + "max": 1 + }, + "desktop_set_resolution": { + "min": 1, + "max": 1 + }, + "desktop_start": { + "min": 1, + "max": 1 + }, + "desktop_stop": { + "min": 1, + "max": 1 + }, + "desktop_text": { + "min": 1, + "max": 1 + }, + "disconnect": { + "min": 1, + "max": 1 + }, + "elevate": { + "min": 1, + "max": 1 + }, + "file_chmod": { + "min": 1, + "max": 1 + }, + "file_copy": { + "min": 1, + "max": 1 + }, + "file_delete": { + "min": 1, + "max": 1 + }, + "file_dirsize": { + "min": 1, + "max": 1 + }, + "file_download": { + "min": 1, + "max": 1 + }, + "file_execute": { + "min": 1, + "max": 1 + }, + "file_hash": { + "min": 1, + "max": 1 + }, + "file_icon": { + "min": 1, + "max": 1 + }, + "file_list": { + "min": 1, + "max": 1 + }, + "file_mkdir": { + "min": 1, + "max": 1 + }, + "file_move": { + "min": 1, + "max": 1 + }, + "file_peek": { + "min": 1, + "max": 1 + }, + "file_read": { + "min": 1, + "max": 1 + }, + "file_request_access": { + "min": 1, + "max": 1 + }, + "file_search": { + "min": 1, + "max": 1 + }, + "file_thumb": { + "min": 1, + "max": 1 + }, + "file_upload": { + "min": 1, + "max": 1 + }, + "file_upload_http": { + "min": 1, + "max": 1 + }, + "file_write": { + "min": 1, + "max": 1 + }, + "file_zip": { + "min": 1, + "max": 1 + }, + "keylog_clear_all": { + "min": 1, + "max": 1 + }, + "keylog_delete": { + "min": 1, + "max": 1 + }, + "keylog_list": { + "min": 1, + "max": 1 + }, + "keylog_request_permission": { + "min": 1, + "max": 1 + }, + "keylog_retrieve": { + "min": 1, + "max": 1 + }, + "plugin_load": { + "min": 1, + "max": 1 + }, + "plugin_load_http": { + "min": 1, + "max": 1 + }, + "plugin_unload": { + "min": 1, + "max": 1 + }, + "privacy_start": { + "min": 1, + "max": 1 + }, + "privacy_status": { + "min": 1, + "max": 1 + }, + "privacy_stop": { + "min": 1, + "max": 1 + }, + "process_icon": { + "min": 1, + "max": 1 + }, + "process_kill": { + "min": 1, + "max": 1 + }, + "process_list": { + "min": 1, + "max": 1 + }, + "process_resume": { + "min": 1, + "max": 1 + }, + "process_suspend": { + "min": 1, + "max": 1 + }, + "proxy_close": { + "min": 1, + "max": 1 + }, + "proxy_connect": { + "min": 1, + "max": 1 + }, + "proxy_data": { + "min": 1, + "max": 1 + }, + "reconnect": { + "min": 1, + "max": 1 + }, + "screenshot": { + "min": 1, + "max": 1 + }, + "script_exec": { + "min": 1, + "max": 1 + }, + "silent_exec": { + "min": 1, + "max": 1 + }, + "uninstall": { + "min": 1, + "max": 1 + }, + "virtual_enable_cursor": { + "min": 1, + "max": 1 + }, + "virtual_enable_keyboard": { + "min": 1, + "max": 1 + }, + "virtual_enable_mouse": { + "min": 1, + "max": 1 + }, + "virtual_key_down": { + "min": 1, + "max": 1 + }, + "virtual_key_up": { + "min": 1, + "max": 1 + }, + "virtual_kill_all": { + "min": 1, + "max": 1 + }, + "virtual_mouse_down": { + "min": 1, + "max": 1 + }, + "virtual_mouse_move": { + "min": 1, + "max": 1 + }, + "virtual_mouse_up": { + "min": 1, + "max": 1 + }, + "virtual_mouse_wheel": { + "min": 1, + "max": 1 + }, + "virtual_request_keyframe": { + "min": 1, + "max": 1 + }, + "virtual_set_fps": { + "min": 1, + "max": 1 + }, + "virtual_set_quality": { + "min": 1, + "max": 1 + }, + "virtual_start": { + "min": 1, + "max": 1 + }, + "virtual_start_process": { + "min": 1, + "max": 1 + }, + "virtual_stop": { + "min": 1, + "max": 1 + }, + "virtual_window_list": { + "min": 1, + "max": 1 + }, + "voice_capabilities": { + "min": 1, + "max": 1 + }, + "voice_downlink": { + "min": 1, + "max": 1 + }, + "voice_session_start": { + "min": 1, + "max": 1 + }, + "voice_session_stop": { + "min": 1, + "max": 1 + }, + "webcam_list": { + "min": 1, + "max": 1 + }, + "webcam_select": { + "min": 1, + "max": 1 + }, + "webcam_set_fps": { + "min": 1, + "max": 1 + }, + "webcam_set_quality": { + "min": 1, + "max": 1 + }, + "webcam_start": { + "min": 1, + "max": 1 + }, + "webcam_stop": { + "min": 1, + "max": 1 + }, + "webrtc_p2p_ice": { + "min": 1, + "max": 1 + }, + "webrtc_p2p_offer": { + "min": 1, + "max": 1 + }, + "webrtc_p2p_stop": { + "min": 1, + "max": 1 + }, + "webrtc_publish": { + "min": 1, + "max": 1 + }, + "webrtc_stop": { + "min": 1, + "max": 1 + }, + "winre_install": { + "min": 1, + "max": 1 + }, + "winre_probe": { + "min": 1, + "max": 1 + }, + "winre_uninstall": { + "min": 1, + "max": 1 + } +} as const satisfies + Record; + +const commandTypeSet: ReadonlySet = new Set(COMMAND_TYPES); +const agentMessageTypeSet: ReadonlySet = new Set(AGENT_TO_SERVER_MESSAGE_TYPES); + +export function isCommandType(value: string): value is CommandType { + return commandTypeSet.has(value); +} + +export function getCommandVersionRange(command: CommandType): CommandVersionRange { + return COMMAND_VERSION_SUPPORT[command]; +} + +export function isSupportedCommandVersion(command: CommandType, version: number): boolean { + const range = getCommandVersionRange(command); + return Number.isInteger(version) && version >= range.min && version <= range.max; +} + +export function getImplicitCommandVersion(command: CommandType): number { + const range = getCommandVersionRange(command); + if (range.min !== range.max) { + throw new Error( + `Command ${command} supports versions ${range.min}-${range.max}; negotiate an explicit commandVersion`, + ); + } + return range.min; +} + +export function isAgentToServerMessageType(value: string): value is AgentToServerMessageType { + return agentMessageTypeSet.has(value); +} diff --git a/Overlord-Server/src/protocol-contract.test.ts b/Overlord-Server/src/protocol-contract.test.ts new file mode 100644 index 0000000..fd0caa8 --- /dev/null +++ b/Overlord-Server/src/protocol-contract.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, test } from "bun:test"; +import { + AGENT_TO_SERVER_MESSAGE_TYPES, + COMMAND_TYPES, + COMMAND_VERSION_SUPPORT, + WIRE_PROTOCOL_VERSION, + getImplicitCommandVersion, + isAgentToServerMessageType, + isCommandType, + isSupportedCommandVersion, +} from "./generated/wire-contract"; +import { + ALLOWED_CLIENT_MESSAGE_TYPES, + isAllowedClientMessageType, +} from "./wsValidation"; + +describe("generated wire protocol contract", () => { + test("publishes the complete command catalog", () => { + expect(WIRE_PROTOCOL_VERSION).toBe(1); + expect(COMMAND_TYPES.length).toBe(141); + expect(new Set(COMMAND_TYPES).size).toBe(COMMAND_TYPES.length); + expect(isCommandType("desktop_start")).toBe(true); + expect(isCommandType("virtual_window_list")).toBe(true); + expect(isCommandType("not_a_command")).toBe(false); + }); + + test("publishes a version range for every command", () => { + expect(Object.keys(COMMAND_VERSION_SUPPORT).length).toBe(COMMAND_TYPES.length); + for (const command of COMMAND_TYPES) { + expect(COMMAND_VERSION_SUPPORT[command]).toEqual({ min: 1, max: 1 }); + expect(isSupportedCommandVersion(command, 1)).toBe(true); + expect(isSupportedCommandVersion(command, 2)).toBe(false); + expect(getImplicitCommandVersion(command)).toBe(1); + } + }); + + test("drives the inbound client allowlist", () => { + expect([...ALLOWED_CLIENT_MESSAGE_TYPES].sort()).toEqual( + [...AGENT_TO_SERVER_MESSAGE_TYPES].sort(), + ); + expect(isAgentToServerMessageType("hello")).toBe(true); + expect(isAllowedClientMessageType("hello")).toBe(true); + expect(isAllowedClientMessageType("hello_ack")).toBe(false); + }); +}); diff --git a/Overlord-Server/src/protocol.test.ts b/Overlord-Server/src/protocol.test.ts index 4704d0f..95806f7 100644 --- a/Overlord-Server/src/protocol.test.ts +++ b/Overlord-Server/src/protocol.test.ts @@ -5,6 +5,7 @@ import { type Frame, type Hello, type Ping, + type Command, } from "./protocol"; const sampleHello: Hello = { @@ -39,6 +40,17 @@ describe("protocol encode/decode", () => { expect(decoded.ts).toBe(123); }); + test("adds the unambiguous v1 command version to legacy call sites", () => { + const command: Command = { + type: "command", + commandType: "process_list", + id: "command-123", + }; + + const decoded = decodeMessage(encodeMessage(command)) as Command; + expect(decoded.commandVersion).toBe(1); + }); + test("round trips h264 frame metadata", () => { const frame: Frame = { type: "frame", diff --git a/Overlord-Server/src/protocol.ts b/Overlord-Server/src/protocol.ts index 09d8877..875b6f1 100644 --- a/Overlord-Server/src/protocol.ts +++ b/Overlord-Server/src/protocol.ts @@ -1,25 +1,16 @@ import { encode, decode } from "@msgpack/msgpack"; - -export type MessageKind = - | "hello" - | "hello_ack" - | "ping" - | "pong" - | "command" - | "command_result" - | "desktop_encoder_capabilities" - | "desktop_stream_stats" - | "desktop_cursor" - | "client_logs_result" - | "screenshot_result" - | "frame" - | "status" - | "plugin_event" - | "notification" - | "webcam_devices" - | "notification_config" - | "enrollment_challenge" - | "enrollment_status"; +import { + getImplicitCommandVersion, + isCommandType, + type CommandType, + type CommandVersionRange, + type MessageKind, +} from "./generated/wire-contract"; +export type { + CommandType, + CommandVersionRange, + MessageKind, +} from "./generated/wire-contract"; export type Hello = { type: "hello"; @@ -48,6 +39,8 @@ export type Hello = { publicIP?: string; lastCrashReason?: string; lastCrashDetail?: string; + protocolVersion?: number; + commandVersions?: Partial>; }; export type EnrollmentChallenge = { @@ -63,6 +56,8 @@ export type EnrollmentStatusMsg = { export type HelloAck = { type: "hello_ack"; id: string; + protocolVersion?: number; + commandVersions?: Partial>; commands?: Command[]; notification?: { keywords: string[]; @@ -73,81 +68,10 @@ export type HelloAck = { export type Ping = { type: "ping"; ts?: number }; export type Pong = { type: "pong"; ts?: number }; -export type CommandType = - | "input" - | "remote_start" - | "remote_stop" - | "webcam_start" - | "webcam_stop" - | "webcam_list" - | "webcam_select" - | "webcam_set_fps" - | "disconnect" - | "reconnect" - | "screenshot" - | "ping" - | "console_start" - | "console_input" - | "console_stop" - | "console_resize" - | "file_list" - | "file_request_access" - | "file_download" - | "file_upload" - | "file_delete" - | "file_mkdir" - | "file_zip" - | "file_read" - | "file_write" - | "file_search" - | "file_copy" - | "file_move" - | "file_chmod" - | "file_execute" - | "silent_exec" - | "voice_session_start" - | "voice_session_stop" - | "voice_downlink" - | "voice_capabilities" - | "client_logs_request" - | "desktop_audio_start" - | "desktop_audio_stop" - | "process_list" - | "process_kill" - | "plugin_load" - | "plugin_load_http" - | "plugin_unload" - | "agent_update" - | "clipboard_set" - | "clipboard_sync_start" - | "clipboard_sync_stop" - | "desktop_start" - | "desktop_stop" - | "desktop_select_display" - | "desktop_enable_mouse" - | "desktop_enable_keyboard" - | "desktop_set_fps" - | "desktop_set_bitrate" - | "darwin_request_permissions" - | "webrtc_publish" - | "webrtc_stop" - | "webrtc_p2p_offer" - | "webrtc_p2p_ice" - | "webrtc_p2p_stop" - | "script_exec" - | "uninstall" - | "elevate" - | "file_upload_http" - | "winre_probe" - | "winre_install" - | "winre_uninstall" - | "file_icon" - | "file_thumb" - | "file_dirsize"; - export type Command = { type: "command"; commandType: CommandType; + commandVersion?: number; payload?: unknown; id?: string; }; @@ -155,8 +79,12 @@ export type Command = { export type CommandResult = { type: "command_result"; commandId?: string; + commandType?: CommandType; + commandVersion?: number; ok: boolean; message?: string; + errorCode?: "unsupported_command_version" | string; + supportedVersions?: CommandVersionRange; }; export type DesktopCodecCapability = { @@ -579,6 +507,14 @@ export type WireMessage = | EnrollmentStatusMsg; export function encodeMessage(msg: WireMessage): Uint8Array { + if (msg.type === "command") { + if (!isCommandType(msg.commandType)) { + throw new Error(`Unknown command type: ${String(msg.commandType)}`); + } + const commandVersion = + msg.commandVersion ?? getImplicitCommandVersion(msg.commandType); + return encode({ ...msg, commandVersion }); + } return encode(msg); } diff --git a/Overlord-Server/src/server/routes/client-routes.ts b/Overlord-Server/src/server/routes/client-routes.ts index 20cca9c..ab7a39f 100644 --- a/Overlord-Server/src/server/routes/client-routes.ts +++ b/Overlord-Server/src/server/routes/client-routes.ts @@ -36,6 +36,7 @@ import { clearThumbnail } from "../../thumbnails"; import { handleClientCommandRoute } from "./client-command-routes"; import { handleClientGroupRoutes } from "./client-group-routes"; import { decryptClientLogBlob, extractSecureLogBlobs } from "../client-log-crypto"; +import { getCommandCompatibilityCatalog } from "../../command-compatibility"; type RequestIpProvider = { requestIP: (req: Request) => { address?: string } | null | undefined; @@ -68,7 +69,15 @@ function mergeLiveClientState(result: any) { items: (result.items || []).map((item: any) => { const live = clientManager.getClient(item.id); return live - ? { ...item, online: true, lastSeen: live.lastSeen, pingMs: live.pingMs ?? item.pingMs } + ? { + ...item, + online: true, + lastSeen: live.lastSeen, + pingMs: live.pingMs ?? item.pingMs, + protocolVersion: live.protocolVersion ?? 1, + commandVersions: live.commandVersions, + commandCompatibility: getCommandCompatibilityCatalog(live), + } : item; }), }; diff --git a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts index a70aa3a..d1dc614 100644 --- a/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts +++ b/Overlord-Server/src/server/routes/websocket-lifecycle-routes.ts @@ -14,6 +14,8 @@ import { getConfig } from "../../config"; import { logger } from "../../logger"; import { metrics } from "../../metrics"; import { decodeMessage, encodeMessage, type WireMessage, type Hello, type Ping } from "../../protocol"; +import { WIRE_PROTOCOL_VERSION } from "../../generated/wire-contract"; +import { getNegotiatedCommandVersions } from "../../command-compatibility"; import * as sessionManager from "../../sessions/sessionManager"; import type { SocketData } from "../../sessions/types"; import type { ClientInfo } from "../../types"; @@ -705,6 +707,8 @@ export async function handleWebSocketMessage( encodeMessage({ type: "hello_ack", id: resolvedId, + protocolVersion: WIRE_PROTOCOL_VERSION, + commandVersions: getNegotiatedCommandVersions(infoObj), notification: { keywords: notificationConfig.keywords || [], minIntervalMs: notificationConfig.minIntervalMs || 8000, diff --git a/Overlord-Server/src/types.ts b/Overlord-Server/src/types.ts index f384cb5..127cb31 100644 --- a/Overlord-Server/src/types.ts +++ b/Overlord-Server/src/types.ts @@ -55,6 +55,11 @@ export type ClientInfo = { groupName?: string | null; groupColor?: string | null; notificationsMuted?: boolean; + protocolVersion?: number; + commandVersions?: Partial>; }; export type ListFilters = { diff --git a/Overlord-Server/src/version.ts b/Overlord-Server/src/version.ts index 3d3ffa9..68e4861 100644 --- a/Overlord-Server/src/version.ts +++ b/Overlord-Server/src/version.ts @@ -1 +1 @@ -export const SERVER_VERSION = "2.6.1"; +export const SERVER_VERSION = "3.0.0"; diff --git a/Overlord-Server/src/wsHandlers.ts b/Overlord-Server/src/wsHandlers.ts index bf99387..27322f8 100644 --- a/Overlord-Server/src/wsHandlers.ts +++ b/Overlord-Server/src/wsHandlers.ts @@ -1,4 +1,5 @@ import { encodeMessage, type Hello, type Ping, type WireMessage } from "./protocol"; +import { sanitizeCommandVersionRanges } from "./command-compatibility"; let _geoip: typeof import("geoip-lite"); async function getGeoip() { @@ -86,6 +87,11 @@ export async function handleHello( info.arch = sanitizeInfoString(payload.arch, 32); info.hostArch = sanitizeInfoString((payload as any).hostArch, 32) || info.arch; info.version = sanitizeInfoString(payload.version, 64); + const rawProtocolVersion = Number((payload as any).protocolVersion); + info.protocolVersion = Number.isInteger(rawProtocolVersion) && rawProtocolVersion > 0 + ? Math.min(rawProtocolVersion, 65_535) + : 1; + info.commandVersions = sanitizeCommandVersionRanges((payload as any).commandVersions); info.user = sanitizeInfoString(payload.user); info.monitors = sanitizeMonitorCount(payload.monitors) ?? info.monitors; const cleanMonitorInfo = sanitizeJsonField((payload as any).monitorInfo, { diff --git a/Overlord-Server/src/wsValidation.ts b/Overlord-Server/src/wsValidation.ts index e72bd15..d49c4be 100644 --- a/Overlord-Server/src/wsValidation.ts +++ b/Overlord-Server/src/wsValidation.ts @@ -1,58 +1,11 @@ +import { AGENT_TO_SERVER_MESSAGE_TYPES } from "./generated/wire-contract"; + export type SocketRole = "client" | "viewer" | "console_viewer" | "rd_viewer" | "webcam_viewer" | "backstage_viewer" | "file_browser_viewer" | "process_viewer" | "keylogger_viewer" | "voice_viewer" | "desktop_audio_viewer" | "notifications_viewer"; const textEncoder = new TextEncoder(); -export const ALLOWED_CLIENT_MESSAGE_TYPES = new Set([ - "hello", - "ping", - "pong", - "frame", - "status", - "console_output", - "file_list_result", - "file_download", - "file_upload_result", - "file_read_result", - "file_search_result", - "file_icon_result", - "file_thumb_result", - "file_dirsize_result", - "file_peek_result", - "file_hash_result", - "command_result", - "desktop_encoder_capabilities", - "desktop_stream_stats", - "desktop_cursor", - "client_logs_result", - "screenshot_result", - "command_progress", - "process_list_result", - "process_icon_result", - "script_result", - "plugin_event", - "notification", - "keylog_file_list", - "keylog_file_content", - "keylog_clear_result", - "keylog_delete_result", - "keylog_permission_result", - "voice_uplink", - "desktop_audio_uplink", - "webcam_devices", - "backstage_clone_progress", - "backstage_lookup_result", - "backstage_browser_check_result", - "backstage_installed_apps_result", - "backstage_dxgi_status", - "backstage_browser_launch_status", - "backstage_window_list_result", - "clipboard_content", - "proxy_data", - "proxy_close", - "disconnect_info", - "webrtc_p2p_answer", - "webrtc_p2p_ice", -]); +export const ALLOWED_CLIENT_MESSAGE_TYPES: ReadonlySet = + new Set(AGENT_TO_SERVER_MESSAGE_TYPES); export function isAllowedClientMessageType(type: string): boolean { return ALLOWED_CLIENT_MESSAGE_TYPES.has(type); diff --git a/protocol/README.md b/protocol/README.md new file mode 100644 index 0000000..1987cf4 --- /dev/null +++ b/protocol/README.md @@ -0,0 +1,71 @@ +# Wire protocol contract + +`wire-contract.json` is the canonical catalog for the MessagePack protocol +shared by the server and agent. Generated TypeScript and Go artifacts must not +be edited directly. + +Generate artifacts from the repository root: + +```text +bun scripts/generate-wire-protocol.ts +``` + +Verify that checked-in artifacts match the contract: + +```text +bun scripts/generate-wire-protocol.ts --check +``` + +Protocol version 1 follows additive compatibility: + +- Existing message and field meanings may not change. +- New optional fields, message types, and commands may be added. +- Fields may not become required for peers that advertise an older or absent + protocol version. +- Removing or redefining a wire value requires a new protocol version. + +Agents that predate protocol negotiation omit `protocolVersion`; the server +treats them as protocol version 1. + +## Command versions + +Every command supports version 1 by default. A breaking change must be added +under `commandVersioning.overrides`; an existing command version must never be +edited to introduce a breaking payload or semantic change. + +An override declares the latest contiguous version and documents every +breaking transition: + +```json +{ + "desktop_start": { + "latestVersion": 2, + "changes": { + "2": { + "breaking": true, + "summary": "Replace numeric quality with a named profile", + "migration": "Map quality 1-40 to low, 41-80 to balanced, and 81-100 to high" + } + } + } +} +``` + +Rules: + +- Adding an optional field with an unchanged meaning does not require a bump. +- Removing or renaming a field requires a new command version. +- Changing a field type, units, default, valid range, or meaning requires a + new command version. +- Changing result semantics requires a new command version. +- Versions are contiguous and old handlers remain available while supported. +- A new version requires migration text and compatibility fixtures. +- A command with more than one version may not be encoded implicitly; the + server must select a mutually supported version for the connected agent. + +At target-aware server boundaries, construct envelopes with +`versionCommandForClient`. It selects the highest shared version and throws +before transmission when the agent omitted the command or has no overlapping +range. Generic legacy send sites remain safe while all declared commands have +exactly one version; the encoder refuses implicit encoding as soon as a command +becomes multi-version. diff --git a/protocol/wire-contract.json b/protocol/wire-contract.json new file mode 100644 index 0000000..ee75c01 --- /dev/null +++ b/protocol/wire-contract.json @@ -0,0 +1,217 @@ +{ + "$schema": "./wire-contract.schema.json", + "schemaVersion": 1, + "protocolVersion": 1, + "encoding": "messagepack", + "compatibility": "additive", + "commandVersioning": { + "defaultVersion": 1, + "overrides": {} + }, + "serverToAgentMessageTypes": [ + "enrollment_challenge", + "enrollment_status", + "hello_ack", + "ping", + "pong", + "command", + "command_abort", + "frame_ack", + "plugin_event", + "notification_config" + ], + "agentToServerMessageTypes": [ + "hello", + "ping", + "pong", + "frame", + "status", + "console_output", + "file_list_result", + "file_download", + "file_upload_result", + "file_read_result", + "file_search_result", + "file_icon_result", + "file_thumb_result", + "file_dirsize_result", + "file_peek_result", + "file_hash_result", + "command_result", + "desktop_encoder_capabilities", + "desktop_stream_stats", + "desktop_cursor", + "client_logs_result", + "screenshot_result", + "command_progress", + "process_list_result", + "process_icon_result", + "script_result", + "plugin_event", + "notification", + "keylog_file_list", + "keylog_file_content", + "keylog_clear_result", + "keylog_delete_result", + "keylog_permission_result", + "voice_uplink", + "desktop_audio_uplink", + "webcam_devices", + "backstage_clone_progress", + "backstage_lookup_result", + "backstage_browser_check_result", + "backstage_installed_apps_result", + "backstage_dxgi_status", + "backstage_browser_launch_status", + "backstage_window_list_result", + "clipboard_content", + "proxy_data", + "proxy_close", + "disconnect_info", + "webrtc_p2p_answer", + "webrtc_p2p_ice" + ], + "commands": [ + "agent_update", + "backstage_browser_check", + "backstage_enable_cursor", + "backstage_enable_dxgi", + "backstage_enable_keyboard", + "backstage_enable_mouse", + "backstage_enable_uia", + "backstage_installed_apps", + "backstage_key_down", + "backstage_key_up", + "backstage_kill_all", + "backstage_lookup", + "backstage_mouse_down", + "backstage_mouse_move", + "backstage_mouse_up", + "backstage_mouse_wheel", + "backstage_request_keyframe", + "backstage_select_display", + "backstage_set_fps", + "backstage_set_quality", + "backstage_set_resolution", + "backstage_start", + "backstage_start_browser_injected", + "backstage_start_chrome_injected", + "backstage_start_process", + "backstage_start_process_injected", + "backstage_stop", + "backstage_window_list", + "client_logs_request", + "clipboard_set", + "clipboard_sync_start", + "clipboard_sync_stop", + "console_input", + "console_resize", + "console_start", + "console_stop", + "darwin_request_permissions", + "desktop_audio_start", + "desktop_audio_stop", + "desktop_enable_cursor", + "desktop_enable_keyboard", + "desktop_enable_mouse", + "desktop_encoder_capabilities", + "desktop_key_down", + "desktop_key_up", + "desktop_mouse_down", + "desktop_mouse_move", + "desktop_mouse_up", + "desktop_mouse_wheel", + "desktop_request_keyframe", + "desktop_select_display", + "desktop_set_bitrate", + "desktop_set_duplication", + "desktop_set_fps", + "desktop_set_profile", + "desktop_set_quality", + "desktop_set_resolution", + "desktop_start", + "desktop_stop", + "desktop_text", + "disconnect", + "elevate", + "file_chmod", + "file_copy", + "file_delete", + "file_dirsize", + "file_download", + "file_execute", + "file_hash", + "file_icon", + "file_list", + "file_mkdir", + "file_move", + "file_peek", + "file_read", + "file_request_access", + "file_search", + "file_thumb", + "file_upload", + "file_upload_http", + "file_write", + "file_zip", + "keylog_clear_all", + "keylog_delete", + "keylog_list", + "keylog_request_permission", + "keylog_retrieve", + "plugin_load", + "plugin_load_http", + "plugin_unload", + "privacy_start", + "privacy_status", + "privacy_stop", + "process_icon", + "process_kill", + "process_list", + "process_resume", + "process_suspend", + "proxy_close", + "proxy_connect", + "proxy_data", + "reconnect", + "screenshot", + "script_exec", + "silent_exec", + "uninstall", + "virtual_enable_cursor", + "virtual_enable_keyboard", + "virtual_enable_mouse", + "virtual_key_down", + "virtual_key_up", + "virtual_kill_all", + "virtual_mouse_down", + "virtual_mouse_move", + "virtual_mouse_up", + "virtual_mouse_wheel", + "virtual_request_keyframe", + "virtual_set_fps", + "virtual_set_quality", + "virtual_start", + "virtual_start_process", + "virtual_stop", + "virtual_window_list", + "voice_capabilities", + "voice_downlink", + "voice_session_start", + "voice_session_stop", + "webcam_list", + "webcam_select", + "webcam_set_fps", + "webcam_set_quality", + "webcam_start", + "webcam_stop", + "webrtc_p2p_ice", + "webrtc_p2p_offer", + "webrtc_p2p_stop", + "webrtc_publish", + "webrtc_stop", + "winre_install", + "winre_probe", + "winre_uninstall" + ] +} diff --git a/protocol/wire-contract.schema.json b/protocol/wire-contract.schema.json new file mode 100644 index 0000000..b13edc1 --- /dev/null +++ b/protocol/wire-contract.schema.json @@ -0,0 +1,81 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://overlord.local/schemas/wire-contract.schema.json", + "title": "Overlord wire contract", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "protocolVersion", + "encoding", + "compatibility", + "commandVersioning", + "serverToAgentMessageTypes", + "agentToServerMessageTypes", + "commands" + ], + "properties": { + "$schema": { "type": "string" }, + "schemaVersion": { "type": "integer", "minimum": 1 }, + "protocolVersion": { "type": "integer", "minimum": 1 }, + "encoding": { "const": "messagepack" }, + "compatibility": { "enum": ["additive"] }, + "commandVersioning": { + "type": "object", + "additionalProperties": false, + "required": ["defaultVersion", "overrides"], + "properties": { + "defaultVersion": { "const": 1 }, + "overrides": { + "type": "object", + "propertyNames": { "$ref": "#/$defs/wireName" }, + "additionalProperties": { + "type": "object", + "additionalProperties": false, + "required": ["latestVersion", "changes"], + "properties": { + "latestVersion": { "type": "integer", "minimum": 2 }, + "changes": { + "type": "object", + "patternProperties": { + "^[2-9][0-9]*$": { + "type": "object", + "additionalProperties": false, + "required": ["breaking", "summary", "migration"], + "properties": { + "breaking": { "const": true }, + "summary": { "type": "string", "minLength": 1 }, + "migration": { "type": "string", "minLength": 1 } + } + } + }, + "additionalProperties": false + } + } + } + } + } + }, + "serverToAgentMessageTypes": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/wireName" } + }, + "agentToServerMessageTypes": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/wireName" } + }, + "commands": { + "type": "array", + "uniqueItems": true, + "items": { "$ref": "#/$defs/wireName" } + } + }, + "$defs": { + "wireName": { + "type": "string", + "pattern": "^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$" + } + } +} diff --git a/scripts/generate-wire-protocol.ts b/scripts/generate-wire-protocol.ts new file mode 100644 index 0000000..6712255 --- /dev/null +++ b/scripts/generate-wire-protocol.ts @@ -0,0 +1,291 @@ +import { dirname, resolve } from "node:path"; +import { mkdir } from "node:fs/promises"; + +type Contract = { + schemaVersion: number; + protocolVersion: number; + encoding: "messagepack"; + compatibility: "additive"; + commandVersioning: { + defaultVersion: 1; + overrides: Record; + }>; + }; + serverToAgentMessageTypes: string[]; + agentToServerMessageTypes: string[]; + commands: string[]; +}; + +const root = resolve(import.meta.dir, ".."); +const contractPath = resolve(root, "protocol", "wire-contract.json"); +const tsOutputPath = resolve(root, "Overlord-Server", "src", "generated", "wire-contract.ts"); +const goOutputPath = resolve(root, "Overlord-Client", "cmd", "agent", "wire", "wire_contract_generated.go"); +const checkOnly = process.argv.includes("--check"); + +const contract = await Bun.file(contractPath).json() as Contract; + +function validateNames(label: string, values: string[]): void { + const pattern = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/; + const seen = new Set(); + for (const value of values) { + if (!pattern.test(value)) throw new Error(`${label} contains invalid wire name: ${value}`); + if (seen.has(value)) throw new Error(`${label} contains duplicate wire name: ${value}`); + seen.add(value); + } +} + +if (!Number.isInteger(contract.schemaVersion) || contract.schemaVersion < 1) { + throw new Error("schemaVersion must be a positive integer"); +} +if (!Number.isInteger(contract.protocolVersion) || contract.protocolVersion < 1) { + throw new Error("protocolVersion must be a positive integer"); +} +if (contract.encoding !== "messagepack" || contract.compatibility !== "additive") { + throw new Error("unsupported encoding or compatibility policy"); +} +validateNames("serverToAgentMessageTypes", contract.serverToAgentMessageTypes); +validateNames("agentToServerMessageTypes", contract.agentToServerMessageTypes); +validateNames("commands", contract.commands); +if (contract.commandVersioning?.defaultVersion !== 1) { + throw new Error("commandVersioning.defaultVersion must be 1"); +} +for (const [command, override] of Object.entries(contract.commandVersioning.overrides)) { + if (!contract.commands.includes(command)) { + throw new Error(`command version override references unknown command: ${command}`); + } + if (!Number.isInteger(override.latestVersion) || override.latestVersion < 2) { + throw new Error(`latestVersion for ${command} must be at least 2`); + } + for (let version = 2; version <= override.latestVersion; version += 1) { + const change = override.changes[String(version)]; + if ( + !change || + change.breaking !== true || + !change.summary?.trim() || + !change.migration?.trim() + ) { + throw new Error(`${command} v${version} requires breaking-change summary and migration text`); + } + } + const unexpected = Object.keys(override.changes) + .filter((value) => !Number.isInteger(Number(value)) || Number(value) < 2 || Number(value) > override.latestVersion); + if (unexpected.length > 0) { + throw new Error(`${command} has invalid change versions: ${unexpected.join(", ")}`); + } +} + +const handlerPath = resolve(root, "Overlord-Client", "cmd", "agent", "handlers", "command.go"); +const handlerSource = await Bun.file(handlerPath).text(); +const handlerCommands = [...handlerSource.matchAll(/^\tcase "([a-z][a-z0-9]*(?:_[a-z0-9]+)*)":/gm)] + .map((match) => match[1]) + .filter((value, index, values) => values.indexOf(value) === index) + .sort(); +const declaredCommands = [...contract.commands].sort(); +const missingFromContract = handlerCommands.filter((value) => !declaredCommands.includes(value)); +const missingFromHandler = declaredCommands.filter((value) => !handlerCommands.includes(value)); +if (missingFromContract.length > 0 || missingFromHandler.length > 0) { + throw new Error([ + "wire command catalog and agent dispatcher have drifted", + missingFromContract.length > 0 + ? `missing from contract: ${missingFromContract.join(", ")}` + : "", + missingFromHandler.length > 0 + ? `missing from handler: ${missingFromHandler.join(", ")}` + : "", + ].filter(Boolean).join("; ")); +} + +const allMessageTypes = [...new Set([ + ...contract.serverToAgentMessageTypes, + ...contract.agentToServerMessageTypes, +])].sort(); +const serverMessages = [...contract.serverToAgentMessageTypes].sort(); +const agentMessages = [...contract.agentToServerMessageTypes].sort(); +const commands = [...contract.commands].sort(); +const commandVersionSupport = Object.fromEntries( + commands.map((command) => [ + command, + { + min: 1, + max: contract.commandVersioning.overrides[command]?.latestVersion ?? 1, + }, + ]), +) as Record; + +function tsArray(name: string, values: string[]): string { + return `export const ${name} = ${JSON.stringify(values, null, 2)} as const;\n`; +} + +const ts = `// Code generated by scripts/generate-wire-protocol.ts; DO NOT EDIT. + +export const WIRE_SCHEMA_VERSION = ${contract.schemaVersion} as const; +export const WIRE_PROTOCOL_VERSION = ${contract.protocolVersion} as const; + +${tsArray("WIRE_MESSAGE_TYPES", allMessageTypes)} +export type MessageKind = (typeof WIRE_MESSAGE_TYPES)[number]; + +${tsArray("SERVER_TO_AGENT_MESSAGE_TYPES", serverMessages)} +export type ServerToAgentMessageType = (typeof SERVER_TO_AGENT_MESSAGE_TYPES)[number]; + +${tsArray("AGENT_TO_SERVER_MESSAGE_TYPES", agentMessages)} +export type AgentToServerMessageType = (typeof AGENT_TO_SERVER_MESSAGE_TYPES)[number]; + +${tsArray("COMMAND_TYPES", commands)} +export type CommandType = (typeof COMMAND_TYPES)[number]; + +export type CommandVersionRange = Readonly<{ min: number; max: number }>; +export const COMMAND_VERSION_SUPPORT = ${JSON.stringify(commandVersionSupport, null, 2)} as const satisfies + Record; + +const commandTypeSet: ReadonlySet = new Set(COMMAND_TYPES); +const agentMessageTypeSet: ReadonlySet = new Set(AGENT_TO_SERVER_MESSAGE_TYPES); + +export function isCommandType(value: string): value is CommandType { + return commandTypeSet.has(value); +} + +export function getCommandVersionRange(command: CommandType): CommandVersionRange { + return COMMAND_VERSION_SUPPORT[command]; +} + +export function isSupportedCommandVersion(command: CommandType, version: number): boolean { + const range = getCommandVersionRange(command); + return Number.isInteger(version) && version >= range.min && version <= range.max; +} + +export function getImplicitCommandVersion(command: CommandType): number { + const range = getCommandVersionRange(command); + if (range.min !== range.max) { + throw new Error( + \`Command \${command} supports versions \${range.min}-\${range.max}; negotiate an explicit commandVersion\`, + ); + } + return range.min; +} + +export function isAgentToServerMessageType(value: string): value is AgentToServerMessageType { + return agentMessageTypeSet.has(value); +} +`; + +function goName(value: string): string { + return value.split("_").map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(""); +} + +function goConstants(prefix: string, typeName: string, values: string[]): string { + return values.map((value) => `\t${prefix}${goName(value)} ${typeName} = "${value}"`).join("\n"); +} + +function goSlice(name: string, typeName: string, prefix: string, values: string[]): string { + return `var ${name} = [...]${typeName}{\n${values.map((value) => `\t${prefix}${goName(value)},`).join("\n")}\n}`; +} + +function goVersionMap(values: string[]): string { + return values + .map((value) => { + const range = commandVersionSupport[value]; + return `\tCommand${goName(value)}: {Min: ${range.min}, Max: ${range.max}},`; + }) + .join("\n"); +} + +const go = `// Code generated by scripts/generate-wire-protocol.ts; DO NOT EDIT. + +package wire + +const ( +\tWireSchemaVersion = ${contract.schemaVersion} +\tWireProtocolVersion = ${contract.protocolVersion} +) + +type MessageType string + +const ( +${goConstants("Message", "MessageType", allMessageTypes)} +) + +type CommandType string + +const ( +${goConstants("Command", "CommandType", commands)} +) + +${goSlice("AgentToServerMessageTypes", "MessageType", "Message", agentMessages)} + +${goSlice("ServerToAgentMessageTypes", "MessageType", "Message", serverMessages)} + +${goSlice("CommandTypes", "CommandType", "Command", commands)} + +type CommandVersionRange struct { +\tMin int \`msgpack:"min" json:"min"\` +\tMax int \`msgpack:"max" json:"max"\` +} + +var CommandVersionSupport = map[CommandType]CommandVersionRange{ +${goVersionMap(commands)} +} + +var commandTypeSet = func() map[CommandType]struct{} { +\tvalues := make(map[CommandType]struct{}, len(CommandTypes)) +\tfor _, value := range CommandTypes { +\t\tvalues[value] = struct{}{} +\t} +\treturn values +}() + +var serverToAgentMessageTypeSet = func() map[MessageType]struct{} { +\tvalues := make(map[MessageType]struct{}, len(ServerToAgentMessageTypes)) +\tfor _, value := range ServerToAgentMessageTypes { +\t\tvalues[value] = struct{}{} +\t} +\treturn values +}() + +func IsCommandType(value string) bool { +\t_, ok := commandTypeSet[CommandType(value)] +\treturn ok +} + +func IsSupportedCommandVersion(command string, version int) bool { +\trangeValue, ok := CommandVersionSupport[CommandType(command)] +\treturn ok && version >= rangeValue.Min && version <= rangeValue.Max +} + +func SupportedCommandVersionRanges() map[string]CommandVersionRange { +\tvalues := make(map[string]CommandVersionRange, len(CommandVersionSupport)) +\tfor command, rangeValue := range CommandVersionSupport { +\t\tvalues[string(command)] = rangeValue +\t} +\treturn values +} + +func IsServerToAgentMessageType(value string) bool { +\t_, ok := serverToAgentMessageTypeSet[MessageType(value)] +\treturn ok +} +`; + +async function emit(path: string, content: string): Promise { + if (checkOnly) { + const current = await Bun.file(path).text().catch(() => ""); + if (current !== content) { + console.error(`Generated artifact is stale: ${path}`); + process.exitCode = 1; + } + return; + } + await mkdir(dirname(path), { recursive: true }); + await Bun.write(path, content); + console.log(`Generated ${path}`); +} + +await Promise.all([ + emit(tsOutputPath, ts), + emit(goOutputPath, go), +]);