車輪の再発明

いったい何番煎じだよ!

PowershellでNTPサーバを書く

NTPの通信をテストしたいのにWindows PCしかない残念な現場があったので、PowershellでNTPサーバを書いてみました。

前提

  • 今どきのWindowsなら必ず動くであろうPowershell V2.0で実装。
  • NTPサーバが待ち受けするUDP/123ポートはWindows Timeというサービスが使用しているので、スクリプトを実行するときはこいつを止める必要があります。
  • 通信の待ち受けは無限ループになっているので終了は×でウインドウを閉じて強制終了。
  • ctrl+cでもいいんですが、リスニングしていたポートを解放する処理$client.close()がうまく実装できなかったので、次回以降起動できません。ウインドウを閉じると解放されるみたいです。
  • NTPはバージョン3or4です。
  • 精度は度外視です。NTPの動作が確認できることを目的に作成しています。

コード

param([switch]$v)

function Convert-Datetime2Byte{
    param([datetime]$datetime)

    $arrInt_unixtime=@()
    $arrByte_unixtime=New-Object System.Byte[] 0

    $double_unixtime=($datetime-([datetime]"1900/1/1 9:0:0")).TotalSeconds

    $arrInt_unixtime+=[System.UInt32]([math]::Truncate($double_unixtime)) 
    $arrInt_unixtime+=[system.uint32]($double_unixtime%1*1000000000)

    $arrInt_unixtime |
        ForEach-Object {
            for($i=3;$i -ge 0;$i--){
                $arrByte_unixtime+=(($_-band (255*([math]::Pow(256,$i))))/([math]::Pow(256,$i)))
            }
        }
    return $arrByte_unixtime
}


$ErrorActionPreference="stop"

if($v){
    $VerbosePreference="continue"
}else{
    $VerbosePreference="silentlycontinue"
}


$client=New-Object System.Net.Sockets.UdpClient(123)

while($true){
    $handler=$client.BeginReceive($null,$null)
    Write-Verbose "Listen UDP/123..."
    while(!$handler.IsCompleted){ }
    $rcvTime=Get-Date
    $remoteEnd=New-Object System.Net.IPEndPoint([system.net.ipaddress]::None,$null)
    $rcvByte=$client.EndReceive($handler,[ref]$remoteEnd)
    switch($rcvByte[0] -band 63){
        35{
            Write-Verbose "NTP ver.4 mode client received from $remoteEnd."
            $responseFlags=36
        }
        27{
            Write-Verbose "NTP ver.3 mode client received from $remoteEnd."
            $responseFlags=28
        }
        default{
            Write-Verbose "Illegal packet or unsupported NTP version received from $remoteEnd."
            $responseFlags=$null
        }
    }

    if($responseFlags -ne $null){
        $sndByte=New-Object System.Byte[] 0
        $sndByte+=$responseFlags
        $sndByte+=@(1,0,0,0,0,0,0,0,0,0,0,0,0,0,0)
        $sndByte+=Convert-Datetime2Byte (Get-Date)
        $sndByte+=$rcvByte[($rcvByte.Length-8)..$rcvByte.Length]
        $sndByte+=Convert-Datetime2Byte $rcvTime
        $sndByte+=Convert-Datetime2Byte (Get-Date)
    
        $client.Send($sndByte,$sndByte.Length,$remoteEnd) >$null
        Write-Verbose "Sent NTP response to $remoteEnd."
    }
}

使い方

Server-NTP.ps1

-vオプションでうるさくなります。 終了するときはウインドウごと閉じます。

コード解説

おおまかに言うと、.NETのUDPClientを使ってUDP/123をリッスンし、リクエストが来たら返答する簡単なスクリプトです。

リクエスト待ち受け

$client=New-Object System.Net.Sockets.UdpClient(123)

while($true){
    $handler=$client.BeginReceive($null,$null)
    Write-Verbose "Listen UDP/123..."
    while(!$handler.IsCompleted){ }
    $rcvTime=Get-Date
    $remoteEnd=New-Object System.Net.IPEndPoint([system.net.ipaddress]::None,$null)
    $rcvByte=$client.EndReceive($handler,[ref]$remoteEnd)

UDP/123を非同期でリスニングします。 $trueになったら即座に$rcvTime=Get-Dateでその時刻を記録します。 本当はwhile()で判定するのではなく、$client.BeginReceive($null,$null)でコールバックを指定したかったんですが、使い方がいまいちわからなかった。コールバックのほうが精度も高くなる気がする。 パケットを受け取ったらバイト型配列$rcvByteに格納します。

リクエストの識別

    switch($rcvByte[0] -band 63){
        35{
            Write-Verbose "NTP ver.4 mode client received from $remoteEnd."
            $responseFlags=36
        }
        27{
            Write-Verbose "NTP ver.3 mode client received from $remoteEnd."
            $responseFlags=28
        }
        default{
            Write-Verbose "Illegal packet or unsupported NTP version received from $remoteEnd."
            $responseFlags=$null
        }
    }

受信したパケットの先頭1byteにバージョン情報が含まれているので識別します。 上位2bitは不要なので$rcvByte[0] -band 63でマスクをかけて除外しています。(630b00111111

レスポンス送信

    if($responseFlags -ne $null){
        $sndByte=New-Object System.Byte[] 0
        $sndByte+=$responseFlags
        $sndByte+=@(1,0,0,0,0,0,0,0,0,0,0,0,0,0,0)
        $sndByte+=Convert-Datetime2Byte (Get-Date)
        $sndByte+=$rcvByte[($rcvByte.Length-8)..$rcvByte.Length]
        $sndByte+=Convert-Datetime2Byte $rcvTime
        $sndByte+=Convert-Datetime2Byte (Get-Date)
    
        $client.Send($sndByte,$sndByte.Length,$remoteEnd) >$null
        Write-Verbose "Sent NTP response to $remoteEnd."
    }

レスポンスを作成し、送信します。時刻はConvert-Datetime2ByteでNTPの64bitのフォーマットに変換しています。

Convert-Datetime2Byte

function Convert-Datetime2Byte{
    param([datetime]$datetime)

    $arrInt_unixtime=@()
    $arrByte_unixtime=New-Object System.Byte[] 0

    $double_unixtime=($datetime-([datetime]"1900/1/1 9:0:0")).TotalSeconds

    $arrInt_unixtime+=[System.UInt32]([math]::Truncate($double_unixtime)) 
    $arrInt_unixtime+=[system.uint32]($double_unixtime%1*1000000000)

    $arrInt_unixtime |
        ForEach-Object {
            for($i=3;$i -ge 0;$i--){
                $arrByte_unixtime+=(($_-band (255*([math]::Pow(256,$i))))/([math]::Pow(256,$i)))
            }
        }
    return $arrByte_unixtime
}

DateTime型の日時をNTPのフォーマットに変換し、バイト型配列で返します。 NTPではUTCをやり取りするので日本標準時のオフセットを加えて、[datetime]"1900/1/1 9:0:0"としています。 9~10行目で浮動小数点型の秒数を整数部32bit、小数点以下32bitに分割し、配列に代入しています。 $arrByte_unixtime+=(($_-band (255*([math]::Pow(256,$i))))/([math]::Pow(256,$i)))は、32bitの数字を8bitごとに分割するために、0b11111111=255のマスクを上から順々にかけて値を取り出しています。

イケてないところ

どなたか教えてください、、、

  • スクリプト終了時に$client.close()を実行できていないので、終了後にポートが解放されない。
  • パケットの待ち受けがwhile()のループ。$client.BeginReceive($null,$null)でコールバックを使いたい。