Add-Hostnames.ps1

function Add-Hostnames {
    <#
    .SYNOPSIS
    Adds one or more hostnames to the hosts file.
 
    .DESCRIPTION
    The hosts file is used to map hostnames to IP addresses.
 
    .PARAMETER IPAddress
    The IP address to map the hostname(s) to.
 
    .PARAMETER Hostnames
    One or more hostnames to map to the specified IP address.
 
    .PARAMETER Comment
    Optional comment that is written above the new hosts entry.
     
    .PARAMETER RetryCount
    How many times to try writing to the hosts file before giving up? Defaults to 30.
 
    IMPORTANT: It's advised to retry as many times as possible to avoid
    transient locks on the hosts file causing a failure.
 
    .EXAMPLE
    Add-TecBoxHostnames 127.0.0.1 foobar
 
    Description
    -----------
    Adds the following line to the hosts file (assuming "foobar" does not already
    exist in the hosts file):
 
    127.0.0.1 foobar
 
    A warning is displayed if "foobar" already exists in the hosts file and is
    mapped to the specified IP address. An error occurs if "foobar" is already
    mapped to a different IP address.
 
    .EXAMPLE
    Add-TecBoxHostnames 127.0.0.1 foo, bar "This is a comment"
 
    Description
    -----------
    Adds the following lines to the hosts file (assuming "foo" and "bar" do not
    already exist in the hosts file):
 
    # This is a comment
    127.0.0.1 foo bar
 
    A warning is displayed if either "foo" or "bar" already exists in the hosts
    file and is mapped to the specified IP address. An error occurs if "foo" or
    "bar" is already mapped to a different IP address.
 
    .NOTES
    This script must be run with administrator privileges.
 
    CRITICAL: The consequence of a failure is the hosts file will be left empty.
    TODO: Improvement robustness to prevent a failure resulting in an empty hosts file
    #>

    [CmdletBinding()]
    param(
        [parameter(Mandatory = $true)]
        [string] $IPAddress,
        [parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]] $Hostnames,
        [string] $Comment,
        [int] $RetryCount = 30
    )

    begin
    {
        Set-StrictMode -Version Latest
        $ErrorActionPreference = "Stop"

        function Execute-WithRetry([ScriptBlock] $command) {
            $attemptCount = 0
            $operationIncomplete = $true
            $maxFailures = $RetryCount
            $sleepBetweenFailures = 2
        
            while ($operationIncomplete -and $attemptCount -lt $maxFailures) {
                $attemptCount = ($attemptCount + 1)
        
                if ($attemptCount -ge 2) {
                    Write-Verbose "Waiting for $sleepBetweenFailures seconds before retrying..."
                    Start-Sleep -s $sleepBetweenFailures
                    Write-Verbose "Retrying..."
                }
        
                try {
                    # Call the script block
                    & $command
        
                    $operationIncomplete = $false
                } catch [System.Exception] {
                    if ($attemptCount -lt ($maxFailures)) {
                        Write-Warning ("Attempt $attemptCount of $maxFailures failed: " + $_.Exception.Message)
                    } else {
                        throw
                    }
                }
            }
        }

        function CreateHostsEntryObject(
            [string] $ipAddress,
            [string[]] $hostnames,
            <# [string] #> $comment) #HACK: never $null if type is specified
        {
            $hostsEntry = New-Object PSObject
            $hostsEntry | Add-Member NoteProperty -Name "IpAddress" `
                -Value $ipAddress

            [System.Collections.ArrayList] $hostnamesList =
                New-Object System.Collections.ArrayList

            $hostsEntry | Add-Member NoteProperty -Name "Hostnames" `
                -Value $hostnamesList

            If ($hostnames -ne $null)
            {
                $hostnames | foreach {
                    $hostsEntry.Hostnames.Add($_) | Out-Null
                }
            }

            $hostsEntry | Add-Member NoteProperty -Name "Comment" -Value $comment

            return $hostsEntry
        }

        function ParseHostsEntry(
            [string] $line)
        {
            $hostsEntry = CreateHostsEntryObject

            Write-Debug "Parsing hosts entry: $line"

            If ($line.Contains("#") -eq $true)
            {
                If ($line -eq "#")
                {
                    $hostsEntry.Comment = [string]::Empty
                }
                Else
                {
                    $hostsEntry.Comment = $line.Substring($line.IndexOf("#") + 1)
                }

                $line = $line.Substring(0, $line.IndexOf("#"))
            }

            $line = $line.Trim()

            If ($line.Length -gt 0)
            {
                $hostsEntry.IpAddress = ($line -Split "\s+")[0]

                Write-Debug "Parsed address: $($hostsEntry.IpAddress)"

                [string[]] $parsedHostnames = $line.Substring(
                    $hostsEntry.IpAddress.Length + 1).Trim() -Split "\s+"

                Write-Debug ("Parsed hostnames ($($parsedHostnames.Length)):" `
                    + " $parsedHostnames")

                $parsedHostnames | foreach {
                    $hostsEntry.Hostnames.Add($_) | Out-Null
                }
            }

            return $hostsEntry
        }

        function ParseHostsFile
        {
            $hostsEntries = New-Object System.Collections.ArrayList

            [string] $hostsFile = $env:WINDIR + "\System32\drivers\etc\hosts"

            If ((Test-Path $hostsFile) -eq $false)
            {
                Write-Verbose "Hosts file does not exist."
            }
            Else
            {
                [string[]] $hostsContent = Get-Content $hostsFile

                $hostsContent | foreach {
                    $hostsEntry = ParseHostsEntry $_

                    $hostsEntries.Add($hostsEntry) | Out-Null
                }
            }

            # HACK: Return an array (containing the ArrayList) to avoid issue with
            # PowerShell returning $null (when hosts file does not exist)
            return ,$hostsEntries
        }

        function UpdateHostsFile(
            $hostsEntries = $(Throw "Value cannot be null: hostsEntries"))
        {
            Write-Verbose "Updatings hosts file..."

            [string] $hostsFile = $env:WINDIR + "\System32\drivers\etc\hosts"

            $buffer = New-Object System.Text.StringBuilder

            $hostsEntries | foreach {

                If ([string]::IsNullOrEmpty($_.IpAddress) -eq $false)
                {
                    $buffer.Append($_.IpAddress) | Out-Null
                    $buffer.Append("`t") | Out-Null
                }

                If ($_.Hostnames -ne $null)
                {
                    [bool] $firstHostname = $true

                    $_.Hostnames | foreach {
                        If ($firstHostname -eq $false)
                        {
                            $buffer.Append(" ") | Out-Null
                        }
                        Else
                        {
                            $firstHostname = $false
                        }

                        $buffer.Append($_) | Out-Null
                    }
                }

                If ($_.Comment -ne $null)
                {
                    If ([string]::IsNullOrEmpty($_.IpAddress) -eq $false)
                    {
                        $buffer.Append(" ") | Out-Null
                    }

                    $buffer.Append("#") | Out-Null
                    $buffer.Append($_.Comment) | Out-Null
                }

                $buffer.Append([System.Environment]::NewLine) | Out-Null
            }

            [string] $hostsContent = $buffer.ToString()

            $hostsContent = $hostsContent.Trim()

            Execute-WithRetry { Set-Content -Path $hostsFile -Value $hostsContent -Force -Encoding ASCII }

            Write-Verbose "Successfully updated hosts file."
        }

        [bool] $isInputFromPipeline =
            ($PSBoundParameters.ContainsKey("Hostnames") -eq $false)

        [int] $pendingUpdates = 0

        [Collections.ArrayList] $hostsEntries = ParseHostsFile
    }

    process
    {
        If ($isInputFromPipeline -eq $true)
        {
            $items = $_
        }
        Else
        {
            $items = $Hostnames
        }

        $newHostsEntry = CreateHostsEntryObject $IpAddress
        $hostsEntries.Add($newHostsEntry) | Out-Null

        $items | foreach {
            [string] $hostname = $_

            [bool] $isHostnameInHostsEntries = $false

            for ([int] $i = 0; $i -lt $hostsEntries.Count; $i++)
            {
                $hostsEntry = $hostsEntries[$i]

                Write-Debug "Hosts entry: $hostsEntry"

                If ($hostsEntry.Hostnames.Count -eq 0)
                {
                    continue
                }

                for ([int] $j = 0; $j -lt $hostsEntry.Hostnames.Count; $j++)
                {
                    [string] $parsedHostname = $hostsEntry.Hostnames[$j]

                    Write-Debug ("Comparing specified hostname" `
                        + " ($hostname) to existing hostname" `
                        + " ($parsedHostname)...")

                    If ([string]::Compare($hostname, $parsedHostname, $true) -eq 0)
                    {
                        $isHostnameInHostsEntries = $true

                        If ($ipAddress -ne $hostsEntry.IpAddress)
                        {
                            Throw "The hosts file already contains the" `
                                + " specified hostname ($parsedHostname) and it is" `
                                + " mapped to a different address" `
                                + " ($($hostsEntry.IpAddress))."
                        }

                        Write-Verbose ("The hosts file already contains the" `
                            + " specified hostname ($($hostsEntry.IpAddress) $parsedHostname).")
                    }
                }
            }

            If ($isHostnameInHostsEntries -eq $false)
            {
                Write-Debug ("Adding hostname ($hostname) to hosts entry...")

                $newHostsEntry.Hostnames.Add($hostname) | Out-Null
                $pendingUpdates++
            }
        }
    }

    end
    {
        If ($pendingUpdates -eq 0)
        {
            Write-Verbose "No changes to the hosts file are necessary."

            return
        }

        Write-Verbose ("There are $pendingUpdates pending update(s) to the hosts" `
            + " file.")

        UpdateHostsFile $hostsEntries
    }
}