update: bootstrap script and runner registry changes
This commit is contained in:
parent
7e51ef913d
commit
2eb9cd3c33
12 changed files with 755 additions and 150 deletions
|
|
@ -2,18 +2,58 @@
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
$serverUrl = ''
|
$serverUrl = if ($env:OTO_BOOTSTRAP_SERVER_URL) { $env:OTO_BOOTSTRAP_SERVER_URL } else { '' }
|
||||||
$socketUrl = ''
|
$socketUrl = if ($env:OTO_BOOTSTRAP_SOCKET_URL) { $env:OTO_BOOTSTRAP_SOCKET_URL } else { '' }
|
||||||
$agentId = ''
|
$agentId = if ($env:OTO_AGENT_ID) { $env:OTO_AGENT_ID } else { '' }
|
||||||
$enrollmentToken = ''
|
$enrollmentToken = ''
|
||||||
$releaseBaseUrl = ''
|
$releaseBaseUrl = if ($env:OTO_BOOTSTRAP_RELEASE_BASE_URL) { $env:OTO_BOOTSTRAP_RELEASE_BASE_URL } else { '' }
|
||||||
$agentAlias = ''
|
$agentAlias = if ($env:OTO_AGENT_ALIAS) { $env:OTO_AGENT_ALIAS } else { '' }
|
||||||
$installDir = Join-Path $env:LOCALAPPDATA 'oto\bin'
|
$installDir = Join-Path $env:LOCALAPPDATA 'oto\bin'
|
||||||
$configPath = Join-Path $env:LOCALAPPDATA 'oto\agent\config.yaml'
|
$configPath = Join-Path $env:LOCALAPPDATA 'oto\agent\config.yaml'
|
||||||
$workspaceRoot = Join-Path $env:LOCALAPPDATA 'oto\workspace'
|
$workspaceRoot = Join-Path $env:LOCALAPPDATA 'oto\workspace'
|
||||||
$logDir = Join-Path $env:LOCALAPPDATA 'oto\agent\log'
|
$logDir = Join-Path $env:LOCALAPPDATA 'oto\agent\log'
|
||||||
$background = $true
|
$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
|
$i = 0
|
||||||
while ($i -lt $args.Count) {
|
while ($i -lt $args.Count) {
|
||||||
switch ($args[$i]) {
|
switch ($args[$i]) {
|
||||||
|
|
@ -21,7 +61,7 @@ while ($i -lt $args.Count) {
|
||||||
'--server-url' { $serverUrl = $args[$i + 1]; $i += 2 }
|
'--server-url' { $serverUrl = $args[$i + 1]; $i += 2 }
|
||||||
'--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 }
|
'--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 }
|
||||||
'--agent-id' { $agentId = $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 }
|
'--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 }
|
||||||
'--agent-alias' { $agentAlias = $args[$i + 1]; $i += 2 }
|
'--agent-alias' { $agentAlias = $args[$i + 1]; $i += 2 }
|
||||||
'--install-dir' { $installDir = $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) {
|
if (-not $serverUrl) { $serverUrl = '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__' }
|
||||||
Write-Error 'Error: Missing required arguments: --server-url, --agent-id, --enrollment-token, --release-base-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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +98,13 @@ if (-not $releaseBaseUrl.StartsWith('https://')) {
|
||||||
exit 1
|
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
|
$arch = $env:PROCESSOR_ARCHITECTURE
|
||||||
$archName = switch ($arch) {
|
$archName = switch ($arch) {
|
||||||
'AMD64' { 'x64' }
|
'AMD64' { 'x64' }
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
server_url=""
|
server_url="${OTO_BOOTSTRAP_SERVER_URL:-}"
|
||||||
socket_url=""
|
socket_url="${OTO_BOOTSTRAP_SOCKET_URL:-}"
|
||||||
agent_id=""
|
agent_id="${OTO_AGENT_ID:-}"
|
||||||
enrollment_token=""
|
enrollment_token=""
|
||||||
release_base_url=""
|
release_base_url="${OTO_BOOTSTRAP_RELEASE_BASE_URL:-}"
|
||||||
agent_alias=""
|
agent_alias="${OTO_AGENT_ALIAS:-}"
|
||||||
install_dir="${HOME}/.oto/bin"
|
install_dir="${HOME}/.oto/bin"
|
||||||
config_path="${HOME}/.oto/agent/config.yaml"
|
config_path="${HOME}/.oto/agent/config.yaml"
|
||||||
workspace_root="${HOME}/.oto/workspace"
|
workspace_root="${HOME}/.oto/workspace"
|
||||||
|
|
@ -14,10 +14,13 @@ log_dir="${HOME}/.oto/agent/log"
|
||||||
background="true"
|
background="true"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 --server-url <url> --socket-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
|
echo "Usage: $0 --token <token> [options]"
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " --socket-url <url> Runner proto-socket URL (default: derived by agent)"
|
echo " --server-url <url> OTO Core HTTP URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SERVER_URL)"
|
||||||
echo " --agent-alias <alias> Alias for the agent"
|
echo " --socket-url <url> Runner proto-socket URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SOCKET_URL)"
|
||||||
|
echo " --release-base-url <url> OTO release asset base URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_RELEASE_BASE_URL)"
|
||||||
|
echo " --agent-id <id> Agent ID override (default: derived from this device)"
|
||||||
|
echo " --agent-alias <alias> Alias override (default: device name)"
|
||||||
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
|
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
|
||||||
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
|
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
|
||||||
echo " --workspace-root <path> Workspace root directory (default: \$HOME/.oto/workspace)"
|
echo " --workspace-root <path> Workspace root directory (default: \$HOME/.oto/workspace)"
|
||||||
|
|
@ -35,6 +38,75 @@ yaml_escape() {
|
||||||
printf '%s' "$value"
|
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
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--server-url|--edge-url)
|
--server-url|--edge-url)
|
||||||
|
|
@ -49,7 +121,7 @@ while [[ $# -gt 0 ]]; do
|
||||||
agent_id="$2"
|
agent_id="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--enrollment-token)
|
--token)
|
||||||
enrollment_token="$2"
|
enrollment_token="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
|
@ -89,8 +161,18 @@ while [[ $# -gt 0 ]]; do
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$server_url" || -z "$agent_id" || -z "$enrollment_token" || -z "$release_base_url" ]]; then
|
if [[ -z "$enrollment_token" ]]; then
|
||||||
echo "Error: Missing required arguments." >&2
|
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
|
usage
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -125,6 +207,17 @@ esac
|
||||||
|
|
||||||
asset_name="oto-${os_name}-${arch_name}.tar.gz"
|
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}"
|
download_url="${release_base_url}/${asset_name}"
|
||||||
tmp_dir=$(mktemp -d)
|
tmp_dir=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
@ -139,6 +232,13 @@ mkdir -p "$install_dir"
|
||||||
cp "$tmp_dir/oto" "$install_dir/oto"
|
cp "$tmp_dir/oto" "$install_dir/oto"
|
||||||
chmod +x "$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..."
|
echo "Verifying OTO binary..."
|
||||||
if ! "$install_dir/oto" --version > /dev/null 2>&1; then
|
if ! "$install_dir/oto" --version > /dev/null 2>&1; then
|
||||||
echo "Error: OTO binary verification failed." >&2
|
echo "Error: OTO binary verification failed." >&2
|
||||||
|
|
|
||||||
|
|
@ -17,9 +17,10 @@ void main() {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should contain required flags', () {
|
test('should contain required flags', () {
|
||||||
|
expect(content, contains('--token'));
|
||||||
expect(content, contains('--server-url'));
|
expect(content, contains('--server-url'));
|
||||||
expect(content, contains('--agent-id'));
|
expect(content, contains('--agent-id'));
|
||||||
expect(content, contains('--enrollment-token'));
|
expect(content, isNot(contains('--enrollment-token')));
|
||||||
expect(content, contains('--release-base-url'));
|
expect(content, contains('--release-base-url'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,9 +44,13 @@ Future<_BootstrapRunResult> _runBootstrapWithFakeUname(
|
||||||
required String unameM,
|
required String unameM,
|
||||||
bool provideTarGz = true,
|
bool provideTarGz = true,
|
||||||
String serverUrl = 'https://server.example.com',
|
String serverUrl = 'https://server.example.com',
|
||||||
|
String socketUrl = 'tcp://socket.example.com:18080',
|
||||||
|
String releaseBaseUrl = 'https://example.com/release',
|
||||||
String agentId = 'test-agent',
|
String agentId = 'test-agent',
|
||||||
String token = 'test-token-secret',
|
String token = 'test-token-secret',
|
||||||
int fakeOtoVersionExitCode = 0,
|
int fakeOtoVersionExitCode = 0,
|
||||||
|
bool passConnectionArgs = true,
|
||||||
|
bool passAgentId = true,
|
||||||
}) async {
|
}) async {
|
||||||
final tmpDir = await Directory.systemTemp.createTemp('oto_matrix_test_');
|
final tmpDir = await Directory.systemTemp.createTemp('oto_matrix_test_');
|
||||||
try {
|
try {
|
||||||
|
|
@ -67,6 +71,12 @@ esac
|
||||||
''');
|
''');
|
||||||
await Process.run('chmod', ['+x', fakeUname.path]);
|
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을 기록할 파일
|
// curl 호출 시 요청된 asset basename을 기록할 파일
|
||||||
final assetLog = File('${tmpDir.path}/requested_asset.txt');
|
final assetLog = File('${tmpDir.path}/requested_asset.txt');
|
||||||
|
|
||||||
|
|
@ -119,18 +129,31 @@ $copyLine
|
||||||
final env = Map<String, String>.from(Platform.environment)
|
final env = Map<String, String>.from(Platform.environment)
|
||||||
..['PATH'] = '${fakeBinDir.path}:${Platform.environment['PATH'] ?? ''}'
|
..['PATH'] = '${fakeBinDir.path}:${Platform.environment['PATH'] ?? ''}'
|
||||||
..['HOME'] = tempHome.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 = <String>[
|
||||||
scriptFile.absolute.path,
|
scriptFile.absolute.path,
|
||||||
'--server-url',
|
if (passConnectionArgs) ...[
|
||||||
serverUrl,
|
'--server-url',
|
||||||
'--agent-id',
|
serverUrl,
|
||||||
agentId,
|
],
|
||||||
'--enrollment-token',
|
if (passAgentId) ...[
|
||||||
|
'--agent-id',
|
||||||
|
agentId,
|
||||||
|
],
|
||||||
|
'--token',
|
||||||
token,
|
token,
|
||||||
'--release-base-url',
|
if (passConnectionArgs) ...[
|
||||||
'https://example.com/release',
|
'--release-base-url',
|
||||||
], environment: env);
|
releaseBaseUrl,
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
final result = await Process.run('bash', args, environment: env);
|
||||||
|
|
||||||
String? requestedAsset;
|
String? requestedAsset;
|
||||||
if (await assetLog.exists()) {
|
if (await assetLog.exists()) {
|
||||||
|
|
@ -178,10 +201,11 @@ void main() {
|
||||||
content = await scriptFile.readAsString();
|
content = await scriptFile.readAsString();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should contain required flags', () {
|
test('should contain required flags and optional connection overrides', () {
|
||||||
expect(content, contains('--server-url'));
|
expect(content, contains('--token'));
|
||||||
expect(content, contains('--agent-id'));
|
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'));
|
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 {
|
test('fails before download for unsupported OS (FreeBSD)', () async {
|
||||||
const token = 'secret-os-token-789';
|
const token = 'secret-os-token-789';
|
||||||
final r = await _runBootstrapWithFakeUname(
|
final r = await _runBootstrapWithFakeUname(
|
||||||
|
|
@ -454,8 +501,7 @@ runtime:
|
||||||
final result = await Process.run('bash', [
|
final result = await Process.run('bash', [
|
||||||
scriptFile.absolute.path,
|
scriptFile.absolute.path,
|
||||||
'--server-url', 'https://server.example.com',
|
'--server-url', 'https://server.example.com',
|
||||||
'--agent-id', 'test-agent',
|
'--token', 'secret-token-123',
|
||||||
'--enrollment-token', 'secret-token-123',
|
|
||||||
'--release-base-url', 'http://example.com/release', // HTTP URL
|
'--release-base-url', 'http://example.com/release', // HTTP URL
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
@ -539,9 +585,7 @@ fi
|
||||||
scriptFile.absolute.path,
|
scriptFile.absolute.path,
|
||||||
'--server-url',
|
'--server-url',
|
||||||
'https://server.example.com',
|
'https://server.example.com',
|
||||||
'--agent-id',
|
'--token',
|
||||||
'test-agent',
|
|
||||||
'--enrollment-token',
|
|
||||||
'secret-token-123',
|
'secret-token-123',
|
||||||
'--release-base-url',
|
'--release-base-url',
|
||||||
'https://example.com/release',
|
'https://example.com/release',
|
||||||
|
|
@ -648,9 +692,7 @@ fi
|
||||||
scriptFile.absolute.path,
|
scriptFile.absolute.path,
|
||||||
'--edge-url',
|
'--edge-url',
|
||||||
'https://edge.example.com',
|
'https://edge.example.com',
|
||||||
'--agent-id',
|
'--token',
|
||||||
'test-agent-alias',
|
|
||||||
'--enrollment-token',
|
|
||||||
'secret-token-123',
|
'secret-token-123',
|
||||||
'--release-base-url',
|
'--release-base-url',
|
||||||
'https://example.com/release',
|
'https://example.com/release',
|
||||||
|
|
|
||||||
|
|
@ -500,7 +500,7 @@ void main() {
|
||||||
headers: {'content-type': 'application/json'},
|
headers: {'content-type': 'application/json'},
|
||||||
body: jsonEncode({
|
body: jsonEncode({
|
||||||
'runner_id': 'test-runner-id',
|
'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);
|
expect(data['bootstrap_command'], isNotNull);
|
||||||
final bootstrapCommand = data['bootstrap_command'] as String;
|
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('http://$serverAddr'));
|
||||||
expect(bootstrapCommand, contains('test-runner-id'));
|
expect(bootstrapCommand, isNot(contains('test-runner-id')));
|
||||||
expect(bootstrapCommand, contains('test-token-123'));
|
expect(bootstrapCommand, contains('test-token-123'));
|
||||||
expect(bootstrapCommand, contains('--server-url'));
|
expect(bootstrapCommand, isNot(contains('--server-url')));
|
||||||
expect(bootstrapCommand, contains('--agent-id'));
|
expect(bootstrapCommand, isNot(contains('--agent-id')));
|
||||||
expect(bootstrapCommand, contains('--enrollment-token'));
|
expect(bootstrapCommand, contains('--token'));
|
||||||
|
expect(bootstrapCommand, isNot(contains('--enrollment-token')));
|
||||||
|
|
||||||
// 2. Full script execution test using fake curl/tar
|
// 2. Full script execution test using fake curl/tar
|
||||||
final tempDir = await Directory.systemTemp.createTemp(
|
final tempDir = await Directory.systemTemp.createTemp(
|
||||||
|
|
@ -523,9 +525,6 @@ void main() {
|
||||||
);
|
);
|
||||||
final tempHome = Directory('${tempDir.path}/home');
|
final tempHome = Directory('${tempDir.path}/home');
|
||||||
await tempHome.create(recursive: true);
|
await tempHome.create(recursive: true);
|
||||||
|
|
||||||
final scriptFile = File('assets/script/shell/oto_agent_bootstrap.sh');
|
|
||||||
|
|
||||||
// Create fake OTO executable source
|
// Create fake OTO executable source
|
||||||
final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src');
|
final fakeOtoSource = Directory('${tempDir.path}/fake_oto_src');
|
||||||
await fakeOtoSource.create(recursive: true);
|
await fakeOtoSource.create(recursive: true);
|
||||||
|
|
@ -548,9 +547,13 @@ void main() {
|
||||||
// Create fake bin dir and fake curl
|
// Create fake bin dir and fake curl
|
||||||
final fakeBinDir = Directory('${tempDir.path}/bin');
|
final fakeBinDir = Directory('${tempDir.path}/bin');
|
||||||
await fakeBinDir.create(recursive: true);
|
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');
|
final fakeCurl = File('${fakeBinDir.path}/curl');
|
||||||
await fakeCurl.writeAsString('''#!/bin/sh
|
await fakeCurl.writeAsString('''#!/bin/sh
|
||||||
out_file=""
|
out_file=""
|
||||||
|
url=""
|
||||||
while [ \$# -gt 0 ]; do
|
while [ \$# -gt 0 ]; do
|
||||||
if [ "\$1" = "-o" ]; then
|
if [ "\$1" = "-o" ]; then
|
||||||
out_file="\$2"
|
out_file="\$2"
|
||||||
|
|
@ -558,6 +561,7 @@ void main() {
|
||||||
elif [ "\$1" = "-fsSL" ]; then
|
elif [ "\$1" = "-fsSL" ]; then
|
||||||
shift 1
|
shift 1
|
||||||
else
|
else
|
||||||
|
url="\$1"
|
||||||
shift 1
|
shift 1
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
@ -565,7 +569,7 @@ void main() {
|
||||||
if [ -n "\$out_file" ]; then
|
if [ -n "\$out_file" ]; then
|
||||||
cp "${fakeTarGz.path}" "\$out_file"
|
cp "${fakeTarGz.path}" "\$out_file"
|
||||||
else
|
else
|
||||||
cat "${scriptFile.absolute.path}"
|
"$realCurlPath" -fsSL "\$url"
|
||||||
fi
|
fi
|
||||||
''');
|
''');
|
||||||
await Process.run('chmod', ['+x', fakeCurl.path]);
|
await Process.run('chmod', ['+x', fakeCurl.path]);
|
||||||
|
|
@ -614,7 +618,7 @@ void main() {
|
||||||
final configContent = await configFile.readAsString();
|
final configContent = await configFile.readAsString();
|
||||||
expect(configContent, contains('server:'));
|
expect(configContent, contains('server:'));
|
||||||
expect(configContent, contains('url: "http://$serverAddr"'));
|
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"'));
|
expect(configContent, contains('enrollment_token: "test-token-123"'));
|
||||||
|
|
||||||
// - Installed binary exists and has execution permissions
|
// - Installed binary exists and has execution permissions
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,58 @@
|
||||||
Set-StrictMode -Version Latest
|
Set-StrictMode -Version Latest
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
$serverUrl = ''
|
$serverUrl = if ($env:OTO_BOOTSTRAP_SERVER_URL) { $env:OTO_BOOTSTRAP_SERVER_URL } else { '' }
|
||||||
$socketUrl = ''
|
$socketUrl = if ($env:OTO_BOOTSTRAP_SOCKET_URL) { $env:OTO_BOOTSTRAP_SOCKET_URL } else { '' }
|
||||||
$agentId = ''
|
$agentId = if ($env:OTO_AGENT_ID) { $env:OTO_AGENT_ID } else { '' }
|
||||||
$enrollmentToken = ''
|
$enrollmentToken = ''
|
||||||
$releaseBaseUrl = ''
|
$releaseBaseUrl = if ($env:OTO_BOOTSTRAP_RELEASE_BASE_URL) { $env:OTO_BOOTSTRAP_RELEASE_BASE_URL } else { '' }
|
||||||
$agentAlias = ''
|
$agentAlias = if ($env:OTO_AGENT_ALIAS) { $env:OTO_AGENT_ALIAS } else { '' }
|
||||||
$installDir = Join-Path $env:LOCALAPPDATA 'oto\bin'
|
$installDir = Join-Path $env:LOCALAPPDATA 'oto\bin'
|
||||||
$configPath = Join-Path $env:LOCALAPPDATA 'oto\agent\config.yaml'
|
$configPath = Join-Path $env:LOCALAPPDATA 'oto\agent\config.yaml'
|
||||||
$workspaceRoot = Join-Path $env:LOCALAPPDATA 'oto\workspace'
|
$workspaceRoot = Join-Path $env:LOCALAPPDATA 'oto\workspace'
|
||||||
$logDir = Join-Path $env:LOCALAPPDATA 'oto\agent\log'
|
$logDir = Join-Path $env:LOCALAPPDATA 'oto\agent\log'
|
||||||
$background = $true
|
$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
|
$i = 0
|
||||||
while ($i -lt $args.Count) {
|
while ($i -lt $args.Count) {
|
||||||
switch ($args[$i]) {
|
switch ($args[$i]) {
|
||||||
|
|
@ -21,7 +61,7 @@ while ($i -lt $args.Count) {
|
||||||
'--server-url' { $serverUrl = $args[$i + 1]; $i += 2 }
|
'--server-url' { $serverUrl = $args[$i + 1]; $i += 2 }
|
||||||
'--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 }
|
'--socket-url' { $socketUrl = $args[$i + 1]; $i += 2 }
|
||||||
'--agent-id' { $agentId = $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 }
|
'--release-base-url' { $releaseBaseUrl = $args[$i + 1]; $i += 2 }
|
||||||
'--agent-alias' { $agentAlias = $args[$i + 1]; $i += 2 }
|
'--agent-alias' { $agentAlias = $args[$i + 1]; $i += 2 }
|
||||||
'--install-dir' { $installDir = $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) {
|
if (-not $serverUrl) { $serverUrl = '__OTO_BOOTSTRAP_DEFAULT_SERVER_URL__' }
|
||||||
Write-Error 'Error: Missing required arguments: --server-url, --agent-id, --enrollment-token, --release-base-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
|
exit 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,6 +98,13 @@ if (-not $releaseBaseUrl.StartsWith('https://')) {
|
||||||
exit 1
|
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
|
$arch = $env:PROCESSOR_ARCHITECTURE
|
||||||
$archName = switch ($arch) {
|
$archName = switch ($arch) {
|
||||||
'AMD64' { 'x64' }
|
'AMD64' { 'x64' }
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,12 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
server_url=""
|
server_url="${OTO_BOOTSTRAP_SERVER_URL:-}"
|
||||||
socket_url=""
|
socket_url="${OTO_BOOTSTRAP_SOCKET_URL:-}"
|
||||||
agent_id=""
|
agent_id="${OTO_AGENT_ID:-}"
|
||||||
enrollment_token=""
|
enrollment_token=""
|
||||||
release_base_url=""
|
release_base_url="${OTO_BOOTSTRAP_RELEASE_BASE_URL:-}"
|
||||||
agent_alias=""
|
agent_alias="${OTO_AGENT_ALIAS:-}"
|
||||||
install_dir="${HOME}/.oto/bin"
|
install_dir="${HOME}/.oto/bin"
|
||||||
config_path="${HOME}/.oto/agent/config.yaml"
|
config_path="${HOME}/.oto/agent/config.yaml"
|
||||||
workspace_root="${HOME}/.oto/workspace"
|
workspace_root="${HOME}/.oto/workspace"
|
||||||
|
|
@ -14,10 +14,13 @@ log_dir="${HOME}/.oto/agent/log"
|
||||||
background="true"
|
background="true"
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
echo "Usage: $0 --server-url <url> --socket-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
|
echo "Usage: $0 --token <token> [options]"
|
||||||
echo "Options:"
|
echo "Options:"
|
||||||
echo " --socket-url <url> Runner proto-socket URL (default: derived by agent)"
|
echo " --server-url <url> OTO Core HTTP URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SERVER_URL)"
|
||||||
echo " --agent-alias <alias> Alias for the agent"
|
echo " --socket-url <url> Runner proto-socket URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SOCKET_URL)"
|
||||||
|
echo " --release-base-url <url> OTO release asset base URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_RELEASE_BASE_URL)"
|
||||||
|
echo " --agent-id <id> Agent ID override (default: derived from this device)"
|
||||||
|
echo " --agent-alias <alias> Alias override (default: device name)"
|
||||||
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
|
echo " --install-dir <path> Directory to install OTO agent (default: \$HOME/.oto/bin)"
|
||||||
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
|
echo " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
|
||||||
echo " --workspace-root <path> Workspace root directory (default: \$HOME/.oto/workspace)"
|
echo " --workspace-root <path> Workspace root directory (default: \$HOME/.oto/workspace)"
|
||||||
|
|
@ -35,6 +38,75 @@ yaml_escape() {
|
||||||
printf '%s' "$value"
|
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
|
while [[ $# -gt 0 ]]; do
|
||||||
case "$1" in
|
case "$1" in
|
||||||
--server-url|--edge-url)
|
--server-url|--edge-url)
|
||||||
|
|
@ -49,7 +121,7 @@ while [[ $# -gt 0 ]]; do
|
||||||
agent_id="$2"
|
agent_id="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
--enrollment-token)
|
--token)
|
||||||
enrollment_token="$2"
|
enrollment_token="$2"
|
||||||
shift 2
|
shift 2
|
||||||
;;
|
;;
|
||||||
|
|
@ -89,8 +161,18 @@ while [[ $# -gt 0 ]]; do
|
||||||
esac
|
esac
|
||||||
done
|
done
|
||||||
|
|
||||||
if [[ -z "$server_url" || -z "$agent_id" || -z "$enrollment_token" || -z "$release_base_url" ]]; then
|
if [[ -z "$enrollment_token" ]]; then
|
||||||
echo "Error: Missing required arguments." >&2
|
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
|
usage
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
@ -125,6 +207,17 @@ esac
|
||||||
|
|
||||||
asset_name="oto-${os_name}-${arch_name}.tar.gz"
|
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}"
|
download_url="${release_base_url}/${asset_name}"
|
||||||
tmp_dir=$(mktemp -d)
|
tmp_dir=$(mktemp -d)
|
||||||
trap 'rm -rf "$tmp_dir"' EXIT
|
trap 'rm -rf "$tmp_dir"' EXIT
|
||||||
|
|
@ -139,6 +232,13 @@ mkdir -p "$install_dir"
|
||||||
cp "$tmp_dir/oto" "$install_dir/oto"
|
cp "$tmp_dir/oto" "$install_dir/oto"
|
||||||
chmod +x "$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..."
|
echo "Verifying OTO binary..."
|
||||||
if ! "$install_dir/oto" --version > /dev/null 2>&1; then
|
if ! "$install_dir/oto" --version > /dev/null 2>&1; then
|
||||||
echo "Error: OTO binary verification failed." >&2
|
echo "Error: OTO binary verification failed." >&2
|
||||||
|
|
|
||||||
|
|
@ -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/bootstrap-command", withCORS(handleRunnerBootstrapCommand(registry)))
|
||||||
mux.HandleFunc("/api/v1/runners/{id}/heartbeat", withCORS(handleRunnerHeartbeat(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}/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.sh", withCORS(handleServeBootstrapScript(embeddedBootstrapProvider{})))
|
||||||
mux.HandleFunc("/bootstrap/oto-agent.ps1", withCORS(handleServeBootstrapPs1(embeddedBootstrapPs1Provider{})))
|
mux.HandleFunc("/bootstrap/oto-agent.ps1", withCORS(handleServeBootstrapPs1(embeddedBootstrapPs1Provider{})))
|
||||||
mux.HandleFunc("/api/v1/", withCORS(handleRouterWithDispatcher(store, registry, dispatcher)))
|
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 {
|
func withCORS(next http.HandlerFunc) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
w.Header().Set("Access-Control-Allow-Origin", "*")
|
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-Allow-Headers", "Content-Type, Authorization")
|
||||||
w.Header().Set("Access-Control-Max-Age", "600")
|
w.Header().Set("Access-Control-Max-Age", "600")
|
||||||
if r.Method == http.MethodOptions {
|
if r.Method == http.MethodOptions {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
func shellEscape(s string) string {
|
||||||
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
return "'" + strings.ReplaceAll(s, "'", "'\\''") + "'"
|
||||||
}
|
}
|
||||||
|
|
@ -189,90 +245,54 @@ func handleRunnerBootstrapCommand(registry *runnerregistry.Registry) http.Handle
|
||||||
return
|
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 {
|
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
|
||||||
writeResponse(w, http.StatusBadRequest, errorToJSON("invalid bootstrap command request"))
|
writeResponse(w, http.StatusBadRequest, errorToJSON("invalid bootstrap command request"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
runnerID := strings.TrimSpace(request.GetRunnerId())
|
token := strings.TrimSpace(request.Token)
|
||||||
token := strings.TrimSpace(request.GetEnrollmentToken())
|
if token == "" {
|
||||||
if runnerID == "" || token == "" {
|
token = strings.TrimSpace(request.EnrollmentToken)
|
||||||
writeResponse(w, http.StatusBadRequest, errorToJSON("missing runner id or enrollment token"))
|
}
|
||||||
|
if token == "" {
|
||||||
|
writeResponse(w, http.StatusBadRequest, errorToJSON("missing token"))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
target := strings.ToLower(strings.TrimSpace(request.GetTarget()))
|
target := strings.ToLower(strings.TrimSpace(request.Target))
|
||||||
if target != "" && target != "linux" && target != "macos" && target != "windows" {
|
if target != "" && target != "linux" && target != "macos" && target != "windows" {
|
||||||
writeResponse(w, http.StatusBadRequest, errorToJSON(fmt.Sprintf("unsupported bootstrap target: %s", target)))
|
writeResponse(w, http.StatusBadRequest, errorToJSON(fmt.Sprintf("unsupported bootstrap target: %s", target)))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
scheme := "http"
|
defaults, err := bootstrapDefaultsForRequest(r, true)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeResponse(w, http.StatusBadRequest, errorToJSON(err.Error()))
|
writeResponse(w, http.StatusBadRequest, errorToJSON(err.Error()))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
serverURL := defaults.ServerURL
|
||||||
|
|
||||||
var bootstrapCmd string
|
var bootstrapCmd string
|
||||||
if target == "windows" {
|
if target == "windows" {
|
||||||
escapedScriptURL := powershellEscape(serverURL + "/bootstrap/oto-agent.ps1")
|
escapedScriptURL := powershellEscape(serverURL + "/bootstrap/oto-agent.ps1")
|
||||||
escapedServerURL := powershellEscape(serverURL)
|
|
||||||
escapedSocketURL := powershellEscape(socketURL)
|
|
||||||
escapedRunnerID := powershellEscape(runnerID)
|
|
||||||
escapedToken := powershellEscape(token)
|
escapedToken := powershellEscape(token)
|
||||||
escapedReleaseURL := powershellEscape(releaseBaseURL)
|
|
||||||
|
|
||||||
bootstrapCmd = fmt.Sprintf(
|
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\"",
|
"powershell -ExecutionPolicy Bypass -Command \"& ([scriptblock]::Create((irm %s))) -- --token %s\"",
|
||||||
escapedScriptURL, escapedServerURL, escapedSocketURL, escapedRunnerID, escapedToken, escapedReleaseURL,
|
escapedScriptURL, escapedToken,
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
escapedScriptURL := shellEscape(serverURL + "/bootstrap/oto-agent.sh")
|
escapedScriptURL := shellEscape(serverURL + "/bootstrap/oto-agent.sh")
|
||||||
escapedServerURL := shellEscape(serverURL)
|
|
||||||
escapedSocketURL := shellEscape(socketURL)
|
|
||||||
escapedRunnerID := shellEscape(runnerID)
|
|
||||||
escapedToken := shellEscape(token)
|
escapedToken := shellEscape(token)
|
||||||
escapedReleaseURL := shellEscape(releaseBaseURL)
|
|
||||||
|
|
||||||
bootstrapCmd = fmt.Sprintf(
|
bootstrapCmd = fmt.Sprintf(
|
||||||
"curl -fsSL %s | bash -s -- --server-url %s --socket-url %s --agent-id %s --enrollment-token %s --release-base-url %s",
|
"curl -fsSL %s | bash -s -- --token %s",
|
||||||
escapedScriptURL, escapedServerURL, escapedSocketURL, escapedRunnerID, escapedToken, escapedReleaseURL,
|
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) {
|
func runnerSocketPublicURL(serverURL string) (string, error) {
|
||||||
if configured := strings.TrimSpace(os.Getenv("OTO_RUNNER_SOCKET_PUBLIC_URL")); configured != "" {
|
if configured := strings.TrimSpace(os.Getenv("OTO_RUNNER_SOCKET_PUBLIC_URL")); configured != "" {
|
||||||
if !strings.Contains(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)
|
http.Error(w, "Bootstrap script not available: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defaults, _ := bootstrapDefaultsForRequest(r, false)
|
||||||
|
content = renderShellBootstrapDefaults(content, defaults)
|
||||||
w.Header().Set("Content-Type", "application/x-sh")
|
w.Header().Set("Content-Type", "application/x-sh")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(content)
|
_, _ = 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)
|
http.Error(w, "Bootstrap PS1 script not available: "+err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defaults, _ := bootstrapDefaultsForRequest(r, false)
|
||||||
|
content = renderPowerShellBootstrapDefaults(content, defaults)
|
||||||
w.Header().Set("Content-Type", "application/x-powershell")
|
w.Header().Set("Content-Type", "application/x-powershell")
|
||||||
w.WriteHeader(http.StatusOK)
|
w.WriteHeader(http.StatusOK)
|
||||||
_, _ = w.Write(content)
|
_, _ = 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) {
|
func writeRunnerRegisterResponse(w http.ResponseWriter, status int, response *otopb.RegisterRunnerResponse) {
|
||||||
w.Header().Set("Content-Type", "application/json")
|
w.Header().Set("Content-Type", "application/json")
|
||||||
w.WriteHeader(status)
|
w.WriteHeader(status)
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestHandleRunnerHeartbeatMismatch(t *testing.T) {
|
||||||
registry := runnerregistry.New()
|
registry := runnerregistry.New()
|
||||||
|
|
||||||
|
|
@ -439,18 +481,18 @@ func TestHandleRunnerHeartbeatMismatch(t *testing.T) {
|
||||||
func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
||||||
registry := runnerregistry.New()
|
registry := runnerregistry.New()
|
||||||
|
|
||||||
// 1. Missing runner_id or enrollment_token should be rejected
|
// 1. Missing token should be rejected
|
||||||
bodyMissing := bytes.NewBufferString(`{"runner_id":"","enrollment_token":""}`)
|
bodyMissing := bytes.NewBufferString(`{}`)
|
||||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMissing)
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMissing)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
||||||
handleRunnerBootstrapCommand(registry)(rr, req)
|
handleRunnerBootstrapCommand(registry)(rr, req)
|
||||||
if rr.Code != http.StatusBadRequest {
|
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
|
// 2. Empty token should be rejected
|
||||||
bodyNoToken := bytes.NewBufferString(`{"runner_id":"runner-123","enrollment_token":""}`)
|
bodyNoToken := bytes.NewBufferString(`{"token":""}`)
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyNoToken)
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyNoToken)
|
||||||
rr = httptest.NewRecorder()
|
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
|
// 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", "")
|
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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
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
|
// 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")
|
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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
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
|
// 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")
|
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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
|
|
@ -500,13 +542,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
||||||
t.Fatalf("decode bootstrap command response: %v", err)
|
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 {
|
if response.GetBootstrapCommand() != expectedCmd {
|
||||||
t.Fatalf("bootstrap_command = %q, want %q", 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
|
// 6. Request with shell metacharacters in token should be safely escaped
|
||||||
bodyMalicious := bytes.NewBufferString(`{"runner_id":"runner; rm -rf /","enrollment_token":"token'$(say hello)'"}`)
|
bodyMalicious := bytes.NewBufferString(`{"token":"token'$(say hello)'"}`)
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMalicious)
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMalicious)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
|
|
@ -520,13 +562,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
||||||
t.Fatalf("decode bootstrap command response: %v", err)
|
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 {
|
if response.GetBootstrapCommand() != expectedEscapedCmd {
|
||||||
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedEscapedCmd)
|
t.Fatalf("bootstrap_command = %q, want %q", response.GetBootstrapCommand(), expectedEscapedCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 7. Malicious Host header with invalid characters should be rejected
|
// 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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
||||||
req.Host = "localhost; rm -rf /"
|
req.Host = "localhost; rm -rf /"
|
||||||
rr = httptest.NewRecorder()
|
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
|
// 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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyValid)
|
||||||
req.Host = "localhost;rm"
|
req.Host = "localhost;rm"
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
|
|
@ -548,7 +590,7 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// 9. target="linux" should work and return Unix shell command
|
// 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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyLinux)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
|
|
@ -560,13 +602,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||||
t.Fatalf("decode bootstrap command response: %v", err)
|
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 {
|
if response.GetBootstrapCommand() != expectedLinuxCmd {
|
||||||
t.Fatalf("bootstrap_command for linux = %q, want %q", 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
|
// 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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyMacos)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
|
|
@ -578,13 +620,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||||
t.Fatalf("decode bootstrap command response: %v", err)
|
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 {
|
if response.GetBootstrapCommand() != expectedMacosCmd {
|
||||||
t.Fatalf("bootstrap_command for macos = %q, want %q", response.GetBootstrapCommand(), expectedMacosCmd)
|
t.Fatalf("bootstrap_command for macos = %q, want %q", response.GetBootstrapCommand(), expectedMacosCmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 11. target="windows" should work and return PowerShell command
|
// 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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyWindows)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
|
|
@ -596,13 +638,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||||
t.Fatalf("decode bootstrap command response: %v", err)
|
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 {
|
if response.GetBootstrapCommand() != expectedWindowsCmd {
|
||||||
t.Fatalf("bootstrap_command for windows = %q, want %q", 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
|
// 12. target="windows" with malicious token should escape properly for PowerShell
|
||||||
bodyWindowsMalicious := bytes.NewBufferString(`{"runner_id":"runner; rm -rf /","enrollment_token":"token'$(say hello)'","target":"windows"}`)
|
bodyWindowsMalicious := bytes.NewBufferString(`{"token":"token'$(say hello)'","target":"windows"}`)
|
||||||
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyWindowsMalicious)
|
req = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyWindowsMalicious)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
rr = httptest.NewRecorder()
|
||||||
|
|
@ -614,13 +656,13 @@ func TestHandleRunnerBootstrapCommand(t *testing.T) {
|
||||||
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
if err := json.Unmarshal(rr.Body.Bytes(), &response); err != nil {
|
||||||
t.Fatalf("decode bootstrap command response: %v", err)
|
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 {
|
if response.GetBootstrapCommand() != expectedWindowsEscapedCmd {
|
||||||
t.Fatalf("bootstrap_command for windows malicious = %q, want %q", 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
|
// 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 = httptest.NewRequest(http.MethodPost, "/api/v1/runners/bootstrap-command", bodyUnsupported)
|
||||||
req.Host = "localhost:8080"
|
req.Host = "localhost:8080"
|
||||||
rr = httptest.NewRecorder()
|
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) {
|
func TestHandleServeBootstrapScript_MethodNotAllowed(t *testing.T) {
|
||||||
req := httptest.NewRequest(http.MethodPost, "/bootstrap/oto-agent.sh", nil)
|
req := httptest.NewRequest(http.MethodPost, "/bootstrap/oto-agent.sh", nil)
|
||||||
rr := httptest.NewRecorder()
|
rr := httptest.NewRecorder()
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,9 @@ func (r *Registry) Register(req *otopb.RegisterRunnerRequest) *otopb.RegisterRun
|
||||||
}
|
}
|
||||||
|
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
|
if existing, ok := r.runners[runnerID]; ok && strings.TrimSpace(existing.Alias) != "" {
|
||||||
|
record.Alias = existing.Alias
|
||||||
|
}
|
||||||
r.runners[runnerID] = record
|
r.runners[runnerID] = record
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
|
|
||||||
|
|
@ -145,6 +148,19 @@ func (r *Registry) Disconnect(runnerID string) bool {
|
||||||
return true
|
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) {
|
func (r *Registry) CheckTimeouts(timeoutDuration time.Duration) {
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
defer r.mu.Unlock()
|
defer r.mu.Unlock()
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestRegistryCheckTimeouts(t *testing.T) {
|
||||||
var mockTime time.Time
|
var mockTime time.Time
|
||||||
registry := NewWithClock(func() time.Time { return mockTime })
|
registry := NewWithClock(func() time.Time { return mockTime })
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue