【C#】非同期HTTPSサーバー通信アプリを作ってみた

# はじめに
組込通信機器をいじっていると、通信テストをしたくても通信相手が出来上がっていないので、動作検証用に対向ソフトを作ることがあったりします。最近は組込機器もHTTPSでデータ送信してくるので、簡単なWebサーバーもどきなんかが欲しかったり。UIが固まるのはイヤなので非同期でおなしゃすみたいな。
ぐぐると『async/awaitつこたらええやないか』って言われますが、クライアント側が組込機器だとサーバー側から通信タイムアウトで強制切断したいとか面倒な要件があったり。
なんだかんだと自分の必要な仕様で実装してみました。同じようなことをしたい人にだけ参考になるかも。
- Windowsフォームアプリ
- 非同期で複数クライアントとHTTPS通信
- 停止操作するまではいつでも新規接続待ち受け
- 停止操作で全クライアント切断
- 無通信５秒経過でクライアント強制切断
- あれこれ動作ログ的なのをフォーム上にリアルタイム表示
- HTTPSはOpenSSLで作った自己証明書を使う
- なので、クライアントはルートCA証明書が信頼されていなくても通信許可する前提で
- HTTPレイヤの処理はとりあえず適当決め打ち
- 開発環境はVisual Studio 2022の.NET6

# FormMain.csのUIとか
フォーム上のUIは以下のものを準備
- 待ち受けIPアドレス（IPComboBox）
- 待ち受けポート番号（PortTextBox）
- 待ち受け開始ボタン（StartButton）
- 待ち受け停止ボタン（StopButton）
- ログ表示（ReceivedDataTextBox）
- 接続中クライアント数表示（NumOfClientsLabel）

IPアドレスは正しく入力するのが面倒だから、NICの設定値をリスト選択するかたちにします。
ポート番号は範囲制限とか数字以外は拒否するとかした方が吉ですが、コード掲載していませんので先達の共有情報を参考にしてください。
```C#:FormMain.cs
private void FormMain_Load(object sender, EventArgs e)
{
    ...
    // 待受けIPv4アドレスリストの初期化
    IPComboBox.DropDownStyle = ComboBoxStyle.DropDownList;
    var ipaddress = GetLocalIPAddress();
    if (ipaddress.Count > 0)
    {
        // コンボボックスにIPv4アドレスリストを登録
        foreach (var addr in ipaddress)
            IPComboBox.Items.Add(addr);
        IPComboBox.SelectedIndex = 0;
    }
    // IPv4設定のNICがない場合は終了
    if (IPComboBox.Items.Count <= 0)
    {
        var message = "No valid IPv4 network interface." + Environment.NewLine
                    + "Exit the application.";
        var caption = Path.GetFileNameWithoutExtension(Application.ExecutablePath);
        MessageBox.Show($"{message}", caption, MessageBoxButtons.OK, MessageBoxIcon.Hand);
        Application.Exit();
        return;
    }
    ...
    // その他もろもろの初期化など
}

// 全てのNICのIPv4アドレスリストを取得する
private List<IPAddress> GetLocalIPAddress()
{
    var ipaddress = new List<IPAddress>();
    // 物理インターフェース情報をすべて取得
    var interfaces = NetworkInterface.GetAllNetworkInterfaces();

    // 各インターフェースごとの情報を調べる
    foreach (var adapter in interfaces)
    {
        // 有効なインターフェースのみを対象とする
        if (adapter.OperationalStatus != OperationalStatus.Up)
            continue;

        // インターフェースに設定されたIPアドレス情報を取得
        var properties = adapter.GetIPProperties();

        // 設定されているすべてのユニキャストアドレスについて
        foreach (var unicast in properties.UnicastAddresses)
        {
            if (!IPAddress.IsLoopback(unicast.Address) && unicast.Address.AddressFamily == AddressFamily.InterNetwork)
                // ループバックでないIPv4アドレスを追加
                ipaddress.Add(unicast.Address);
        }
    }
    return ipaddress;
}
```
FormMain.csの本体はこんなかんじです。
ログ表示はUIスレッドでやらないといけないので、コールバックで表示します。
```C#:FormMain.cs
public partial class FormMain : Form
{
    private TcpServer _tcpServer;
    private const string NUMOFCLIENTS_LABEL = "Connecting client : ";

    public FormMain()
    {
        InitializeComponent();

        // TCPサーバークラスのインスタンス作成
        _tcpServer = new TcpServer();
        _tcpServer.ListenStartCB += ListenStartReceived;
        _tcpServer.MessageUpdateCB += MessageUpdateReceived;
        _tcpServer.NumberOfClientsCB += NumberOfClientsReceived;
        _tcpServer.MessageBoxCB += MessageBoxReceived;
    }

    // 待受開始ボタン
    private void StartButton_Click(object sender, EventArgs e)
    {
        var SelectedItem = IPComboBox.SelectedItem.ToString();
        if (SelectedItem != null)
        {
            // 接続元情報を作成
            var addr = IPAddress.Parse(SelectedItem);
            var port = int.Parse(PortTextBox.Text);
            _tcpServer.ListenStart(addr, port);
        }
    }

    // 待受停止ボタン
    private void StopButton_Click(object sender, EventArgs e)
    {
        StartButton.Enabled = true;
        StopButton.Enabled = false;
        IPComboBox.Enabled = true;
        PortTextBox.Enabled = true;

        _tcpServer.ListenStop();
        // tcpListenerを止めても生成済のtcpClientは有効で通信可能なため破棄する
        _tcpServer.DisconnectAllClients();
    }

    // _tcpServer.Start()時のボタン処理    ※例外発生時は実行されない
    private void ListenStartReceived()
    {
        StartButton.Enabled = false;
        StopButton.Enabled = true;
        IPComboBox.Enabled = false;
        PortTextBox.Enabled = false;
    }

    // ログメッセージの表示更新
    private void MessageUpdateReceived(string message)
    {
        if (InvokeRequired)
            Invoke(new Action(() => ReceivedDataTextBox.AppendText(message)));
        else
            ReceivedDataTextBox.AppendText(message);
    }

    // 接続クライアント数の表示更新
    private void NumberOfClientsReceived(int count)
    {
        if (InvokeRequired)
            Invoke(new Action(() => NumOfClientsLabel.Text = NUMOFCLIENTS_LABEL + count.ToString()));
        else
            NumOfClientsLabel.Text = NUMOFCLIENTS_LABEL + count.ToString();
    }

    // メッセージボックス表示
    private void MessageBoxReceived(string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon)
    {
        if (InvokeRequired)
            Invoke(new Action(() => MessageBox.Show($"{message}", caption, buttons, icon)));
        else
            MessageBox.Show($"{message}", caption, buttons, icon);
    }
}
```

# 証明書の作成
PCにOpenSSLがインストールされているか、とか、証明書のファイルが存在するか、とか、証明書の有効期限が切れてないか、とかチェックして自動作成・更新するようにしましたが、説明が面倒かつコードが冗長なのでOpenSSLのコマンドだけ書いときます。
アプリ内で生成しなくても有効なPKCS12（pfx）証明書ファイルさえ実行時に存在していればいいので、とりあえず作ってみましょう。

1. 2048bitのRSA形式の秘密鍵（private.key）を共通鍵パスワードでAES256で暗号化して作成
`openssl genrsa -aes256 -passout pass:共通鍵パスワード文字列 -out private.key 2048`

1. 公開鍵の証明書署名要求（server.csr）の作成
秘密鍵から公開鍵を作るのが本来の流れですが、OpenSSLのreqコマンドで秘密鍵を指定すると勝手に公開鍵を取り出してくれるので、公開鍵を作るプロセスは必要ないです。
※ Chromeからサーバーアプリにお試し接続する場合は、SAN情報がないとChromeが認証しないので設定しています
`openssl req -subj "/C=JP/ST=Tokyo/L=Minato-ku/O=HOGEHOGE Co., LTD./CN=*.hogehoge.com" -addext "subjectAltName = DNS:hogehoge.com, DNS:*.hogehoge.com, IP:PCのプライベートIP" -new -key private.key -passin pass:共通鍵パスワード文字列 -out server.csr`

1. サーバー証明書（server.cer）の作成
本来は認証局に署名してもらいますが自分の秘密鍵でCSRに署名します。
`openssl x509 -req -days 365 -passin pass:共通鍵パスワード文字列 -signkey private.key -in server.csr -out server.cer`

1. サーバー証明書をPKCS12形式に変換する
.NETのX509Certificate2の入力にはPKCS12（pfxファイル）が必要です。
`openssl pkcs12 -export -passin pass:共通鍵パスワード文字列 -inkey private.key -in server.cer -passout pass:pfxファイル保護パスワード文字列 -out server.pfx`

# TcpServer.cs
今回のメインです。非同期で複数クライアント同時通信します。とりあえず全体を載っけときます。説明はコード内のコメント参照（ヲイ

１点だけ説明すると、クライアントからの受信データ読込は同期ブロッキングメソッド（sslStream.Read）を使っています。理由はタイムアウトをさせたいからで、非同期のReadAsyncは以下の理由から使えません。
- ReadTimeoutプロパティが機能しない
- 読込待ち中にCancellationTokenを送ってもExceptionが発生せず、何らかのデータを受信して読込んだ後にExceptionが発生する。すなわち、データ受信しない限りReadAsyncタスクが動いたまま（結局ブロッキングのまま）
- 別タイマで強制停止させるならsslStream.Dispose()してObjectDisposedExceptionをcatchすることになるがごちゃごちゃしてイヤ

で、Readメソッドを使いながらも非同期にしたいがために、await Task.Run(() => {...} で括るという姑息なことをやっております。
```C#:TcpServer.cs
internal class TcpServer
{
    private static TcpListener? _tcpListener = null;
    private List<TcpClient> _tcpClients;
    private object _lockObject;

    public delegate void ListenStartCallback();
    public delegate void MessageUpdateCallback(string message);
    public delegate void NumberOfClientsCallback(int count);
    public delegate void MessageBoxCallback(string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon);
    public event ListenStartCallback ListenStartCB = delegate () { };
    public event MessageUpdateCallback MessageUpdateCB = delegate (string message) { };
    public event NumberOfClientsCallback NumberOfClientsCB = delegate (int count) { };
    public event MessageBoxCallback MessageBoxCB = delegate (string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon) { };

    public TcpServer()
    {
        // 接続中クライアントリスト
        _tcpClients = new List<TcpClient>();
        // _tcpClients排他制御用ロックオブジェクト
        _lockObject = new object();
    }

    // 接続待受開始
    public void ListenStart(IPAddress addr, int port)
    {
        if (_tcpListener == null)
        {
            // インスタンス生成して接続元情報を登録設定(bind)
            var ipep = new IPEndPoint(addr, port);
            _tcpListener = new TcpListener(ipep);
            // 同一ソケットの再接続を許可する
            _tcpListener.Server.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);

            try
            {
                // 接続待ち開始準備
                _tcpListener.Start();
                // 接続待受開始の表示
                var message = "> The server has started listening." + Environment.NewLine;
                MessageUpdateCB(message);
                ListenStartCB();
                // 非同期接続待ち→通信タスク開始
                _ = AcceptWaitAsync();
                // AcceptWaitAsync()内でawait AcceptTcpClientAsync()をするとここに戻り以下の処理へ進む
            }
            catch (SocketException ex)
            {
                // TcpLisnterがStart()できない場合
                _tcpListener = null;
                var message = "> " + ex.ErrorCode + " Check network settings.";
                MessageBoxCB(message, "", MessageBoxButtons.OK, MessageBoxIcon.Hand);
                return;
            }
        }
    }

    // 接続待受停止
    public void ListenStop()
    {
        _tcpListener?.Stop();
        _tcpListener = null;
        var message = "> The server has stopped listening." + Environment.NewLine;
        MessageUpdateCB(message);
    }

    // 全クライアント切断
    public void DisconnectAllClients()
    {
        // _tcpClientsを排他制御
        lock (_lockObject)
        {
            foreach (var client in _tcpClients)
            {
                // サーバー側から全クライアントを切断する
                var exMessage = $"> Client({client.Client.RemoteEndPoint}) disconnected." + Environment.NewLine;
                MessageUpdateCB(exMessage);
                client.Close();
            }
            _tcpClients.Clear();
            NumberOfClientsCB(_tcpClients.Count);
        }
    }

    // 非同期でクライアントからの接続を待ち受けて通信する
    private async Task AcceptWaitAsync()
    {
        if (_tcpListener == null)
            return;

        while (true)
        {
            TcpClient tcpClient;

            try
            {
                // クライアント接続待ち（非同期）
                tcpClient = await _tcpListener.AcceptTcpClientAsync();
                // acceptされたら以下の処理へ
            }
            catch (SocketException)
            {
                // _tcpListenerをStop()した場合
                return;
            }
            catch (ObjectDisposedException)
            {
                // _tcpListenerをStop()した場合
                return;
            }

            // 接続情報を表示
            var remoteEP = tcpClient.Client.RemoteEndPoint;
            if (remoteEP != null)
            {
                var remoteIPEP = (IPEndPoint)remoteEP;
                var message = $"> Client({remoteIPEP}) connected." + Environment.NewLine;
                MessageUpdateCB(message);
            }
            // Keep-aliveをenableに設定
            tcpClient.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.KeepAlive, true);
            // Keep-aliveのパラメーター設定
            var tcpKeepAlive = new byte[12];
            BitConverter.GetBytes((Int32)1).CopyTo(tcpKeepAlive, 0);        //onoffスイッチ
            BitConverter.GetBytes((Int32)2000).CopyTo(tcpKeepAlive, 4);     //wait time(ms)
            BitConverter.GetBytes((Int32)500).CopyTo(tcpKeepAlive, 8);      //interval(ms)
            tcpClient.Client.IOControl(IOControlCode.KeepAliveValues, tcpKeepAlive, null);

            // クライアントリスト追加
            lock (_lockObject)
            {
                _tcpClients.Add(tcpClient);
            }
            NumberOfClientsCB(_tcpClients.Count);

            // 非同期受信タスク開始
            _ = ReceivedWaitAsync(tcpClient);
            // ReceivedWaitAsync()内でawait Task.Run(() => stream.Read())をするとここに戻り以下の処理へ進む
        }
    }

    // 非同期でクライアントからデータ受信を待ち受けて応答する
    private async Task ReceivedWaitAsync(TcpClient tcpClient)
    {
        // 自己証明書
        var certificate = Environment.CurrentDirectory + "server.pfx";
        var password = "pfxファイル保護パスワード文字列";

        using (var sslStream = new SslStream(tcpClient.GetStream(), false))
        using (var cert = new X509Certificate2(certificate, password))
        {
            try
            {
                // サーバー認証用証明書
                sslStream.AuthenticateAsServer(cert, false, SslProtocols.Tls12 | SslProtocols.Tls13, false);
            }
            catch (AuthenticationException)
            {
                // SSL/TLSプロトコルバージョンの違いなどで例外発生
                var exMessage = $"> Client({tcpClient.Client.RemoteEndPoint}) disconnected." + Environment.NewLine
                              + ">>> Connection authentication failed." + Environment.NewLine
                              + ">>> TLS version must be 1.2 or 1.3." + Environment.NewLine;
                MessageUpdateCB(exMessage);
                lock (_lockObject)
                {
                    _tcpClients.Remove(tcpClient);
                    tcpClient.Close();
                }
                NumberOfClientsCB(_tcpClients.Count);
                return;
            }
            catch (IOException)
            {
                var exMessage = $"> Client({tcpClient.Client.RemoteEndPoint}) disconnected." + Environment.NewLine;
                MessageUpdateCB(exMessage);
                lock (_lockObject)
                {
                    _tcpClients.Remove(tcpClient);
                    tcpClient.Close();
                }
                NumberOfClientsCB(_tcpClients.Count);
                return;
            }

            // Readのタイムアウト時間を設定（同期受信のみ有効）
            // ※受信開始からn秒経過でのタイムアウト処理はできない
            // ※データ受信ごとにタイマリセットされるため、データ未受信がn秒経過でタイムアウトになる
            sslStream.ReadTimeout = 5_000;

            // 受信データの読み込み（HTTP全データを受信するまでループ）
            var buffer = new byte[1024 * 16];
            var strBuf = new StringBuilder();
            do
            {
                int bytes = 0;
                // データ受信（非同期）
                await Task.Run(() =>
                {
                    try
                    {
                        // クライアントから受信したデータを読む
                        // ※ReadAsyncはtimeoutもCancellationTokenもデータ受信後にしか応答しないため使用不可
                        bytes = sslStream.Read(buffer, 0, buffer.Length);
                    }
                    // タイムアウト時に例外発生
                    // TCP切断時に例外発生
                    catch (IOException)
                    {
                        lock (_lockObject)
                        {
                            // サーバー側で切断された場合はtcpClientが破棄されているため有無をチェック
                            if (_tcpClients.Contains(tcpClient))
                            {
                                var exMessage = $"> Client({tcpClient.Client.RemoteEndPoint}) disconnected." + Environment.NewLine;
                                MessageUpdateCB(exMessage);
                                _tcpClients.Remove(tcpClient);
                                tcpClient.Close();
                            }
                        }
                        NumberOfClientsCB(_tcpClients.Count);
                    }
                });
                // 受信データがない場合は切断とみなして戻る
                if (bytes <= 0)
                {
                    _tcpClients.Remove(tcpClient);
                    tcpClient.Close();
                    return;
                }

                var decoder = Encoding.UTF8.GetDecoder();
                var chars = new char[decoder.GetCharCount(buffer, 0, bytes)];
                decoder.GetChars(buffer, 0, bytes, chars, 0);
                strBuf.Append(chars);

                // 受信文字列のHTTPメッセージボディ長が"Content-Length"ヘッダ値と同じか判定する
            } while (!CheckBodyLength(strBuf.ToString()));

            // HTTP受信文字列を解析
            var recvHttpData = new HttpData();
            recvHttpData.recvStr = strBuf.ToString();
            MessageUpdateCB($">>>>> HTTP受信データ{Environment.NewLine}{recvHttpData.recvStr}");
            recvHttpData.ParseHttpData();

            // HTTP応答データを生成
            var resultCheckHeader = recvHttpData.CheckRecvHeaderProperty();
            var sendStr = recvHttpData.BuildHttpData(resultCheckHeader);
            MessageUpdateCB($">>>>> HTTP応答データ{Environment.NewLine}{sendStr}");
            // HTTP応答データを送信
            var sendBuffer = Encoding.GetEncoding("utf-8").GetBytes(sendStr);
            sslStream.Write(sendBuffer);

            // 通信終了処理
            var message = $"> Client({tcpClient.Client.RemoteEndPoint}) disconnected." + Environment.NewLine;
            MessageUpdateCB(message);
            lock (_lockObject)
            {
                _tcpClients.Remove(tcpClient);
                tcpClient.Close();
            }
            NumberOfClientsCB(_tcpClients.Count);
        }
    }

    // 受信文字列のHTTPメッセージボディ長が"Content-Length"ヘッダ値と同じか判定する
    private bool CheckBodyLength(string httpStr)
    {
        // 改行コード連続（ヘッダとメッセージボディの境界）を探す
        const string separateStr = "\r\n\r\n";
        var separateIndex = httpStr.IndexOf(separateStr);
        if (separateIndex < 0)
            return false;

        // ヘッダ境界がある場合はヘッダ内の"Content-Length: "を探す
        const string headerStr = "Content-Length: ";
        var startIndex = httpStr.IndexOf(headerStr, StringComparison.OrdinalIgnoreCase);
        if (startIndex < 0)
            return false;

        // ヘッダ境界が"Content-Length: "以降であることをチェック
        if (separateIndex < startIndex)
        {
            // "Content-Length: "以降にヘッダ境界があれば差替え
            separateIndex = httpStr.IndexOf(separateStr, startIndex);
            if (separateIndex < 0)
                return false;
        }

        // "Content-Length: "がある場合は行末の改行コードを探す
        var endIndex = httpStr.IndexOf("\r\n", startIndex);
        if (endIndex < 0)
            return false;

        // Content-Length値＝メッセージボディのデータ長を抽出
        var valueLength = endIndex - startIndex - headerStr.Length;
        var valueStr = httpStr.Substring(startIndex + headerStr.Length, valueLength);
        if (!int.TryParse(valueStr, out var contentLength))
            return false;

        // 文字列のメッセージボディ長がContent-Length値より小さいかチェックする
        if (httpStr.Substring(separateIndex + separateStr.Length).Length < contentLength)
            return false;

        return true;
    }
}    
```
# HttpData.cs
HTTP受信データの評価とHTTP応答データの生成をしています。最低限の内容なので必要に応じて機能付加してください。
受信データのBody部はList\<string\> DataLinesに入れているので、後処理が必要ならそれを処理してください。
```C#:HttpData.cs
internal class HttpData
{
    // Headerエラーコード
    public const int CORRECT_HEADER = 0x0000;
    public const int ERR_HEADER_REQUEST = 0x0001;
    public const int ERR_HEADER_HOST = 0x0002;
    public const int ERR_HEADER_ACCEPT = 0x0003;
    public const int ERR_HEADER_AGENT = 0x0004;
    public const int ERR_HEADER_AUTH = 0x0005;
    public const int ERR_HEADER_LENGTH = 0x0006;
    public const int ERR_HEADER_TYPE = 0x0007;
    public const int ERR_HEADER_CONNECTION = 0x0008;

    // Received Request string
    public string recvStr { get; set; } = String.Empty;
    // Header
    public string Request { get; private set; } = String.Empty;
    public string Host { get; private set; } = String.Empty;
    public string Accept { get; private set; } = String.Empty;
    public string UserAgent { get; private set; } = String.Empty;
    public string Authorization { get; private set; } = String.Empty;
    public string ContentLength { get; private set; } = String.Empty;
    public string ContentType { get; private set; } = String.Empty;
    public string Connection { get; private set; } = String.Empty;
    public string Boundary { get; private set; } = String.Empty;
    // Body
    public List<string> DataLines { get; private set; }

    // Send Response string
    public string Reply { get; private set; } = String.Empty;
    public string Date { get; private set; } = String.Empty;
    public string Server { get; private set; } = String.Empty;

    public HttpData()
    {
        DataLines = new List<string>();
    }

    // HTTP受信文字列(recvStr)を解析してプロパティにセットする
    public void ParseHttpData()
    {
        // 改行コードを\r\nに統一する
        recvStr = recvStr.Replace("\r\n", "\n");
        recvStr = recvStr.Replace("\r", "\n");
        recvStr = recvStr.Replace("\n", "\r\n");

        // 改行コードで１行ごとに分離
        var recvLines = recvStr.Split(new[] { "\r\n" }, StringSplitOptions.None);
        var isWorkingInHeader = true;
        var isFindContentLength = false;
        var headerLines = new List<string>();
        var bodyLines = new List<string>();
        foreach (var line in recvLines)
        {
            // ヘッダ境界は"Content-Length: "以降にあるとする
            if (line.IndexOf("Content-Length: ", StringComparison.OrdinalIgnoreCase) >= 0)
                isFindContentLength = true;
            // 改行コードのみ行をヘッダとメッセージボディの境界とみなす
            // ※改行コードはSplitで削除されているため、string.Emptyになっている
            if (isFindContentLength && (line == string.Empty))
            {
                isWorkingInHeader = false;
                continue;
            }
            // データ振り分け
            if (isWorkingInHeader)
                headerLines.Add(line);
            else
                bodyLines.Add(line);
        }

         // セット前に初期化
         Request = string.Empty;
         Host = string.Empty;
         Accept = string.Empty;
         UserAgent = string.Empty;
         Authorization = string.Empty;
         ContentLength = string.Empty;
         ContentType = string.Empty;
         Connection = string.Empty;
         Boundary = string.Empty;

         // Header部プロパティにセット
         foreach (var line in headerLines)
         {
            // 最初の空白文字でHTTPヘッダ名とHTTPヘッダデータを分離する
            var blankIndex = line.IndexOf(" ");
            if (blankIndex < 0)
                continue;
            // 小文字で比較
            switch (line.Substring(0, blankIndex).ToLower())
            {
                case "post":
                    Request = line.Substring(blankIndex + 1);
                    break;

                case "host:":
                    Host = line.Substring(blankIndex + 1);
                    break;

                case "accept:":
                    Accept = line.Substring(blankIndex + 1);
                    break;

                case "user-agent:":
                    UserAgent = line.Substring(blankIndex + 1);
                    break;

                case "authorization:":
                    Authorization = line.Substring(blankIndex + 1);
                    break;

                case "content-length:":
                    ContentLength = line.Substring(blankIndex + 1);
                    break;

                case "content-type:":
                    ContentType = line.Substring(blankIndex + 1);
                    // multipart/form-data の場合に boundary文字列を抽出
                    if (ContentType.Contains("multipart/form-data;", StringComparison.Ordinal))
                    {
                        var BOUNDARY_KEYWORD = "boundary=";
                        var boundaryIndex = ContentType.IndexOf(BOUNDARY_KEYWORD);
                        if (boundaryIndex >= 0)
                            Boundary = ContentType.Substring(boundaryIndex + BOUNDARY_KEYWORD.Length);
                    }
                    break;

                case "connection:":
                    Connection = line.Substring(blankIndex + 1);
                    break;

                default:
                    break;
            }
        }

        // Body部プロパティにセット
        // boundary文字列が設定されている場合はBodyからデータブロック以外を除外
        // multipart/form-data のデータブロックは１つだけを前提とする（複数ブロック未対応）
        foreach (var line in bodyLines)
        {
            // 始めと終わりの境界を除外
            if (line.Contains(Boundary))
                continue;
            // データブロックのContentヘッダを除外
            if (line.Contains("Content-", StringComparison.OrdinalIgnoreCase))
                continue;
            // データブロックの境界(改行)を除外
            if (line == string.Empty)
                continue;
            // 上記以外をデータとみなす
            DataLines.Add(line);
        }
    }

    // HTTP受信ヘッダプロパティの正当性を評価する
    public int CheckRecvHeaderProperty()
    {
        if (!Request.Equals("/test/request.cgi HTTP/1.1", StringComparison.Ordinal))
            return ERR_HEADER_REQUEST;

        if (!Authorization.Equals("BASIC dXNlcmlkOnBhc3N3b3Jk", StringComparison.Ordinal))
            return ERR_HEADER_AUTH;

        return CORRECT_HEADER;
    }

    // HTTP応答データを生成する
    public string BuildHttpData(int result)
    {
        string responseMessage;
        switch (result)
        {
            case CORRECT_HEADER:
                responseMessage = "200 OK";
                break;

            case ERR_HEADER_REQUEST:
                responseMessage = "404 Not Found";
                break;

            case ERR_HEADER_AUTH:
                responseMessage = "401 Unauthorized";
                break;

            default:
                responseMessage = "403 Forbidden";
                break;
        }

        // body
        var bodyStr = new StringBuilder();
        bodyStr.Append("<html>\r\n");
        bodyStr.Append("<head>\r\n");
        bodyStr.Append("</head>\r\n");
        bodyStr.Append("<body>\r\n");
        bodyStr.Append(responseMessage + "\r\n");
        bodyStr.Append("</body>\r\n");
        bodyStr.Append("</html>\r\n");

        // Header
        Reply = "HTTP/1.1 " + responseMessage;
        Date = DateTime.Now.ToUniversalTime().ToString("r");
        Server = Path.GetFileNameWithoutExtension(Application.ExecutablePath) + "/" + FileVersionInfo.GetVersionInfo(Assembly.GetExecutingAssembly().Location).FileVersion;
        var httpStr = new StringBuilder();
        httpStr.Append(Reply + "\r\n");
        httpStr.Append("Date: " + Date + "\r\n");
        httpStr.Append("Server: " + Server + "\r\n");
        httpStr.Append("Content-Length: " + bodyStr.Length + "\r\n");
        httpStr.Append("Content-Type: text/plain\r\n");
        httpStr.Append("Connection: close\r\n");
        httpStr.Append("\r\n");
        httpStr.Append(bodyStr);

        return httpStr.ToString();
    }
}
```
# おわりに
クライアント同時接続数の制限はしていないので事切れるまで繋がると思いますが、所詮は検証用のテストアプリなので負荷かけるような使い方はどうなるか分かりません。もろもろ自己責任でお願いします。