Http Monitor

Tool per il monitoraggio di siti web in Powershell

Http Monitor è un tool Powershell che controlla la raggiungibilità di siti web e traccia le statistiche su database MSSQL. Puoi scaricare l’applicazione qui e controllare il progetto git-hub. In questo articolo voglio raccontare come l’ho realizzato, in modo da spiegare le basi di powershell e con qualche esempio farvi capire come si può realizzare un’applicazione in maniera veloce.

 

Perché utilizzare Powershell per monitorare siti web

Questo tool è un sistema molto potente e funzionale per monitorare siti web senza installare software complessi o utilizzare servizi esterni. Può essere utile monitorare vari siti, anche solo per vedere quali sono up o down in maniera sporadica. Inoltre, salvando i dati su un db, sarà facile effettuare tutte le query che ti servono.

Per questo tipo di applicazione ho deciso di fare un qualcosa di estremamente semplice, senza dipendenze. L’idea è di scaricare un file ed eseguirlo, niente di più. Per questo uno script è ottimo, ho utilizzato Powershell che è il top per sviluppare script su windows.

Questo script si installerà come servizio (poi vedremo come è possibile…) oppure potrà essere registrato come task di windows. Leggerà una serie di impostazioni da file di configurazioni, almeno l’elenco dei siti da monitorare e salverà i dati su db. In più dovrà notificare via email le irregolarità riscontrate.

Il tema del monitoraggio di siti web

Il monitoraggio è un tema molto ampio e ci sarebbe da scrivere un libro solo per coprire le basi. Ci occuperemo solo del tema del monitoraggio di siti web per circoscrivere il problema all’ambito che ci interessa. Tutti siamo d’accordo che gli imprevisti sono in agguato ed un sito che dovrebbe avere un uptime del 100% a volte non è raggiungile. Le variabili che possono dare problemi sono molteplici: dal disco che si satura alla out of memory di java, passando per tutta una serie di intoppi che avrete sicuramente sperimentato (e mettiamo in conto che anche i programmatori possono sbagliare).

Come ci può aiutare un sistema di monitoraggio? Teniamo conto che oggi giorno  anche nel sito più sfigato passa traffico e quel sito è l’immagine dell’azienda che rappresenta. Anche l’1% di downtime corrisponde all’incirca a 7h di disservizio al mese. Se sul sito ci passano soldi (anche sotto forma di leads…) qualcuno si arrabbierà. Il target di HttpMonitor è individuare il disservizio appena si manifesta e segnalarlo. Per questo effettua un controllo incrociato sulla raggiungibilità e sul codice di risposta, loggando tutto su database. Questo meccanismo è chiamato “monitoraggio attivo” perché introduce traffico artificiale e ne valuta i risultati (in alternativa il monitoraggio passivo analizza il traffico reale inserendo opportune sonde).

Implementare un   Http-Monitor in Powershell

Questo capitolo illustra le componenti principali dell’applicazione. Così facendo, voglio soffermarmi sugli aspetti meno standard, così da aiutarvi nel caso vogliate sviluppare un tool con powershell.

Iniziamo col dire cosa fa Http-Monitor:

  • Leggere un elenco di url da file => Input process
  • Verificare la congruenza dei DNS => Avoiding false positives
  • Effettuare la chiamata http e interpretare i risultati => Do Check
  • Salvare il risultato su database => Result logging
  • Inviare un email in caso di errore => Alerting

 

 

Input process

Questa è la parte facile. Basta il comando  “Get-Content” su un file di testo. Ogni riga è un sito da monitorare.

 $webSitesToMonitor = Get-Content $dbpath

Avoiding false positives

Manutenere un database di url molto grande potrebbe essere difficile.Il problema vero è che spesso e volentieri i siti possono essere dismessi o trasferiti su server che non sono in gestione a noi. Quindi ho introdotto la possibilità di tenere in considerazione solo un determinato set di indirizzi ip.

Ovviamente la soluzione migliore per non uscire pazzi è aggiornare periodicamente le url, ma quando ci sono cambiamenti non notificati questa euristica ci tutela da una valanga di email inutili.

Se il DNS del sito che monitoriamo non punta ai nostri server, viene ignorato.

try
{
    $ip=[System.Net.Dns]::GetHostAddresses($line.Substring($line.IndexOf("//")+2))
                          .IPAddressToString
    Write-Host $line " respond to ip " $ip
    $monitorStatus="OK"

    if($monitoring.Length -gt 0)
    {
        $toMonitor=$toMonitor -and $monitoring.Contains($ip)
        if($toMonitor -eq $false)
        {
            $monitorStatus="SKIPPED"
        }
    }
}
catch
{
    $toMonitor=$false
    Write-Warning " $line unable to resolve IP "
    throw $_
    $monitorStatus="NOT RESOLVED"
}

Do Check

Effettuare il controllo è molto semplice perché anche in questo caso Powershell offre un semplice comando da lanciare. L’oggetto di ritorno contiene tutti i dettagli della risposta.

    try
    {
        $RequestTime = Get-Date
        $R = Invoke-WebRequest -URI $line -UserAgent $userAgent
        $TimeTaken = ((Get-Date) - $RequestTime).TotalMilliseconds 
        $status=$R.StatusCode
        $len=$R.RawContentLength

    } 
    catch 
    {
        #many http status fall in exception 
        $status=$_.Exception.Response.StatusCode.Value__
        $len=0
    }

Result logging

La soluzione migliore è loggare su database. Avevo pensato anche ad un file CSV per evitare la dipendenza dal database, ma poi mi sono immaginato di doverci fare sopra delle query… beh, non è il massimo. Per semplicità ho usato MSSQL (anche la versione express va bene). In alternativa si può disattivare la scrittura su db e lasciare solo il log su file.

Nell’implementare questa funzionalità, dovendo usare i wrap di ADO.NET, ho fatto un salto nel passato e, nostalgia a parte, è stato molto facile.

 

    # Function used to create table if not exists during setup
    
    
Function CreateTableIfNotExists 
{
[CmdletBinding()]
    Param(
    [System.Data.SqlClient.SqlConnection]$OpenSQLConnection
    )
  $script=@" 
  if not exists (select * from sysobjects where name='logs' and xtype='U')
	CREATE TABLE [logs]
	(	[date] [datetime] NOT NULL DEFAULT (getdate()) ,
		[site] [varchar](500) NULL,
		[status] [varchar](50) NULL,
		[length] [bigint] NULL,
		[time] [bigint] NULL,
        [ip] [varchar](50) NULL,
        [monitored] [varchar](50) NULL
	) ON [PRIMARY]
"@
 $sqlCommand = New-Object System.Data.SqlClient.SqlCommand
    $sqlCommand.Connection = $sqlConnection
 
    # This SQL query will insert 1 row based on the parameters, 
    # and then will return the ID
    # field of the row that was inserted.
    $sqlCommand.CommandText =$script
    $result= $sqlCommand.ExecuteNonQuery()
    Write-Warning "Table log created $result"
}

#-----------------------------------------------------------------------------#
#                                                                             #
#   Function        WriteLogToDB                                              #
#                                                                             #
#   Description     Write a log row to db                                     #
#                                                                             #
#   Arguments       See the Param() block                                     #
#                                                                             #
#   Notes                                                                     #
#                                                                             #
#                                                                             #
#-----------------------------------------------------------------------------#
Function WriteLogToDB { 
    [CmdletBinding()]
    Param(
    [System.Data.SqlClient.SqlConnection]$OpenSQLConnection, 
    [string]$site,
    [int]$status,
    [int]$length,
    [int]$time,
    [string]$ip,
    [string]$monitored
    ) 
 
    $sqlCommand = New-Object System.Data.SqlClient.SqlCommand
    $sqlCommand.Connection = $sqlConnection
 
    # This SQL query will insert 1 row based on the parameters, and then will return the ID
    # field of the row that was inserted.
    $sqlCommand.CommandText =
        "INSERT INTO [dbo].[logs] ([site] ,[status] ,[length] ,[time],[ip],[monitored]) "+
        " VALUES   (@site,@status  ,@lenght ,@time,@ip,@monitored) " 
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@site",[Data.SQLDBType]::NVarChar, 500)) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@status",[Data.SQLDBType]::NVarChar, 500)) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@lenght",[Data.SQLDBType]::BigInt)) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@time",[Data.SQLDBType]::BigInt)) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@ip",[Data.SQLDBType]::NVarChar, 500))) | Out-Null
    $sqlCommand.Parameters.Add(New-Object SqlParameter("@monitored",[Data.SQLDBType]::NVarChar, 500)) | Out-Null
   
        # Here we set the values of the pre-existing parameters based on the $file iterator
        $sqlCommand.Parameters[0].Value = $site
        $sqlCommand.Parameters[1].Value = $status
        $sqlCommand.Parameters[2].Value = $length
        $sqlCommand.Parameters[3].Value = $time
        $sqlCommand.Parameters[4].Value = $ip
        $sqlCommand.Parameters[5].Value = $monitored
 
        # Run the query and get the scope ID back into $InsertedID
        $InsertedID = $sqlCommand.ExecuteScalar()
        # Write to the console.
        # "Inserted row ID $InsertedID for file " + $file.Name
    
 
}
    
    # Funtion that write a single line of log
    
    
    #... and its usage into monitor cycle
    
    #if db write is enabled log into SQL SERVER
    if($writeToDB -eq $true)
    {
        WriteLogToDB $sqlConnection $line $status $len $TimeTaken $ip $monitored
    }

Alerting

Il sistema più semplice di effettuare una notifica è usare la mail. So che oggi giorno siamo tempestati da migliaia di email inutile e così facendo rischiamo di passare inosservati, tuttavia, senza una UI, mi sembra la migliore soluzione.

Anche in questo caso con Powershell si raggiunge lo scopo con poco sforzo:

    #if send mail is enabled send an alert
    if($sendEmail -eq $true -and $emailErrorCodes.Length -ge 0 -and $emailErrorCodes.Contains( $R.StatusCode) )
    {
    $statusEmail=$R.StatusCode
    $content=$R.RawContent


        
        $subject="$errorSubject $line"
        $body= @".. this value is omitted for readability "@
        #prepare attachment
        $attachment="$workingPath\tmp.txt"      
        # In case some previous execution goes in error whitout deleting the temp file      
        Remove-Item $workingPath\tmp.txt -Force 
        Write-Host $attachment
        $content|Set-Content $attachment

        #send email
        Write-Host "Sending  mail notification"
        Send-MailMessage -From $emailFrom -To $emailTo -Subject $subject  -Body $body -Attachments $attachment -Priority High -dno onSuccess, onFailure -SmtpServer $smtpServer

        #remove attachment
        Remove-Item $workingPath\tmp.txt -Force

    }

 

Continuous monitoring

Questa è la parte più ostica e difficile da implementare. Prima di inziare ricordo che risultati simili si raggiungono schedulando lo script tramite task. Basta dire che il task parta ogni X minuti e che non ci siano istanze multiple. La soluzione “come servizio” è molto più complicata, ma un ottimo esercizio per iniziare a fare cose complicate.

Come si installa uno script powershell come servizio windows

La risposta breve sarebbe che non si può fare, nel senso che solo eseguibili con una determinata struttura possono agire come servizi. Diversamente da Linux, dove qualunque eseguibile può girare come servizio, su windows con .net framework è necessario implementare una classe per registrare le funzionalità di start e stop del servizio. Più o meno la stessa cosa usando altri linuguaggi\tencologie. Guardando in giro ho trovato una soluzione elegante per aggirare il problema:

  1. Inserire dentro lo script powershell un blocco di codice c#
  2. In questa classe implementiamo l’interfaccia di servizio che invoca lo script powershell.
  3. Lo script è reso dinamico e viene completato durante il setup (es. path e dettagli che si conoscono solo durante l’installazione)
  4. Il setup
    1. la classe template viene riempita con i parametri giusti
    2. la classe è compilatoa
    3. viene creato un eseguibile: il servizio
    4. questo eseguibile è registrato come servizio.

 

    
    # The class script embedded into code (some part are omitter for readability)
    $source = @"
  using System;
  using System.ServiceProcess;
  using System.Diagnostics;
  using System.Runtime.InteropServices;                                 // SET STATUS
  using System.ComponentModel;                                          // SET STATUS
 
 
  public class Service_$serviceName : ServiceBase { 
    [DllImport("advapi32.dll", SetLastError=true)]                      // SET STATUS
    private static extern bool SetServiceStatus(IntPtr handle, ref ServiceStatus serviceStatus);
    protected override void OnStart(string [] args) {
      EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Entry. Starting script '$scriptCopyCname' -Start"); // EVENT LOG
      // Set the service state to Start Pending.                        // SET STATUS [
      // Only useful if the startup time is long. Not really necessary here for a 2s startup time.
      serviceStatus.dwServiceType = ServiceType.SERVICE_WIN32_OWN_PROCESS;
      serviceStatus.dwCurrentState = ServiceState.SERVICE_START_PENDING;
      serviceStatus.dwWin32ExitCode = 0;
      serviceStatus.dwWaitHint = 2000; // It takes about 2 seconds to start PowerShell
      SetServiceStatus(ServiceHandle, ref serviceStatus);               // SET STATUS ]
      // Start a child process with another copy of this script
      try {
        Process p = new Process();
        // Redirect the output stream of the child process.
        p.StartInfo.UseShellExecute = false;
        p.StartInfo.RedirectStandardOutput = true;
        p.StartInfo.FileName = "PowerShell.exe";
        p.StartInfo.Arguments = "-ExecutionPolicy Bypass -c & '$scriptCopyCname' -Start"; // Works if path has spaces, but not if it contains ' quotes.
        p.Start();
        // Read the output stream first and then wait. (To avoid deadlocks says Microsoft!)
        string output = p.StandardOutput.ReadToEnd();
        // Wait for the completion of the script startup code, that launches the -Service instance
        p.WaitForExit();
        if (p.ExitCode != 0) throw new Win32Exception((int)(Win32Error.ERROR_APP_INIT_FAILURE));
        // Success. Set the service state to Running.                   // SET STATUS
        serviceStatus.dwCurrentState = ServiceState.SERVICE_RUNNING;    // SET STATUS
      } catch (Exception e) {
        EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Failed to start $scriptCopyCname. " + e.Message, EventLogEntryType.Error); // EVENT LOG
        // Change the service state back to Stopped.                    // SET STATUS [
        serviceStatus.dwCurrentState = ServiceState.SERVICE_STOPPED;
        Win32Exception w32ex = e as Win32Exception; // Try getting the WIN32 error code
        if (w32ex == null) { // Not a Win32 exception, but maybe the inner one is...
          w32ex = e.InnerException as Win32Exception;
        }    
        if (w32ex != null) {    // Report the actual WIN32 error
          serviceStatus.dwWin32ExitCode = w32ex.NativeErrorCode;
        } else {                // Make up a reasonable reason
          serviceStatus.dwWin32ExitCode = (int)(Win32Error.ERROR_APP_INIT_FAILURE);
        }                                                               // SET STATUS ]
      } finally {
        serviceStatus.dwWaitHint = 0;                                   // SET STATUS
        SetServiceStatus(ServiceHandle, ref serviceStatus);             // SET STATUS
        EventLog.WriteEntry(ServiceName, "$exeName OnStart() // Exit"); // EVENT LOG
      }
    }
    protected override void OnStop() {
      EventLog.WriteEntry(ServiceName, "$exeName OnStop() // Entry");   // EVENT LOG
      // Start a child process with another copy of ourselves
      Process p = new Process();
      // Redirect the output stream of the child process.
      p.StartInfo.UseShellExecute = false;
      p.StartInfo.RedirectStandardOutput = true;
      p.StartInfo.FileName = "PowerShell.exe";
      p.StartInfo.Arguments = "-c & '$scriptCopyCname' -Stop"; // Works if path has spaces, but not if it contains ' quotes.
      p.Start();
      // Read the output stream first and then wait.
      string output = p.StandardOutput.ReadToEnd();
      // Wait for the PowerShell script to be fully stopped.
      p.WaitForExit();
      // Change the service state back to Stopped.                      // SET STATUS
      serviceStatus.dwCurrentState = ServiceState.SERVICE_STOPPED;      // SET STATUS
      SetServiceStatus(ServiceHandle, ref serviceStatus);               // SET STATUS
      EventLog.WriteEntry(ServiceName, "$exeName OnStop() // Exit");    // EVENT LOG
    }
    public static void Main() {
      System.ServiceProcess.ServiceBase.Run(new Service_$serviceName());
    }
  }
"@



# The setup part
try {
    $pss = Get-Service $serviceName -ea stop # Will error-out if not installed
    #service installed. Nothing to do
    Write-Warning "Service installed nothing to do."
    exit 0
  } catch {
    # This is the normal case here. Do not throw or write any error!
    Write-Debug "Installation is necessary" # Also avoids a ScriptAnalyzer warning
    # And continue with the installation.
  }
  if (!(Test-Path $installDir)) {
    New-Item -ItemType directory -Path $installDir | Out-Null
  }
  
  # Generate the service .EXE from the C# source embedded in this script
  try {
    Write-Verbose "Compiling $exeFullName"
    Add-Type -TypeDefinition $source -Language CSharp -OutputAssembly $exeFullName -OutputType ConsoleApplication -ReferencedAssemblies "System.ServiceProcess" -Debug:$false
  } catch {
    $msg = $_.Exception.Message
    Write-error "Failed to create the $exeFullName service stub. $msg"
    exit 1
  }
  # Register the service
  Write-Verbose "Registering service $serviceName"
  $pss = New-Service $serviceName $exeFullName -DisplayName $serviceDisplayName -Description $ServiceDescription -StartupType Automatic

 

Note: questo codice deriva dall’eccellente lavoro di  JFLarvoire scpript , ed è stato adattato per le esigenze specifiche di Http-Monitor. Se ti servisse ti consiglio di far riferimento al lavoro originale.

Configurazione dinamica 

A questo punto è importante capire come definire le impostazioni applicative. L’approccio classico per gli script è inserire un serie di variabili in cima allo script e cambiarle a seconda delle esigenze. Questo sistema purtroppo ha dei grossi limiti in caso di aggiornamento dello script perché richiede di integrare manualmente le modifiche. Quindi ho valutato l’ipotesi di usare un file psd1 esterno (soluzione standard per poweshell). Il problema di questi files è che sono statici. Ad esempio non si possono concatenare variabili. Quarda questo esempio sul tema.

Quindi per non perdere questa flessibilità, ho deciso di utilizzare la soluzione artigianale, ma funziona, di utilizzare uno script esterno ed includerlo. In questo modo riesco a tenere separate le variabili senza rischiare sovrascritture in caso di aggiornamento.

    # DB SETTINGS
# -----------------------------------   
$writeToDB= $true # enable or disable db logging
$DBServer = "(localdb)\Projects" # MSSQL host, usully .\SQLEXPRESS, .\SQLSERVER 
$DBName = "httpstatus" # name of db.(HAVE TO BE CREATED)
# full connection string. Write here password if not in integrated security
$ConnectionString = "Server=$DBServer;Database=$DBName;Integrated Security=True;" 

# EMAIL SETTINGS
# -----------------------------------

#... 

# MONITOR SETTINGS
# ----------------------------------   

# ...

# LOGGING FILES
# ----------------------------------

#....


if ( (Test-Path -Path $workingPath\settings.ps1))
{
    Write-Host "Apply external changes"
   . ("$workingPath\settings.ps1") 
}

 

Http-Monitor in azione: installazione ed istruzioni d’uso

Installazione

Http-Monitor ha diverse modalità d’uso:

  • standalone, da eseguirsi a mano
  • applicazione schedulata tramite task ed eseguita periodicamente
  • servizio, che gira in background

Run once

Esegui questo comando per eseguire il test una tantum su tutti i siti.

Http-Monitor -Run

Run as scheduled task

Http-monitor tool setup in powershellFacile da installare con pochi passaggi:

  • imposta per eseguire ogni 5 minuti (or other interval)
  • imposta il percorso del file
  • evita di creare istanze multiple

 

Run as service

Per installare http-monitor come servizio

 PS> Http-Monitor -Setup

una volta installato lo puoi controllare tramite command line o servizi di windows

PS> Http-Monitor -Start

PS> Http-Monitor -Stop

Configure

La configurazione è semplice ci sono solo due file da modificare

  1. Application settings: dove devi mettere i dati di connessione al db e le altre impostazioni
  2. Input: la lsita di files da monitorare (1 per riga)

 

Conclusioni

Powershell è una piattaforma molto versatile e potente che permette di creare soluzioni semplici molto velocemente. Potenzialmente puoi fare tutto quello che c’è a disposizione nel mondo .NET, e in più ci sono tanti potentissimi  snippet per operare a livello di sistema operativo. Abbiamo anche un IDE, che non è Visual Studio, ma permette il debug e ha l’autocomplete, quindi abbastanza per lavorare bene.

L’unica nota è che bisogna sempre ricordarsi che si tratta di un linguaggio di script. Quindi quando avrete bisogno di un interfaccia grafica dovrete passare ad altro. Inoltre per sviluppare logiche complicate inizia a sentirsi il bisogno di un orm e di librerie esterne.

Riassumendo, Http-Monitor è stata un’ottima esperienza che mi ha permesso di spingere Powershell fino ai suoi limiti dimostrando tutta la sua potenza. Prossimo passo: mettere una UI e cambiare tecnologia… ma con un altro framework.

Riferimenti

danielefontani

Actually CTO in Sintra Consulting s.r.l, I'm senior developer and architect specialized on portals, intranets, and others business applications. Particularly interested in Agile developing and open source projects, I worked on some of this as project manager and developer. My experience include: Frameworks \Technlogies: .NET Framework (C# & VB), ASP.NET, Java, php, Spring Client languages: XML, HTML, CSS, JavaScript, Angular.js,Angular. jQuery Platforms: Sharepoint,Liferay, Drupal Databases: MSSQL, ORACLE, MYSQL, Postgres