Marli-Net.psm1

#Requires -Version 5.1
<#
    Marli-Net - Netzwerkkarten-Verwaltung
    Version 2.0.1 (Autor: Marco Lippert)

    Aufruf: Start-MarliNet (oder wie früher: Marli-Net)

    Funktionen:
      D = IPv4 per DHCP
      S = Statische IPv4 (Maske als 255.255.255.0 ODER 24 eingeben)
      R = Netzwerkkarte neu starten
      C = Netzwerkkarte komplett neu installieren + "Geister-Karten" entfernen
#>


# Modulordner merken (für den Neustart mit Admin-Rechten)
$script:ModuleRoot = $PSScriptRoot

#region ---------- Hilfsfunktionen (nicht exportiert) ----------

function Test-MarliAdmin {
    $identity  = [Security.Principal.WindowsIdentity]::GetCurrent()
    $principal = New-Object Security.Principal.WindowsPrincipal $identity
    return $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
}

# 24 -> 255.255.255.0
function ConvertTo-SubnetMask {
    param([Parameter(Mandatory)][ValidateRange(0, 32)][int]$PrefixLength)

    $octets = foreach ($i in 0..3) {
        $bits = [Math]::Min(8, [Math]::Max(0, $PrefixLength - (8 * $i)))
        [int](256 - [Math]::Pow(2, 8 - $bits))
    }
    return ($octets -join '.')
}

# 255.255.255.0 -> 24 (gibt $null zurück, wenn die Maske ungültig ist)
function ConvertTo-PrefixLength {
    param([Parameter(Mandatory)][string]$SubnetMask)

    if (-not (Test-IPv4Address $SubnetMask)) { return $null }

    $bytes  = ([ipaddress]$SubnetMask).GetAddressBytes()
    $binary = ($bytes | ForEach-Object { [Convert]::ToString($_, 2).PadLeft(8, '0') }) -join ''

    # Gültige Maske = erst nur Einsen, dann nur Nullen (z.B. 255.0.255.0 ist ungültig)
    if ($binary -match '01') { return $null }

    return ($binary -replace '0').Length
}

function Test-IPv4Address {
    param([string]$Address)

    if ($Address -notmatch '^\d{1,3}(\.\d{1,3}){3}$') { return $false }
    $ip = $null
    return [ipaddress]::TryParse($Address, [ref]$ip)
}

function Read-IPv4 {
    param(
        [Parameter(Mandatory)][string]$Prompt,
        [switch]$AllowEmpty
    )

    while ($true) {
        $value = "$(Read-Host $Prompt)".Trim()
        if ($AllowEmpty -and -not $value) { return $null }
        if (Test-IPv4Address $value)      { return $value }
        Write-Host ' Invalid IPv4 address, please try again.' -ForegroundColor Red
    }
}

function Read-YesNo {
    param([Parameter(Mandatory)][string]$Prompt)
    return ("$(Read-Host "$Prompt (Y/N)")".Trim() -match '^[yYjJ]$')
}

function Wait-Enter {
    [void](Read-Host ' Press Enter to continue')
}

function Write-MarliHeader {
    param([string]$Subtitle)

    Clear-Host
    Write-Host ''
    Write-Host ' Marli-Net - Networkcard changer' -ForegroundColor Cyan
    Write-Host (' ' + ('-' * 60))
    if ($Subtitle) { Write-Host " $Subtitle" -ForegroundColor Yellow }
    Write-Host ''
}

function Write-MarliValue {
    param([string]$Label, [string]$Value, [string]$Color = 'Yellow')
    Write-Host (' {0,-14}' -f $Label) -NoNewline
    Write-Host $Value -ForegroundColor $Color
}

# Alle "echten" Netzwerkkarten (ohne Loopback/Bluetooth)
function Get-MarliAdapter {
    $adapters = Get-NetAdapter -ErrorAction SilentlyContinue |
        Where-Object { $_.InterfaceDescription -notmatch 'Bluetooth|Loopback' -and $_.Name -notmatch 'Bluetooth' } |
        Sort-Object Name
    return @($adapters)
}

# Aktuelle IPv4-Konfiguration einer Karte als Objekt
function Get-MarliIpInfo {
    param([Parameter(Mandatory)][string]$Alias)

    $adapter = Get-NetAdapter -Name $Alias -ErrorAction Stop
    $ipIf    = Get-NetIPInterface -InterfaceAlias $Alias -AddressFamily IPv4 -ErrorAction SilentlyContinue
    $address = @(Get-NetIPAddress -InterfaceAlias $Alias -AddressFamily IPv4 -ErrorAction SilentlyContinue) | Select-Object -First 1
    $gateway = @(Get-NetRoute -InterfaceAlias $Alias -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue) | Select-Object -First 1
    $dns     = @(Get-DnsClientServerAddress -InterfaceAlias $Alias -AddressFamily IPv4 -ErrorAction SilentlyContinue |
                 ForEach-Object { $_.ServerAddresses })

    [pscustomobject]@{
        Alias       = $Alias
        Description = $adapter.InterfaceDescription
        Status      = [string]$adapter.Status
        LinkSpeed   = [string]$adapter.LinkSpeed
        Mac         = [string]$adapter.MacAddress
        Mode        = $(if ($ipIf -and "$($ipIf.Dhcp)" -eq 'Enabled') { 'DHCP' } else { 'STATIC' })
        IPAddress   = $(if ($address) { $address.IPAddress } else { '-' })
        SubnetMask  = $(if ($address) { ConvertTo-SubnetMask $address.PrefixLength } else { '-' })
        Gateway     = $(if ($gateway) { $gateway.NextHop } else { '-' })
        Dns         = $(if ($dns.Count -gt 0) { $dns -join ', ' } else { '-' })
    }
}

function Show-MarliIpInfo {
    param([Parameter(Mandatory)]$Info)

    $statusColor = if ($Info.Status -eq 'Up') { 'Green' } else { 'Red' }

    Write-MarliValue 'Name:'        $Info.Alias
    Write-MarliValue 'Status:'      "$($Info.Status) ($($Info.LinkSpeed))" $statusColor
    Write-MarliValue 'MAC:'         $Info.Mac
    Write-Host ''
    Write-MarliValue 'Mode:'        $Info.Mode 'Green'
    Write-MarliValue 'IP-Address:'  $Info.IPAddress
    Write-MarliValue 'Subnetmask:'  $Info.SubnetMask
    Write-MarliValue 'Gateway:'     $Info.Gateway
    Write-MarliValue 'DNS:'         $Info.Dns
    Write-Host (' ' + ('-' * 60))
}

# Wartet, bis die Karte wieder "Up" ist (max. X Sekunden)
function Wait-MarliAdapter {
    param([Parameter(Mandatory)][string]$Alias, [int]$TimeoutSeconds = 20)

    Write-Host ' Please wait...' -ForegroundColor DarkGray
    $watch = [Diagnostics.Stopwatch]::StartNew()
    do {
        Start-Sleep -Seconds 1
        $isUp = "$((Get-NetAdapter -Name $Alias -ErrorAction SilentlyContinue).Status)" -eq 'Up'
    } until ($isUp -or $watch.Elapsed.TotalSeconds -ge $TimeoutSeconds)

    Start-Sleep -Seconds 2   # DHCP braucht kurz für die Adresse
}

# Entfernt alte IPv4-Adressen und das alte Standard-Gateway
function Clear-MarliIpv4 {
    param([Parameter(Mandatory)][string]$Alias)

    Get-NetIPAddress -InterfaceAlias $Alias -AddressFamily IPv4 -ErrorAction SilentlyContinue |
        Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue

    Get-NetRoute -InterfaceAlias $Alias -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue |
        Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue
}

#endregion

#region ---------- Aktionen ----------

function Set-MarliDhcp {
    param([Parameter(Mandatory)][string]$Alias)

    Clear-MarliIpv4 -Alias $Alias
    Set-NetIPInterface         -InterfaceAlias $Alias -AddressFamily IPv4 -Dhcp Enabled -ErrorAction Stop
    Set-DnsClientServerAddress -InterfaceAlias $Alias -ResetServerAddresses -ErrorAction Stop
    Restart-NetAdapter         -Name $Alias -Confirm:$false -ErrorAction Stop
    Wait-MarliAdapter -Alias $Alias
}

function Read-MarliStaticConfig {
    Write-Host ''
    $ip = Read-IPv4 -Prompt ' New IPv4 address'

    while ($true) {
        $mask   = "$(Read-Host ' Subnet mask (e.g. 255.255.255.0 or 24)')".Trim().TrimStart('/')
        $prefix = $null
        if ($mask -match '^\d{1,2}$') {
            if ([int]$mask -ge 1 -and [int]$mask -le 32) { $prefix = [int]$mask }
        }
        elseif ($mask) {
            $prefix = ConvertTo-PrefixLength $mask
        }
        if ($prefix) { break }
        Write-Host ' Invalid subnet mask, please try again.' -ForegroundColor Red
    }

    $gateway = Read-IPv4 -Prompt ' Default gateway (Enter = none)' -AllowEmpty
    $dns1    = Read-IPv4 -Prompt ' Primary DNS (Enter = none)' -AllowEmpty
    $dns2    = $null
    if ($dns1) { $dns2 = Read-IPv4 -Prompt ' Secondary DNS (Enter = none)' -AllowEmpty }

    $dnsList = @(@($dns1, $dns2) | Where-Object { $_ })

    # Zusammenfassung vor dem Übernehmen
    Write-Host ''
    Write-MarliValue 'IP-Address:' $ip
    Write-MarliValue 'Subnetmask:' "$(ConvertTo-SubnetMask $prefix) (/$prefix)"
    Write-MarliValue 'Gateway:'    $(if ($gateway) { $gateway } else { '-' })
    Write-MarliValue 'DNS:'        $(if ($dnsList) { $dnsList -join ', ' } else { '-' })
    Write-Host ''

    if (-not (Read-YesNo ' Apply these settings?')) { return $null }

    return @{
        IPAddress    = $ip
        PrefixLength = $prefix
        Gateway      = $gateway
        Dns          = @($dnsList)
    }
}

function Set-MarliStatic {
    param(
        [Parameter(Mandatory)][string]$Alias,
        [Parameter(Mandatory)][string]$IPAddress,
        [Parameter(Mandatory)][int]$PrefixLength,
        [string]$Gateway,
        [string[]]$Dns
    )

    Set-NetIPInterface -InterfaceAlias $Alias -AddressFamily IPv4 -Dhcp Disabled -ErrorAction Stop
    Clear-MarliIpv4 -Alias $Alias

    $newIp = @{
        InterfaceAlias = $Alias
        AddressFamily  = 'IPv4'
        IPAddress      = $IPAddress
        PrefixLength   = $PrefixLength
        ErrorAction    = 'Stop'
    }
    if ($Gateway) { $newIp.DefaultGateway = $Gateway }
    New-NetIPAddress @newIp | Out-Null

    if ($Dns -and $Dns.Count -gt 0) {
        Set-DnsClientServerAddress -InterfaceAlias $Alias -ServerAddresses $Dns -ErrorAction Stop
    }
    else {
        Set-DnsClientServerAddress -InterfaceAlias $Alias -ResetServerAddresses -ErrorAction Stop
    }
}

function Restart-MarliAdapter {
    param([Parameter(Mandatory)][string]$Alias)

    Restart-NetAdapter -Name $Alias -Confirm:$false -ErrorAction Stop
    Wait-MarliAdapter -Alias $Alias
}

# Karte deinstallieren + neu erkennen lassen, Geister-Karten entfernen.
# Gibt $true zurück, wenn die Karte neu installiert wurde (Name kann sich ändern).
function Reset-MarliAdapter {
    param([Parameter(Mandatory)][string]$Alias)

    if ([Environment]::OSVersion.Version.Build -lt 19041) {
        Write-Host ' This function needs Windows 10 2004 or newer (pnputil).' -ForegroundColor Red
        Wait-Enter
        return $false
    }

    Write-Host ''
    Write-Host ' WARNING: The network card will be uninstalled and reinstalled.' -ForegroundColor Red
    Write-Host ' All settings of this card (IP, DNS, ...) will be lost.'  -ForegroundColor Red
    Write-Host ' Remote connections over this card will be interrupted!'  -ForegroundColor Red
    if (-not (Read-YesNo ' Continue?')) { return $false }

    $instanceId = (Get-NetAdapter -Name $Alias -ErrorAction Stop).PnPDeviceID

    Write-Host ' Flushing DNS cache and resetting Winsock...' -ForegroundColor DarkGray
    ipconfig /flushdns   | Out-Null
    netsh winsock reset  | Out-Null

    Write-Host ' Removing network card...' -ForegroundColor DarkGray
    pnputil /remove-device "$instanceId" | Out-Null
    if ($LASTEXITCODE -ne 0) { Write-Host " pnputil exit code $LASTEXITCODE" -ForegroundColor Yellow }

    # "Geister-Karten" = Netzwerkkarten, die nicht mehr vorhanden sind
    $ghosts = @(Get-PnpDevice -Class Net -ErrorAction SilentlyContinue | Where-Object { -not $_.Present })
    foreach ($ghost in $ghosts) {
        Write-Host " Removing old device: $($ghost.FriendlyName)" -ForegroundColor DarkGray
        pnputil /remove-device "$($ghost.InstanceId)" | Out-Null
    }

    Write-Host ' Searching for new hardware...' -ForegroundColor DarkGray
    pnputil /scan-devices | Out-Null
    Start-Sleep -Seconds 5

    Write-Host ''
    Write-Host ' Done. A restart is recommended (Winsock reset).' -ForegroundColor Green
    Wait-Enter
    return $true
}

#endregion

#region ---------- Menüs ----------

function Select-MarliAdapter {
    while ($true) {
        $adapters = Get-MarliAdapter
        Write-MarliHeader 'Please select the network adapter'

        if ($adapters.Count -eq 0) {
            Write-Host ' No network adapter found.' -ForegroundColor Red
            Wait-Enter
            return $null
        }

        for ($i = 0; $i -lt $adapters.Count; $i++) {
            $a     = $adapters[$i]
            $up    = "$($a.Status)" -eq 'Up'
            $state = if ($up) { 'Connected' } else { "$($a.Status)" }
            $color = if ($up) { 'Green' } else { 'Red' }

            Write-Host (' [{0,2}] ' -f ($i + 1)) -NoNewline -ForegroundColor Yellow
            Write-Host ('{0,-48}' -f $a.InterfaceDescription) -NoNewline
            Write-Host ('{0,-11}' -f $a.LinkSpeed) -NoNewline -ForegroundColor Yellow
            Write-Host $state -ForegroundColor $color
        }

        Write-Host ''
        $choice = "$(Read-Host ' Select Nr (Q = Quit)')".Trim()
        if ($choice -match '^[qQ]$') { return $null }

        $nr = 0
        if ([int]::TryParse($choice, [ref]$nr) -and $nr -ge 1 -and $nr -le $adapters.Count) {
            return $adapters[$nr - 1]
        }
    }
}

function Invoke-MarliAdapterMenu {
    param([Parameter(Mandatory)][string]$Alias)

    while ($true) {
        try {
            $info = Get-MarliIpInfo -Alias $Alias
        }
        catch {
            Write-Host " Adapter '$Alias' not found anymore." -ForegroundColor Red
            Wait-Enter
            return
        }

        Write-MarliHeader $info.Description
        Show-MarliIpInfo $info
        Write-Host ' (D)HCP (S)tatic IP (R)estart (C)lear networkcard (B)ack'
        $mode = "$(Read-Host ' Select')".Trim().ToUpper()

        try {
            switch ($mode) {
                'D' { Set-MarliDhcp -Alias $Alias }
                'S' {
                    $config = Read-MarliStaticConfig
                    if ($config) { Set-MarliStatic -Alias $Alias @config }
                }
                'R' { Restart-MarliAdapter -Alias $Alias }
                'C' { if (Reset-MarliAdapter -Alias $Alias) { return } }
                'B' { return }
            }
        }
        catch {
            Write-Host ''
            Write-Host " ERROR: $($_.Exception.Message)" -ForegroundColor Red
            Wait-Enter
        }
    }
}

#endregion

#region ---------- Öffentliche Funktion ----------

function Start-MarliNet {
    <#
    .SYNOPSIS
        Manage IPv4 (DHCP / static), restart or reset a network card.
    .EXAMPLE
        Start-MarliNet
    .EXAMPLE
        Marli-Net
    #>

    [CmdletBinding()]
    param()

    if (-not (Test-MarliAdmin)) {
        Write-Warning 'Administrator rights required - starting an elevated PowerShell...'
        $manifest = Join-Path $script:ModuleRoot 'Marli-Net.psd1'
        $command  = "Import-Module '$manifest' -Force; Start-MarliNet"
        try {
            Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList @(
                '-NoProfile', '-NoExit', '-Command', "& { $command }"
            ) -ErrorAction Stop
        }
        catch {
            Write-Warning 'Elevation was cancelled.'
        }
        return
    }

    while ($true) {
        $adapter = Select-MarliAdapter
        if (-not $adapter) { break }
        Invoke-MarliAdapterMenu -Alias $adapter.Name
    }

    Clear-Host
}

#endregion

New-Alias -Name 'Marli-Net' -Value 'Start-MarliNet' -Force
Export-ModuleMember -Function 'Start-MarliNet' -Alias 'Marli-Net'