From 2eb9cd3c3366115e60824354bfe3c99df2f8d847 Mon Sep 17 00:00:00 2001 From: toki Date: Sun, 21 Jun 2026 07:35:51 +0900 Subject: [PATCH] update: bootstrap script and runner registry changes --- .../script/powershell/oto_agent_bootstrap.ps1 | 78 ++++++- .../script/shell/oto_agent_bootstrap.sh | 122 +++++++++- .../test/oto_agent_bootstrap_ps1_test.dart | 3 +- .../test/oto_agent_bootstrap_script_test.dart | 82 +++++-- .../oto_server_connection_smoke_test.dart | 26 ++- .../httpserver/oto_agent_bootstrap.ps1 | 78 ++++++- .../httpserver/oto_agent_bootstrap.sh | 122 +++++++++- services/core/internal/httpserver/routes.go | 4 +- .../internal/httpserver/runner_handlers.go | 211 +++++++++++++----- .../core/internal/httpserver/server_test.go | 118 ++++++++-- .../core/internal/runnerregistry/registry.go | 16 ++ .../internal/runnerregistry/registry_test.go | 45 ++++ 12 files changed, 755 insertions(+), 150 deletions(-) diff --git a/apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 b/apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 index e6a691f..833b479 100644 --- a/apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 +++ b/apps/runner/assets/script/powershell/oto_agent_bootstrap.ps1 @@ -2,18 +2,58 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$serverUrl = '' -$socketUrl = '' -$agentId = '' +$serverUrl = if ($env:OTO_BOOTSTRAP_SERVER_URL) { $env:OTO_BOOTSTRAP_SERVER_URL } else { '' } +$socketUrl = if ($env:OTO_BOOTSTRAP_SOCKET_URL) { $env:OTO_BOOTSTRAP_SOCKET_URL } else { '' } +$agentId = if ($env:OTO_AGENT_ID) { $env:OTO_AGENT_ID } else { '' } $enrollmentToken = '' -$releaseBaseUrl = '' -$agentAlias = '' +$releaseBaseUrl = if ($env:OTO_BOOTSTRAP_RELEASE_BASE_URL) { $env:OTO_BOOTSTRAP_RELEASE_BASE_URL } else { '' } +$agentAlias = if ($env:OTO_AGENT_ALIAS) { $env:OTO_AGENT_ALIAS } else { '' } $installDir = Join-Path $env:LOCALAPPDATA 'oto\bin' $configPath = Join-Path $env:LOCALAPPDATA 'oto\agent\config.yaml' $workspaceRoot = Join-Path $env:LOCALAPPDATA 'oto\workspace' $logDir = Join-Path $env:LOCALAPPDATA 'oto\agent\log' $background = $true +function Get-HashHex([string]$value) { + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($value) + $hashBytes = $sha.ComputeHash($bytes) + return -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) + } finally { + $sha.Dispose() + } +} + +function Get-DeviceIdentitySource { + $source = '' + try { + $machineGuid = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid + if ($machineGuid) { $source = [string]$machineGuid } + } catch { + $source = '' + } + if (-not $source -and $env:COMPUTERNAME) { + $source = $env:COMPUTERNAME + } + if (-not $source) { + $source = [System.Net.Dns]::GetHostName() + } + return $source +} + +function Get-DefaultAgentId { + $hash = Get-HashHex "oto:windows:$(Get-DeviceIdentitySource)" + return "oto-$($hash.Substring(0, [Math]::Min(32, $hash.Length)))" +} + +function Get-DefaultAgentAlias { + if ($env:COMPUTERNAME) { return $env:COMPUTERNAME } + $hostName = [System.Net.Dns]::GetHostName() + if ($hostName) { return $hostName } + return 'oto-runner' +} + $i = 0 while ($i -lt $args.Count) { switch ($args[$i]) { @@ -21,7 +61,7 @@ while ($i -lt $args.Count) { '--server-url' { $serverUrl = $args[$i + 1]; $i += 2 } '--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 } '--agent-id' { $agentId = $args[$i + 1]; $i += 2 } - '--enrollment-token' { $enrollmentToken = $args[$i + 1]; $i += 2 } + '--token' { $enrollmentToken = $args[$i + 1]; $i += 2 } '--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 } '--agent-alias' { $agentAlias = $args[$i + 1]; $i += 2 } '--install-dir' { $installDir = $args[$i + 1]; $i += 2 } @@ -33,8 +73,23 @@ while ($i -lt $args.Count) { } } -if (-not $serverUrl -or -not $agentId -or -not $enrollmentToken -or -not $releaseBaseUrl) { - Write-Error 'Error: Missing required arguments: --server-url, --agent-id, --enrollment-token, --release-base-url' +if (-not $serverUrl) { $serverUrl = '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__' } +if (-not $socketUrl) { $socketUrl = '__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__' } +if (-not $releaseBaseUrl) { $releaseBaseUrl = '__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__' } +if ($serverUrl -eq '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__') { $serverUrl = '' } +if ($socketUrl -eq '__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__') { $socketUrl = '' } +if ($releaseBaseUrl -eq '__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__') { $releaseBaseUrl = '' } + +if (-not $enrollmentToken) { + Write-Error 'Error: Missing required argument: --token' + exit 1 +} +if (-not $serverUrl) { + Write-Error 'Error: Missing OTO Core URL. Use --server-url or fetch this script from the OTO Core bootstrap endpoint.' + exit 1 +} +if (-not $releaseBaseUrl) { + Write-Error 'Error: Missing release base URL. Use --release-base-url or fetch this script from the OTO Core bootstrap endpoint.' exit 1 } @@ -43,6 +98,13 @@ if (-not $releaseBaseUrl.StartsWith('https://')) { exit 1 } +if (-not $agentId) { $agentId = Get-DefaultAgentId } +if (-not $agentAlias) { $agentAlias = Get-DefaultAgentAlias } +if (-not $agentId) { + Write-Error 'Error: Unable to derive agent id from this device. Use --agent-id to override.' + exit 1 +} + $arch = $env:PROCESSOR_ARCHITECTURE $archName = switch ($arch) { 'AMD64' { 'x64' } diff --git a/apps/runner/assets/script/shell/oto_agent_bootstrap.sh b/apps/runner/assets/script/shell/oto_agent_bootstrap.sh index 57af11a..432bcd1 100644 --- a/apps/runner/assets/script/shell/oto_agent_bootstrap.sh +++ b/apps/runner/assets/script/shell/oto_agent_bootstrap.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -server_url="" -socket_url="" -agent_id="" +server_url="${OTO_BOOTSTRAP_SERVER_URL:-}" +socket_url="${OTO_BOOTSTRAP_SOCKET_URL:-}" +agent_id="${OTO_AGENT_ID:-}" enrollment_token="" -release_base_url="" -agent_alias="" +release_base_url="${OTO_BOOTSTRAP_RELEASE_BASE_URL:-}" +agent_alias="${OTO_AGENT_ALIAS:-}" install_dir="${HOME}/.oto/bin" config_path="${HOME}/.oto/agent/config.yaml" workspace_root="${HOME}/.oto/workspace" @@ -14,10 +14,13 @@ log_dir="${HOME}/.oto/agent/log" background="true" usage() { - echo "Usage: $0 --server-url --socket-url --agent-id --enrollment-token --release-base-url [options]" + echo "Usage: $0 --token [options]" echo "Options:" - echo " --socket-url Runner proto-socket URL (default: derived by agent)" - echo " --agent-alias Alias for the agent" + echo " --server-url OTO Core HTTP URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SERVER_URL)" + echo " --socket-url Runner proto-socket URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SOCKET_URL)" + echo " --release-base-url OTO release asset base URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_RELEASE_BASE_URL)" + echo " --agent-id Agent ID override (default: derived from this device)" + echo " --agent-alias Alias override (default: device name)" echo " --install-dir Directory to install OTO agent (default: \$HOME/.oto/bin)" echo " --config-path Path to config file (default: \$HOME/.oto/agent/config.yaml)" echo " --workspace-root Workspace root directory (default: \$HOME/.oto/workspace)" @@ -35,6 +38,75 @@ yaml_escape() { printf '%s' "$value" } +hash_value() { + local value="$1" + if command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$value" | sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + printf '%s' "$value" | shasum -a 256 | awk '{print $1}' + else + printf '%s' "$value" | cksum | awk '{print $1}' + fi +} + +device_identity_source() { + local source="" + if [[ "${os_name:-}" = "macos" ]] && command -v ioreg >/dev/null 2>&1; then + source="$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformUUID/ { print $(NF-1); exit }' || true)" + fi + if [[ -z "$source" && "${os_name:-}" = "linux" ]]; then + if [[ -r /etc/machine-id ]]; then + source="$(tr -d '[:space:]' < /etc/machine-id)" + elif [[ -r /var/lib/dbus/machine-id ]]; then + source="$(tr -d '[:space:]' < /var/lib/dbus/machine-id)" + fi + fi + if [[ -z "$source" ]]; then + source="$(hostname 2>/dev/null || true)" + fi + printf '%s' "$source" +} + +default_agent_id() { + local source hash + source="$(device_identity_source)" + hash="$(hash_value "oto:${os_name}:${source}")" + printf 'oto-%s' "${hash:0:32}" +} + +default_agent_alias() { + local alias="" + if [[ "${os_name:-}" = "macos" ]] && command -v scutil >/dev/null 2>&1; then + alias="$(scutil --get ComputerName 2>/dev/null || true)" + fi + if [[ -z "$alias" ]]; then + alias="$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)" + fi + if [[ -z "$alias" ]]; then + alias="oto-runner" + fi + printf '%s' "$alias" +} + +if [[ -z "$server_url" ]]; then + server_url='__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__' +fi +if [[ -z "$socket_url" ]]; then + socket_url='__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__' +fi +if [[ -z "$release_base_url" ]]; then + release_base_url='__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__' +fi +if [[ "$server_url" = "__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__" ]]; then + server_url="" +fi +if [[ "$socket_url" = "__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__" ]]; then + socket_url="" +fi +if [[ "$release_base_url" = "__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__" ]]; then + release_base_url="" +fi + while [[ $# -gt 0 ]]; do case "$1" in --server-url|--edge-url) @@ -49,7 +121,7 @@ while [[ $# -gt 0 ]]; do agent_id="$2" shift 2 ;; - --enrollment-token) + --token) enrollment_token="$2" shift 2 ;; @@ -89,8 +161,18 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$server_url" || -z "$agent_id" || -z "$enrollment_token" || -z "$release_base_url" ]]; then - echo "Error: Missing required arguments." >&2 +if [[ -z "$enrollment_token" ]]; then + echo "Error: Missing required argument: --token." >&2 + usage + exit 1 +fi +if [[ -z "$server_url" ]]; then + echo "Error: Missing OTO Core URL. Use --server-url or fetch this script from the OTO Core bootstrap endpoint." >&2 + usage + exit 1 +fi +if [[ -z "$release_base_url" ]]; then + echo "Error: Missing release base URL. Use --release-base-url or fetch this script from the OTO Core bootstrap endpoint." >&2 usage exit 1 fi @@ -125,6 +207,17 @@ esac asset_name="oto-${os_name}-${arch_name}.tar.gz" +if [[ -z "$agent_id" ]]; then + agent_id="$(default_agent_id)" +fi +if [[ -z "$agent_alias" ]]; then + agent_alias="$(default_agent_alias)" +fi +if [[ -z "$agent_id" ]]; then + echo "Error: Unable to derive agent id from this device. Use --agent-id to override." >&2 + exit 1 +fi + download_url="${release_base_url}/${asset_name}" tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT @@ -139,6 +232,13 @@ mkdir -p "$install_dir" cp "$tmp_dir/oto" "$install_dir/oto" chmod +x "$install_dir/oto" +if [[ "$os_name" = "macos" ]]; then + xattr -dr com.apple.quarantine "$install_dir/oto" 2>/dev/null || true + if command -v codesign >/dev/null 2>&1; then + codesign --force --sign - "$install_dir/oto" >/dev/null 2>&1 || true + fi +fi + echo "Verifying OTO binary..." if ! "$install_dir/oto" --version > /dev/null 2>&1; then echo "Error: OTO binary verification failed." >&2 diff --git a/apps/runner/test/oto_agent_bootstrap_ps1_test.dart b/apps/runner/test/oto_agent_bootstrap_ps1_test.dart index 21f00be..84ddddf 100644 --- a/apps/runner/test/oto_agent_bootstrap_ps1_test.dart +++ b/apps/runner/test/oto_agent_bootstrap_ps1_test.dart @@ -17,9 +17,10 @@ void main() { }); test('should contain required flags', () { + expect(content, contains('--token')); expect(content, contains('--server-url')); expect(content, contains('--agent-id')); - expect(content, contains('--enrollment-token')); + expect(content, isNot(contains('--enrollment-token'))); expect(content, contains('--release-base-url')); }); diff --git a/apps/runner/test/oto_agent_bootstrap_script_test.dart b/apps/runner/test/oto_agent_bootstrap_script_test.dart index 817a9d0..145dd92 100644 --- a/apps/runner/test/oto_agent_bootstrap_script_test.dart +++ b/apps/runner/test/oto_agent_bootstrap_script_test.dart @@ -44,9 +44,13 @@ Future<_BootstrapRunResult> _runBootstrapWithFakeUname( required String unameM, bool provideTarGz = true, String serverUrl = 'https://server.example.com', + String socketUrl = 'tcp://socket.example.com:18080', + String releaseBaseUrl = 'https://example.com/release', String agentId = 'test-agent', String token = 'test-token-secret', int fakeOtoVersionExitCode = 0, + bool passConnectionArgs = true, + bool passAgentId = true, }) async { final tmpDir = await Directory.systemTemp.createTemp('oto_matrix_test_'); try { @@ -67,6 +71,12 @@ esac '''); await Process.run('chmod', ['+x', fakeUname.path]); + final fakeHostname = File('${fakeBinDir.path}/hostname'); + await fakeHostname.writeAsString('''#!/bin/sh +echo "fake-device" +'''); + await Process.run('chmod', ['+x', fakeHostname.path]); + // curl 호출 시 요청된 asset basename을 기록할 파일 final assetLog = File('${tmpDir.path}/requested_asset.txt'); @@ -119,18 +129,31 @@ $copyLine final env = Map.from(Platform.environment) ..['PATH'] = '${fakeBinDir.path}:${Platform.environment['PATH'] ?? ''}' ..['HOME'] = tempHome.path; + if (!passConnectionArgs) { + env['OTO_BOOTSTRAP_SERVER_URL'] = serverUrl; + env['OTO_BOOTSTRAP_SOCKET_URL'] = socketUrl; + env['OTO_BOOTSTRAP_RELEASE_BASE_URL'] = releaseBaseUrl; + } - final result = await Process.run('bash', [ + final args = [ scriptFile.absolute.path, - '--server-url', - serverUrl, - '--agent-id', - agentId, - '--enrollment-token', + if (passConnectionArgs) ...[ + '--server-url', + serverUrl, + ], + if (passAgentId) ...[ + '--agent-id', + agentId, + ], + '--token', token, - '--release-base-url', - 'https://example.com/release', - ], environment: env); + if (passConnectionArgs) ...[ + '--release-base-url', + releaseBaseUrl, + ], + ]; + + final result = await Process.run('bash', args, environment: env); String? requestedAsset; if (await assetLog.exists()) { @@ -178,10 +201,11 @@ void main() { content = await scriptFile.readAsString(); }); - test('should contain required flags', () { - expect(content, contains('--server-url')); + test('should contain required flags and optional connection overrides', () { + expect(content, contains('--token')); expect(content, contains('--agent-id')); - expect(content, contains('--enrollment-token')); + expect(content, isNot(contains('--enrollment-token'))); + expect(content, contains('--server-url')); expect(content, contains('--release-base-url')); }); @@ -286,6 +310,29 @@ void main() { }); } + test( + 'uses served bootstrap defaults so command only needs token', + () async { + final r = await _runBootstrapWithFakeUname( + scriptFile, + unameS: 'Darwin', + unameM: 'arm64', + provideTarGz: true, + passConnectionArgs: false, + passAgentId: false, + ); + expect(r.exitCode, 0, reason: 'stderr: ${r.stderr}'); + expect(r.requestedAsset, 'oto-macos-arm64.tar.gz'); + expect(r.configContent, matches(RegExp(r'id: "oto-[0-9a-f]{32}"'))); + expect(r.configContent, matches(RegExp(r'alias: ".+"'))); + expect(r.configContent, contains('url: "https://server.example.com"')); + expect( + r.configContent, + contains('socket_url: "tcp://socket.example.com:18080"'), + ); + }, + ); + test('fails before download for unsupported OS (FreeBSD)', () async { const token = 'secret-os-token-789'; final r = await _runBootstrapWithFakeUname( @@ -454,8 +501,7 @@ runtime: final result = await Process.run('bash', [ scriptFile.absolute.path, '--server-url', 'https://server.example.com', - '--agent-id', 'test-agent', - '--enrollment-token', 'secret-token-123', + '--token', 'secret-token-123', '--release-base-url', 'http://example.com/release', // HTTP URL ]); @@ -539,9 +585,7 @@ fi scriptFile.absolute.path, '--server-url', 'https://server.example.com', - '--agent-id', - 'test-agent', - '--enrollment-token', + '--token', 'secret-token-123', '--release-base-url', 'https://example.com/release', @@ -648,9 +692,7 @@ fi scriptFile.absolute.path, '--edge-url', 'https://edge.example.com', - '--agent-id', - 'test-agent-alias', - '--enrollment-token', + '--token', 'secret-token-123', '--release-base-url', 'https://example.com/release', diff --git a/apps/runner/test/oto_server_connection_smoke_test.dart b/apps/runner/test/oto_server_connection_smoke_test.dart index 90601cb..25bf924 100644 --- a/apps/runner/test/oto_server_connection_smoke_test.dart +++ b/apps/runner/test/oto_server_connection_smoke_test.dart @@ -500,7 +500,7 @@ void main() { headers: {'content-type': 'application/json'}, body: jsonEncode({ 'runner_id': 'test-runner-id', - 'enrollment_token': 'test-token-123', + 'token': 'test-token-123', }), ); @@ -509,13 +509,15 @@ void main() { expect(data['bootstrap_command'], isNotNull); final bootstrapCommand = data['bootstrap_command'] as String; - // Verify command contains server URL, runner ID, enrollment token + // Verify command contains server URL and token only. Runner ID is + // derived on the node by the bootstrap script. expect(bootstrapCommand, contains('http://$serverAddr')); - expect(bootstrapCommand, contains('test-runner-id')); + expect(bootstrapCommand, isNot(contains('test-runner-id'))); expect(bootstrapCommand, contains('test-token-123')); - expect(bootstrapCommand, contains('--server-url')); - expect(bootstrapCommand, contains('--agent-id')); - expect(bootstrapCommand, contains('--enrollment-token')); + expect(bootstrapCommand, isNot(contains('--server-url'))); + expect(bootstrapCommand, isNot(contains('--agent-id'))); + expect(bootstrapCommand, contains('--token')); + expect(bootstrapCommand, isNot(contains('--enrollment-token'))); // 2. Full script execution test using fake curl/tar final tempDir = await Directory.systemTemp.createTemp( @@ -523,9 +525,6 @@ void main() { ); final tempHome = Directory('${tempDir.path}/home'); await tempHome.create(recursive: true); - - final scriptFile = File('assets/script/shell/oto_agent_bootstrap.sh'); - // Create fake OTO executable source final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src'); await fakeOtoSource.create(recursive: true); @@ -548,9 +547,13 @@ void main() { // Create fake bin dir and fake curl final fakeBinDir = Directory('${tempDir.path}/bin'); await fakeBinDir.create(recursive: true); + final realCurlResult = await Process.run('which', ['curl']); + final realCurlPath = realCurlResult.stdout.toString().trim(); + expect(realCurlPath, isNotEmpty, reason: 'curl must be available'); final fakeCurl = File('${fakeBinDir.path}/curl'); await fakeCurl.writeAsString('''#!/bin/sh out_file="" + url="" while [ \$# -gt 0 ]; do if [ "\$1" = "-o" ]; then out_file="\$2" @@ -558,6 +561,7 @@ void main() { elif [ "\$1" = "-fsSL" ]; then shift 1 else + url="\$1" shift 1 fi done @@ -565,7 +569,7 @@ void main() { if [ -n "\$out_file" ]; then cp "${fakeTarGz.path}" "\$out_file" else - cat "${scriptFile.absolute.path}" + "$realCurlPath" -fsSL "\$url" fi '''); await Process.run('chmod', ['+x', fakeCurl.path]); @@ -614,7 +618,7 @@ void main() { final configContent = await configFile.readAsString(); expect(configContent, contains('server:')); expect(configContent, contains('url: "http://$serverAddr"')); - expect(configContent, contains('id: "test-runner-id"')); + expect(configContent, matches(RegExp(r'id: "oto-[0-9a-f]{32}"'))); expect(configContent, contains('enrollment_token: "test-token-123"')); // - Installed binary exists and has execution permissions diff --git a/services/core/internal/httpserver/oto_agent_bootstrap.ps1 b/services/core/internal/httpserver/oto_agent_bootstrap.ps1 index e6a691f..833b479 100644 --- a/services/core/internal/httpserver/oto_agent_bootstrap.ps1 +++ b/services/core/internal/httpserver/oto_agent_bootstrap.ps1 @@ -2,18 +2,58 @@ Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' -$serverUrl = '' -$socketUrl = '' -$agentId = '' +$serverUrl = if ($env:OTO_BOOTSTRAP_SERVER_URL) { $env:OTO_BOOTSTRAP_SERVER_URL } else { '' } +$socketUrl = if ($env:OTO_BOOTSTRAP_SOCKET_URL) { $env:OTO_BOOTSTRAP_SOCKET_URL } else { '' } +$agentId = if ($env:OTO_AGENT_ID) { $env:OTO_AGENT_ID } else { '' } $enrollmentToken = '' -$releaseBaseUrl = '' -$agentAlias = '' +$releaseBaseUrl = if ($env:OTO_BOOTSTRAP_RELEASE_BASE_URL) { $env:OTO_BOOTSTRAP_RELEASE_BASE_URL } else { '' } +$agentAlias = if ($env:OTO_AGENT_ALIAS) { $env:OTO_AGENT_ALIAS } else { '' } $installDir = Join-Path $env:LOCALAPPDATA 'oto\bin' $configPath = Join-Path $env:LOCALAPPDATA 'oto\agent\config.yaml' $workspaceRoot = Join-Path $env:LOCALAPPDATA 'oto\workspace' $logDir = Join-Path $env:LOCALAPPDATA 'oto\agent\log' $background = $true +function Get-HashHex([string]$value) { + $sha = [System.Security.Cryptography.SHA256]::Create() + try { + $bytes = [System.Text.Encoding]::UTF8.GetBytes($value) + $hashBytes = $sha.ComputeHash($bytes) + return -join ($hashBytes | ForEach-Object { $_.ToString('x2') }) + } finally { + $sha.Dispose() + } +} + +function Get-DeviceIdentitySource { + $source = '' + try { + $machineGuid = (Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Cryptography' -Name MachineGuid -ErrorAction Stop).MachineGuid + if ($machineGuid) { $source = [string]$machineGuid } + } catch { + $source = '' + } + if (-not $source -and $env:COMPUTERNAME) { + $source = $env:COMPUTERNAME + } + if (-not $source) { + $source = [System.Net.Dns]::GetHostName() + } + return $source +} + +function Get-DefaultAgentId { + $hash = Get-HashHex "oto:windows:$(Get-DeviceIdentitySource)" + return "oto-$($hash.Substring(0, [Math]::Min(32, $hash.Length)))" +} + +function Get-DefaultAgentAlias { + if ($env:COMPUTERNAME) { return $env:COMPUTERNAME } + $hostName = [System.Net.Dns]::GetHostName() + if ($hostName) { return $hostName } + return 'oto-runner' +} + $i = 0 while ($i -lt $args.Count) { switch ($args[$i]) { @@ -21,7 +61,7 @@ while ($i -lt $args.Count) { '--server-url' { $serverUrl = $args[$i + 1]; $i += 2 } '--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 } '--agent-id' { $agentId = $args[$i + 1]; $i += 2 } - '--enrollment-token' { $enrollmentToken = $args[$i + 1]; $i += 2 } + '--token' { $enrollmentToken = $args[$i + 1]; $i += 2 } '--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 } '--agent-alias' { $agentAlias = $args[$i + 1]; $i += 2 } '--install-dir' { $installDir = $args[$i + 1]; $i += 2 } @@ -33,8 +73,23 @@ while ($i -lt $args.Count) { } } -if (-not $serverUrl -or -not $agentId -or -not $enrollmentToken -or -not $releaseBaseUrl) { - Write-Error 'Error: Missing required arguments: --server-url, --agent-id, --enrollment-token, --release-base-url' +if (-not $serverUrl) { $serverUrl = '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__' } +if (-not $socketUrl) { $socketUrl = '__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__' } +if (-not $releaseBaseUrl) { $releaseBaseUrl = '__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__' } +if ($serverUrl -eq '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__') { $serverUrl = '' } +if ($socketUrl -eq '__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__') { $socketUrl = '' } +if ($releaseBaseUrl -eq '__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__') { $releaseBaseUrl = '' } + +if (-not $enrollmentToken) { + Write-Error 'Error: Missing required argument: --token' + exit 1 +} +if (-not $serverUrl) { + Write-Error 'Error: Missing OTO Core URL. Use --server-url or fetch this script from the OTO Core bootstrap endpoint.' + exit 1 +} +if (-not $releaseBaseUrl) { + Write-Error 'Error: Missing release base URL. Use --release-base-url or fetch this script from the OTO Core bootstrap endpoint.' exit 1 } @@ -43,6 +98,13 @@ if (-not $releaseBaseUrl.StartsWith('https://')) { exit 1 } +if (-not $agentId) { $agentId = Get-DefaultAgentId } +if (-not $agentAlias) { $agentAlias = Get-DefaultAgentAlias } +if (-not $agentId) { + Write-Error 'Error: Unable to derive agent id from this device. Use --agent-id to override.' + exit 1 +} + $arch = $env:PROCESSOR_ARCHITECTURE $archName = switch ($arch) { 'AMD64' { 'x64' } diff --git a/services/core/internal/httpserver/oto_agent_bootstrap.sh b/services/core/internal/httpserver/oto_agent_bootstrap.sh index 57af11a..432bcd1 100644 --- a/services/core/internal/httpserver/oto_agent_bootstrap.sh +++ b/services/core/internal/httpserver/oto_agent_bootstrap.sh @@ -1,12 +1,12 @@ #!/usr/bin/env bash set -euo pipefail -server_url="" -socket_url="" -agent_id="" +server_url="${OTO_BOOTSTRAP_SERVER_URL:-}" +socket_url="${OTO_BOOTSTRAP_SOCKET_URL:-}" +agent_id="${OTO_AGENT_ID:-}" enrollment_token="" -release_base_url="" -agent_alias="" +release_base_url="${OTO_BOOTSTRAP_RELEASE_BASE_URL:-}" +agent_alias="${OTO_AGENT_ALIAS:-}" install_dir="${HOME}/.oto/bin" config_path="${HOME}/.oto/agent/config.yaml" workspace_root="${HOME}/.oto/workspace" @@ -14,10 +14,13 @@ log_dir="${HOME}/.oto/agent/log" background="true" usage() { - echo "Usage: $0 --server-url --socket-url --agent-id --enrollment-token --release-base-url [options]" + echo "Usage: $0 --token [options]" echo "Options:" - echo " --socket-url Runner proto-socket URL (default: derived by agent)" - echo " --agent-alias Alias for the agent" + echo " --server-url OTO Core HTTP URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SERVER_URL)" + echo " --socket-url Runner proto-socket URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SOCKET_URL)" + echo " --release-base-url OTO release asset base URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_RELEASE_BASE_URL)" + echo " --agent-id Agent ID override (default: derived from this device)" + echo " --agent-alias Alias override (default: device name)" echo " --install-dir Directory to install OTO agent (default: \$HOME/.oto/bin)" echo " --config-path Path to config file (default: \$HOME/.oto/agent/config.yaml)" echo " --workspace-root Workspace root directory (default: \$HOME/.oto/workspace)" @@ -35,6 +38,75 @@ yaml_escape() { printf '%s' "$value" } +hash_value() { + local value="$1" + if command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$value" | sha256sum | awk '{print $1}' + elif command -v shasum >/dev/null 2>&1; then + printf '%s' "$value" | shasum -a 256 | awk '{print $1}' + else + printf '%s' "$value" | cksum | awk '{print $1}' + fi +} + +device_identity_source() { + local source="" + if [[ "${os_name:-}" = "macos" ]] && command -v ioreg >/dev/null 2>&1; then + source="$(ioreg -rd1 -c IOPlatformExpertDevice 2>/dev/null | awk -F'"' '/IOPlatformUUID/ { print $(NF-1); exit }' || true)" + fi + if [[ -z "$source" && "${os_name:-}" = "linux" ]]; then + if [[ -r /etc/machine-id ]]; then + source="$(tr -d '[:space:]' < /etc/machine-id)" + elif [[ -r /var/lib/dbus/machine-id ]]; then + source="$(tr -d '[:space:]' < /var/lib/dbus/machine-id)" + fi + fi + if [[ -z "$source" ]]; then + source="$(hostname 2>/dev/null || true)" + fi + printf '%s' "$source" +} + +default_agent_id() { + local source hash + source="$(device_identity_source)" + hash="$(hash_value "oto:${os_name}:${source}")" + printf 'oto-%s' "${hash:0:32}" +} + +default_agent_alias() { + local alias="" + if [[ "${os_name:-}" = "macos" ]] && command -v scutil >/dev/null 2>&1; then + alias="$(scutil --get ComputerName 2>/dev/null || true)" + fi + if [[ -z "$alias" ]]; then + alias="$(hostname -s 2>/dev/null || hostname 2>/dev/null || true)" + fi + if [[ -z "$alias" ]]; then + alias="oto-runner" + fi + printf '%s' "$alias" +} + +if [[ -z "$server_url" ]]; then + server_url='__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__' +fi +if [[ -z "$socket_url" ]]; then + socket_url='__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__' +fi +if [[ -z "$release_base_url" ]]; then + release_base_url='__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__' +fi +if [[ "$server_url" = "__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__" ]]; then + server_url="" +fi +if [[ "$socket_url" = "__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__" ]]; then + socket_url="" +fi +if [[ "$release_base_url" = "__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__" ]]; then + release_base_url="" +fi + while [[ $# -gt 0 ]]; do case "$1" in --server-url|--edge-url) @@ -49,7 +121,7 @@ while [[ $# -gt 0 ]]; do agent_id="$2" shift 2 ;; - --enrollment-token) + --token) enrollment_token="$2" shift 2 ;; @@ -89,8 +161,18 @@ while [[ $# -gt 0 ]]; do esac done -if [[ -z "$server_url" || -z "$agent_id" || -z "$enrollment_token" || -z "$release_base_url" ]]; then - echo "Error: Missing required arguments." >&2 +if [[ -z "$enrollment_token" ]]; then + echo "Error: Missing required argument: --token." >&2 + usage + exit 1 +fi +if [[ -z "$server_url" ]]; then + echo "Error: Missing OTO Core URL. Use --server-url or fetch this script from the OTO Core bootstrap endpoint." >&2 + usage + exit 1 +fi +if [[ -z "$release_base_url" ]]; then + echo "Error: Missing release base URL. Use --release-base-url or fetch this script from the OTO Core bootstrap endpoint." >&2 usage exit 1 fi @@ -125,6 +207,17 @@ esac asset_name="oto-${os_name}-${arch_name}.tar.gz" +if [[ -z "$agent_id" ]]; then + agent_id="$(default_agent_id)" +fi +if [[ -z "$agent_alias" ]]; then + agent_alias="$(default_agent_alias)" +fi +if [[ -z "$agent_id" ]]; then + echo "Error: Unable to derive agent id from this device. Use --agent-id to override." >&2 + exit 1 +fi + download_url="${release_base_url}/${asset_name}" tmp_dir=$(mktemp -d) trap 'rm -rf "$tmp_dir"' EXIT @@ -139,6 +232,13 @@ mkdir -p "$install_dir" cp "$tmp_dir/oto" "$install_dir/oto" chmod +x "$install_dir/oto" +if [[ "$os_name" = "macos" ]]; then + xattr -dr com.apple.quarantine "$install_dir/oto" 2>/dev/null || true + if command -v codesign >/dev/null 2>&1; then + codesign --force --sign - "$install_dir/oto" >/dev/null 2>&1 || true + fi +fi + echo "Verifying OTO binary..." if ! "$install_dir/oto" --version > /dev/null 2>&1; then echo "Error: OTO binary verification failed." >&2 diff --git a/services/core/internal/httpserver/routes.go b/services/core/internal/httpserver/routes.go index 145203b..f0ac020 100644 --- a/services/core/internal/httpserver/routes.go +++ b/services/core/internal/httpserver/routes.go @@ -24,7 +24,7 @@ func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store mux.HandleFunc("/api/v1/runners/bootstrap-command", withCORS(handleRunnerBootstrapCommand(registry))) mux.HandleFunc("/api/v1/runners/{id}/heartbeat", withCORS(handleRunnerHeartbeat(registry))) mux.HandleFunc("/api/v1/runners/{id}/disconnect", withCORS(handleRunnerDisconnect(registry))) - mux.HandleFunc("/api/v1/runners/{id}", withCORS(handleGetRunner(registry))) + mux.HandleFunc("/api/v1/runners/{id}", withCORS(handleRunnerResource(registry))) mux.HandleFunc("/bootstrap/oto-agent.sh", withCORS(handleServeBootstrapScript(embeddedBootstrapProvider{}))) mux.HandleFunc("/bootstrap/oto-agent.ps1", withCORS(handleServeBootstrapPs1(embeddedBootstrapPs1Provider{}))) mux.HandleFunc("/api/v1/", withCORS(handleRouterWithDispatcher(store, registry, dispatcher))) @@ -33,7 +33,7 @@ func registerRoutes(mux *http.ServeMux, registry *runnerregistry.Registry, store func withCORS(next http.HandlerFunc) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") - w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") w.Header().Set("Access-Control-Max-Age", "600") if r.Method == http.MethodOptions { diff --git a/services/core/internal/httpserver/runner_handlers.go b/services/core/internal/httpserver/runner_handlers.go index 5578ca8..5c0f18c 100644 --- a/services/core/internal/httpserver/runner_handlers.go +++ b/services/core/internal/httpserver/runner_handlers.go @@ -174,6 +174,62 @@ func handleGetRunner(registry *runnerregistry.Registry) http.HandlerFunc { } } +func handleUpdateRunner(registry *runnerregistry.Registry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + + runnerID := r.PathValue("id") + if runnerID == "" { + parts := strings.Split(r.URL.Path, "/") + if len(parts) >= 5 { + runnerID = parts[4] + } + } + + if runnerID == "" { + writeResponse(w, http.StatusBadRequest, errorToJSON("missing runner id")) + return + } + + var request struct { + Alias string `json:"alias"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + writeResponse(w, http.StatusBadRequest, errorToJSON("invalid runner update request")) + return + } + alias := strings.TrimSpace(request.Alias) + if alias == "" { + writeResponse(w, http.StatusBadRequest, errorToJSON("missing alias")) + return + } + + record, ok := registry.UpdateAlias(runnerID, alias) + if !ok { + writeResponse(w, http.StatusNotFound, errorToJSON("runner not found")) + return + } + + writeResponse(w, http.StatusOK, runnerRecordToJSON(record)) + } +} + +func handleRunnerResource(registry *runnerregistry.Registry) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + handleGetRunner(registry)(w, r) + case http.MethodPatch: + handleUpdateRunner(registry)(w, r) + default: + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + } + } +} + func shellEscape(s string) string { return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'" } @@ -189,90 +245,54 @@ func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.Handle return } - var request otopb.BootstrapCommandRequest + var request struct { + EnrollmentToken string `json:"enrollment_token"` + Token string `json:"token"` + Target string `json:"target"` + } if err := json.NewDecoder(r.Body).Decode(&request); err != nil { writeResponse(w, http.StatusBadRequest, errorToJSON("invalid bootstrap command request")) return } - runnerID := strings.TrimSpace(request.GetRunnerId()) - token := strings.TrimSpace(request.GetEnrollmentToken()) - if runnerID == "" || token == "" { - writeResponse(w, http.StatusBadRequest, errorToJSON("missing runner id or enrollment token")) + token := strings.TrimSpace(request.Token) + if token == "" { + token = strings.TrimSpace(request.EnrollmentToken) + } + if token == "" { + writeResponse(w, http.StatusBadRequest, errorToJSON("missing token")) return } - target := strings.ToLower(strings.TrimSpace(request.GetTarget())) + target := strings.ToLower(strings.TrimSpace(request.Target)) if target != "" && target != "linux" && target != "macos" && target != "windows" { writeResponse(w, http.StatusBadRequest, errorToJSON(fmt.Sprintf("unsupported bootstrap target: %s", target))) return } - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - serverURL := scheme + "://" + r.Host - - u, err := url.Parse(serverURL) - if err != nil || u.Host == "" || u.Scheme == "" { - writeResponse(w, http.StatusBadRequest, errorToJSON("invalid server URL")) - return - } - - // Host validation to prevent command injection and host header spoofing - for _, char := range u.Host { - if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || - char == '.' || char == '-' || char == ':' || char == '[' || char == ']') { - writeResponse(w, http.StatusBadRequest, errorToJSON("invalid characters in server Host")) - return - } - } - - releaseBaseURL := os.Getenv("OTO_RUNNER_RELEASE_BASE_URL") - if releaseBaseURL == "" { - if scheme == "https" { - releaseBaseURL = serverURL + "/releases" - } else { - writeResponse(w, http.StatusBadRequest, errorToJSON("release base URL must use HTTPS (set OTO_RUNNER_RELEASE_BASE_URL environment variable)")) - return - } - } else { - if !strings.HasPrefix(releaseBaseURL, "https://") { - writeResponse(w, http.StatusBadRequest, errorToJSON("OTO_RUNNER_RELEASE_BASE_URL must use https:// scheme")) - return - } - } - socketURL, err := runnerSocketPublicURL(serverURL) + defaults, err := bootstrapDefaultsForRequest(r, true) if err != nil { writeResponse(w, http.StatusBadRequest, errorToJSON(err.Error())) return } + serverURL := defaults.ServerURL var bootstrapCmd string if target == "windows" { escapedScriptURL := powershellEscape(serverURL + "/bootstrap/oto-agent.ps1") - escapedServerURL := powershellEscape(serverURL) - escapedSocketURL := powershellEscape(socketURL) - escapedRunnerID := powershellEscape(runnerID) escapedToken := powershellEscape(token) - escapedReleaseURL := powershellEscape(releaseBaseURL) bootstrapCmd = fmt.Sprintf( - "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm %s))) -- --server-url %s --socket-url %s --agent-id %s --enrollment-token %s --release-base-url %s\"", - escapedScriptURL, escapedServerURL, escapedSocketURL, escapedRunnerID, escapedToken, escapedReleaseURL, + "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm %s))) -- --token %s\"", + escapedScriptURL, escapedToken, ) } else { escapedScriptURL := shellEscape(serverURL + "/bootstrap/oto-agent.sh") - escapedServerURL := shellEscape(serverURL) - escapedSocketURL := shellEscape(socketURL) - escapedRunnerID := shellEscape(runnerID) escapedToken := shellEscape(token) - escapedReleaseURL := shellEscape(releaseBaseURL) bootstrapCmd = fmt.Sprintf( - "curl -fsSL %s | bash -s -- --server-url %s --socket-url %s --agent-id %s --enrollment-token %s --release-base-url %s", - escapedScriptURL, escapedServerURL, escapedSocketURL, escapedRunnerID, escapedToken, escapedReleaseURL, + "curl -fsSL %s | bash -s -- --token %s", + escapedScriptURL, escapedToken, ) } @@ -284,6 +304,61 @@ func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.Handle } } +type bootstrapDefaults struct { + ServerURL string + SocketURL string + ReleaseBaseURL string +} + +func bootstrapDefaultsForRequest(r *http.Request, requireRelease bool) (bootstrapDefaults, error) { + scheme := "http" + if r.TLS != nil { + scheme = "https" + } + serverURL := scheme + "://" + r.Host + + u, err := url.Parse(serverURL) + if err != nil || u.Host == "" || u.Scheme == "" { + return bootstrapDefaults{}, fmt.Errorf("invalid server URL") + } + + // Host validation to prevent command injection and host header spoofing. + for _, char := range u.Host { + if !((char >= 'a' && char <= 'z') || (char >= 'A' && char <= 'Z') || (char >= '0' && char <= '9') || + char == '.' || char == '-' || char == ':' || char == '[' || char == ']') { + return bootstrapDefaults{}, fmt.Errorf("invalid characters in server Host") + } + } + + releaseBaseURL := strings.TrimSpace(os.Getenv("OTO_RUNNER_RELEASE_BASE_URL")) + if releaseBaseURL == "" { + if scheme == "https" { + releaseBaseURL = serverURL + "/releases" + } else if requireRelease { + return bootstrapDefaults{}, fmt.Errorf("release base URL must use HTTPS (set OTO_RUNNER_RELEASE_BASE_URL environment variable)") + } + } else if !strings.HasPrefix(releaseBaseURL, "https://") { + if requireRelease { + return bootstrapDefaults{}, fmt.Errorf("OTO_RUNNER_RELEASE_BASE_URL must use https:// scheme") + } + releaseBaseURL = "" + } + + socketURL, err := runnerSocketPublicURL(serverURL) + if err != nil { + if requireRelease { + return bootstrapDefaults{}, err + } + socketURL = "" + } + + return bootstrapDefaults{ + ServerURL: serverURL, + SocketURL: socketURL, + ReleaseBaseURL: releaseBaseURL, + }, nil +} + func runnerSocketPublicURL(serverURL string) (string, error) { if configured := strings.TrimSpace(os.Getenv("OTO_RUNNER_SOCKET_PUBLIC_URL")); configured != "" { if !strings.Contains(configured, "://") { @@ -313,6 +388,8 @@ func handleServeBootstrapScript(provider bootstrapScriptProvider) http.HandlerFu http.Error(w, "Bootstrap script not available: "+err.Error(), http.StatusInternalServerError) return } + defaults, _ := bootstrapDefaultsForRequest(r, false) + content = renderShellBootstrapDefaults(content, defaults) w.Header().Set("Content-Type", "application/x-sh") w.WriteHeader(http.StatusOK) _, _ = w.Write(content) @@ -330,12 +407,38 @@ func handleServeBootstrapPs1(provider bootstrapPs1Provider) http.HandlerFunc { http.Error(w, "Bootstrap PS1 script not available: "+err.Error(), http.StatusInternalServerError) return } + defaults, _ := bootstrapDefaultsForRequest(r, false) + content = renderPowerShellBootstrapDefaults(content, defaults) w.Header().Set("Content-Type", "application/x-powershell") w.WriteHeader(http.StatusOK) _, _ = w.Write(content) } } +func renderShellBootstrapDefaults(content []byte, defaults bootstrapDefaults) []byte { + rendered := string(content) + rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__", shellSingleQuoteContent(defaults.ServerURL), 1) + rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__", shellSingleQuoteContent(defaults.SocketURL), 1) + rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__", shellSingleQuoteContent(defaults.ReleaseBaseURL), 1) + return []byte(rendered) +} + +func renderPowerShellBootstrapDefaults(content []byte, defaults bootstrapDefaults) []byte { + rendered := string(content) + rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__", powershellSingleQuoteContent(defaults.ServerURL), 1) + rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__", powershellSingleQuoteContent(defaults.SocketURL), 1) + rendered = strings.Replace(rendered, "__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__", powershellSingleQuoteContent(defaults.ReleaseBaseURL), 1) + return []byte(rendered) +} + +func shellSingleQuoteContent(s string) string { + return strings.ReplaceAll(s, "'", "'\\''") +} + +func powershellSingleQuoteContent(s string) string { + return strings.ReplaceAll(s, "'", "''") +} + func writeRunnerRegisterResponse(w http.ResponseWriter, status int, response *otopb.RegisterRunnerResponse) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/services/core/internal/httpserver/server_test.go b/services/core/internal/httpserver/server_test.go index ea9f021..a46b355 100644 --- a/services/core/internal/httpserver/server_test.go +++ b/services/core/internal/httpserver/server_test.go @@ -384,6 +384,48 @@ func TestHandleGetRunner(t *testing.T) { } } +func TestHandleUpdateRunnerAlias(t *testing.T) { + registry := runnerregistry.New() + registry.Register(&otopb.RegisterRunnerRequest{ + EnrollmentToken: "token-123", + RunnerId: "runner-123", + Alias: "mac-mini", + ProtocolVersion: "oto.runner.v1", + Capability: &otopb.RunnerCapability{ + Name: "oto-runner", + Version: "1.0.0", + }, + }) + + body := bytes.NewBufferString(`{"alias":"Studio Mini"}`) + req := httptest.NewRequest(http.MethodPatch, "/api/v1/runners/runner-123", body) + rr := httptest.NewRecorder() + + handleRunnerResource(registry)(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %v, want %v; body=%s", rr.Code, http.StatusOK, rr.Body.String()) + } + + var res map[string]interface{} + if err := json.Unmarshal(rr.Body.Bytes(), &res); err != nil { + t.Fatalf("decode response: %v", err) + } + if res["runner_id"] != "runner-123" { + t.Fatalf("runner_id = %v", res["runner_id"]) + } + if res["alias"] != "Studio Mini" { + t.Fatalf("alias = %v, want Studio Mini", res["alias"]) + } + + record, ok := registry.Snapshot("runner-123") + if !ok { + t.Fatal("runner missing after update") + } + if record.Alias != "Studio Mini" { + t.Fatalf("registry alias = %q, want Studio Mini", record.Alias) + } +} + func TestHandleRunnerHeartbeatMismatch(t *testing.T) { registry := runnerregistry.New() @@ -439,18 +481,18 @@ func TestHandleRunnerHeartbeatMismatch(t *testing.T) { func TestHandleRunnerBootstrapCommand(t *testing.T) { registry := runnerregistry.New() - // 1. Missing runner_id or enrollment_token should be rejected - bodyMissing := bytes.NewBufferString(`{"runner_id":"","enrollment_token":""}`) + // 1. Missing token should be rejected + bodyMissing := bytes.NewBufferString(`{}`) req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMissing) rr := httptest.NewRecorder() handleRunnerBootstrapCommand(registry)(rr, req) if rr.Code != http.StatusBadRequest { - t.Fatalf("status = %v, want %v for missing runner_id/token", rr.Code, http.StatusBadRequest) + t.Fatalf("status = %v, want %v for missing token", rr.Code, http.StatusBadRequest) } - // 2. Missing token only should be rejected - bodyNoToken := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":""}`) + // 2. Empty token should be rejected + bodyNoToken := bytes.NewBufferString(`{"token":""}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyNoToken) rr = httptest.NewRecorder() @@ -461,7 +503,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { // 3. Request should be rejected if server is HTTP and OTO_RUNNER_RELEASE_BASE_URL is not set t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "") - bodyValid := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`) + bodyValid := bytes.NewBufferString(`{"token":"token-123"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -473,7 +515,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { // 4. Request should be rejected if OTO_RUNNER_RELEASE_BASE_URL is not HTTPS t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "http://example.com/releases") - bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`) + bodyValid = bytes.NewBufferString(`{"token":"token-123"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -485,7 +527,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { // 5. Valid request should return correct escaped command when OTO_RUNNER_RELEASE_BASE_URL is set to HTTPS t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "https://example.com/releases") - bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`) + bodyValid = bytes.NewBufferString(`{"token":"token-123"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -500,13 +542,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { t.Fatalf("decode bootstrap command response: %v", err) } - expectedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'" + expectedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --token 'token-123'" if response.GetBootstrapCommand() != expectedCmd { t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedCmd) } - // 6. Request with shell metacharacters in runner_id/token should be safely escaped - bodyMalicious := bytes.NewBufferString(`{"runner_id":"runner; rm -rf /","enrollment_token":"token'$(say hello)'"}`) + // 6. Request with shell metacharacters in token should be safely escaped + bodyMalicious := bytes.NewBufferString(`{"token":"token'$(say hello)'"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMalicious) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -520,13 +562,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { t.Fatalf("decode bootstrap command response: %v", err) } - expectedEscapedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner; rm -rf /' --enrollment-token 'token'\\''$(say hello)'\\''' --release-base-url 'https://example.com/releases'" + expectedEscapedCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --token 'token'\\''$(say hello)'\\'''" if response.GetBootstrapCommand() != expectedEscapedCmd { t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedEscapedCmd) } // 7. Malicious Host header with invalid characters should be rejected - bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`) + bodyValid = bytes.NewBufferString(`{"token":"token-123"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid) req.Host = "localhost; rm -rf /" rr = httptest.NewRecorder() @@ -537,7 +579,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { } // 8. Host with shell metacharacter but no space (e.g. localhost;rm) should also be rejected - bodyValid = bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123"}`) + bodyValid = bytes.NewBufferString(`{"token":"token-123"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid) req.Host = "localhost;rm" rr = httptest.NewRecorder() @@ -548,7 +590,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { } // 9. target="linux" should work and return Unix shell command - bodyLinux := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123","target":"linux"}`) + bodyLinux := bytes.NewBufferString(`{"token":"token-123","target":"linux"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyLinux) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -560,13 +602,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { t.Fatalf("decode bootstrap command response: %v", err) } - expectedLinuxCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'" + expectedLinuxCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --token 'token-123'" if response.GetBootstrapCommand() != expectedLinuxCmd { t.Fatalf("bootstrap_command for linux = %q, want %q", response.GetBootstrapCommand(), expectedLinuxCmd) } // 10. target="macos" should work and return Unix shell command - bodyMacos := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123","target":"macos"}`) + bodyMacos := bytes.NewBufferString(`{"token":"token-123","target":"macos"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMacos) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -578,13 +620,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { t.Fatalf("decode bootstrap command response: %v", err) } - expectedMacosCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'" + expectedMacosCmd := "curl -fsSL 'http://localhost:8080/bootstrap/oto-agent.sh' | bash -s -- --token 'token-123'" if response.GetBootstrapCommand() != expectedMacosCmd { t.Fatalf("bootstrap_command for macos = %q, want %q", response.GetBootstrapCommand(), expectedMacosCmd) } // 11. target="windows" should work and return PowerShell command - bodyWindows := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123","target":"windows"}`) + bodyWindows := bytes.NewBufferString(`{"token":"token-123","target":"windows"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyWindows) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -596,13 +638,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { t.Fatalf("decode bootstrap command response: %v", err) } - expectedWindowsCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner-123' --enrollment-token 'token-123' --release-base-url 'https://example.com/releases'\"" + expectedWindowsCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --token 'token-123'\"" if response.GetBootstrapCommand() != expectedWindowsCmd { t.Fatalf("bootstrap_command for windows = %q, want %q", response.GetBootstrapCommand(), expectedWindowsCmd) } - // 12. target="windows" with malicious runner_id/token should escape properly for PowerShell - bodyWindowsMalicious := bytes.NewBufferString(`{"runner_id":"runner; rm -rf /","enrollment_token":"token'$(say hello)'","target":"windows"}`) + // 12. target="windows" with malicious token should escape properly for PowerShell + bodyWindowsMalicious := bytes.NewBufferString(`{"token":"token'$(say hello)'","target":"windows"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyWindowsMalicious) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -614,13 +656,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) { if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil { t.Fatalf("decode bootstrap command response: %v", err) } - expectedWindowsEscapedCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --server-url 'http://localhost:8080' --socket-url 'tcp://localhost:18080' --agent-id 'runner; rm -rf /' --enrollment-token 'token''$(say hello)''' --release-base-url 'https://example.com/releases'\"" + expectedWindowsEscapedCmd := "powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm 'http://localhost:8080/bootstrap/oto-agent.ps1'))) -- --token 'token''$(say hello)'''\"" if response.GetBootstrapCommand() != expectedWindowsEscapedCmd { t.Fatalf("bootstrap_command for windows malicious = %q, want %q", response.GetBootstrapCommand(), expectedWindowsEscapedCmd) } // 13. unsupported target should be rejected with 400 Bad Request - bodyUnsupported := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":"token-123","target":"freebsd"}`) + bodyUnsupported := bytes.NewBufferString(`{"token":"token-123","target":"freebsd"}`) req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyUnsupported) req.Host = "localhost:8080" rr = httptest.NewRecorder() @@ -673,6 +715,34 @@ func TestHandleServeBootstrapScript(t *testing.T) { } } +func TestHandleServeBootstrapScriptInjectsConnectionDefaults(t *testing.T) { + t.Setenv("OTO_RUNNER_RELEASE_BASE_URL", "https://example.com/releases") + t.Setenv("OTO_RUNNER_SOCKET_PUBLIC_URL", "tcp://socket.example.com:18080") + + content := []byte(strings.Join([]string{ + "server_url='__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__'", + "socket_url='__OTO_BOOTSTRAP_DEFAULT_SOCKET_URL__'", + "release_base_url='__OTO_BOOTSTRAP_DEFAULT_RELEASE_BASE_URL__'", + }, "\n")) + req := httptest.NewRequest(http.MethodGet, "http://localhost:8080/bootstrap/oto-agent.sh", nil) + rr := httptest.NewRecorder() + + handleServeBootstrapScript(staticProvider{content: content})(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %v, want %v; body = %s", rr.Code, http.StatusOK, rr.Body.String()) + } + body := rr.Body.String() + if !strings.Contains(body, "server_url='http://localhost:8080'") { + t.Fatalf("server default not injected: %s", body) + } + if !strings.Contains(body, "socket_url='tcp://socket.example.com:18080'") { + t.Fatalf("socket default not injected: %s", body) + } + if !strings.Contains(body, "release_base_url='https://example.com/releases'") { + t.Fatalf("release default not injected: %s", body) + } +} + func TestHandleServeBootstrapScript_MethodNotAllowed(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/bootstrap/oto-agent.sh", nil) rr := httptest.NewRecorder() diff --git a/services/core/internal/runnerregistry/registry.go b/services/core/internal/runnerregistry/registry.go index 5acf7d1..9116516 100644 --- a/services/core/internal/runnerregistry/registry.go +++ b/services/core/internal/runnerregistry/registry.go @@ -92,6 +92,9 @@ func (r *Registry) Register(req *otopb.RegisterRunnerRequest) *otopb.RegisterRun } r.mu.Lock() + if existing, ok := r.runners[runnerID]; ok && strings.TrimSpace(existing.Alias) != "" { + record.Alias = existing.Alias + } r.runners[runnerID] = record r.mu.Unlock() @@ -145,6 +148,19 @@ func (r *Registry) Disconnect(runnerID string) bool { return true } +func (r *Registry) UpdateAlias(runnerID, alias string) (RunnerRecord, bool) { + r.mu.Lock() + defer r.mu.Unlock() + record, ok := r.runners[runnerID] + if !ok { + return RunnerRecord{}, false + } + record.Alias = strings.TrimSpace(alias) + r.runners[runnerID] = record + record.CommandTypes = append([]string(nil), record.CommandTypes...) + return record, true +} + func (r *Registry) CheckTimeouts(timeoutDuration time.Duration) { r.mu.Lock() defer r.mu.Unlock() diff --git a/services/core/internal/runnerregistry/registry_test.go b/services/core/internal/runnerregistry/registry_test.go index 0b27d40..77a36d1 100644 --- a/services/core/internal/runnerregistry/registry_test.go +++ b/services/core/internal/runnerregistry/registry_test.go @@ -191,6 +191,51 @@ func TestRegistryDisconnect(t *testing.T) { } } +func TestRegistryUpdatesAliasAndPreservesItOnReregister(t *testing.T) { + registry := New() + registry.Register(&otopb.RegisterRunnerRequest{ + EnrollmentToken: "token-123", + RunnerId: "runner-123", + Alias: "device-default", + ProtocolVersion: "oto.runner.v1", + Capability: &otopb.RunnerCapability{ + Name: "oto-runner", + Version: "1.0.0", + }, + }) + + record, ok := registry.UpdateAlias("runner-123", "friendly-name") + if !ok { + t.Fatal("expected alias update to succeed") + } + if record.Alias != "friendly-name" { + t.Fatalf("alias = %q, want friendly-name", record.Alias) + } + + registry.Register(&otopb.RegisterRunnerRequest{ + EnrollmentToken: "token-123", + RunnerId: "runner-123", + Alias: "device-default", + ProtocolVersion: "oto.runner.v1", + Capability: &otopb.RunnerCapability{ + Name: "oto-runner", + Version: "1.0.0", + }, + }) + + record, ok = registry.Snapshot("runner-123") + if !ok { + t.Fatal("expected runner to exist") + } + if record.Alias != "friendly-name" { + t.Fatalf("alias after reregister = %q, want friendly-name", record.Alias) + } + + if _, ok := registry.UpdateAlias("unknown-runner", "name"); ok { + t.Fatal("expected alias update on unknown runner to fail") + } +} + func TestRegistryCheckTimeouts(t *testing.T) { var mockTime time.Time registry := NewWithClock(func() time.Time { return mockTime })