LoginSignup
5
7

More than 3 years have passed since last update.

【小ネタ】PowershellでWoL送信機能を自作する

Last updated at Posted at 2020-04-29

【小ネタ】PowershellでWoL送信機能を自作する話

1. はじめに

久々の投稿です。
ええそうです、コロナで暇してるからです。
と言っても残業規制が厳しくなっただけで通常業務はあり、今回はその際に発生した「テレワーク中に検証PCが何者かによってシャットダウンされてしまった事件」を解決するためにWoL環境を構築したのがきっかけです。
(ま、私はテレワークになっていないため私に連絡もらえればポチッと出来るんですけどね…)

今回は「相も変わらずツールダウンロードが勝手に出来ないから作っちゃえ」シリーズ(…はて、シリーズ化してたかな…σ(∵`)?)ワンショット完結の小ネタです。

2. おさらい

WoLって何だっけ?の方はコチラをどうぞ

リンク踏むのすら面倒な方向けにざっくり説明すると、、、

遠隔PCの電源を入れるためにぷるぷるさせながら頑張って腕を伸ばすマジックパケットを送出するWoLクライアントと、電源が落ちている状態でそれを受け取って起動するサーバーのお話です。

3. サーバー環境構築

サーバーと言っても普通のパソコンだったりもしますし、ラックマウントだったりもします。つまり、対象マシンの種別は問わない(`・ω・´)キリッ

最近のWindowsなら大抵いけるはず。

この辺の環境周りのお話は幾つかの参考サイトにお任せします(`・ω・´)
以下ではNICの設定変更、電源プランの変更、BIOSの設定変更等の詳細が掲載されているので大筋で
このサイト通りにやれば環境構築はOKでしょう。

場合によってはレジストリキーを追加しなければならないため、以下も参考にしてください。
(私はこれを知らずに少しハマりました…HPマシン固有の問題なのかしらん?)

4. クライアント側コーディング

ここで改めて、WoLのツールがダウンロード出来るようならそっちの方が早いです(`・ω・´)
先ほどの参考サイトにも幾つかのソフトウェアが紹介されています(`・ω・´)
使える環境ならばおとなしくそっちを使いましょう(`・ω・´)

さて、本題に入ります(`・ω・´)

このコマンドレットの主な動作仕様は以下の通り
1. IPアドレス文字列またはMACアドレス文字列(256bit毎ハイフン区切り)を受け取る
2. IPアドレスとして認識した場合は端末のARPテーブルを照会して一致するMACアドレスを取得する

使い方はこんなイメージです

IPアドレスを渡す場合
Send-MagicPacket -Addr 10.0.0.1

MACアドレスを渡す場合
Send-MagicPacket -Addr 00-11-22-33-44-55

まずはソースをどぞう(= ・ω・)っ
(ヘッダコメント等余分な部分はカットしています)

Send-MagicPacket.psm1
Using Namespace System.Net
Using Namespace System.Net.NetworkInformation
Using Namespace System.Net.Sockets
Using Namespace System.Diagnostics
Using Namespace System.Management.Automation

# Do-Loop内のタイムリミットを60秒とする
$TIMELIMIT = 60

# UDPのウェルノウンポート列挙型
Enum UdpWellKnownPort {
    ICMPEcho = 7
    MagicPacket = 9
    DNS = 53
}

function Send-MagicPacket {
    [CmdletBinding(DefaultParameterSetName='Addr',          SupportsShouldProcess=$true,                PositionalBinding=$false,                  ConfirmImpact='Medium')]
    [Alias()]
    [OutputType([IPAddress])]
    Param
    (
        [Parameter(Mandatory=$true, 
                   ValueFromPipeline=$true,
                   ValueFromPipelineByPropertyName=$true, 
                   ValueFromRemainingArguments=$false, 
                   Position=0,
                   ParameterSetName='Address')]
        [ValidateNotNull()]
        [ValidateNotNullOrEmpty()]
        [ValidateCount(7,17)]
        [Alias("Address")]
        [String]$Addr
    )

    Begin {
        # 引数AddrがMACアドレスかIPアドレスかの判定用
        $OutputEncoding = [Text.Encoding]::UTF8
        [PhysicalAddress]$MACAddress = [PhysicalAddress]::None
        [IPAddress]$IPAddress = [IPAddress]::None

        # AddrはIPアドレス文字列か?
        If ( ![IPAddress]::TryParse( $Addr, [ref]$IPAddress ) ) {

            # AddrはMACアドレス文字列か?
            try {
                $MACAddress = [PhysicalAddress]::Parse( $Addr.toUpper() )
            } catch [FormatException] {
                Write-Error -Message "Addr引数の解析中にエラーが発生しました。" -CategoryReason "渡されたアドレス文字列は正しいIPアドレス記述でもMACアドレス記述でもありません。" -Exception $_.Exception
                exit 1
            }
        } else {
            # IPアドレスからMACAddressの取得を試みる
            If ( $IPAddress -ne [ipaddress]::None ) {
                try {
                    $arp_result = Get-NetNeighbor $IPAddress.ToString() -ErrorAction Stop
                    $MACAddress = [PhysicalAddress]::Parse( $arp_result.LinkLayerAddress )
                } catch [CimJobException] {
                    If ( $_.CategoryInfo.Category -eq [ErrorCategory]::ObjectNotFound ) {
                        Write-Error -Message "渡されたアドレス文字列はARPエントリに見つかりませんでした。"
                        exit 2
                    }
                }
            }
        }

        # この時点でMACAddressを取得できていない場合は異常終了とする
        If ( $MACAddress -eq [PhysicalAddress]::None ) {
            Write-Error -Message "プログラムが異常終了しました。MACアドレスが得出来ませんでした。" -TargetObject $Addr
            exit 3
        }
    }

    Process {

        # マジックパケットの生成
        [Byte[]]$MACtoBytes = $MACAddress.GetAddressBytes()
        [Byte[]]$MagicPacket = ( [Byte[]](@( "0xff" ) * 6 ) ) + $MACtoBytes * 16
        [UdpClient]$UdpClient = [UdpClient]::new()
        $UdpClient.Connect( [IPAddress]::Broadcast, [UdpWellKnownPort]::MagicPacket )

        $SendSize = $UdpClient.Send( $MagicPacket, $MagicPacket.Length )
        Write-Host ( "送出したパケットサイズ:{0}バイト" -f $SendSize )
        Write-Verbose ( "`n" + ( ( $MagicPacket | Format-Hex ) -join "`n" ) )
    }

    End {
        $UdpClient.Close()

        # RARPのためのMACアドレス文字の組み立て
        [String]$MACWithHyphen = ($MACtoBytes | %{ $_.toString("X") } | %{ If( $_.length -eq 1 ){ "0{0}" -f $_ } else { $_ } } ) -join "-"

        # 疑似RARPでIPアドレスを取得する
        $rarp_result = $null
        $StopWatch = [StopWatch]::StartNew()
        Do {
            $rarp_result = Get-NetNeighbor -LinkLayerAddress $MACWithHyphen -ErrorAction Ignore
            If ( $StopWatch.Elapsed.Seconds -ge $TIMELIMIT ) {
                Write-Error -CategoryActivity "起動確認エラー" -CategoryReason "マジックパケットは送出できましたが、時間内にRARP解決できませんでした。" -TargetObject $MACWithHyphen
                exit 4
            }

            Write-Progress -Activity マジックパケット送出 -CurrentOperation 疑似RARPの実施中 `
                            -Status ("{0}秒中{1}秒待っています。" -f $TIMELIMIT, $StopWatch.Elapsed.Seconds ) `
                            -PercentComplete ([Math]::Truncate($StopWatch.Elapsed.Seconds * 100 / $TIMELIMIT))

        } Until ( [IPAddress]::TryParse( $rarp_result.IPAddress, [ref]$IPAddress ) )

        # 念のため疎通確認
        $Ping = [Ping]::new()
        $StopWatch.Restart()
        Do {
            $ping_reply = $Ping.Send( $IPAddress )
            Write-Verbose ( "ping結果=>`n`tターゲット`t`t`t`t:{0}`n`tステータス`t`t`t`t:{1}`n`tラウンドトリップタイム`t:{2}" -f $ping_reply.Address, $ping_reply.Status, $ping_reply.RoundtripTime )
            If ( $StopWatch.Elapsed.Seconds -ge $TIMELIMIT ) {
                Write-Error -Message "Pingの実行中にタイムアウトしました。ホストが起動しているか確認してください。" -TargetObject $IPAddress
                exit 5
            }
            Write-Progress -Activity マジックパケット送出 -CurrentOperation Ping応答確認中 `
                            -Status ("{0}秒中{1}秒待っています。" -f $TIMELIMIT, $StopWatch.Elapsed.Seconds ) `
                            -PercentComplete ([Math]::Truncate($StopWatch.Elapsed.Seconds * 100 / $TIMELIMIT))
        } Until ( $ping_reply.Status -eq [IPStatus]::Success )

        $OutputEncoding = [Text.Encoding]::Default
        return $IPAddress
    }
}

Export-ModuleMember -Function Send-MagicPacket

ではセクションごとに説明していきます(`・ω・´)


4.1 引数チェックとMACアドレスの取得

このコマンドレットが受け取る引数は文字列です。

ただし、ありとあらゆる文字列が来ても困っちゃうので、IPv4アドレスの最小文字数である1.1.1.1(7文字)からMACアドレスの桁数である00-00-00-00-00-00(17文字)を想定して VaridateCountで7~17文字という制限をここでかけてます。

(このパラメータ属性って沢山あってワケワカメになるのは私だけでしょうか…(´・ω・`)?)

4.1.1 コード内引数チェックの準備

続いてBegin句内でその文字列を IPAddress型に、あるいはPhysicalAddress型にパースしようとします。まずはTryParseに必要な空のオブジェクトを準備します。

Send-MagicPacket.psm1-62~65行目
        # 引数AddrがMACアドレスかIPアドレスかの判定用
        $OutputEncoding = [Text.Encoding]::UTF8
        [PhysicalAddress]$MACAddress = [PhysicalAddress]::None
        [IPAddress]$IPAddress = [IPAddress]::None

$OutputEncoding変数はPowershellの出力エンコードを定義する、Powershellがデフォルトで使用するための予約変数です。
ここでは出力はUTF8にします。
また、本来PowershellではStrictModeにしなければ変数宣言は不要ですが、TryParseは参照型を受け取って値をセットするという挙動なので、それに必要な空のオブジェクトを準備しています。PhysicalAddressにはTryParseがないため不要ですが、揃えるために同様に空のオブジェクトを準備しました(`・ω・´)

4.1.2 コード内引数チェックの前半(受け取ったAddrがMACアドレスだった場合の処理)

実際に引数チェックを行う部分です。If構文のTrueパートを前半、Falseパートを後半と見立て、このセクションでは前半の説明をします。

Send-MagicPacket.psm1-67~77行目
# AddrはIPアドレス文字列か?
If ( ![IPAddress]::TryParse( $Addr, [ref]$IPAddress ) ) {

    # AddrはMACアドレス文字列か?
    try {
        $MACAddress = [PhysicalAddress]::Parse( $Addr.toUpper() )
    } catch [FormatException] {
        Write-Error -Message "Addr引数の解析中にエラーが発生しました。" -CategoryReason "渡されたアドレス文字列は正しいIPアドレス記述でもMACアドレス記述でもありません。" -Exception $_.Exception
        exit 1
    }
} else {

冒頭のIf構文は否定から入っていますので、Trueパートの意味は「Addr引数はIPアドレス型にパースできなかった場合」です。
「Addr引数がIPアドレスではないならば、MACアドレスか!?」というのがtry-catchの部分です。前述のとおり、PhysicalAddresクラスにはTryParseがないため、通常のtry構文を使用しています(´・ω・`)

どっちでもいいじゃんと思うなかれ!
一般的にはパフォーマンスの観点からTryParseが推奨されます。
例外のスローにはそれなりのリソースを使用するためです(`・ω・´)
よりパフォーマンスが優先される様な環境の場合は、極力無駄なリソースを使わない様に設計する必要があります(`・ω・´)

とはいえ、このコードが使用される環境を想定するとそんなにパフォーマンスを意識する必要もなさそうなので、結局のところ今回はどっちでもいいということになりますな(´・ω・`)

4.1.3 コード内引数チェックの後半(受け取ったAddrがIPアドレスだった場合の処理)

Falseパートの意味は「Addr引数がIPアドレスだった場合」です。
マジックパケットには対象マシンのMACアドレスが必須であるため、IPアドレスを渡されても正直困ってしまいます(´・ω・`)

じゃあ初めから受け付けるなよ!という指摘があちこちから聞こえてきそうですが、MACアドレスなんていちいち覚えていられませんよね…
なので、ARPテーブルに静的エントリを追加しておいてIPアドレスを渡してキックできた方が便利かなと思ったため、
IPアドレス文字列も受け入れるようにしておりやす(`・ω・´)

SendMagicPacket-77~90行目
} else {
    # IPアドレスからMACAddressの取得を試みる
    If ( $IPAddress -ne [ipaddress]::None ) {
        try {
            $arp_result = Get-NetNeighbor $IPAddress.ToString() -ErrorAction Stop
            $MACAddress = [PhysicalAddress]::Parse( $arp_result.LinkLayerAddress )
        } catch [CimJobException] {
            If ( $_.CategoryInfo.Category -eq [ErrorCategory]::ObjectNotFound ) {
                Write-Error -Message "渡されたアドレス文字列はARPエントリに見つかりませんでした。"
                exit 2
            }
        }
    }
}

最初のIf構文はIPAddress型変数に、ネットワークアドレスを使用しないという意味の[IPAddress]::None(255.255.255.255)の値が入っていない場合に処理を行うというものです。このtryに失敗し、かつエラーの内容がObjectNotFoundだった場合は該当のIPアドレスがネイバー(お隣さん)に存在しなかったということでエラー終了させます。

さて、tryの中でやっている以下について少し説明します。

SendMagicPacket.psm1-81~82行目
$arp_result = Get-NetNeighbor $IPAddress.ToString() -ErrorAction Stop
$MACAddress = [PhysicalAddress]::Parse( $arp_result.LinkLayerAddress )

Get-NetNeighborコマンドレットはNICのARPキャッシュのデータを取得するコマンドです。

参考☞Get-NetNeighborコマンドレットについて

Get-NetNeighborコマンドレットはWMIへ処理を移譲しています。同様のコマンドは以下で実現可能で、必要なプロパティだけを集約した形でCIMインスタンスを生成している様です。(Sourceが不明のため一部想像です汗)
Get-WmiObject -Namespace ROOT\StandardCimv2 -Recurse -Class MSFT_NetNeighbor

※Powershell v6.0以降では、WmiではなくCIMに置き換えられている様ですがここでは取り扱いません(`・ω・´)
間違いを恐れずに盛大に知ったかぶりするならば、(たぶん、、、)WBEMベースであるWMIだと色々と悪いことも出来てしまうので、情報収集するためだけのコンテナ的なオブジェクトの集合体にしたかったのかなあ??←ご存知の方からのありがたい指摘をお待ちしております(`・ω・´)
参考:CIM Wikipedia

4.1.4 ここまででMACアドレスを取得できなかったら異常終了

タイトルと以下の様にこの時点でMACアドレスを取得出来なければプログラムを終了します。

SendMagicPacket.psm1-92~96行目
# この時点でMACAddressを取得できていない場合は異常終了とする
If ( $MACAddress -eq [PhysicalAddress]::None ) {
    Write-Error -Message "プログラムが異常終了しました。MACアドレスが得出来ませんでした。" -TargetObject $Addr
    exit 3
}

4.2 マジックパケットの生成~送出

いよいよProsses句でマジックパケットの生成を始めます。
実装はこの通りシンプルです。

SendMagicPacket.psm1-99~110行目
Process {

    # マジックパケットの生成
    [Byte[]]$MACtoBytes = $MACAddress.GetAddressBytes()
    [Byte[]]$MagicPacket = ( [Byte[]](@( "0xff" ) * 6 ) ) + $MACtoBytes * 16
    [UdpClient]$UdpClient = [UdpClient]::new()
    $UdpClient.Connect( [IPAddress]::Broadcast, [UdpWellKnownPort]::MagicPacket )

    $SendSize = $UdpClient.Send( $MagicPacket, $MagicPacket.Length )
    Write-Host ( "送出したパケットサイズ:{0}バイト" -f $SendSize )
    Write-Verbose ( "`n" + ( ( $MagicPacket | Format-Hex ) -join "`n" ) )
}

まず初めにマジックパケットの仕様を、RFCで探してみたが、、、
Screenshot_20200423-094039.png

…何だかEDNS0なるものしかヒットしない(´・ω・`)

Wikipediaでいいかなぁ…(´・ω・`)

いや、一応公式を見るのがセオリーだ(`・ω・´)キリッ

と、いうことでEDNS0なる物の中を覗いて、参考資料のセクションを見てみると、、、

image.png

結局Wikipediaじゃねーかwww

ってことでおとなしく
WoL Wikipedia
を見ましょう(`・ω・´)

image.png

大事なのは赤枠部分です。要約すると「パケットの先頭はFFFFFFFFFFFFで埋めてその後ろに起動したいマシンのMACアドレスを16回並べよ」と。

1 . まずは取得したMACアドレスを、PhysicalAddressクラスのGetAddressBytesメソッドでバイト配列にします。

[Byte[]]$MACtoBytes = $MACAddress.GetAddressBytes()

2 . つづいて先頭をFFFFFFFFFFFFで埋め、MACアドレスのバイト配列を16個並べます

[Byte[]]$MagicPacket = ( [Byte[]](@( "0xff" ) * 6 ) ) + $MACtoBytes * 16

3 . パケットが出来たらUDPClientで送信するだけ

    # UdpClientインスタンスの生成
    [UdpClient]$UdpClient = [UdpClient]::new()

    # 宛先をブロードキャストとし、MagicPacket用通信ポートである9番を指定してセッション開始
    $UdpClient.Connect( [IPAddress]::Broadcast, [UdpWellKnownPort]::MagicPacket )

    # パケット送信
    $SendSize = $UdpClient.Send( $MagicPacket, $MagicPacket.Length )

なお、UDPですので送りっぱなしです。そのため、ここで終わってもいいですが、
実際に起動したのかどうかが分からないため疎通確認処理をEnd句で行っています。


4.3 疎通確認

End句内の疎通確認は主に2つの処理をしています。

4.3.1 ReverseARP

ReverseARPとは、ARPパケットを利用して通常のARPの逆引きを行う処理です。
実際にWMIがARPを投げているかどうかが分からないためここでは疑似RARPとしています。
Get-NetNeighborではARPキャッシュを参照しますが、
見つからない場合にARPを送出するかどうかまでは分かりませんでした。
(Wiresharkで見た限りではARP送出していましたが、それがWMIによるものなのかが判断できませんでした)

疑似RARPもGet-NetNeighborコマンドレットで行います。
ARPの時と異なるのは、引数にIPアドレスを指定するのではなく、MACアドレスを指定するところです。

SendMagicPacket.psm1-112~132行目
End {
    $UdpClient.Close()

    # RARPのためのMACアドレス文字の組み立て
    [String]$MACWithHyphen = ($MACtoBytes | %{ $_.toString("X") } | %{ If( $_.length -eq 1 ){ "0{0}" -f $_ } else { $_ } } ) -join "-"

    # 疑似RARPでIPアドレスを取得する
    $rarp_result = $null
    $StopWatch = [StopWatch]::StartNew()
    Do {
        $rarp_result = Get-NetNeighbor -LinkLayerAddress $MACWithHyphen -ErrorAction Ignore
        If ( $StopWatch.Elapsed.Seconds -ge $TIMELIMIT ) {
            Write-Error -CategoryActivity "起動確認エラー" -CategoryReason "マジックパケットは送出できましたが、時間内にRARP解決できませんでした。" -TargetObject $MACWithHyphen
            exit 4
        }

        Write-Progress -Activity マジックパケット送出 -CurrentOperation 疑似RARPの実施中 `
                        -Status ("{0}秒中{1}秒待っています。" -f $TIMELIMIT, $StopWatch.Elapsed.Seconds ) `
                        -PercentComplete ([Math]::Truncate($StopWatch.Elapsed.Seconds * 100 / $TIMELIMIT))

    } Until ( [IPAddress]::TryParse( $rarp_result.IPAddress, [ref]$IPAddress ) )

以下のMACアドレス文字列の組み立てですが、たぶん初めにもらったMACアドレスを使った方が楽ですしそれで問題ないと思いますが、
一応、処理後のMACアドレスで起動している事の証明になるかなあと思ってこっちを採用しました。(が、あまり意味はない気がします)
はじめのパイプ処理で$MACtoBytesに入ったバイト配列を16進数の文字列に変換します。
次のパイプ処理で、変換後の16進数文字列が1桁だった場合は2桁になる様に0で埋めます。
ここはビットシフトした方がかっこよかったかもですね(´・ω・`)
最後にそれらの16進数をハイフンでつなげればMACアドレス文字列の完成です。

[String]$MACWithHyphen = ($MACtoBytes | %{ $_.toString("X") } | %{ If( $_.length -eq 1 ){ "0{0}" -f $_ } else { $_ } } ) -join "-"

なお、マジックパケットを送出後、端末が起動するまでのタイムラグがあるため、ここでは冒頭にセットしたタイマーリミットである60秒間ループ処理します。Get-NetNeighborの実行結果からIPアドレスが取得出来ればループ処理を抜けます。

4.3.2 疎通確認

やや冗長的な確認になりますが、ARP静的エントリを考慮してIPアドレスを受け取る想定をしているため、RARPだけでは疎通確認としては不十分です。
そのため、ここで最終的にPingによって疎通確認を行います。
ここでも60秒間Pingによるループ処理を行います。
Ping結果がSuccessになればループを抜け、IPAddressを返して処理は終了です。

SendMagicPacket.psm1-134~151行目
    # 念のため疎通確認
    $Ping = [Ping]::new()
    $StopWatch.Restart()
    Do {
        $ping_reply = $Ping.Send( $IPAddress )
        Write-Verbose ( "ping結果=>`n`tターゲット`t`t`t`t:{0}`n`tステータス`t`t`t`t:{1}`n`tラウンドトリップタイム`t:{2}" -f $ping_reply.Address, $ping_reply.Status, $ping_reply.RoundtripTime )
        If ( $StopWatch.Elapsed.Seconds -ge $TIMELIMIT ) {
            Write-Error -Message "Pingの実行中にタイムアウトしました。ホストが起動しているか確認してください。" -TargetObject $IPAddress
            exit 5
        }
        Write-Progress -Activity マジックパケット送出 -CurrentOperation Ping応答確認中 `
                        -Status ("{0}秒中{1}秒待っています。" -f $TIMELIMIT, $StopWatch.Elapsed.Seconds ) `
                        -PercentComplete ([Math]::Truncate($StopWatch.Elapsed.Seconds * 100 / $TIMELIMIT))
    } Until ( $ping_reply.Status -eq [IPStatus]::Success )

    $OutputEncoding = [Text.Encoding]::Default
    return $IPAddress
}

5. Powershellコマンドレットとしてエクスポートする

最後に、いちいちこのプログラムを読み込まなくてもいい様に、モジュール化してエクスポート処理をします。
モジュール化するといっても簡単で、psm1拡張子にして指定のフォルダにこのスクリプトファイルを配置するだけです。
64bitと32bitで若干配置場所が異なりますが、$env:PSModulePathに含まれるパスに配置すればOKです。

6. 拡張の検討について

このスクリプトはLAN内で使用することを想定しています。
つまり、ブロードキャストドメインを超えた(ルーター越しの)パケット送出は出来ません。ルーターが不要と判断して破棄します。

しかしテレワークであれば大抵はVPN等で社内リソースにまずアクセスするはずなので大方これで問題ないはず。
どうしてもWAN越しにWoLを成立させたい場合、このパケットをユニキャストパケットでカプセル化して送出する必要があります。
カプセル化するということは、経路のどこかで非カプセル化しないといけないため、対象マシンと同じブロードキャストドメイン内に
エンドポイントを作成する必要があります。

7. 最後に

Powershell慣れしていない方にも伝わる様になるべく詳解したつもりですがいかがでしたでしょうか(`・ω・´)
質問やご意見ご指摘は随時承っておりますのでよろしくお願いします(`・ω・´)

それでは、よいリモートワークを!

5
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
7