#Requires -Version 5.1 <# .SYNOPSIS Installs the Logthread Agent as a Windows service. .DESCRIPTION Downloads (or takes a local) logthread-agent zip, verifies its SHA-256, installs it to "C:\Program Files\Logthread\Agent", writes the selected profiles to C:\ProgramData\Logthread\Agent\conf.d, registers the Windows service "LogthreadAgent" (LocalSystem, automatic start, restart on failure), stores the ingest key in the service's own environment (HKLM\SYSTEM\CurrentControlSet\Services\LogthreadAgent, Environment) with an ACL limited to SYSTEM and Administrators, and starts it. Safe to re-run: it reconfigures an existing install. .EXAMPLE .\install.ps1 -Key YOUR_INGEST_KEY -Profiles windows-server,iis & ([scriptblock]::Create((irm https://downloads.logthread.com/agent/install.ps1))) -Key YOUR_INGEST_KEY -Profiles windows-server,iis,sqlserver -SqlServerDsn 'server=.;port=1433;Integrated Security=SSPI;encrypt=true;TrustServerCertificate=true' .\install.ps1 -Uninstall # remove the service and program files; keeps the key and state .\install.ps1 -Uninstall -Purge # also remove C:\ProgramData\Logthread\Agent #> [CmdletBinding()] param( [string]$Key = $env:LOGTHREAD_INGEST_KEY, [string]$KeyFile, [string[]]$Profiles, [string]$Endpoint, [switch]$Fleet, [string]$Labels, [string]$SqlServerDsn, [string]$Version, [string]$BaseUrl = $(if ($env:LOGTHREAD_AGENT_BASE_URL) { $env:LOGTHREAD_AGENT_BASE_URL } else { 'https://downloads.logthread.com/agent' }), [string]$LocalZip, [string]$IisLogDir, [switch]$NoStart, [switch]$Uninstall, [switch]$Purge ) $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 $ServiceName = 'LogthreadAgent' $InstallDir = Join-Path $env:ProgramFiles 'Logthread\Agent' $DataRoot = Join-Path $env:ProgramData 'Logthread\Agent' $ConfDir = Join-Path $DataRoot 'conf.d' $StateDir = Join-Path $DataRoot 'data' $LogDir = Join-Path $DataRoot 'logs' $ServiceKey = "HKLM:\SYSTEM\CurrentControlSet\Services\$ServiceName" function Say([string]$m) { Write-Host $m } function Fail([string]$m) { Write-Error "logthread-agent install: $m"; exit 1 } $principal = New-Object Security.Principal.WindowsPrincipal([Security.Principal.WindowsIdentity]::GetCurrent()) if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { Fail 'run this from an elevated (Administrator) PowerShell' } function Restrict-Acl([string]$path, [bool]$isRegistry) { # Only SYSTEM and Administrators; no inheritance. $acl = Get-Acl -Path $path $acl.SetAccessRuleProtection($true, $false) foreach ($r in @($acl.Access)) { [void]$acl.RemoveAccessRule($r) } $sys = New-Object Security.Principal.SecurityIdentifier('S-1-5-18') $adm = New-Object Security.Principal.SecurityIdentifier('S-1-5-32-544') if ($isRegistry) { foreach ($sid in @($sys, $adm)) { $acl.AddAccessRule((New-Object Security.AccessControl.RegistryAccessRule($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) } } else { foreach ($sid in @($sys, $adm)) { $acl.AddAccessRule((New-Object Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) } } Set-Acl -Path $path -AclObject $acl } function Stop-AgentService { $svc = Get-Service -Name $ServiceName -ErrorAction SilentlyContinue if ($svc -and $svc.Status -ne 'Stopped') { Stop-Service -Name $ServiceName -Force -ErrorAction SilentlyContinue $svc.WaitForStatus('Stopped', [TimeSpan]::FromSeconds(30)) 2>$null } } # ---- uninstall ------------------------------------------------------------------------------ if ($Uninstall) { if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) { Stop-AgentService & sc.exe delete $ServiceName | Out-Null } if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir } $parent = Split-Path $InstallDir if ((Test-Path $parent) -and -not (Get-ChildItem $parent -Force)) { Remove-Item -Force $parent } if ($Purge -and (Test-Path $DataRoot)) { Remove-Item -Recurse -Force $DataRoot Say 'Logthread Agent removed, including its key, configuration and state.' } else { Say "Logthread Agent removed. Kept $DataRoot (configuration and state); add -Purge to delete it." } exit 0 } # ---- inputs --------------------------------------------------------------------------------- if ($KeyFile) { $Key = (Get-Content -Raw -Path $KeyFile).Trim() } function Read-ServiceEnv { $map = [ordered]@{} if (Test-Path $ServiceKey) { $cur = (Get-ItemProperty -Path $ServiceKey -Name Environment -ErrorAction SilentlyContinue).Environment foreach ($line in @($cur)) { if ($line -match '^([^=]+)=(.*)$') { $map[$Matches[1]] = $Matches[2] } } } return $map } $envMap = Read-ServiceEnv if (-not $Key -and $envMap.Contains('LOGTHREAD_INGEST_KEY')) { $Key = $envMap['LOGTHREAD_INGEST_KEY'] } if (-not $Key) { Fail 'an ingest key is required: -Key lt_... (create one in Logthread, Keys)' } if ($Key -notmatch '^[A-Za-z0-9_.-]+$') { Fail 'the ingest key contains unexpected characters' } if (-not $Profiles) { $Profiles = @('windows-server') } $Profiles = @($Profiles | ForEach-Object { $_ -split ',' } | ForEach-Object { $_.Trim() } | Where-Object { $_ }) foreach ($p in $Profiles) { if ($p -notmatch '^[a-z0-9][a-z0-9-]*$') { Fail "invalid profile name: $p" } } if ($env:PROCESSOR_ARCHITECTURE -ne 'AMD64' -and $env:PROCESSOR_ARCHITEW6432 -ne 'AMD64') { Fail "unsupported CPU architecture ($env:PROCESSOR_ARCHITECTURE): only Windows x64 is published" } # ---- fetch and verify ----------------------------------------------------------------------- $tmp = Join-Path ([IO.Path]::GetTempPath()) ('logthread-agent-' + [guid]::NewGuid().ToString('N')) New-Item -ItemType Directory -Path $tmp | Out-Null try { if ($LocalZip) { if (-not (Test-Path $LocalZip)) { Fail "no such file: $LocalZip" } $zip = (Resolve-Path $LocalZip).Path $sums = Join-Path (Split-Path $zip) 'SHA256SUMS.txt' if (Test-Path $sums) { $want = (Get-Content $sums | Where-Object { $_ -match (' ' + [regex]::Escape((Split-Path $zip -Leaf)) + '$') } | Select-Object -First 1) if ($want) { $want = ($want -split '\s+')[0] if ((Get-FileHash -Algorithm SHA256 -Path $zip).Hash.ToLower() -ne $want.ToLower()) { Fail 'SHA-256 mismatch for the local zip; nothing was installed' } Say 'SHA-256 verified' } } else { Say 'No SHA256SUMS.txt next to the local zip; skipping verification.' } } else { if (-not $Version) { $Version = ((Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/latest.txt").Content).Trim() } if ($Version -notmatch '^[0-9A-Za-z.+-]+$') { Fail "invalid version: $Version" } $name = "logthread-agent_${Version}_windows_amd64.zip" Say "Downloading Logthread Agent $Version" $zip = Join-Path $tmp $name Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/$Version/$name" -OutFile $zip $sumsText = (Invoke-WebRequest -UseBasicParsing -Uri "$BaseUrl/$Version/SHA256SUMS.txt").Content $line = ($sumsText -split "`n" | Where-Object { $_ -match (' ' + [regex]::Escape($name) + '\s*$') } | Select-Object -First 1) if (-not $line) { Fail "$name is not listed in SHA256SUMS.txt" } $want = ($line.Trim() -split '\s+')[0] $have = (Get-FileHash -Algorithm SHA256 -Path $zip).Hash if ($have.ToLower() -ne $want.ToLower()) { Fail "SHA-256 mismatch for $name (expected $want, got $have); nothing was installed" } Say 'SHA-256 verified' } $x = Join-Path $tmp 'x' Expand-Archive -Path $zip -DestinationPath $x -Force $root = Get-ChildItem -Path $x -Directory | Select-Object -First 1 if (-not $root -or -not (Test-Path (Join-Path $root.FullName 'logthread-agent.exe'))) { Fail 'the zip does not contain logthread-agent.exe' } $src = $root.FullName # ---- install files ---------------------------------------------------------------------- Stop-AgentService New-Item -ItemType Directory -Force -Path $InstallDir, $ConfDir, $StateDir, $LogDir | Out-Null Copy-Item -Force (Join-Path $src 'logthread-agent.exe') $InstallDir if (Test-Path (Join-Path $src 'logthread-agent-supervisor.exe')) { Copy-Item -Force (Join-Path $src 'logthread-agent-supervisor.exe') $InstallDir } New-Item -ItemType Directory -Force -Path (Join-Path $InstallDir 'profiles') | Out-Null Copy-Item -Force (Join-Path $src 'profiles\*.yaml') (Join-Path $InstallDir 'profiles') foreach ($f in 'README.md', 'NOTICE', 'install.ps1') { if (Test-Path (Join-Path $src $f)) { Copy-Item -Force (Join-Path $src $f) $InstallDir } } # ---- profiles -> conf.d (copies; custom files such as 90-local.yaml are never touched) ---- Copy-Item -Force (Join-Path $InstallDir 'profiles\base.yaml') (Join-Path $ConfDir '00-base.yaml') Get-ChildItem -Path $ConfDir -Filter '10-*.yaml' | Remove-Item -Force foreach ($p in $Profiles) { $pf = Join-Path $InstallDir "profiles\$p.yaml" if (-not (Test-Path $pf)) { Fail "unknown profile: $p (available: $((Get-ChildItem (Join-Path $InstallDir 'profiles') -Filter *.yaml | ForEach-Object { $_.BaseName }) -join ', '))" } Copy-Item -Force $pf (Join-Path $ConfDir "10-$p.yaml") } } finally { Remove-Item -Recurse -Force $tmp -ErrorAction SilentlyContinue } # ---- service environment (key stays out of every file) -------------------------------------- $envMap['LOGTHREAD_INGEST_KEY'] = $Key if ($Endpoint) { $envMap['LOGTHREAD_ENDPOINT'] = $Endpoint } if ($Labels) { $envMap['LOGTHREAD_FLEET_LABELS'] = $Labels } if ($SqlServerDsn) { $envMap['LOGTHREAD_SQLSERVER_DSN'] = $SqlServerDsn } if ($IisLogDir) { $envMap['LOGTHREAD_IIS_LOG_DIR'] = ($IisLogDir -replace '\\', '/') } if ($Profiles -contains 'iis' -and -not $envMap.Contains('LOGTHREAD_IIS_LOG_DIR')) { $envMap['LOGTHREAD_IIS_LOG_DIR'] = ($env:SystemDrive + '/inetpub/logs/LogFiles') } if ($Profiles -contains 'sqlserver' -and -not $envMap.Contains('LOGTHREAD_SQLSERVER_ERRORLOG_DIR')) { $envMap['LOGTHREAD_SQLSERVER_ERRORLOG_DIR'] = (($env:ProgramFiles -replace '\\', '/') + '/Microsoft SQL Server') } $envMap['LOGTHREAD_DATA_DIR'] = $StateDir $envMap['LOGTHREAD_LOG_FILE'] = Join-Path $LogDir 'agent.log' $envMap['LOGTHREAD_CONF_DIR'] = $ConfDir $fleetOn = $Fleet.IsPresent -or ($envMap['LOGTHREAD_FLEET'] -eq 'true') $envMap['LOGTHREAD_FLEET'] = $(if ($fleetOn) { 'true' } else { 'false' }) if ($Profiles -contains 'sqlserver' -and -not $envMap.Contains('LOGTHREAD_SQLSERVER_DSN')) { Write-Warning 'The sqlserver profile is enabled without -SqlServerDsn: only the Windows performance counters are collected, no top queries.' } # ---- service -------------------------------------------------------------------------------- $agentExe = Join-Path $InstallDir 'logthread-agent.exe' $cfgArgs = (Get-ChildItem -Path $ConfDir -Filter '*.yaml' | Sort-Object Name | ForEach-Object { "--config=file:$($_.FullName)" }) -join ' ' if ($fleetOn) { $supExe = Join-Path $InstallDir 'logthread-agent-supervisor.exe' if (-not (Test-Path $supExe)) { Fail 'Fleet needs logthread-agent-supervisor.exe, which is missing from this package' } $ep = if ($envMap.Contains('LOGTHREAD_ENDPOINT')) { $envMap['LOGTHREAD_ENDPOINT'] } else { 'https://ingest.logthread.com' } $ep = $ep.TrimEnd('/') -replace '^https://', 'wss://' -replace '^http://', 'ws://' $supDir = Join-Path $StateDir 'supervisor' New-Item -ItemType Directory -Force -Path $supDir | Out-Null $y = New-Object System.Text.StringBuilder [void]$y.AppendLine('# Generated by install.ps1. Re-run the installer to change it.') [void]$y.AppendLine('server:') [void]$y.AppendLine(" endpoint: '$ep/v1/opamp'") [void]$y.AppendLine(' headers:') [void]$y.AppendLine(' Authorization: "Bearer ${env:LOGTHREAD_INGEST_KEY}"') if ($ep -like 'ws://*') { [void]$y.AppendLine(' tls:'); [void]$y.AppendLine(' insecure: true') } [void]$y.AppendLine('capabilities:') foreach ($c in 'accepts_remote_config', 'reports_effective_config', 'reports_health', 'reports_heartbeat') { [void]$y.AppendLine(" ${c}: true") } [void]$y.AppendLine('agent:') [void]$y.AppendLine(" executable: '$agentExe'") [void]$y.AppendLine(' passthrough_logs: true') [void]$y.AppendLine(' config_files:') foreach ($f in (Get-ChildItem -Path $ConfDir -Filter '*.yaml' | Sort-Object Name)) { [void]$y.AppendLine(" - '$($f.FullName)'") } [void]$y.AppendLine(' description:') [void]$y.AppendLine(' non_identifying_attributes:') $labelPairs = @() if ($envMap.Contains('LOGTHREAD_FLEET_LABELS')) { $labelPairs = $envMap['LOGTHREAD_FLEET_LABELS'] -split ',' } if (-not $labelPairs) { [void]$y.AppendLine(' logthread.agent: "true"') } foreach ($kv in $labelPairs) { $k, $v = $kv.Trim() -split '=', 2 if ($k) { [void]$y.AppendLine(" '$k': '$v'") } } [void]$y.AppendLine('storage:') [void]$y.AppendLine(" directory: '$supDir'") $supCfg = Join-Path $supDir 'supervisor.yaml' [IO.File]::WriteAllText($supCfg, $y.ToString(), (New-Object Text.UTF8Encoding($false))) $binPath = "`"$supExe`" --config=`"$supCfg`"" } else { $binPath = "`"$agentExe`" $cfgArgs" } if (Get-Service -Name $ServiceName -ErrorAction SilentlyContinue) { # ImagePath is the service command line; writing it directly avoids sc.exe quoting problems. Set-ItemProperty -Path $ServiceKey -Name ImagePath -Value $binPath Set-Service -Name $ServiceName -StartupType Automatic } else { New-Service -Name $ServiceName -DisplayName 'Logthread Agent' -BinaryPathName $binPath -StartupType Automatic ` -Description 'Collects logs, metrics and Windows, IIS and SQL Server signals and sends them to Logthread.' | Out-Null } & sc.exe failure $ServiceName reset= 86400 actions= restart/5000/restart/5000/restart/30000 | Out-Null $envLines = @($envMap.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) New-ItemProperty -Path $ServiceKey -Name Environment -PropertyType MultiString -Value $envLines -Force | Out-Null Restrict-Acl -path $ServiceKey -isRegistry $true Restrict-Acl -path $DataRoot -isRegistry $false # ---- start ---------------------------------------------------------------------------------- if ($NoStart) { Say "Installed. Start it with: Start-Service $ServiceName" exit 0 } Start-Service -Name $ServiceName Start-Sleep -Seconds 3 $svc = Get-Service -Name $ServiceName if ($svc.Status -eq 'Running') { Say "Logthread Agent is running (profiles: $($Profiles -join ', '); fleet: $fleetOn)." Say "Logs: $(Join-Path $LogDir 'agent.log') Status: Get-Service $ServiceName" } else { Say "The service is $($svc.Status). See $(Join-Path $LogDir 'agent.log') and the Application event log." exit 1 }