Bootstrap の Modal を promisify して async/await で直列に書けるようにする

js から Bootstrap の Modal を表示してそのモーダル上での操作に応じてなにかしたいとき(典型的には確認ダイアログとか)、Modal を Promise として扱えるようにしておくと async/await で直列に書けて便利です。

const promisifyModal = (modal, param) =>{
    const $modal = $(modal);
    return new Promise((resolve) => {
        $modal
            .data('modal-param', param)
            .data('modal-result', null)
            .off('hidden.bs.modal')
            .one('hidden.bs.modal', () => {
                resolve($modal.data('modal-result'));
                $modal
                    .data('modal-param', null)
                    .data('modal-result', null)
            })
            .modal('show')
    });
};

モーダルを開く側は次のように await でモーダルが閉じるのを待ってその結果を得ることが出来ます。

$('#show-modal').on('click', async (ev) => {
    console.log('モーダルを表示します');
    try {
        // モーダルにわたすパラメータ
        const param = 'ほげほげ';
        // モーダルを表示して閉じるまで待つ
        const result = await promisifyModal('#modal', param);
        if (result != null) {
            // モーダルの結果でなにかする
            console.log(`モーダルの結果は ${result} です`);
        }
    } finally {
        // モーダルが閉じた後
        console.log('モーダルが閉じました');
    }
});

表示されるモーダルの側は次のような HTML と js です。モーダルの表示時は $modal.data('modal-param') でモーダルを開く側から渡されたパラメータが受け取れて、モーダルの結果は $modal.data('modal-result') に入れています。

<div class="modal fade" id="modal" tabindex="-1">
  <div class="modal-dialog">
    <div class="modal-content">
      <div class="modal-header">
        <h5 class="modal-title">モーダル</h5>
        <button type="button" class="close" data-dismiss="modal">
          <span>&times;</span>
        </button>
      </div>
      <div class="modal-body">
        <input type="text">
      </div>
      <div class="modal-footer">
        <button type="button" class="btn btn-primary" data-modal-ok>OK</button>
        <button type="button" class="btn btn-secondary" data-dismiss="modal">キャンセル</button>
      </div>
    </div>
  </div>
</div>
const $modal = $('#modal');

$modal.on('show.bs.modal', (ev) => {
    // モーダルに渡されたパラメータでなにかする
    const param = $modal.data('modal-param');
    $modal.find('input[type="text"]').val(param);
});

$modal.find('[data-modal-ok]').on('click', (ev) => {
    // モーダルの結果を設定して閉じる
    const result = $modal.find('input[type="text"]').val();
    $modal.data('modal-result', result);
    $modal.modal('hide');
});

MySQL の行ベースレプリケーションで multi delete するとレプリケーションが止まる件

MySQL で行ベースレプリケーションしていて、特定の操作を行ったときにレプリケーションがエラーで止まる事象が発生しました。

そのときの調査のログや対応の方法のメモ。

原因調査

まず、レプリケーションのスレーブでレプリケーションの状態を見ました。

mysql -e 'show slave status \G'

以下は抜粋です。

      Master_Log_File: binlog.000225
  Read_Master_Log_Pos: 42626469
Relay_Master_Log_File: binlog.000216
     Slave_IO_Running: Yes
    Slave_SQL_Running: No
  Exec_Master_Log_Pos: 66177326
       Last_SQL_Errno: 1032
       Last_SQL_Error: Could not execute Delete_rows event on table ore_db.ore_category; Can't find record in 'ore_category', Error_code: 1032; handler error HA_ERR_KEY_NOT_FOUND; the event's master log binlog.000216, end_log_pos 66178214

ore_category というテーブルの Delete_rows イベントを実行しようとしたところ HA_ERR_KEY_NOT_FOUND となったようです。要するに削除しようとしたレコードが存在しなかったということです。

また、スレーブのエラーログには下記が記録されていました(見やすくするために適当に改行を入れています)。

[ERROR] [MY-010584] [Repl] Slave SQL for channel '': Could not execute Delete_rows event on table ore_db.ore_category;
    Can't find record in 'ore_category', Error_code: 1032;
    handler error HA_ERR_KEY_NOT_FOUND; the event's master log binlog.000216, end_log_pos 66178214, Error_code: MY-001032
[Warning] [MY-010584] [Repl] Slave: Can't find record in 'ore_category' Error_code: MY-001032
[ERROR] [MY-010586] [Repl] Error running query, slave SQL thread aborted. Fix the problem, and restart the slave SQL thread with "SLAVE START".
    We stoppedat log 'binlog.000216' position 66177326

マスターのバイナリログ binlog.00021666178214 でエラーになったためレプリケーションが 66177326 で止まっている、とのことです。

この情報を頼りにバイナリログから原因のクエリを特定します。

mysqlbinlog binlog.000216 --base64-output=decode-rows -vv -j 66177326 --stop-position=66178214 > binlog.000216.sql

エラーでコケたトランザクションのクエリが表示されます。原因となるクエリは下記でした。

### DELETE FROM `ore_db`.`ore_list`
### WHERE
###   @1=1 /* LONGINT meta=0 nullable=1 is_null=0 */
###   @2='' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */
###   @3=900 /* SHORTINT meta=0 nullable=1 is_null=0 */
###   @4='2019-09-17 10:26:10' /* DATETIME(0) meta=0 nullable=1 is_null=0 */

### DELETE FROM `ore_db`.`ore_category`
### WHERE
###   @1=1 /* LONGINT meta=0 nullable=1 is_null=0 */
###   @2=1 /* LONGINT meta=0 nullable=1 is_null=0 */
###   @3='oreore' /* VARSTRING(1020) meta=1020 nullable=1 is_null=0 */
###   @4=NULL /* LONGINT meta=0 nullable=1 is_null=1 */

このスレーブの ore_category を見てみたところ ore_listore_category も上記のクエリに該当する行は存在していました。

ore_category には FOREIGN KEY (ore_list_id) REFERENCES ore_list (ore_list_id) ON DELETE CASCADE ON UPDATE CASCADE のような外部キー制約があって ore_list が削除されると ON DELETE CASCADE によって一緒に消えます。

どうやら、外部キー制約の ON DELETE CASCADE によって消えたはずの ore_category をさらに削除しようとしてエラーになったっぽいです。

適当なテーブルを作って試行錯誤してみたところ 外部キー制約の ON DELETE CASCADE によって削除された行はバイナリログに書かれない ことがわかりました。

つまりこのケースだと ore_category の削除がバイナリログに記録されるはずありません(ore_list の削除の時点で ore_category は消えてるので削除する方法がない(クエリを実行しても対象行がなければ行ベースのバイナリログには記録されない))。

これは MySQL のバグ臭い・・・と思って調べると下記が見つかりました。

multi-table DELETE

うーん Laravel の Eloquent 使っているのでそんなクエリは無いと思うのだけど・・・・

DELETE L, E, C, A
FROM ore_latest L
    LEFT JOIN ore_list E ON E.ore_list_id = L.ore_list_id
    LEFT JOIN ore_category C ON C.ore_list_id = E.ore_list_id
    LEFT JOIN ore_article A ON A.article_id = C.article_id
WHERE L.ore_latest_id = ?

普通にあったわ。記憶にあるような無いような・・・

復旧方法

MySQL でレプリケーションが止まったときの復旧方法といえば、かつては手作業でマスター・スレーブ間の整合をとって SET GLOBAL SQL_SLAVE_SKIP_COUNTER = 1 でステートメントをスキップする、とかでしたが、GTID レプリケーションしてたのでこの方法は使えません。

GTID レプリケーションの場合は原因となっている GTID を特定してスレーブでその GTID を空のトランザクションで上書きする、みたいな方法が使えるのですが、今回のケースだと ore_category の削除が誤ってバイナリログに書かれてしまっているだけなので HA_ERR_KEY_NOT_FOUND のエラーだけ無視してしまえば OK です。

slave_skip_errors パラメータを使えば特定のエラーだけ無視させることができるのですが、今回のケースなら slave_exec_modeIDEMPOTENT にしてしまえばこのエラーは無視できます。

ので、原因の multi-table DELETE を個別の DELETE になるように修正した上で、下記の手順でレプリケーションを復旧させました。

set global slave_exec_mode = 'IDEMPOTENT';
show variables like 'slave_exec_mode';
/* IDEMPOTENT になっている */

start slave;
show slave status \G
/* Read_Master_Log_Pos と Exec_Master_Log_Pos が一致することを確認 */
/* Slave_SQL_Running が Yes */
/* Last_Error は記録されていない */

set global slave_exec_mode = 'STRICT';
show variables like 'slave_exec_mode';
/* STRICT になっている */

さいごに

この MySQL のバグは Verified なのでまだ修正されていないもよう。とりあえず行ベースレプリケーションしてるなら multi-table DELETE は使ったらダメ。

あと slave_exec_mode = IDEMPOTENT はそれほど害はないと思うし NDB ストレージエンジン(MySQL Cluster)だとこっちがデフォルトらしいので、もう slave_exec_mode = IDEMPOTENT で運用しでもいいんじゃないかと思った。

Alpine Linux の php で CP932 な CSV をストリームフィルタを使って読む

<?php
$filter = urlencode('convert.iconv.cp932/utf-8');
$filename = urlencode('sjis.csv');
$fp = fopen("php://filter/read=$filter/resource=$filename", 'r');
// Warning: fopen(): unable to create or locate filter "convert.iconv.cp932/utf-8"
while ($csv = fgetcsv($fp)) {
    print_r($csv);
}

Alpine Linux の iconv だと cp932 がダメなようです。

shift_jis なら通りますが・・

<?php
$filter = urlencode('convert.iconv.shift_jis/utf-8');
$filename = urlencode('sjis.csv');
$fp = fopen("php://filter/read=$filter/resource=$filename", 'r');
while ($csv = fgetcsv($fp)) {
    // Warning: fgetcsv(): iconv stream filter ("shift_jis"=>"utf-8"): invalid multibyte sequence
    print_r($csv);
}

cp932 の CSV を読みたいのです。


それっぽいワードでぐぐるとと下記がでてきました。

https://github.com/docker-library/php/issues/240#issuecomment-305038173

次のようなワークアラウンドがあるようです。

RUN apk add --no-cache --repository http://dl-3.alpinelinux.org/alpine/edge/testing gnu-libiconv
ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so php

tinc で Window 10 -> CentOS 7 な VPN を作る

tinc は Windows 10 でも使えるので、Window 10 -> CentOS 7 な VPN を試してみました。Window 側の作業は基本的に管理者として実行された cmd.exe で行っています。

サーバの設定

前回作業した CentOS 7 -> CentOS 7 な VPN のサーバ設定は基本的にそのままです。

Windows の設定

tinc は Chocolatey でサクッと入れられます。

choco install tinc

C:\Program Files (x86)\tinc\my_vpn の中に以下のファイルを作成します。基本的に CentOS 7 でやったときと大差ないですが tinc.conf では Device ではなく Interface で、後で作成する仮想デバイスの名前を指定します。なぜか Device の場合はデバイスの CLSID を指定する必要がありました。

tinc.conf

Name = laptop
Interface = tinc
ConnectTo = server

hosts/server

サーバの /etc/tinc/my_vpn/hosts/server を貼り付けます。

hosts/laptop

Subnet = 192.168.255.3/32

次に、認証用の RSA 鍵を作成するために C:\Program Files (x86)\tinc で以下を実行します。

cd "C:\Program Files (x86)\tinc"
tincd -n my_vpn -K4096

C:\Program Files (x86)\tinc\my_vpn\hosts/laptop に公開鍵が追記されているので、サーバにコピーします。

type "my_vpn\hosts\laptop"

Windows で仮想アダプタを準備

Linux の TUN デバイスに代わる仮想アダプタが必要です。

C:\Program Files (x86)\tinc\tap-win64\addtap.bat を実行します。

cd "C:\Program Files (x86)\tinc\tap-win64"
addtap.bat

新たに作成されたインタフェースの名前とアドレスを設定します。

netsh interface ipv4 show interfaces
netsh interface set interface name = "イーサネット 5" newname = "tinc"
netsh interface ip set address "tinc" static 192.168.255.3 255.255.255.0

VPN の開始

サーバで tinc を開始します。

systemctl start tinc@my_vpn.service
systemctl status tinc@my_vpn.service
systemctl enable tinc@my_vpn.service

Windows で tincd をフォアグラウンドでお試し実行します。

cd "C:\Program Files (x86)\tinc"
tincd -n my_vpn -D -d3

問題無さそうなら Ctrl+C で止めてサービスとしてインストールして開始します。

cd "C:\Program Files (x86)\tinc"
tincd -n my_vpn

サービスの停止や開始は次のようにコマンドラインからできます(GUI でもできますが)

sc stop tinc.my_vpn
sc start tinc.my_vpn

相互に ping が通れば成功です。

ping -n 192.168.255.1
ping -n 192.168.255.3

サーバで NAT して VPN 経由で外に出る

サーバの設定は CentOS 7 -> CentOS 7 な VPN と特に変わりありません。

  • /etc/tinc/my_vpn/hosts/serverSubnet = 0.0.0.0/0 を追記
  • firewalld を入れて external にして 655 ポートを許可
  • /etc/tinc/my_vpn/tinc-up/etc/tinc/my_vpn/tinc-down を作る
  • 以上のことを行ってサービスをリスタートしておく

クライアント側は、まずサーバのホスト設定ファイルで Subnet = 0.0.0.0/0 を追加します。

cd "C:\Program Files (x86)\tinc"
notepad.exe "my_vpn\hosts\server"
Address = 203.0.113.100
Subnet = 192.168.255.0/24
Subnet = 0.0.0.0/0

-----BEGIN RSA PUBLIC KEY-----
...
-----END RSA PUBLIC KEY-----

サービスを再起動します。

sc stop tinc.my_vpn
sc start tinc.my_vpn

ルーティングを設定します。まずはサーバへのルートを現在のデフォルトルートに向けます。前回 CentOS -> CentOS でやったときはこれ忘れてました。サーバとクライアントが同じサブネットだったのでなくても大丈夫でしたけど。普通はそうじゃないので必要です。

203.0.113.100 がサーバのアドレスで、192.168.0.1 クライアントのデフォルトゲートウェイです。適宜読み替えが必要です。

route ADD 203.0.113.100/32 192.168.0.1

次にデフォルトルートを VPN のサーバ側アドレスに変えます。

route CHANGE 0.0.0.0/0 192.168.255.1

サーバで tcpdump とかして VPN 経由でクライアントからのトラフィックが流れているのを確認できれば成功です。

終わったら元に戻しておきます。

route CHANGE 0.0.0.0/0 192.168.0.1
route DELETE 203.0.113.100/32

接続時に自動でルーティング設定

いちおう↑の内容でバッチファイルを作ればサーバと接続したときに自動でルーティング設定とかはできますけど・・・

cd "C:\Program Files (x86)\tinc"
notepad.exe "my_vpn\hosts\server-up.bat"
route ADD 203.0.113.100/32 192.168.0.1
route CHANGE 0.0.0.0/0 192.168.255.1
notepad.exe "my_vpn\hosts\server-down.bat"
route CHANGE 0.0.0.0/0 192.168.0.1
route DELETE 203.0.113.100/32

んで、サービスをリスタート。

sc stop tinc.my_vpn
sc start tinc.my_vpn

デフォルトゲートウェイをベタ書きにするとラップトップとかで使い物にならないし、かといって Windows の route コマンドとバッチファイルでそのへんい感じにやるのはつらい。

デフォルトゲートウェイは変えずに VPN の先にあるプライベートなサブネットだけ設定するとかならまだ楽なんですけど・・・

route ADD 8.0.0.0/8 192.168.255.1
route DELETE 8.0.0.0/8

Windows からサービスやデバイスを削除

次のようにサービスやデバイスは削除できます。

sc stop tinc.my_vpn
sc delete tinc.my_vpn

cd "C:\Program Files (x86)\tinc\tap-win64"
deltapall.bat

tinc で CentOS 7 -> CentOS 7 な VPN を作る

tinc というものを使えば簡単に VPN ができるようなので試してみました。

クライアント/サーバ型で VPN 作ります。クライアント・サーバともに CentOS 7 です。VPN の端点は次の通り IP アドレスにします。

  • Server: 192.169.255.1
  • Client: 192.169.255.2

tinc はホストが元々持っている NIC とは別に TUN デバイスを作成してその TUN 同士を接続します。TUN というのは L3 をエミュレーションする仮想デバイスです。OpenVPN や OpenSSL で作る VPN もだいたい同じ仕組みなので Linux でソフトウェア VPN といえば TUN なのかもしれません。なお TAP という L2 をエミュレーションする仮想デバイスもあるのですが今回は使いません。

インストール

tinc は CentOS 7 なら epel からインストールできます。

yum install -y epel-release
yum install -y tinc

以下のファイルが含まれていました。

rpm -ql tinc
# /usr/lib/systemd/system/tinc.service
# /usr/lib/systemd/system/tinc@.service
# /usr/sbin/tincd
# /usr/share/doc/tinc-1.0.35
# /usr/share/doc/tinc-1.0.35/AUTHORS
# /usr/share/doc/tinc-1.0.35/COPYING.README
# /usr/share/doc/tinc-1.0.35/NEWS
# /usr/share/doc/tinc-1.0.35/README
# /usr/share/doc/tinc-1.0.35/THANKS
# /usr/share/doc/tinc-1.0.35/sample-config
# /usr/share/doc/tinc-1.0.35/sample-config/hosts
# /usr/share/doc/tinc-1.0.35/sample-config/hosts/alpha
# /usr/share/doc/tinc-1.0.35/sample-config/hosts/beta
# /usr/share/doc/tinc-1.0.35/sample-config/rsa_key.priv
# /usr/share/doc/tinc-1.0.35/sample-config/tinc-down
# /usr/share/doc/tinc-1.0.35/sample-config/tinc-up
# /usr/share/doc/tinc-1.0.35/sample-config/tinc.conf
# /usr/share/doc/tinc-1.0.35/texinfo.tex
# /usr/share/info/tinc.info.gz
# /usr/share/licenses/tinc-1.0.35
# /usr/share/licenses/tinc-1.0.35/COPYING
# /usr/share/man/man5/tinc.conf.5.gz
# /usr/share/man/man8/tincd.8.gz

サーバの設定

まずはサーバを設定します。

作成する VPN に関係するファイルを配置するディレクトリを作成します。このディレクトリの名前は最後に行う tuncd-n オプションの名前になります(systemd のテンプレートユニットのパラメータ部分でもあります)。

mkdir -p /etc/tinc/my_vpn/hosts

tinc が開始したときと終了したときに実行されるスクリプトを作成します。このスクリプトで TUN デバイスのアクティブ化や IP アドレスの設定を行います。tinc が開始する前は TUN デバイスがまだ無いので開始時に行う必要があります。

cat <<'EOS'> /etc/tinc/my_vpn/tinc-up
#!/bin/sh
ip link set $INTERFACE up
ip addr add 192.168.255.1/32 dev $INTERFACE
ip route add 192.168.255.0/24 dev $INTERFACE
EOS

cat <<'EOS'> /etc/tinc/my_vpn/tinc-down
#!/bin/sh
ip route del 192.168.255.0/24 dev $INTERFACE
ip addr del 192.168.255.1/32 dev $INTERFACE
ip link set $INTERFACE down
EOS

chmod +x /etc/tinc/my_vpn/tinc-up
chmod +x /etc/tinc/my_vpn/tinc-down

tinc の設定ファイルを作ります。Name/etc/tinc/my_vpn/hosts/ に作成するファイル名と一致する必要があります。Device には TUN デバイスを指定します。/dev/net/tun とすれば良いようです。

cat <<'EOS'> /etc/tinc/my_vpn/tinc.conf
Name = server
Device = /dev/net/tun
EOS

次にこのサーバのホスト設定ファイルを作成します。前述の通り↑の Name と一致している必要があります。Address にはクライアントから見たサーバの IP アドレスを、Subnet はこの VPN 上でのネットワークアドレスを指定します。

cat <<EOS> /etc/tinc/my_vpn/hosts/server
Address = 203.0.113.100
Subnet = 192.168.255.0/24
EOS

サーバの認証用の RSA 鍵を作成します。

tincd -n my_vpn -K4096

↑のコマンドでサーバのホスト設定ファイルに公開鍵が追記されます。

cat /etc/tinc/my_vpn/hosts/server

クライアントの設定

次にクライアントを設定します。ディレクトリや開始・終了のスクリプトの作成はサーバと同じです。ただし、スクリプトにはクライアント側の IP アドレスを記載する必要があります。

mkdir -p /etc/tinc/my_vpn/hosts

cat <<'EOS'> /etc/tinc/my_vpn/tinc-up
#!/bin/sh
ip link set $INTERFACE up
ip addr add 192.168.255.2/32 dev $INTERFACE
ip route add 192.168.255.0/24 dev $INTERFACE
EOS

cat <<'EOS'> /etc/tinc/my_vpn/tinc-down
#!/bin/sh
ip route del 192.168.255.0/24 dev $INTERFACE
ip addr del 192.168.255.2/32 dev $INTERFACE
ip link set $INTERFACE down
EOS

chmod +x /etc/tinc/my_vpn/tinc-up
chmod +x /etc/tinc/my_vpn/tinc-down

tinc の設定ファイルを作ります。クライアントは ConnectTo で接続するサーバ名を指定します。この名前も /etc/tinc/my_vpn/hosts/ の中のファイル名に一致する必要があります。その他はサーバと特に変わりありません。

cat <<'EOS'> /etc/tinc/my_vpn/tinc.conf
Name = client
Device = /dev/net/tun
ConnectTo = server
EOS

クライアントのホスト設定ファイルを作成します。クライアントは Address は不要です。Subnet には VPN 接続上のこのホストの IP アドレスを指定します。

cat <<EOS> /etc/tinc/my_vpn/hosts/client
Subnet = 192.168.255.2/32
EOS

認証用の RSA 鍵を作成します。

tincd -n my_vpn -K4096

↑のコマンドでクライアントのホスト設定ファイルに公開鍵が追記されます。

cat /etc/tinc/my_vpn/hosts/client

ホストの設定ファイルの交換

何らかの方法でサーバとクライアントのホスト設定ファイルをコピーします。

  • サーバの /etc/tinc/my_vpn/hosts/server をクライアントの同ディレクトリにコピー
  • クライアントの /etc/tinc/my_vpn/hosts/client をサーバの同ディレクトリにコピー

クライアント・サーバともに次のようなディレクトリ構成になります。

/etc/tinc/my_vpn/
├── hosts
│   ├── client
│   └── server
├── rsa_key.priv
├── tinc.conf
├── tinc-down
└── tinc-up

VPN の開始

クライアントとサーバの両方で tincd をお試しで開始します。

tincd -n my_vpn -D -d3

起動に成功して接続も問題なさそうなら殺します。

pkill tincd

systemd でサービスとして実行します。

systemctl start tinc@my_vpn.service
systemctl status tinc@my_vpn.service
systemctl enable tinc@my_vpn.service

相互に ping が通れば成功です。

ping -n 192.168.255.1
ping -n 192.168.255.2

サーバで NAT して VPN 経由で外に出る

これだけだとサーバとクライアントが P2P で繋がっているだけなので、クライアントから VPN を通ってサーバを経由して外に出られるようにしてみます。

まず、サーバのホスト設定ファイルで Subnet = 0.0.0.0/0 を追加します。これはクライアントとサーバの両方で行います。

vim /etc/tinc/my_vpn/hosts/server
Address = 203.0.113.100
Subnet = 192.168.255.0/24
Subnet = 0.0.0.0/0

-----BEGIN RSA PUBLIC KEY-----
...
-----END RSA PUBLIC KEY-----

サーバに NAT のために firewalld を入れます。

yum install -y firewalld
systemctl start firewalld
systemctl status firewalld
systemctl enable firewalld

サーバの I/F を external にして 655 ポートを許可します。 なお、external はデフォでマスカレードが有効なので VPN 経由でこのポートから出るときに NAT されるようになります。

firewall-cmd --zone=external --add-interface=eth0
firewall-cmd --zone=external --add-port=655/tcp
firewall-cmd --zone=external --add-port=655/udp
firewall-cmd --runtime-to-permanent

サーバで VPN が開始したときに VPN の I/F が trusted ゾーンになるようにします。

cat <<'EOS'> /etc/tinc/my_vpn/tinc-up
#!/bin/sh
ip link set $INTERFACE up
ip addr add 192.168.255.1/32 dev $INTERFACE
ip route add 192.168.255.0/24 dev $INTERFACE
firewall-cmd --zone=trusted --add-interface=$INTERFACE
EOS

cat <<'EOS'> /etc/tinc/my_vpn/tinc-down
#!/bin/sh
firewall-cmd --zone=trusted --remove-interface=$INTERFACE
ip route del 192.168.255.0/24 dev $INTERFACE
ip addr del 192.168.255.1/32 dev $INTERFACE
ip link set $INTERFACE down
EOS

chmod +x /etc/tinc/my_vpn/tinc-up
chmod +x /etc/tinc/my_vpn/tinc-down

次にクライアント側で VPN に接続したときにデフォルトゲートウェイが VPN を向くようにします。

cat <<'EOS'>> /etc/tinc/my_vpn/hosts/server-up
#!/bin/sh
ip route save match 0.0.0.0/0 > /etc/tinc/my_vpn/route.dump
ip route replace default via 192.168.255.1
EOS

cat <<'EOS'>> /etc/tinc/my_vpn/hosts/server-down
#!/bin/sh
ip route flush match 0.0.0.0/0
ip route restore < /etc/tinc/my_vpn/route.dump
EOS

chmod +x /etc/tinc/my_vpn/hosts/server-up
chmod +x /etc/tinc/my_vpn/hosts/server-down

クライアントとサーバの両方で tinc デーモンをリスタートします。

systemctl restart tinc@my_vpn.service

クライアントでルーティングが設定されていることを確認します。

ip route list match 0.0.0.0/0
#=> default via 192.168.255.1 dev my_vpn

サーバで firewlld が設定されていることを確認します。

firewall-cmd --get-active-zone
#=> external
#=>   interfaces: eth0
#=> trusted
#=>   interfaces: my_vpn

サーバで tcpdump で VPN を監視して

tcpdump -nn -i my_vpn

クライアントで適当に外部にアクセスして反応があれば OK です。

curl httpbin.org/get

あ、よく考えたら今回はクライアントとサーバが同じサブネットにあったのでガスっとデフォルトゲートウェイ変更しましたけど普通はそうじゃないのでクライアントのルーティングテーブルでサーバへの経路を確保しておく必要がありますね。

さいごに

OpenVPN と比べると ipfirewall-cmd で自前でネットワーク関係の構成を設定する必要があるぶん tinc 自体はシンプルですかね(OpenVPN はもっとサクッと NAT とかさせられた気がする? うろ覚え)

また、以下のように Windows でも動きそうです(未確認)(OpenVPN も Windows で動くだろうけど)

そのうち Windows でも試してみようと思います。

参考にしたサイトとか

Cloudflare DNS + Docker + Let's Encrypt + Nginx で TLS->TLS なプロキシを作る

要するに次のことを行います。

  • Nginx で TLS->TLS なプロキシを作る
    • TLS は Nginx でいったん切る
  • 証明書は Let's Encrypt で取得する
    • Nginx は http はリッスンさせないので DNS 認証を使う
  • ドメインは Cloudflare で管理している
    • ゾーンの委任やレコードの登録は既に終わっている前提

次のような docker-compose.yml を用意します。

version: "3.7"

services:
  nginx:
    image: nginx:alpine
    ports:
      - 9999:9999
    pid: service:letsencrypt
    volumes:
      - letsencrypt:/etc/letsencrypt/
      - run:/run/
    environment:
      NGINX_CONFIG: |
        daemon off;
        worker_processes auto;
        error_log /dev/stderr notice;
        pid /run/nginx.pid;
        events {
            worker_connections 32;
        }
        stream {
            error_log /dev/stderr info;
            upstream backend {
                server 192.0.2.123:9999;
            }
            server {
                listen 9999 ssl;
                proxy_pass backend;
                proxy_ssl on;
                ssl_certificate /etc/letsencrypt/live/ore.example.com/fullchain.pem;
                ssl_certificate_key /etc/letsencrypt/live/ore.example.com/privkey.pem;
                ssl_session_timeout 1d;
                ssl_session_cache shared:MozSSL:10m;
                ssl_session_tickets off;
                ssl_protocols TLSv1.3;
                ssl_prefer_server_ciphers off;
            }
        }
    command:
      - sh
      - -euc
      - |
        echo "$$NGINX_CONFIG" > /root/nginx.conf
        exec nginx -c /root/nginx.conf

  letsencrypt:
    image: certbot/dns-cloudflare
    volumes:
      - letsencrypt:/etc/letsencrypt/
      - run:/run/
    environment:
      DNS_CLOUDFLARE_EMAIL: ~
      DNS_CLOUDFLARE_API_KEY: ~
    entrypoint:
      - sh
      - -euc
      - |
        mkdir -p /root/.cloudflare/
        > /root/.cloudflare/credentials
        chmod 700 /root/.cloudflare/
        chmod 600 /root/.cloudflare/credentials
        echo dns_cloudflare_email   = "$$DNS_CLOUDFLARE_EMAIL"   >> /root/.cloudflare/credentials
        echo dns_cloudflare_api_key = "$$DNS_CLOUDFLARE_API_KEY" >> /root/.cloudflare/credentials
        exec "$$@"
      - --
    command:
      - sh
      - -euc
      - |
        while :; do
          certbot renew -n --agree-tos --keep --post-hook='kill -HUP "$$(cat /run/nginx.pid)"'
          sleep 86400
        done

volumes:
  letsencrypt:
  run:

要点を記載します。

  • nginx で TLS->TLS なプロキシをします
    • stream の listen の ssl と proxy_ssl
  • サーバ証明書のドメイン認証には DNS 認証を使います
    • DNS は CloudFlare に委任しており certbot の dns-cloudflare で DNS 認証できます
  • 証明書更新時に nginx に HUP を撃つ必要があるので・・・
    • pid: service:letsencrypt で PID 名前空間を共用します
    • nginx の pid ファイルのディレクトリをボリュームで共有します
  • nginx.conf は環境変数で渡して実行時にファイルに書き出し
    • Docker Remote API でデプロイする想定なのでホストのファイルのマウントはやりたくない
  • CloudFlare のAPIキーも環境変数で渡します
    • ENTRYPOINT でファイルに書き込みます
    • docker-compose run したときでも使えるように

まずは次のように実行して証明書を取得します。

docker-compose run --rm letsencrypt \
  certbot certonly -n --agree-tos \
    --test-cert \
    --dns-cloudflare \
    --dns-cloudflare-credentials /root/.cloudflare/credentials \
    --dns-cloudflare-propagation-seconds 60 \
    -m 'ore@example.com' \
    -d 'ore.example.com'

サービスを開始します。

docker-compose up -d
docker-compose ps
docker-compose logs -f

動作確認します。

openssl s_client -connect ore.example.com:9999 </dev/null | openssl x509 -noout -text

さいごに

とあるニッチな理由で TLS->TLS なプロキシが欲しくてやったのだけど、よく考えると使わなさそうでお蔵入りしそうなので備忘的にポスト。

curl で相手が突然死すると無限に待ってシグナルでも死なない件

なんで今さら ZF1 やねんという話ですが、Zend_Http_Client_Adapter_Curltimeout は接続のタイムアウトなので、Read/Write も含めたタイムアウトである request_timeout が未指定だと接続後に相手が突然死すると無限に待ちます。

ネットワーク経路のステートフルなファイアウォールとかが RST なりなんなり返すならそうでもないと思いますけど、そういうのが中間に入っていなくてかつサーバが RST も返すことなく突然死したりあるいは経路上のネットワークがぶつ切りになったりしたときなどです。

https://github.com/zendframework/zf1/blob/release-1.12.20/library/Zend/Http/Client/Adapter/Curl.php#L224-L242

curl のオプションとは次のように対応しています。

  • timeout -> CURLOPT_CONNECTTIMEOUT
  • request_timeout -> CURLOPT_TIMEOUT

とあるシステムでこれが原因で PHP のプロセスがいつまでも終わらずにいつまでも実行し続けてしまう問題が発生しました。しかたないので TERM シグナルで殺そうとしたところ・・死なない。

実はそのプロセスは簡易なデーモンになっていて TERM シグナルをハンドリングしていたんですね。 PHP の pcntl_signal で指定するハンドラは本当のシグナルハンドラとして実行されるわけではないので curl の中で止まっているとシグナルを撃っても PHP のシグナルハンドラは呼ばれません。

PHP のシグナル周りは これ とか これ とか が詳しいです。

ただ、I/O 待ちでシグナルを受ければ curl も EINTR とかで中断されてエラーになって PHP まで戻ってきそうな気がしますが・・・

うーん?

https://github.com/curl/curl/blob/curl-7_64_0/lib/select.c#L211-L230

  if(error && ERROR_NOT_EINTR(error))
    break;

https://github.com/curl/curl/blob/curl-7_64_0/lib/select.c#L57

#define ERROR_NOT_EINTR(error) (Curl_ack_eintr || error != EINTR)

https://github.com/curl/curl/blob/curl-7_64_0/lib/easy.c#L266-L267

  if(flags & CURL_GLOBAL_ACK_EINTR)
    Curl_ack_eintr = 1;

PHP からこのフラグを指定する術は無さそうです。ので、PHP で curl がネットワーク I/O 待ちになったとき、シグナルハンドラを仕込んでいるとそのシグナルでは死ななくなります。

下記のようなコードで確認できます。シグナルが撃たれてもリクエストが完了するまで待たされます。

<?php
pcntl_async_signals(true);

$curl = curl_init();
curl_setopt_array($curl, [
    CURLOPT_URL => 'http://httpbin.org/delay/10',
]);

pcntl_signal(SIGTERM, function ($signo) {
    var_dump($signo);
});

$mypid = getmypid();
putenv("mypid=$mypid");
system('(sleep 1 && kill -- "$mypid") </dev/null >/dev/null 2>&1 &');
curl_exec($curl);

なお、シグナルハンドラをコメントアウトすればシグナルで死ぬようになります。curl が EINTR をどう扱っているかにせよ PHP からシグナルハンドラを設定していないのならシグナルでプロセスが死ぬからです。

さいごに

curl あるいは curl を使用する HTTP クライアントライブラリを使うときは CURLOPT_CONNECTTIMEOUT だけでなく CURLOPT_TIMEOUT も指定しないと意図せず無限に待ち続けてしまうことがあるかもしれないので注意。

ただ、curl を使う PHP の HTTP クライアントライブラリは大抵は普通にタイムアウトを設定すれば CURLOPT_TIMEOUT が設定されるようです。

CURLOPT_CONNECTTIMEOUT を指定するのは connect_timeout とか(Guzzle)、connecttimeout とか(ZF2)、いかにもそれっぽい名前になっていることが多いようです。