From 2b321327827a5f377976017655e4d1ea968e9c8b Mon Sep 17 00:00:00 2001 From: fivetran-pavlonikolenko Date: Thu, 16 Jul 2026 12:55:07 -0700 Subject: [PATCH 1/5] add windows install script --- install.ps1 | 281 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 281 insertions(+) create mode 100644 install.ps1 diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..28b9a74 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,281 @@ +#Requires -Version 5.1 +# +# Installer for Fivetran Proxy Agent on Windows using Docker +# +# For more information: +# https://github.com/fivetran/proxy_agent +# +param( + [Parameter(Position = 0)] + [string]$ConfigPath, + [string]$InstallDir +) + +$ErrorActionPreference = 'Stop' +$ProgressPreference = 'SilentlyContinue' + +# ── Constants ──────────────────────────────────────────────────────────────── + +$DEFAULT_INSTALL_DIR = Join-Path $env:USERPROFILE 'fivetran-proxy-agent' +$MIN_DOCKER_VERSION = '20.10.17' +$MIN_RECOMMENDED_CPU_COUNT = 4 +$MIN_RECOMMENDED_RAM_MB = 5120 +$MIN_RECOMMENDED_DISK_SPACE_MB = 2048 +$AGENT_SCRIPT = 'proxy-agent-manager.ps1' +$AGENT_SCRIPT_URL = 'https://raw.githubusercontent.com/fivetran/proxy_agent/main/proxy-agent-manager.ps1' +$REGISTRY_TAGS_URL = 'https://us-docker.pkg.dev/v2/prod-eng-fivetran-public-repos/public-docker-us/proxy-agent/tags/list' +$DEFAULT_FIVETRAN_API_URL = 'https://api.fivetran.com' + +$script:Warnings = [System.Collections.Generic.List[string]]::new() +$script:Errors = [System.Collections.Generic.List[string]]::new() + +# ── Helpers ────────────────────────────────────────────────────────────────── + +function Show-Usage { + Write-Host @' +Usage: + $env:RUNTIME='docker'; .\install.ps1 [] [-InstallDir ] + $env:TOKEN=''; $env:RUNTIME='docker'; .\install.ps1 [-InstallDir ] + +Options: + -InstallDir Installation directory (default: %USERPROFILE%\fivetran-proxy-agent) +'@ + exit 1 +} + +# ── Validation ─────────────────────────────────────────────────────────────── + +function Test-DockerVersion { + if (-not (Get-Command 'docker' -ErrorAction SilentlyContinue)) { + $script:Errors.Add("docker is not installed") + return + } + + $versionOutput = docker --version 2>&1 + if ($LASTEXITCODE -ne 0) { + $script:Errors.Add("Failed to execute 'docker --version'. Docker may not be functioning properly") + return + } + + if ($versionOutput -match 'Docker version (\d+\.\d+\.\d+)') { + $version = $Matches[1] + if ([version]$version -lt [version]$MIN_DOCKER_VERSION) { + $script:Errors.Add("Docker version $version does not meet the minimum requirement of $MIN_DOCKER_VERSION") + } + } else { + $script:Warnings.Add("Unable to determine Docker version") + } + + try { docker info 2>&1 | Out-Null } catch {} + if ($LASTEXITCODE -ne 0) { + $script:Errors.Add("Docker is installed but the Docker daemon is not accessible. Ensure that Docker Desktop is running and that your user has permission to access it.") + } +} + +function Test-Resources { + $cpuCount = [Environment]::ProcessorCount + if ($cpuCount -gt 0 -and $cpuCount -lt $MIN_RECOMMENDED_CPU_COUNT) { + $script:Warnings.Add("CPU count ($cpuCount) is below the recommended minimum of $MIN_RECOMMENDED_CPU_COUNT") + } + + try { + $cs = Get-CimInstance Win32_ComputerSystem + $totalRamMB = [math]::Round($cs.TotalPhysicalMemory / 1MB) + if ($totalRamMB -lt $MIN_RECOMMENDED_RAM_MB) { + $script:Warnings.Add("RAM (${totalRamMB}MB) is below the recommended minimum of ${MIN_RECOMMENDED_RAM_MB}MB") + } + } catch { + $script:Warnings.Add("Unable to determine available memory") + } +} + +function Test-DiskSpace { + param([string]$Path) + $parentDir = Split-Path $Path -Parent + if (-not (Test-Path $parentDir)) { return } + + try { + $qualifier = Split-Path -Qualifier $parentDir + $drive = Get-PSDrive ($qualifier.TrimEnd(':')) + $freeSpaceMB = [math]::Round($drive.Free / 1MB) + if ($freeSpaceMB -lt $MIN_RECOMMENDED_DISK_SPACE_MB) { + $script:Warnings.Add("Available disk space (${freeSpaceMB}MB) is below the recommended minimum of ${MIN_RECOMMENDED_DISK_SPACE_MB}MB") + } + } catch { + $script:Warnings.Add("Unable to determine available disk space for $parentDir") + } +} + +function Show-WarningsAndErrors { + if ($script:Warnings.Count -gt 0) { + Write-Host "`nWARNINGS:" + foreach ($w in $script:Warnings) { Write-Host " - $w" } + } + if ($script:Errors.Count -gt 0) { + Write-Host "`nERRORS:" + foreach ($e in $script:Errors) { Write-Host " - $e" } + Write-Host "" + Write-Host "ERROR: Please resolve the above errors before proceeding." -ForegroundColor Red + exit 1 + } + Write-Host "" +} + +# ── Version resolution ─────────────────────────────────────────────────────── + +function Get-LatestVersion { + try { + $tagsJson = Invoke-RestMethod -Uri $REGISTRY_TAGS_URL -Method Get + } catch { + Write-Host "ERROR: Unable to query image registry for latest version: $_" -ForegroundColor Red + exit 1 + } + + $versions = $tagsJson.tags | Where-Object { $_ -match '^\d+\.\d+\.\d+$' } + if (-not $versions) { + Write-Host "ERROR: No valid version tags found in registry" -ForegroundColor Red + exit 1 + } + + return ($versions | Sort-Object { [version]$_ } | Select-Object -Last 1) +} + +# ── Entry point ────────────────────────────────────────────────────────────── + +if ($env:RUNTIME -ne 'docker') { + Write-Host "ERROR: RUNTIME must be set to 'docker' (got: '$($env:RUNTIME)')." -ForegroundColor Red + exit 1 +} + +$currentPrincipal = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent() +if ($currentPrincipal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Write-Warning "Running as Administrator is not recommended. Consider running as a regular user." +} + +if (-not $InstallDir) { $InstallDir = $DEFAULT_INSTALL_DIR } + +if (-not $ConfigPath -and -not $env:TOKEN) { Show-Usage } + +if ($ConfigPath -and -not (Test-Path $ConfigPath -PathType Leaf)) { + Write-Host "ERROR: Config file not found or not readable: $ConfigPath" -ForegroundColor Red + exit 1 +} + +# Detect WebSocket agent config and migrate to TOKEN flow +if ($ConfigPath -and -not $env:TOKEN) { + $rawConfig = Get-Content $ConfigPath -Raw + if ($rawConfig -match '"proxy_server_uri"') { + $parsed = $rawConfig | ConvertFrom-Json + $agentId = $parsed.agent_id + $authToken = $parsed.auth_token + if (-not $agentId -or -not $authToken) { + Write-Host "ERROR: Could not extract credentials from WebSocket config" -ForegroundColor Red + exit 1 + } + $env:TOKEN = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${agentId}:${authToken}")) + $ConfigPath = '' + Write-Host "Detected WebSocket agent configuration. Fetching updated config from Fivetran..." + } +} + +Write-Host "Installing Fivetran Proxy Agent...`n" + +# Pre-flight checks +Write-Host -NoNewline "Checking prerequisites... " +Test-DockerVersion +Test-Resources +Test-DiskSpace $InstallDir + +if ($script:Warnings.Count -eq 0 -and $script:Errors.Count -eq 0) { + Write-Host "OK`n" +} else { + Write-Host "" + Show-WarningsAndErrors +} + +# Directory setup +if (Test-Path $InstallDir) { + Write-Host "$InstallDir already exists, will re-use it." +} else { + $null = New-Item -ItemType Directory -Force -Path $InstallDir +} + +$testFile = Join-Path $InstallDir ".write-test-$PID" +try { + $null = New-Item -ItemType File -Path $testFile -Force + Remove-Item $testFile -Force +} catch { + Write-Host "ERROR: Insufficient permissions to write to $InstallDir" -ForegroundColor Red + exit 1 +} + +$null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'config') +$null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') + +# Download management script from public repo (temp file → move for atomicity) +Write-Host "Downloading management script..." +$tmpScript = Join-Path $InstallDir "$AGENT_SCRIPT.tmp" +try { + Invoke-WebRequest -Uri $AGENT_SCRIPT_URL -OutFile $tmpScript -UseBasicParsing +} catch { + Remove-Item -Path $tmpScript -Force -ErrorAction SilentlyContinue + Write-Host "ERROR: Failed to download management script from ${AGENT_SCRIPT_URL}: $_" -ForegroundColor Red + exit 1 +} +Move-Item -Path $tmpScript -Destination (Join-Path $InstallDir $AGENT_SCRIPT) -Force + +# Bootstrap or copy config +$configDest = Join-Path $InstallDir 'config\config.json' +$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name + +# Create with restricted ACL before writing credentials (no world-readable window) +$null = New-Item -ItemType File -Path $configDest -Force +$configAcl = Get-Acl $configDest +$configAcl.SetAccessRuleProtection($true, $false) +$configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $currentUser, 'ReadAndExecute,Write', 'Allow'))) +$configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + 'NT AUTHORITY\SYSTEM', 'Read', 'Allow'))) +Set-Acl -Path $configDest -AclObject $configAcl + +if ($ConfigPath) { + [System.IO.File]::WriteAllText($configDest, (Get-Content $ConfigPath -Raw), [System.Text.UTF8Encoding]::new($false)) + Write-Host "Config copied to $configDest" +} else { + $apiUrl = if ($env:FIVETRAN_API_URL) { $env:FIVETRAN_API_URL } else { $DEFAULT_FIVETRAN_API_URL } + Write-Host "Fetching agent config from $apiUrl..." + try { + $configData = Invoke-RestMethod -Uri "$apiUrl/proxy-agent/configure" ` + -Method POST ` + -Headers @{ Authorization = "Basic $($env:TOKEN)"; Accept = 'application/json' } + } catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + if ($statusCode) { + Write-Host "ERROR: Configure endpoint returned HTTP $statusCode" -ForegroundColor Red + } else { + Write-Host "ERROR: Failed to connect to configure endpoint: $_" -ForegroundColor Red + } + exit 1 + } + [System.IO.File]::WriteAllText($configDest, ($configData | ConvertTo-Json -Depth 10 -Compress), [System.Text.UTF8Encoding]::new($false)) +} + +# Resolve and pin version +Write-Host "Resolving latest proxy agent version..." +$version = Get-LatestVersion +Set-Content -Path (Join-Path $InstallDir 'version') -Value $version -Encoding ascii +Write-Host "Using version $version" + +# Clear credential from environment before spawning child process +Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue + +# Start agent +& (Join-Path $InstallDir $AGENT_SCRIPT) start +if ($LASTEXITCODE -ne 0) { + Write-Host "Installation complete, but agent failed to start." + Write-Host "To try to start the agent again, run: & '$(Join-Path $InstallDir $AGENT_SCRIPT)' start" + exit 1 +} + +Write-Host "`nInstallation complete." +Write-Host "Install directory: $InstallDir" From c59e786afda177bde8a274a08751adedf58885c2 Mon Sep 17 00:00:00 2001 From: fivetran-pavlonikolenko Date: Thu, 16 Jul 2026 13:33:04 -0700 Subject: [PATCH 2/5] review comments --- install.ps1 | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/install.ps1 b/install.ps1 index 28b9a74..e38a661 100644 --- a/install.ps1 +++ b/install.ps1 @@ -125,7 +125,7 @@ function Show-WarningsAndErrors { function Get-LatestVersion { try { - $tagsJson = Invoke-RestMethod -Uri $REGISTRY_TAGS_URL -Method Get + $tagsJson = Invoke-RestMethod -Uri $REGISTRY_TAGS_URL -Method Get -TimeoutSec 30 } catch { Write-Host "ERROR: Unable to query image registry for latest version: $_" -ForegroundColor Red exit 1 @@ -165,7 +165,12 @@ if ($ConfigPath -and -not (Test-Path $ConfigPath -PathType Leaf)) { if ($ConfigPath -and -not $env:TOKEN) { $rawConfig = Get-Content $ConfigPath -Raw if ($rawConfig -match '"proxy_server_uri"') { - $parsed = $rawConfig | ConvertFrom-Json + try { + $parsed = $rawConfig | ConvertFrom-Json + } catch { + Write-Host "ERROR: Could not parse config file as JSON: $ConfigPath" -ForegroundColor Red + exit 1 + } $agentId = $parsed.agent_id $authToken = $parsed.auth_token if (-not $agentId -or -not $authToken) { @@ -216,7 +221,7 @@ $null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') Write-Host "Downloading management script..." $tmpScript = Join-Path $InstallDir "$AGENT_SCRIPT.tmp" try { - Invoke-WebRequest -Uri $AGENT_SCRIPT_URL -OutFile $tmpScript -UseBasicParsing + Invoke-WebRequest -Uri $AGENT_SCRIPT_URL -OutFile $tmpScript -UseBasicParsing -TimeoutSec 30 } catch { Remove-Item -Path $tmpScript -Force -ErrorAction SilentlyContinue Write-Host "ERROR: Failed to download management script from ${AGENT_SCRIPT_URL}: $_" -ForegroundColor Red @@ -247,7 +252,8 @@ if ($ConfigPath) { try { $configData = Invoke-RestMethod -Uri "$apiUrl/proxy-agent/configure" ` -Method POST ` - -Headers @{ Authorization = "Basic $($env:TOKEN)"; Accept = 'application/json' } + -Headers @{ Authorization = "Basic $($env:TOKEN)"; Accept = 'application/json' } ` + -TimeoutSec 30 } catch { $statusCode = $_.Exception.Response.StatusCode.value__ if ($statusCode) { From 630562bc4d444f6821b43d44ebad0a4024394a30 Mon Sep 17 00:00:00 2001 From: fivetran-pavlonikolenko Date: Tue, 21 Jul 2026 11:10:37 -0700 Subject: [PATCH 3/5] address review comments --- install.ps1 | 245 +++++++++++++++++++++++++++++----------------------- 1 file changed, 135 insertions(+), 110 deletions(-) diff --git a/install.ps1 b/install.ps1 index e38a661..fa97665 100644 --- a/install.ps1 +++ b/install.ps1 @@ -156,132 +156,157 @@ if (-not $InstallDir) { $InstallDir = $DEFAULT_INSTALL_DIR } if (-not $ConfigPath -and -not $env:TOKEN) { Show-Usage } -if ($ConfigPath -and -not (Test-Path $ConfigPath -PathType Leaf)) { - Write-Host "ERROR: Config file not found or not readable: $ConfigPath" -ForegroundColor Red - exit 1 +# Read config file once; also validates existence and readability +$configFileContent = $null +if ($ConfigPath) { + try { + $configFileContent = Get-Content $ConfigPath -Raw + } catch { + Write-Host "ERROR: Config file not found or not readable: $ConfigPath" -ForegroundColor Red + exit 1 + } } -# Detect WebSocket agent config and migrate to TOKEN flow -if ($ConfigPath -and -not $env:TOKEN) { - $rawConfig = Get-Content $ConfigPath -Raw - if ($rawConfig -match '"proxy_server_uri"') { - try { - $parsed = $rawConfig | ConvertFrom-Json - } catch { - Write-Host "ERROR: Could not parse config file as JSON: $ConfigPath" -ForegroundColor Red - exit 1 - } - $agentId = $parsed.agent_id - $authToken = $parsed.auth_token - if (-not $agentId -or -not $authToken) { - Write-Host "ERROR: Could not extract credentials from WebSocket config" -ForegroundColor Red - exit 1 +try { + # Detect WebSocket agent config and migrate to TOKEN flow. + # Store derived credential in a local variable; $env:TOKEN is set only just before the + # configure API call so that pre-flight docker commands do not inherit it. + $derivedToken = $null + if ($configFileContent -and -not $env:TOKEN) { + if ($configFileContent -match '"proxy_server_uri"') { + try { + $parsed = $configFileContent | ConvertFrom-Json + } catch { + Write-Host "ERROR: Could not parse config file as JSON: $ConfigPath" -ForegroundColor Red + exit 1 + } + $agentId = $parsed.agent_id + $authToken = $parsed.auth_token + if (-not $agentId -or -not $authToken) { + Write-Host "ERROR: Could not extract credentials from WebSocket config" -ForegroundColor Red + exit 1 + } + $derivedToken = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${agentId}:${authToken}")) + $ConfigPath = '' + Write-Host "Detected WebSocket agent configuration. Fetching updated config from Fivetran..." } - $env:TOKEN = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes("${agentId}:${authToken}")) - $ConfigPath = '' - Write-Host "Detected WebSocket agent configuration. Fetching updated config from Fivetran..." } -} -Write-Host "Installing Fivetran Proxy Agent...`n" + Write-Host "Installing Fivetran Proxy Agent...`n" -# Pre-flight checks -Write-Host -NoNewline "Checking prerequisites... " -Test-DockerVersion -Test-Resources -Test-DiskSpace $InstallDir + # Pre-flight checks + Write-Host -NoNewline "Checking prerequisites... " + Test-DockerVersion + Test-Resources + Test-DiskSpace $InstallDir -if ($script:Warnings.Count -eq 0 -and $script:Errors.Count -eq 0) { - Write-Host "OK`n" -} else { - Write-Host "" - Show-WarningsAndErrors -} - -# Directory setup -if (Test-Path $InstallDir) { - Write-Host "$InstallDir already exists, will re-use it." -} else { - $null = New-Item -ItemType Directory -Force -Path $InstallDir -} + if ($script:Warnings.Count -eq 0 -and $script:Errors.Count -eq 0) { + Write-Host "OK`n" + } else { + Write-Host "" + Show-WarningsAndErrors + } -$testFile = Join-Path $InstallDir ".write-test-$PID" -try { - $null = New-Item -ItemType File -Path $testFile -Force - Remove-Item $testFile -Force -} catch { - Write-Host "ERROR: Insufficient permissions to write to $InstallDir" -ForegroundColor Red - exit 1 -} + # Directory setup + if (Test-Path $InstallDir -PathType Container) { + Write-Host "$InstallDir already exists, will re-use it." + } else { + $null = New-Item -ItemType Directory -Force -Path $InstallDir + } -$null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'config') -$null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') + $testFile = Join-Path $InstallDir ".write-test-$PID" + try { + $null = New-Item -ItemType File -Path $testFile -Force + Remove-Item $testFile -Force + } catch { + Write-Host "ERROR: Insufficient permissions to write to $InstallDir" -ForegroundColor Red + exit 1 + } -# Download management script from public repo (temp file → move for atomicity) -Write-Host "Downloading management script..." -$tmpScript = Join-Path $InstallDir "$AGENT_SCRIPT.tmp" -try { - Invoke-WebRequest -Uri $AGENT_SCRIPT_URL -OutFile $tmpScript -UseBasicParsing -TimeoutSec 30 -} catch { - Remove-Item -Path $tmpScript -Force -ErrorAction SilentlyContinue - Write-Host "ERROR: Failed to download management script from ${AGENT_SCRIPT_URL}: $_" -ForegroundColor Red - exit 1 -} -Move-Item -Path $tmpScript -Destination (Join-Path $InstallDir $AGENT_SCRIPT) -Force - -# Bootstrap or copy config -$configDest = Join-Path $InstallDir 'config\config.json' -$currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name - -# Create with restricted ACL before writing credentials (no world-readable window) -$null = New-Item -ItemType File -Path $configDest -Force -$configAcl = Get-Acl $configDest -$configAcl.SetAccessRuleProtection($true, $false) -$configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( - $currentUser, 'ReadAndExecute,Write', 'Allow'))) -$configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( - 'NT AUTHORITY\SYSTEM', 'Read', 'Allow'))) -Set-Acl -Path $configDest -AclObject $configAcl + $null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'config') + $null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') -if ($ConfigPath) { - [System.IO.File]::WriteAllText($configDest, (Get-Content $ConfigPath -Raw), [System.Text.UTF8Encoding]::new($false)) - Write-Host "Config copied to $configDest" -} else { - $apiUrl = if ($env:FIVETRAN_API_URL) { $env:FIVETRAN_API_URL } else { $DEFAULT_FIVETRAN_API_URL } - Write-Host "Fetching agent config from $apiUrl..." + # Download management script from public repo (temp file → move for atomicity) + Write-Host "Downloading management script..." + $agentScript = Join-Path $InstallDir $AGENT_SCRIPT + $tmpScript = Join-Path $InstallDir "$AGENT_SCRIPT.$PID.tmp" try { - $configData = Invoke-RestMethod -Uri "$apiUrl/proxy-agent/configure" ` - -Method POST ` - -Headers @{ Authorization = "Basic $($env:TOKEN)"; Accept = 'application/json' } ` - -TimeoutSec 30 + Invoke-WebRequest -Uri $AGENT_SCRIPT_URL -OutFile $tmpScript -UseBasicParsing -TimeoutSec 30 } catch { - $statusCode = $_.Exception.Response.StatusCode.value__ - if ($statusCode) { - Write-Host "ERROR: Configure endpoint returned HTTP $statusCode" -ForegroundColor Red - } else { - Write-Host "ERROR: Failed to connect to configure endpoint: $_" -ForegroundColor Red - } + Remove-Item -Path $tmpScript -Force -ErrorAction SilentlyContinue + Write-Host "ERROR: Failed to download management script from ${AGENT_SCRIPT_URL}: $_" -ForegroundColor Red exit 1 } - [System.IO.File]::WriteAllText($configDest, ($configData | ConvertTo-Json -Depth 10 -Compress), [System.Text.UTF8Encoding]::new($false)) -} + Move-Item -Path $tmpScript -Destination $agentScript -Force + Unblock-File -Path $agentScript + + # Bootstrap or copy config + $configDest = Join-Path $InstallDir 'config\config.json' + $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name + + # Create with restricted ACL before writing credentials (no world-readable window) + try { + $null = New-Item -ItemType File -Path $configDest -Force + } catch [System.UnauthorizedAccessException] { + Write-Host "ERROR: Cannot create $configDest — if reinstalling, run as the original installing user or delete the existing file manually." -ForegroundColor Red + exit 1 + } + $configAcl = Get-Acl $configDest + $configAcl.SetAccessRuleProtection($true, $false) + $configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + $currentUser, 'Read,Write', 'Allow'))) + $configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( + 'NT AUTHORITY\SYSTEM', 'Read', 'Allow'))) + Set-Acl -Path $configDest -AclObject $configAcl + + if ($ConfigPath) { + [System.IO.File]::WriteAllText($configDest, $configFileContent, [System.Text.UTF8Encoding]::new($false)) + Write-Host "Config copied to $configDest" + } else { + $apiUrl = if ($env:FIVETRAN_API_URL) { $env:FIVETRAN_API_URL } else { $DEFAULT_FIVETRAN_API_URL } + Write-Host "Fetching agent config from $apiUrl..." + if ($derivedToken) { $env:TOKEN = $derivedToken } + try { + $response = Invoke-WebRequest -Uri "$apiUrl/proxy-agent/configure" ` + -Method POST ` + -Headers @{ Authorization = "Basic $($env:TOKEN)"; Accept = 'application/json' } ` + -TimeoutSec 30 ` + -UseBasicParsing + } catch { + $statusCode = $_.Exception.Response.StatusCode.value__ + if ($statusCode) { + Write-Host "ERROR: Configure endpoint returned HTTP $statusCode" -ForegroundColor Red + } else { + Write-Host "ERROR: Failed to connect to configure endpoint: $_" -ForegroundColor Red + } + exit 1 + } + if ($response.StatusCode -ne 200) { + Write-Host "ERROR: Configure endpoint returned HTTP $($response.StatusCode)" -ForegroundColor Red + exit 1 + } + [System.IO.File]::WriteAllText($configDest, $response.Content, [System.Text.UTF8Encoding]::new($false)) + } -# Resolve and pin version -Write-Host "Resolving latest proxy agent version..." -$version = Get-LatestVersion -Set-Content -Path (Join-Path $InstallDir 'version') -Value $version -Encoding ascii -Write-Host "Using version $version" + # Resolve and pin version + Write-Host "Resolving latest proxy agent version..." + $version = Get-LatestVersion + Set-Content -Path (Join-Path $InstallDir 'version') -Value $version -Encoding ascii + Write-Host "Using version $version" -# Clear credential from environment before spawning child process -Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue + # Clear credential from environment before spawning child process + Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue -# Start agent -& (Join-Path $InstallDir $AGENT_SCRIPT) start -if ($LASTEXITCODE -ne 0) { - Write-Host "Installation complete, but agent failed to start." - Write-Host "To try to start the agent again, run: & '$(Join-Path $InstallDir $AGENT_SCRIPT)' start" - exit 1 -} + # Start agent + & $agentScript start + if (-not $?) { + Write-Host "Installation complete, but agent failed to start." + Write-Host "To try to start the agent again, run: & '$agentScript' start" + exit 1 + } -Write-Host "`nInstallation complete." -Write-Host "Install directory: $InstallDir" + Write-Host "`nInstallation complete." + Write-Host "Install directory: $InstallDir" +} finally { + Remove-Item Env:\TOKEN -ErrorAction SilentlyContinue +} From 8afca3643f1bbd5580ba41ff052de0d2f3d15420 Mon Sep 17 00:00:00 2001 From: fivetran-pavlonikolenko Date: Tue, 21 Jul 2026 13:13:41 -0700 Subject: [PATCH 4/5] cleanup --- install.ps1 | 44 +++++++++++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/install.ps1 b/install.ps1 index fa97665..592d9e8 100644 --- a/install.ps1 +++ b/install.ps1 @@ -91,8 +91,11 @@ function Test-Resources { function Test-DiskSpace { param([string]$Path) - $parentDir = Split-Path $Path -Parent - if (-not (Test-Path $parentDir)) { return } + $parentDir = Split-Path -LiteralPath $Path -Parent + if (-not (Test-Path -LiteralPath $parentDir)) { + $script:Warnings.Add("Unable to determine available disk space: parent directory $parentDir does not exist") + return + } try { $qualifier = Split-Path -Qualifier $parentDir @@ -160,7 +163,7 @@ if (-not $ConfigPath -and -not $env:TOKEN) { Show-Usage } $configFileContent = $null if ($ConfigPath) { try { - $configFileContent = Get-Content $ConfigPath -Raw + $configFileContent = Get-Content -LiteralPath $ConfigPath -Raw } catch { Write-Host "ERROR: Config file not found or not readable: $ConfigPath" -ForegroundColor Red exit 1 @@ -208,23 +211,23 @@ try { } # Directory setup - if (Test-Path $InstallDir -PathType Container) { + if (Test-Path -LiteralPath $InstallDir -PathType Container) { Write-Host "$InstallDir already exists, will re-use it." } else { - $null = New-Item -ItemType Directory -Force -Path $InstallDir + $null = New-Item -ItemType Directory -Force -LiteralPath $InstallDir } $testFile = Join-Path $InstallDir ".write-test-$PID" try { - $null = New-Item -ItemType File -Path $testFile -Force - Remove-Item $testFile -Force + $null = New-Item -ItemType File -LiteralPath $testFile -Force + Remove-Item -LiteralPath $testFile -Force } catch { Write-Host "ERROR: Insufficient permissions to write to $InstallDir" -ForegroundColor Red exit 1 } - $null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'config') - $null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') + $null = New-Item -ItemType Directory -Force -LiteralPath (Join-Path $InstallDir 'config') + $null = New-Item -ItemType Directory -Force -LiteralPath (Join-Path $InstallDir 'logs') # Download management script from public repo (temp file → move for atomicity) Write-Host "Downloading management script..." @@ -233,31 +236,32 @@ try { try { Invoke-WebRequest -Uri $AGENT_SCRIPT_URL -OutFile $tmpScript -UseBasicParsing -TimeoutSec 30 } catch { - Remove-Item -Path $tmpScript -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $tmpScript -Force -ErrorAction SilentlyContinue Write-Host "ERROR: Failed to download management script from ${AGENT_SCRIPT_URL}: $_" -ForegroundColor Red exit 1 } - Move-Item -Path $tmpScript -Destination $agentScript -Force - Unblock-File -Path $agentScript + Move-Item -LiteralPath $tmpScript -Destination $agentScript -Force + Unblock-File -LiteralPath $agentScript # Bootstrap or copy config $configDest = Join-Path $InstallDir 'config\config.json' $currentUser = [Security.Principal.WindowsIdentity]::GetCurrent().Name - # Create with restricted ACL before writing credentials (no world-readable window) + # Create empty file first, then restrict ACL, then write credentials. + # The file is empty (no credentials) during the brief window between creation and ACL restriction. try { - $null = New-Item -ItemType File -Path $configDest -Force + $null = New-Item -ItemType File -LiteralPath $configDest -Force } catch [System.UnauthorizedAccessException] { Write-Host "ERROR: Cannot create $configDest — if reinstalling, run as the original installing user or delete the existing file manually." -ForegroundColor Red exit 1 } - $configAcl = Get-Acl $configDest + $configAcl = Get-Acl -LiteralPath $configDest $configAcl.SetAccessRuleProtection($true, $false) $configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( $currentUser, 'Read,Write', 'Allow'))) $configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( 'NT AUTHORITY\SYSTEM', 'Read', 'Allow'))) - Set-Acl -Path $configDest -AclObject $configAcl + Set-Acl -LiteralPath $configDest -AclObject $configAcl if ($ConfigPath) { [System.IO.File]::WriteAllText($configDest, $configFileContent, [System.Text.UTF8Encoding]::new($false)) @@ -285,13 +289,19 @@ try { Write-Host "ERROR: Configure endpoint returned HTTP $($response.StatusCode)" -ForegroundColor Red exit 1 } + try { + $null = $response.Content | ConvertFrom-Json + } catch { + Write-Host "ERROR: Configure endpoint returned an invalid JSON response" -ForegroundColor Red + exit 1 + } [System.IO.File]::WriteAllText($configDest, $response.Content, [System.Text.UTF8Encoding]::new($false)) } # Resolve and pin version Write-Host "Resolving latest proxy agent version..." $version = Get-LatestVersion - Set-Content -Path (Join-Path $InstallDir 'version') -Value $version -Encoding ascii + Set-Content -LiteralPath (Join-Path $InstallDir 'version') -Value $version -Encoding ascii Write-Host "Using version $version" # Clear credential from environment before spawning child process From 07ab80ba6806051f9d9240c13b7286fabe3bf226 Mon Sep 17 00:00:00 2001 From: fivetran-pavlonikolenko Date: Tue, 21 Jul 2026 14:02:27 -0700 Subject: [PATCH 5/5] fix issues found in testing --- install.ps1 | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/install.ps1 b/install.ps1 index 592d9e8..8217c6b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -91,7 +91,7 @@ function Test-Resources { function Test-DiskSpace { param([string]$Path) - $parentDir = Split-Path -LiteralPath $Path -Parent + $parentDir = Split-Path -LiteralPath $Path if (-not (Test-Path -LiteralPath $parentDir)) { $script:Warnings.Add("Unable to determine available disk space: parent directory $parentDir does not exist") return @@ -214,20 +214,20 @@ try { if (Test-Path -LiteralPath $InstallDir -PathType Container) { Write-Host "$InstallDir already exists, will re-use it." } else { - $null = New-Item -ItemType Directory -Force -LiteralPath $InstallDir + $null = New-Item -ItemType Directory -Force -Path $InstallDir } $testFile = Join-Path $InstallDir ".write-test-$PID" try { - $null = New-Item -ItemType File -LiteralPath $testFile -Force + $null = New-Item -ItemType File -Path $testFile -Force Remove-Item -LiteralPath $testFile -Force } catch { Write-Host "ERROR: Insufficient permissions to write to $InstallDir" -ForegroundColor Red exit 1 } - $null = New-Item -ItemType Directory -Force -LiteralPath (Join-Path $InstallDir 'config') - $null = New-Item -ItemType Directory -Force -LiteralPath (Join-Path $InstallDir 'logs') + $null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'config') + $null = New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'logs') # Download management script from public repo (temp file → move for atomicity) Write-Host "Downloading management script..." @@ -250,18 +250,18 @@ try { # Create empty file first, then restrict ACL, then write credentials. # The file is empty (no credentials) during the brief window between creation and ACL restriction. try { - $null = New-Item -ItemType File -LiteralPath $configDest -Force + $null = New-Item -ItemType File -Path $configDest -Force } catch [System.UnauthorizedAccessException] { - Write-Host "ERROR: Cannot create $configDest — if reinstalling, run as the original installing user or delete the existing file manually." -ForegroundColor Red + Write-Host "ERROR: Cannot create $configDest - if reinstalling, run as the original installing user or delete the existing file manually." -ForegroundColor Red exit 1 } - $configAcl = Get-Acl -LiteralPath $configDest + $configAcl = [System.Security.AccessControl.FileSecurity]::new() $configAcl.SetAccessRuleProtection($true, $false) $configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( $currentUser, 'Read,Write', 'Allow'))) $configAcl.SetAccessRule((New-Object Security.AccessControl.FileSystemAccessRule( 'NT AUTHORITY\SYSTEM', 'Read', 'Allow'))) - Set-Acl -LiteralPath $configDest -AclObject $configAcl + [System.IO.File]::SetAccessControl($configDest, $configAcl) if ($ConfigPath) { [System.IO.File]::WriteAllText($configDest, $configFileContent, [System.Text.UTF8Encoding]::new($false)) @@ -278,7 +278,9 @@ try { -UseBasicParsing } catch { $statusCode = $_.Exception.Response.StatusCode.value__ - if ($statusCode) { + if ($statusCode -eq 401) { + Write-Host "ERROR: Authentication failed (HTTP 401). Ensure your TOKEN is valid." -ForegroundColor Red + } elseif ($statusCode) { Write-Host "ERROR: Configure endpoint returned HTTP $statusCode" -ForegroundColor Red } else { Write-Host "ERROR: Failed to connect to configure endpoint: $_" -ForegroundColor Red