<# .SYNOPSIS Download, verify, configure, install and start one Mugnsoft component as a Windows service, in one command. Run it from an elevated PowerShell. .EXAMPLE .\mugnsoft-install.ps1 webserver -License .\license_MNS.dat .EXAMPLE .\mugnsoft-install.ps1 monitor -Webserver 10.0.0.5:8050 -SharedInternal 3f9c...e1 -Location Paris .PARAMETER Component webserver, monitor, integrator, sentinel or webserver-front. .PARAMETER Version Release to install (default 4.2.0). .PARAMETER Dir Parent folder (default C:\Mugnsoft); the component goes in \. .PARAMETER Package Install this package (.zip) instead of downloading it. .PARAMETER NoVerify Skip the GPG signature check (not recommended). .PARAMETER License webserver and integrator: the license_MNS.dat received from Mugnsoft. .PARAMETER ApiPort webserver: API port (default 8050). .PARAMETER WebPort webserver: web UI port (default 9090). .PARAMETER Webserver monitor, integrator, sentinel: the Webserver API address, host:port (e.g. 10.0.0.5:8050). .PARAMETER SharedInternal monitor, integrator, sentinel: the "_sharedInternal" value of the Webserver's webserver.json. .PARAMETER Name Component name (default -). .PARAMETER Port Component API port (default: the package's). .PARAMETER Location Location shown on the Webserver (monitor, sentinel). .PARAMETER Advertise monitor: IP address the Webserver must use to reach this probe (default: auto-detected). .PARAMETER WebserverUrl integrator: Webserver web UI URL used in notifications (default https://:9090). .PARAMETER FrontPki webserver-front: the front\ folder produced by mugnsoft-front-pki.sh. .PARAMETER NoFirewall Do not add the Windows Firewall rule opening the component's port(s). #> [CmdletBinding()] param( [Parameter(Mandatory = $true, Position = 0)] [ValidateSet("webserver", "monitor", "integrator", "sentinel", "webserver-front")] [string]$Component, [string]$Version = "4.2.0", [string]$Dir = "C:\Mugnsoft", [string]$Package = "", [switch]$NoVerify, [string]$License = "", [string]$ApiPort = "", [string]$WebPort = "", [string]$Webserver = "", [string]$SharedInternal = "", [string]$Name = "", [string]$Port = "", [string]$Location = "", [string]$Advertise = "", [string]$WebserverUrl = "", [string]$FrontPki = "", [switch]$NoFirewall ) $ErrorActionPreference = "Stop" $ProgressPreference = "SilentlyContinue" # Invoke-WebRequest is 10x faster without the progress bar [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 $BaseUrl = "https://mugnsoft.com/bin" function Fail($msg) { Write-Host "ERROR: $msg" -ForegroundColor Red; exit 1 } function Info($msg) { Write-Host "==> $msg" -ForegroundColor Cyan } # Run a native program and capture stdout+stderr. Windows PowerShell turns every # stderr line into a terminating error under ErrorActionPreference=Stop, so relax # it for the call only. function Native([string]$exe, [string[]]$a) { $prev = $ErrorActionPreference; $ErrorActionPreference = "Continue" try { $o = & $exe @a 2>&1 | ForEach-Object { "$_" }; $code = $LASTEXITCODE } finally { $ErrorActionPreference = $prev } return @{ Out = ($o -join "`n"); Code = $code } } # component -> package folder and binary $pkg = @{ "webserver" = "webserver"; "webserver-front" = "webserver-front"; "monitor" = "monitor"; "integrator" = "integrator"; "sentinel" = "discovery_agent" }[$Component] $bin = "$pkg.exe"; $conf = "$pkg.json" $target = Join-Path $Dir $pkg # ---- checks, before anything is written ------------------------------------- $admin = ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) if (-not $admin) { Fail "run this script from an elevated PowerShell (Run as administrator)" } foreach ($p in @(@("-ApiPort", $ApiPort), @("-WebPort", $WebPort), @("-Port", $Port))) { if ($p[1] -and -not ($p[1] -match '^\d+$' -and [int]$p[1] -ge 1 -and [int]$p[1] -le 65535)) { Fail "$($p[0]) must be a port number, got '$($p[1])'" } } foreach ($p in @(@("-Location", $Location), @("-Advertise", $Advertise), @("-WebserverUrl", $WebserverUrl))) { if ($p[1] -notmatch '^[A-Za-z0-9._:/@+=, -]*$') { Fail "$($p[0]) contains characters that are not allowed: '$($p[1])'" } } if ($Component -in @("webserver", "integrator")) { if (-not $License) { Fail "-License is required for the $Component" } if (-not (Test-Path $License -PathType Leaf)) { Fail "license file not found: $License" } } if ($Component -in @("monitor", "integrator", "sentinel")) { if (-not $Webserver) { Fail "-Webserver is required (the Webserver API address)" } if ($Webserver -notmatch '^[A-Za-z0-9.-]+:\d+$') { Fail "-Webserver must look like host:port, e.g. 10.0.0.5:8050" } if (-not $SharedInternal) { Fail "-SharedInternal is required: copy `"_sharedInternal`" from the Webserver's webserver.json" } if ($SharedInternal -notmatch '^[A-Za-z0-9._~!#%^*+=-]+$') { Fail "-SharedInternal holds unexpected characters" } if (-not $Name) { $Name = "$Component-$($env:COMPUTERNAME.ToLower())" } if ($Name -notmatch '^[A-Za-z0-9-]+$') { Fail "-Name may hold letters, digits and hyphens only: '$Name'" } } if ($Component -eq "webserver-front") { if (-not $FrontPki) { Fail "-FrontPki is required: the front\ folder made by mugnsoft-front-pki.sh" } if (-not ((Test-Path "$FrontPki\config\ssl\certificates\webserver.pem") -and (Test-Path "$FrontPki\webserver-front.json"))) { Fail "$FrontPki does not look like the front\ folder of mugnsoft-front-pki.sh" } } if (Test-Path $target) { Fail "$target already exists: this script installs, it does not upgrade (see the Upgrade section of the docs)" } $work = Join-Path $env:TEMP ("mugnsoft-install-" + [guid]::NewGuid().ToString("N")) New-Item -ItemType Directory $work | Out-Null try { # ---- 1. get the package ----------------------------------------------------- $file = "mugnsoft-$pkg-$Version.windows-amd64.zip" if (-not $Package) { Info "Downloading $file" try { Invoke-WebRequest "$BaseUrl/$Version/$file" -OutFile "$work\$file" -UseBasicParsing } catch { Fail "download failed: $BaseUrl/$Version/$file" } if (-not $NoVerify) { try { Invoke-WebRequest "$BaseUrl/$Version/$file.sig" -OutFile "$work\$file.sig" -UseBasicParsing } catch { Fail "signature download failed" } } $Package = "$work\$file" } else { if (-not (Test-Path $Package -PathType Leaf)) { Fail "package not found: $Package" } $Package = (Resolve-Path $Package).Path if (-not $NoVerify -and -not (Test-Path "$Package.sig")) { Fail "no $Package.sig next to the package: download it too, or pass -NoVerify" } } # ---- 2. verify its signature (gpg from PATH, Git for Windows or Gpg4win) ------ if (-not $NoVerify) { $gpg = (Get-Command gpg -ErrorAction SilentlyContinue).Source $git = (Get-Command git -ErrorAction SilentlyContinue).Source # Git for Windows ships gpg in usr\bin $fromGit = if ($git) { Join-Path (Split-Path (Split-Path $git)) "usr\bin\gpg.exe" } else { "" } foreach ($c in @($fromGit, "$env:ProgramFiles\Git\usr\bin\gpg.exe", "${env:ProgramFiles(x86)}\GnuPG\bin\gpg.exe", "$env:ProgramFiles\GnuPG\bin\gpg.exe")) { if (-not $gpg -and $c -and (Test-Path $c)) { $gpg = $c } } if (-not $gpg) { Fail "gpg not found: install Git for Windows or Gpg4win to verify the package (or pass -NoVerify)" } Info "Verifying the GPG signature" try { Invoke-WebRequest "$BaseUrl/Mugnsoft_public.key" -OutFile "$work\Mugnsoft_public.key" -UseBasicParsing } catch { Fail "could not download Mugnsoft_public.key" } # a throw-away keyring, given as a RELATIVE --homedir: Git's (MSYS) gpg misreads # an absolute Windows path there, Gpg4win accepts both Push-Location $work New-Item -ItemType Directory gnupg | Out-Null Native $gpg @("--homedir", "gnupg", "--batch", "--quiet", "--import", "$work\Mugnsoft_public.key") | Out-Null $sig = if (Test-Path "$Package.sig") { "$Package.sig" } else { "$work\$file.sig" } $ok = (Native $gpg @("--homedir", "gnupg", "--batch", "--verify", $sig, $Package)).Code -eq 0 Pop-Location if (-not $ok) { Fail "SIGNATURE CHECK FAILED for $Package : do not install it" } Info "Signature OK" } else { Write-Host "WARNING: signature not verified (-NoVerify)" -ForegroundColor Yellow } # ---- 3. extract --------------------------------------------------------------- Info "Extracting to $target" New-Item -ItemType Directory -Force $Dir | Out-Null Expand-Archive $Package -DestinationPath "$work\x" if (-not (Test-Path "$work\x\$pkg")) { Fail "unexpected package layout: no $pkg\ folder inside" } Move-Item "$work\x\$pkg" $target Set-Location $target if (-not (Test-Path $conf)) { Fail "$conf missing from the package" } # set "key": "value" in a flat JSON file, adding the key when it is absent; # written back as UTF-8 WITHOUT BOM (a BOM makes the JSON unreadable to the component) $script:text = [IO.File]::ReadAllText("$target\$conf") function SetKey($key, $val) { $re = '("' + [regex]::Escape($key) + '"\s*:\s*)"[^"]*"' if ($script:text -match $re) { $script:text = [regex]::Replace($script:text, $re, { param($m) $m.Groups[1].Value + '"' + $val + '"' }) } else { $script:text = ([regex]'\{').Replace($script:text, "{`r`n `"$key`": `"$val`",", 1) } } # ---- 4. configure --------------------------------------------------------------- Info "Configuring $conf" $ports = @() switch ($Component) { "webserver" { Copy-Item $License "$target\license_MNS.dat" if ($ApiPort) { SetKey "PortAPI" $ApiPort } if ($WebPort) { SetKey "PortWEB" $WebPort } } "webserver-front" { Copy-Item -Recurse -Force "$FrontPki\*" $target; $script:text = [IO.File]::ReadAllText("$target\$conf") } default { SetKey "_sharedInternal" $SharedInternal SetKey "webserver" $Webserver SetKey "name" $Name SetKey "description" "Mugnsoft $Component on $env:COMPUTERNAME" if ($Port) { SetKey "port" $Port } if ($Component -ne "integrator") { SetKey "location" $Location } if ($Component -eq "monitor" -and $Advertise) { SetKey "ip" $Advertise } if ($Component -eq "integrator") { Copy-Item $License "$target\license_MNS.dat" SetKey "os" "windows" if (-not $WebserverUrl) { $WebserverUrl = "https://" + $Webserver.Split(":")[0] + ":9090" } SetKey "webserverUrl" $WebserverUrl } } } [IO.File]::WriteAllText("$target\$conf", $script:text, (New-Object Text.UTF8Encoding $false)) $cfg = $script:text | ConvertFrom-Json if ($Component -in @("webserver", "webserver-front")) { $ports = @($cfg.PortAPI, $cfg.PortWEB) } else { $ports = @($cfg.port) } # ---- 5. open the port(s) in Windows Firewall -------------------------------------- if (-not $NoFirewall) { $rule = "Mugnsoft $Component" if (-not (Get-NetFirewallRule -DisplayName $rule -ErrorAction SilentlyContinue)) { New-NetFirewallRule -DisplayName $rule -Direction Inbound -Protocol TCP -LocalPort $ports -Action Allow | Out-Null Info "Firewall: inbound TCP $($ports -join ', ') allowed (rule '$rule')" } } # ---- 6. install the service (components register with the Webserver here) ------ Info "Installing the service" $out = (Native "$target\$bin" @("install")).Out $out -split "`r?`n" | Where-Object { $_ -and $_ -notmatch '^time=' } | ForEach-Object { Write-Host " $_" } # "Service '' installed." - not "Service '' not installed." if ($out -notmatch "Service '[^']+' installed\.") { Fail "the service was not installed - read the messages above, fix, then run: cd $target; .\$bin install" } # ---- 7. start it ---------------------------------------------------------------- Info "Starting the service" (Native "$target\$bin" @("start")).Out -split "`r?`n" | Where-Object { $_ -and $_ -notmatch '^time=' } | ForEach-Object { Write-Host " $_" } # install generates the webserver's _sharedInternal: read the config again $cfg = [IO.File]::ReadAllText("$target\$conf") | ConvertFrom-Json Write-Host "" switch ($Component) { "webserver" { Write-Host "Webserver installed in $target" Write-Host " Web UI: https://:$($cfg.PortWEB)/ (admin / admin, change it at first login)" Write-Host " API: :$($cfg.PortAPI) -> the -Webserver value for the components" Write-Host " _sharedInternal (pass it to every component as -SharedInternal):" Write-Host " $($cfg._sharedInternal)" } "webserver-front" { Write-Host "Webserver-Front installed in $target - now configure ADMIN (see the Webserver-Front install page)." } default { Write-Host "$Component '$Name' installed in $target and registered with $Webserver." Write-Host "Last step: on the Webserver, open Settings (gear) > Components, right-click '$Name' and choose enable server." } } } finally { Set-Location $env:TEMP Remove-Item -Recurse -Force $work -ErrorAction SilentlyContinue }