同じコマンドがシェルからは接続でき、cron の下では死ぬので、トンネルか証明書が原因だと思うのは自然だ。信じる前に、バイナリも設定も資格情報も同一で、変わったのはプロセスの環境だけだという共通点を見てほしい。プログラムがそもそも見つからないなら、TLS のデバッグをいくらしても意味がない。修正はたいていパス、作業ディレクトリ、いくつかの環境変数にあり、VPN にはない。

動くシェルで command -v openvpn を実行すれば答えが表示され、それは多くの場合 /usr/sbin/openvpn で、cron はこのディレクトリを検索パスに入れていない。

トンネルは一度も始まっていないので、トンネルが問題ではない

cron が起動するジョブは最小限の環境、慣例的には PATH=/usr/bin:/bin を引き継ぎ、制御端末を持たない。/usr/sbin や /usr/local/sbin にインストールされたものはその一覧に単に存在せず、ログ行は TLS エラーではなく openvpn: not found と表示する。Python の subprocess は、インタプリタプロセスの環境を引き継ぐので cron よりは豊かかもしれないが、チルダを展開するシェルは依然としてないという形で失敗する。どちらの失敗も、パケットが一パケットもホストを出る前に起きている。

見分ける近道は、最後のエラーではなく最初のエラーを読むことだ。バイナリがなければシェルや Python の例外になり、設定が悪ければ OpenVPN の使い方のメッセージになり、本当のハンドシェイク失敗ならタイムスタンプとピアのアドレスが出る。三番目だけがトンネルの問題である。

絶対パスと引数リストはシェル文字列に勝る

解決済みのパスを明示的に渡し、シェルを完全に省く。subprocess.run(["/usr/sbin/openvpn", "--config", config, "--verb", "4"], capture_output=True, text=True, timeout=90) は戻りコード、stdout、stderr を返し、シェルにファイル名を解釈させない。設定のパスは os.environ["HOME"] か pathlib.Path.home() から組み立て、チルダを展開する必要をなくす。本当にシェルの構文が要るときは shlex.split を使うが、.split() で文字列を分割すると空白を含むパスで壊れるからだ。stderr も読み、OpenVPN が初期の失敗のほとんどをそこに書くことを忘れない。

同じ規則は cron にも当てはまり、ジョブ行はログインシェルではないのでプロファイルは読まれず、エイリアスも定義されない。

crontab が実際に引き継ぐもの

crontab のエントリは crontab が宣言した環境にいくつかの既定値が加わったものを引き継ぐので、パスを書き切るか、それをしてくれるスクリプトで呼び出しを包む。ファイル先頭の PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin のような行は通常のインストール先を網羅し、>> cron.log 2>&1 でのリダイレクトが次の失敗を読めるものにする。パスフレーズで保護されたプロファイルは、尋ねる端末が存在しないため cron の下で秘密を尋ねる手段がない。そのような接続は資格情報ファイルを読める systemd ユニットに属し、対話的を装うジョブに属さない。

相対パスが二つ目の罠だ。テスト実行はプロファイルのあるフォルダから行われることが多く、cron ジョブはまったく別の場所から始まるので、--config office.ovpn はそこにないファイルに解決され、メッセージはトンネル不在ではなくファイル不在として読める。

環境を削いで証明する

最小限の環境を直接再現し、env -i PATH=/usr/bin:/bin /usr/sbin/openvpn --config office.ovpn --verb 4 を実行する。その実行でパスや端末の不満が出るなら、問題は VPN より上にある。接続できるなら、残る容疑者は実行しているアカウント、開始ディレクトリ、提示している資格情報だ。どちらの結果でも、証明書の確認をもう一巡するよりましである。

次にラッパーを自己申告させる。到達した段階ごとに別の終了状態を返し、両方のストリームをタイムスタンプ付きで記録すれば、次の失敗がパス解決、認証、すでにトンネルが上がった後のどこで死んだかを教えてくれる。

ユニットには正しい順序も要り、Wants=network-online.target と After=network-online.target があればアドレスや既定ルートができる前にクライアントが起動せず、そうでなければ手動で再起動すると消える証明書や TLS のエラーとして現れる。

関連記事