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
|
||||
$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' }
|
||||
|
|
|
|||
|
|
@ -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 <url> --socket-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
|
||||
echo "Usage: $0 --token <token> [options]"
|
||||
echo "Options:"
|
||||
echo " --socket-url <url> Runner proto-socket URL (default: derived by agent)"
|
||||
echo " --agent-alias <alias> Alias for the agent"
|
||||
echo " --server-url <url> OTO Core HTTP URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SERVER_URL)"
|
||||
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 " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
|
||||
echo " --workspace-root <path> 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
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String, String>.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 = <String>[
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
|
|
|
|||
|
|
@ -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 <url> --socket-url <url> --agent-id <id> --enrollment-token <token> --release-base-url <url> [options]"
|
||||
echo "Usage: $0 --token <token> [options]"
|
||||
echo "Options:"
|
||||
echo " --socket-url <url> Runner proto-socket URL (default: derived by agent)"
|
||||
echo " --agent-alias <alias> Alias for the agent"
|
||||
echo " --server-url <url> OTO Core HTTP URL (default: served by bootstrap endpoint or OTO_BOOTSTRAP_SERVER_URL)"
|
||||
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 " --config-path <path> Path to config file (default: \$HOME/.oto/agent/config.yaml)"
|
||||
echo " --workspace-root <path> 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 })
|
||||
|
|
|
|||
Loading…
Reference in a new issue