2019年4月28日日曜日

ffplay+ffmpegでその場で二つの動画の右半分と左半分を同時に再生する方法

この記事では、エンコード前の動画とエンコード済みの動画の画質の差や、同一の動画を違う形式でエンコードしたときの画質の差を、それぞれの動画を単純に左右に並べるのではなく、動画を右半分と左半分に分割して比較したい場合を想定しています。
ほら、テレビショッピングとかでよくあるじゃないですか。
同じ人の顔を画面上で半分に割って、左半分はナントカクリームを塗らなかった場合、右半分はナントカクリームでお肌ツヤツヤってやつを見せるアレ。
ああいうイメージです。

さて、画質の差は結局のところ数値化などは意味がなくて、見ている本人が納得できるかできないか、という点に尽きると思います。

まず、最も簡単な比較方法は動画プレイヤーを二つ並べて再生することです。
が、同一シーンを同時比較するためには、神業のような再生ボタン押下処理が必要になります。神ならぬ身の上、凡人にはとてもできません。

また、もしできたとしても、元画像が1440x1080だったりFHDだったりした場合、素直に横に並べると大きすぎて位置合わせが難しかったりします。だからと言って普段見るはずのサイズでチェックしないと検査になってないので、縮小等リサイズして比較するのは避けたいところです。

ということで、表題の件のように、二つの動画の左半分と右半分を同時に再生して、すぐに官能検査してみようというのが今回の目論見です。

これを行うのに必要なプログラムはffmpegとffplayだけです。
ffplayはvlc playerでも代替できます。

まず結論として、コマンドライン一式をWindowsのバッチファイルとして作成すると以下になります。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
@echo off
setlocal
set SRC1=%1
set SRC2=%2
 
if not defined SRC1 goto invalidargument
 
REM remove doublequotation
set SRC1=%~1
 
if "%SRC1%"=="/?" goto help
if /i "%SRC1%" equ "/h" goto help
if /i "%SRC1%" equ "/help" goto help
if "%SRC1%"=="-?" goto help
if /i "%SRC1%" equ "-h" goto help
if /i "%SRC1%" equ "-help" goto help
 
if not defined SRC2 goto invalidargument
 
REM remove doublequotation
set SRC2=%~2
 
if not exist "%SRC1%" (
  echo エラー: %SRC1%がありません。
  goto help
)
if not exist "%SRC2%" (
  echo エラー: %SRC2%がありません。
  goto help
)
 
REM オーバーレイ文字列用ファイル名.拡張子抽出
set SRC1FNAME=%~nx1
set SRC2FNAME=%~nx2
REM 念のためアポストロフィを除去
set SRC1FNAME=%SRC1FNAME:'=%
set SRC2FNAME=%SRC2FNAME:'=%
 
REM 時間指定パラメタの記述場所判定用拡張子抽出
set SRC1EXT=%~x1
set SRC2EXT=%~x2
 
set STARTTIME=%~3
set ENDTIME=%~4
if defined STARTTIME ( set STARTTIME= -ss %STARTTIME% )
if defined ENDTIME ( set ENDTIME= -to %ENDTIME% )
 
set FFMPEG=ffmpeg.exe
set FFPLAY=ffplay.exe
set FFPROBE=ffprobe.exe
 
REM ぢでじ用パラメータ
set ASPECT=16:9
set MOVIE_WIDTH=1440
set MOVIE_HEIGHT=1080
set FPS=29.97
set CENTERLINE_WIDTH=2
 
if exist "%FFPROBE%" call :trygetparams
 
REM ファイル名表示用フォントファイル(要エスケープ: コロン及び¥)
set FONT=C\\:/WINDOWS/fonts/meiryob.ttc
REM 長いファイル名対策のため左右にテキストを流す
set DRAWTEXT_FNAME_OPT=fontfile=%FONT%:fontsize=24:fontcolor=darkgreen:shadowx=1:shadowy=1:x=w-mod(n*4\,w+tw):y=h-th-24:
REM 再生中動画のPTSとフレーム番号
set DRAWTEXT_PTS_OPT=fontfile=%FONT%:fontsize=12:fontcolor=white:shadowx=1:shadowy=1:text= pts\\:%%{pts\\:hms} %%{pts\\:flt} frame\\:%%{frame_num}
 
 
REM 再生開始及び終了時間指定
set DURATION1= %STARTTIME% %ENDTIME%
set DURATION2= %DURATION1%
REM (ffmpegではmpeg2tsは前置指定での時間指定不可なので引数の位置を後置する)
set DURATION_POSITION=PRE
if /i "%SRC1EXT%" equ ".ts" set DURATION_POSITION=POST
if /i "%SRC2EXT%" equ ".ts" set DURATION_POSITION=POST
if %DURATION_POSITION%==POST (
  set DURATION_POST=%DURATION1%
  set DURATION1=
  set DURATION2=
  echo  *警告: mpeg2tsのため指定時刻への頭出しに馬鹿みたいに時間がかかります*
  pause
)
 
set /A HALF_WIDTH=%MOVIE_WIDTH%/2
set /A RIGHT_HALF_X=%HALF_WIDTH%+%CENTERLINE_WIDTH%
 
set FILTER_OPT=^
    nullsrc=size=%MOVIE_WIDTH%x%MOVIE_HEIGHT%,fps=%FPS% [canvas]; ^
    [0:v] crop=%HALF_WIDTH%:%MOVIE_HEIGHT%:0:0,fps=%FPS%, ^
          drawtext=%DRAWTEXT_PTS_OPT% , ^
          drawtext=%DRAWTEXT_FNAME_OPT%:text=%SRC1FNAME% [left]; ^
    [1:v] crop=%HALF_WIDTH%:%MOVIE_HEIGHT%:%HALF_WIDTH%:0,fps=%FPS%,^
          drawtext=%DRAWTEXT_PTS_OPT% , ^
          drawtext=%DRAWTEXT_FNAME_OPT%:text=%SRC2FNAME% [right]; ^
    [canvas][left] overlay=x=0:shortest=1 [lefthalf]; ^
    [lefthalf][right] overlay=x=%RIGHT_HALF_X%:shortest=1
 
%FFMPEG% %DURATION1% -i "%SRC1%" %DURATION2% -i "%SRC2%" ^
 -filter_complex "%FILTER_OPT%" -f nut ^
 -aspect %ASPECT% -c:a copy -c:v rawvideo %DURATION_POST% pipe:1 ^
 | %FFPLAY% -i pipe:0 -fflags nobuffer
REM | %FFPLAY% -i pipe:0 -fflags nobuffer
REM VLCの場合は、「 | vlc.exe -」
goto end
 
:trygetparams
REM ffprobeの出力からfilter_complexの各値の取得を試みる
set FFPROBEOPT=%FFPROBE% -i "%SRC1%" -of csv -show_streams -hide_banner
REM ffprobeの結果をcsv形式で受け取り、カンマで分割した10,11,16,28の要素を取得
REM ※ここで直接リダイレクト先とパイプを指定しないとうまくいかないようだ
REM ※おまけにexeのパス名にスペースが入っていてもまともに動かないと来た。
for /f "usebackq tokens=10,11,16,28 delims=," %%a in (`%FFPROBEOPT% 2^>NUL ^| findstr video`) do (
  set MOVIE_WIDTH=%%a
  set MOVIE_HEIGHT=%%b
  set ASPECT=%%c
  set FPS=%%d
)
 
echo   ffprobe結果;
echo     size=%MOVIE_WIDTH%x%MOVIE_HEIGHT%
echo     aspect=%ASPECT%
echo     fps=%FPS%
pause
exit /b
 
:invalidargument
echo エラー: 引数が不足しています
:help
echo 使い方: %0 左半分を表示する動画 右半分を表示する動画 [ 再生開始時刻 [ 再生終了時刻 ] ]
echo         再生開始・終了時刻書式 HH:MM:SS または 秒
:end
endlocal
上記の内容を"左右2分割.bat"とでも名前を付けて保存して実行してください。使い方が表示されます。
ffmpegの特徴は複雑なことができる代償として呪文のような意味不明なコマンドラインを構築しなければならないところですが、今回も例にもれず一見意味不明な呪文が並びます。
おまけに当方の環境の都合で(サーバにはAV機能が皆無なので)Windows用のバッチとしたため、シェルスクリプトに比べて極端にひねくれています。
この両者の強力な組み合わせによって、上記のバッチは心の底からうんざりさせてくれるに十二分の威力を備えています。

さて、バッチの書式とか細かいことは放っておいて、重要なのは画像をそれぞれ左半分だけ、右半分だけ、それぞれ切り出して一枚の画像にすることですが、それをffmpegの -filter_complexオプションで行います。
このパラメータはffmpegの中でも極端に面倒くさいので、冗長になることを恐れずに説明したいと思います。

なお、行末の^(サーカムフレックス)はWindowsのcmd.exeで1行で記述しなければならない行を複数行に分割する記号ですので無視してください。
  1. 88行目のnullsrc=ですが、ffmpegが予め用意している多くの入力ソースの中の一つです。ここではこの上に2枚の動画を重ねてゆくベース動画として利用します。
    パラメータはsizeとfpsを指定しています。
    このバッチではsizeは最終的に出力される動画サイズ(1440x1080)、fpsは29.97が指定されています。これらの値はエンコード元が地デジのtsと仮定しています。ことさらにfpsを指定しているのはffmpegは無指定だと25fpsとして処理するからです。
    そして、このnullsrcから入力して加工(sizeとfps)した動画に対して[canvas]と名付けました。これの名づけルールは自由です。
    ここまででベース動画に対する定義は終わりました。続きがあるためセミコロンをつけます。
  2. 89行目の[0:v]ですが、これは1つ目の入力ソース(動画ファイル)コンテナ中で最初に見つかった動画を表しています。これを名付けたのはffmpegです。これに対してcropとfpsとdrawtextの指定をしています。
    cropは切り抜き範囲を指定します。このパラメータはffmpegのバージョンによって解釈される意味合いが変わるというとんでもない代物なので要注意です。
    バージョン20190426-f857753では切り抜き幅:同高さ:元動画に対する切り抜き開始位置X:同Yの順で解釈されます。
    上記のバッチでは720:1080:0:0として左半分を切り抜いています。言い換えると、1つ目の入力ソースの座標(左から0,上から0)から(幅720,高さ1080)だけ切り抜け、という意味合いになります。
    drawtextパラメータはどちらのファイルを切り抜いたかわかるようにファイル名を動画にオーバーレイするためのものです。
    以上を[left]と名付けました。これも名づけルールは自由です。続きがあるためセミコロンをつけます。
  3. 92行目の[1:v]ですが、これは2つ目の入力ソース(動画ファイル)中で最初に見つかった動画を表しています。これを名付けたのはffmpegです。これに対してもcropとfpsとdrawtextの指定をしています。
    異なるのは切り抜き位置で、上記のバッチでは720:1080:720:0を指定して右半分を切り抜いています。2つ目の入力ソースの座標(左から720,上から0)から(幅720,高さ1080)だけ切り抜けという意味合いになります。
    以上を[right]と名付けました。これも名づけルールは自由です。続きがあるためセミコロンをつけます。
  4. 95行目の[canvas][left]ですが、これは私がつけた名前です。この二つを重ね合わせるため、overlay=x=0:shortest=1というパラメータを指定しています。重ねる順番は左から順に重なります。この例では[canvas]の上に[left]が重なります。
    x=0は[left]を[canvas]上のどこに重ねるのかを指定しています。この場合は重ね合わせのx座標を0、つまり一番左としています。本来はこの記述は不要ですが、次の手順で右側に重ね合わせる際の座標指定との対比を分かりやすくするために付与しています。
    shortest=1はどちらかの動画の再生が終わったら処理を完了するというおまじないです。[canvas]の動画はnullsrc由来でdurationを指定していないので無限に続きますので、[left]の再生が終わったら完了するという意味になります。
    以上を[lefthalf]と名付けました。これも名づけルールは自由です。続きがあるためセミコロンをつけます。
  5. 96行目の[lefthalf][right]ですが、これは私がつけた名前です。この二つを重ね合わせるため、 overlay=x=%RIGHT_HALF_X%:shortest=1というパラメータを指定しています。重ねる順番は左から順に重なります。この例では[lefthalf]の上に[right]が重なります。
    x座標に指定している %RIGHT_HALF_X% の値は722ですので、2ピクセル分(本バッチの環境変数"CENTERLINE_WIDTH=2"として定義しています)だけ右にずれて重ね合わされます。これによって、隙間に[canvas]の地の動画が出ます(緑単色)。これは、左右の動画がどこで分割されているのかを分かりやすくするためにあえて行っています。
    以上でベース動画に右半分と左半分をオーバーレイ出来ましたので続きはありませんのでセミコロンをつけません
    なお、ここでセミコロンをつけますとffmpeg様に激烈に叱られた挙句ABENDしてくださいます。
というようなことをやった挙句、ようやく--filter_complex=オプションにダブルクォーテーションで囲んで指定します。

ここまでで2つの入力ソース(動画ファイル)が合成されているので、これをどこかに吐き出さないと人間様には見えません。
今回はいったんファイル化するのではなく、いきなり再生してしまおうというのが趣旨ですので、ffmpegにもれなくついてくるffplayで直接再生させたいと思います。

そのためのオプションは
-f nut -aspect %ASPECT% -c:a copy -c:v rawvideo pipe:1 | %FFPLAY% -i pipe:0 -fflags nobuffer
となります。

出力フォーマットにnut, ソースのサイズは1440x1080なのでぢでじのアスペクト比16:9を明示的に指定して、音声は再変換せずコピーして動画のコーデックをrawvideoとしてパイプ経由でffplayに出力しています。
vlc playerで再生したい場合は、パイプでつなぐアプリをffplayからvlc -(ハイフン)に置き換えてください。こちらもstdinから読み込んで再生してくれます。
率直に言ってffplayはとても使いづらいので、普段ffplayで動画を見ている人というのも少ないでしょうし、vlcも多くのOSに移植されていますから、vlcで官能試験を行ったほうが普段の視聴環境により近いかもしれません。

出力フォーマットのnutというのは単純にピクセルの羅列を扱えますので、rawvideoな動画を扱う際のフォーマットとして優れています。お望みであればこれをファイルに保存してもいいのですが、大変愉快なファイルサイズになります。

蛇足ですが、上記のバッチの例をお読みになれば上下分割や四分割なんかも簡単にできることはご理解いただけると思いますので、見やすいように改造してみてください。

なお、入力ソース次第では-ssが効かない場合があるとか使用するffmepgのバージョンが違うので挙動が違うとかザラです。
文中にもありますが、本稿は20190426-f857753を対象に記事を書いています。

個人的にはffmpegって強力で頼もしいけど融通は利かないしコロコロ仕様が変わるんで尊敬はするけど近づきたくはないです。

以上、大変くだらない記事で恐縮ですが、ここまでお読みいただいてありがとうございました。

以下、駄文です。
なんでこんなことをしようと思ったのかというと、HandBrakeのバージョンが上がるたびに同じH.265のエンコードパラメータを与えても妙に平均ビットレートが上がってきていて、同じCRF値でも1タモリ倶楽部単位(エンコネタではタモリ倶楽部1話分を当blogの基本単位とさせていただいております)の保存ファイルサイズが100MBほど大きくなってきたのが発端です。

なぜそうなるのかはHanbBrakeのせいなのか、libx265のせいなのかはわかりませんが、同一CRF値を指定してもエンコードされたファイルの平均ビットレートが上がっているのでファイルサイズが大きくなっているのが原因なのはわかったので、そっちが同じパラメータでも挙動を変えようって魂胆なら、こっちはもうCRF値を下げちまえ、もともとCRF値は28という(劣化に厳しい諸兄にはありえない)数値でエンコードしていましたが、今回は大台の30にして比較してみよう、でも画質はどうなるのかな?という素朴な疑問を持ったため、尊敬はするけど近寄りたくないffmpeg様のお力を拝借することにした次第です。

やってみてわかったことは、H.265の低ビットレートにおける再現力の高さです。
確かにわずかに28と30では違うかもしれないが、そもそもいずれもオリジナルのtsとの差ともわずかだ、という官能評価に至りました。
ぶっちゃけ、個人的評価ですがエンコ元先を左右に並べても上下に並べてもいずれも全然気にならないことが確認できました。

HEVC、恐るべし。そんなことをつらつら思う平成最後の日曜日でございます。
駄文までお読みいただき、誠にありがとうございました。

2019年4月21日日曜日

空き時間に合わせて本を一冊、無作為に青空文庫の公開中の作品から提案するページ

あまりにもgoogle app engine上でphpを動かすのが私でも楽過ぎたので、調子に乗って表題のようなものを作ってしまいました。
見てくれは地味で恐縮ですが、こんな感じです(javascriptで動作します)。


今回の一冊 🔄
リクエスト中...


ご希望の読書量は:
+その他の検索条件  
児童書は?
旧字は?
旧かなは?
読みたいジャンルは?
著者名(and,or,notは正規表現でお願いします)


とりあえず何でもいいからランダムに一冊選べるといいなあ、とずっと思っていたのですが、ついでに時間をつぶしたい時間を指定できればいいかな、と思って作ってみたところ、思いのほか気に入ったので、さらに提案機能をオリジン間リソース共有可能なAPI化して公開してみました。
この上の画面は、そのAPIをajaxで呼び出して実装しています。

作品が気に入らなかったらリロード絵文字(絵文字が見えない場合は「今回の一冊」という行)をクリックしますと、読書時間に合わせた別の作品を提案します。

それにしても、こういう一発ネタも膨大なデータをそろえて公開してくれている人や組織、団体様あってこそでございます。
今回は青空文庫さんからGitHubさん経由でデータを拝借して実現できました。
ありがとうございます。

geocitiesや大学のサイトなどで公開されている作品は除外しましたが、提案候補は一万五千冊を超えてます。
すごい。

なお、この例のようなデータを取得するAPIの詳細についてはこちらをご参照ください。
お読みいただいてありがとうございました。

---
2019/07/13 選択対象元リストを表示する機能(Selection candidate listボタン)を追加しました
2019/08/27 著者名を正規表現(perl互換)を用いて絞り込む機能を追加しました。

2019年4月19日金曜日

samba上で共有中のファイルをVisual Studioで編集すると実行可能になってしまう

sambaで共有中のファイルをうっかり Visual Studio から編集したファイルが、Linux上でlsすると実行可能になっているように見えてしまうケースのお話です。

結論から述べると、原因は保存されたファイルに付与されたACLのマスク値です。

以上です。

以下、駄文です。
祝Visual Studio 2019正式リリース、ということで思い出したのでこの記事を書いたのですが、これは2019以前から同じです。

以下ダラダラと書き綴りますが、その前に要約すると、Visual Studioはファイルを変更したときの保存方法は、実はファイルを保存する毎に新規作成をしてエディタの中身を保存してから、元のファイルを消しています。ファイルの内容の保護のためと思います。
従って、その副作用として、Windowsから見た場合はACL、Linuxから見た場合はパーミッションが、新規作成時のものになります。
平凡なアプリは気にもしてくれませんが、Visual Studioはさすがによくできていて、元のファイルのACLを新しいファイルへ再設定しようとします。
そのため、samba側も頑張って異文化のACLとの共存を図るべく必死に対応しようとするのですが、ここでlsコマンドさんが乱入してきてナンダコレ、というのが以下のあらすじです。

一応触れておきますが、以上の理由から、シェルスクリプトなど、パーミッションが重要なたぐいのファイルに対してはカジュアルにsamba越しにVisual Studioでファイルを編集してはいけません。

では、順次どうなっているのか見ていきたいと思います。
前提としてsambaは4.8.3、設定はほとんどデフォルト、ユーザ認証でホームディレクトリのcreate maskは0644でserver roleはstandaloneです。

さて、まず、Visual Studioでファイルを新規に作成し、sambaで共有しているディレクトリに保存した場合、lsコマンドで見てもごく普通のファイルに見えます。

たとえば、以下のように。
[ayumi@test ~]$ ls -la acltest.txt
-rw-r--r-- 1 ayumi ayumi 4  4月 19 21:55 acltest.txt

ついでにACLも確認しておきます。
[ayumi@test ~]$ getfacl acltest.txt
# file: acltest.txt
# owner: ayumi
# group: ayumi
user::rw-
group::r--
other::r--

どちらもsambaの設定どおりの挙動です。ACLは設定されていません。

さらに次いでに、WindowsからACLを確認してみます。
X:>icacls acltest.txt
acltest.txt S-1-5-21-3384635174-2336484230-2688597162-1001:(R,W)
            S-1-22-2-1000:(R)
            Everyone:(R)

長いSIDはsambaが出鱈目につけたayumiのSID, 短いSIDはグループのSID、EveryoneはotherのSIDですから、getfaclコマンドの結果と完全に一致しています。

さて、続けて同じファイルをVisual Studioから保存してみます。
すると、lsコマンドで見るとこうなります。
[ayumi@test ~]$ ls -la acltest.txt
-rw-rwxr--+ 1 ayumi ayumi 6  4月 19 22:03 acltest.txt*

ファイル名末尾にアスタリスク*がついて、グループにxがついていますからグループayumiに属する人に実行権が与えられているように見えることが確認できます。

ここで、パーミッションリストの末尾にプラス+がついているのでACLが追加されていることも同時にわかります。
getfaclコマンドの結果は以下のようになっています。
[ayumi@test ~]$ getfacl acltest.txt
# file: acltest.txt
# owner: ayumi
# group: ayumi
user::rw-
user:ayumi:rw-
group::r--
group:ayumi:r--
mask::rwx
other::r--

確かに元からのowner:groupに加えて、名指しでayumi:ayumiが追加されています。Windowsから見たらもともとの伝統的ファイルパーミッションはACLにしか見えませんから、それをコピーしただけの事ですが、Linux上ではパーミッションとACLは別々に管理されるため、新しく増えてしまったわけです。
しかし、read,write,executeのフラグはowner,group,otherすべてが644のままです。
実際に、先ほどのテキストが本当に実行可能なのか試してみると・・・

[ayumi@test ~]$ ./acltest.txt
-bash: ./acltest.txt: 許可がありません

と叱られます。

さて、ついでですからwindowsから見るとどうなっているでしょうか。
X:>icacls acltest.txt
acltest.txt S-1-5-21-3384635174-2336484230-2688597162-1001:(R,W)
            S-1-22-2-1000:(R)
            S-1-22-2-1000:(R)
            S-1-5-21-3384635174-2336484230-2688597162-1001:(R,W)
            Everyone:(R)

確かに、同じユーザとグループがリストに追加されているという奇妙な状態ではありますが、ReadとWriteしか付与されておらず、実行権はありません。getfaclの結果と同様で、矛盾はありません。

しかし、lsコマンドで見ると、確かに実行権が付いているようにしか見えません。
オイ、maskがrwxじゃねえか!!!とお叱りの向きもございましょう。
しかし、このmaskはorではなくてandのために使用されます。
つまり、ユーザーがいっくらchmod 0777としても、maskが0600だった場合は、実際には0600として動作しますよ、というように、です。

実際にmaskをreadだけにしてみます。
[ayumi@test ~]$ setfacl -m mask:r acltest.txt

すると、それぞれ以下のように見えます。
[ayumi@test ~]$ ls -l acltest.txt
-rw-r--r--+ 1 ayumi ayumi 34  4月 19 22:08 acltest.txt
[ayumi@test ~]$ getfacl acltest.txt
# file: acltest.txt
# owner: ayumi
# group: ayumi
user::rw-
user:ayumi:rw-                  #effective:r--
group::r--
group:ayumi:r--
mask::r--
other::r--

lsで見ると実行権は消えても相変わらずwrite権限を持っているように見えますが、getfaclコマンドの結果では
#effective:r--
とあります。これは「設定ではrw-だけど実際の効果はr--だよ」ということを示しています。
ここから実際のACLリストに追加されたユーザはすべてread権限しかないことがわかります。

windowsから見ると次のように見えます。
X:>icacls acltest.txt
acltest.txt S-1-5-21-3384635174-2336484230-2688597162-1001:(R,W)
            S-1-22-2-1000:(R)
            S-1-22-2-1000:(R)
            S-1-5-21-3384635174-2336484230-2688597162-1001:(R)
            Everyone:(R)

getfaclの結果と矛盾していません。

結局、ACLが付与されたファイルの場合はlsコマンドは普段と表示方法を変更しているのですが、manページにも記載がなく、大変迷惑な話です。
centos7のgitは古いからかもしれませんが、実際にgitコマンドでもcommit時のパーミッションの記録時に騙されたりしちゃったりして実害もあります。

そこで、じゃあ、マスク値をsamba側でコントロールできないのかという疑問が出てきますが、マニュアルを読む限りどこにもありませんでした。
そりゃそうですよね、最初っからマスクを下手に最小限にしたら、誰にもアクセスできないファイルが簡単にできてしまいます。

ではVisual StudioでACLを追加させない方法があるのか、または編集内容の保存方法を変える方法があるのか、という疑問が出てきますが、設定全般を見る限りはないようです。
まあ、繰り返しになりますが、もともとのVisual Studioが編集結果をファイルに保存する際は常に
  1. 元のファイルをテンポラリな名前に変更し、
  2. 新規に元のファイル名でファイルを作成し、
  3. 新規に作ったファイルにエディタ上の中身を保存し、
  4. ACLを新規ファイルに再設定し、
  5. 元のファイルを削除する
という保存方法のため、Visual Studioの挙動も、保存中にファイルが壊れた、なんてことがないようにする配慮ですからごもっともとしか言いようがありません。

lsコマンドにもlsコマンドなりの理由があって、+がついてたらACLが設定されてるよ、ACLに設定されてる情報をなるべく伝えたいよという(でもACLの中身とかattrとかはlsは詳しいことは見せないよ、というちょっと意地悪でもある)善意からのものですので、これもまた責めるのはかわいそうです。

それぞれの言い分が鋭く対立した結果、こうなってしまったわけですが、結局、どこかで誰かに妥協してもらわないと実際に使っている側が困ってしまいます。

仕方がないので、smb.confの以下のパラメータを順次ひとつづつ"No"にして再起動(reloadだと効果が出ないものもありますので)をしたらどうなるかを試した結果をまとめました。

実行順パラメタ既定値結果
1map archiveYes変化なし
2map hiddenNo変化なし
3map read onlyyes変化なし
4map systemNo変化なし
5store dos attributes記載なし変化なし
6acl map full controlYes変化なし
7acl allow execute alwaysNo変化なし
8acl check permissionsYes変化なし
9acl group controlNo変化なし
10nt acl supportYes効果あり

※既定値はtestparm -vコマンドで表示したときのものです。map read onlyの既定値が小文字なのも原文ママです。
変化なしの意味はlsでの見え方です。効果ありの意味はACLそのものが付与されていないことを意味しています。

この表からお分かりの通り、結局、ACLの取り扱いそのものを停止しないとダメでした!わはは

だから何だって?

以上、お読みいただいてありがとうございました。

2019年4月14日日曜日

CentOS7.6における2019年4月14日現在でのLet's encryptの証明書の取得方法

*** この記事は2019年4月14日現在のものです。 ***

Let's encryptって得られるのはサーバ証明書だけですが、その目的のためならばオレオレ証明書を作るよりよほど簡単に証明書が得られて本当に助かります。
androidやiosのようなオレオレ証明書を使うための設定が難しかったりする端末がある環境だと、ことさらありがたさが身に沁みます。

ただ、困ったことに、Let's Encryptの情報を検索すると、古いのと新しい情報がぐっちゃぐっちゃに混在した状況で、まことに混乱をしてしまいます。

そこで、本日、実際にCentOS7.6上でcertbotを用いてLet's Encryptの証明書を取得する機会を得ましたので、その手順と結果を記録しておきます。
当然ながらこの手順で得られた証明書は、apache,postfix,dovecotをはじめとしてサーバ証明書を利用できるアプリケーションで使いまわせます。

前提条件
  1. epelリポジトリから取得できるcertbot 0.31.0を使用して証明書を得る。
    理由:2019年4月14日にyum install certbot でインストールされるcertbotのバージョンが0.31.0だというだけの話です。
  2. 複数のサブドメインに対応した証明書を求めるがワイルドカードな証明書は求めない。
    理由:サブドメイン毎に証明書を変えられるのと、http-01での認証が可能なため、ワイルドカードな証明書を得るために必要なdns-01の場合に必須となるdnsのTXTの更新が不要だからです。
  3. Webサーバの構築が正しく行われていること。
    言い直すと、申請するドメイン名およびサブドメイン名すべてでhttp経由で/.well-known/acme-challenge/ ディレクトリの中身が外部から読み出せること。
    理由:ACME v2でhttp-01を用いて認証してもらうために一時的にLet's Encrypt サーバからトークンを取得して/.well-known/acme-challenge/ ディレクトリに配置し、Let's Encryptのサーバから読み出してもらうことで認証を行うためです。
    ドットから始まるファイルやディレクトリを外部から見えない設定にしていたり、アクセス元をIPアドレスやドメイン名などで制限していると失敗します。
    なお、このディレクトリはcertbotコマンドが勝手に掘って、認証が終わり次第削除されます。
    httpのポート番号はデフォルトでは80ですが、certbotに --http-01-port オプションを与えると、ポート番号を変更できます。
    https経由で認証をしてもらうtls-sni-01はもはや利用できません。
では実際に申請してみたいと思います。
このサンプルではドメイン名一つ(xxx.yyy)と5つのサブドメイン名を一度に認証して1つにまとめた証明書を発行してもらうコマンドでテストしてみる例を示します。

certbot certonly \
    --dry-run \
    --agree-tos \
    --webroot \
    -w /home/httpd/site0 -d xxx.yyy \
    -w /home/httpd/site1 -d site1.xxx.yyy \
    -w /home/httpd/site2 -d site2.xxx.yyy \
    -w /home/httpd/site3 -d site3.xxx.yyy \
    -w /home/httpd/site4 -d site4.xxx.yyy \
    -w /home/httpd/site5 -d site5.xxx.yyy \
    -m 更新切れ通知などを受け取るメールアドレス

certbotへ与えたオプションはそれぞれ以下の通りです。
  • certonly
    証明書の取得だけを行います。
  • --dry-run
    実際には証明書の取得を行わずに、取得の手順を一通り行ってくれます。
    Webサーバの設定が誤っていないかどうかもチェックしてもらえますので、いきなり証明書を取得せずにこのオプションをつけてテストすることをお勧めします。
    当然、証明書を実際に取得する際にはこのオプションを外して実行してください。
  • --agree-tos
    利用規約を読みもせずに無条件にやみくもに受け入れます。
    初回くらいこのオプションを外して読んだほうがいいとは思いますが、思うだけです。
  • --webroot
    この後に続く-wオプションで指定されたディレクトリにcertbotが/.well-known/acme-challenge/ディレクトリを掘って、トークンを配置して、-dで指定されたドメイン名でLet's Encryptサーバがアクセスを行い、読み出しを行って認証するhttp-01手順で行うことを指示しています。
  • -w と -d のペア
    -wは--webroot-pathの省略形です。-dで定義されるドメイン名のドキュメントルートディレクトリを記述します。
    -dは--domainsおよび--domainの省略形です。ドメイン名を表します。
    なお、-wは-dより先に記述する必要があります。
    その理由は、-dで指定されたドメイン名のドキュメントルートディレクトリは、直前の-wで指定された値を使うということになっているためです。
    この理由で、各ドメインで/.well-known/acme-challenge/以下を共有する設定にしてあるなら、一対一で記述する必要は特にありません。
上記コマンドを実行すると、一回だけ
Would you be willing to share your email address with the Electronic Frontier Foundation, a founding partner of the Let's Encrypt project and the non-profit organization that develops Certbot? We'd like to send you email about our work encrypting the web, EFF news, campaigns, and ways to support digital freedom.
と聞かれて、Yes/Noの入力を求められます。電子フロンティア財団の最新情報などが欲しければYesに、更新時期が近付いたなどの通知が欲しいだけならNoを選択することになるかと思います。

この選択が終わったら実際のテストが始まります。
各ドメイン名に実際にアクセスが行われ、その結果が逐次表示されていきます。
すべてうまくいけば、
IMPORTANT NOTES:
 - Congratulations! Your certificate and chain have been saved at:
云々と表示されます。
エラーが出たら「おめでとう」とは言ってくれません。エラーになった原因が明確に示されますので、その情報に基づいて修正を行ってください。

おめでとう、と言われたら、IMPORTANT NOTES:欄ではどこに何の証明書を作ったか、その証明書の失効日はいつか、ということが明記されていますので一応一通り目を通しておいてください。

今回の場合は/etc/letsencrypt/live/ドメイン名/にドメイン名とサブドメイン名がまとめて証明された証明書が1つ配置されていますので、各サーバに証明書を適用していきます。
  • httpd 2.4.6-88(CentOS7.6ではapache2.4.6)
    今回申請した各VirtualHostにサーバ証明書(cert.pem)と秘密鍵(privkey.pem)と中間証明書(chain.pem)を設定します。
    SSLCertificateFile /etc/letsencrypt/live/ドメイン名/cert.pem
    SSLCertificateKeyFile  /etc/letsencrypt/live/ドメイン名/privkey.pem
    SSLCertificateChainFile /etc/letsencrypt/live/ドメイン名/chain.pem
  • postfix 2.10.1-7
    /etc/postfix/main.cfでサーバ証明書+中間証明書(fullchain.pem)と秘密鍵を設定します。fullchain.pemの中身はcert.pemとchain.pemが1ファイルにまとまっているものです。
    smtpd_tls_cert_file = /etc/letsencrypt/live/ドメイン名/fullchain.pem
    smtp_tls_cert_file = /etc/letsencrypt/live/ドメイン名/fullchain.pem
    smtpd_tls_key_file = /etc/letsencrypt/live/ドメイン名/privkey.pem
    smtp_tls_key_file = /etc/letsencrypt/live/ドメイン名/privkey.pem
  • dovecot 2.2.36-3
    /etc/dovecot/conf.d/10-ssl.conにpostfixと同様にサーバ証明書+中間証明書と秘密鍵を設定します。
    ssl_cert = </etc/letsencrypt/live/ドメイン名/fullchain.pem
    ssl_key = </etc/letsencrypt/live/ドメイン名/privkey.pem
それぞれ、設定が終わったら
systemctl reload httpd
systemctl reload postfix
systemctl reload dovecot
を行って設定を適用してください。

ここまでで各サーバに証明書が適用されました。

今度は、証明書の自動更新を行う手続きを定義します。
Let's Encryptの証明書の有効期限は90日固定なので、手動で更新するにはちと煩瑣ですので、cronで毎日更新を試みることにする場合は、/etc/cron.dailyに以下の内容を実行するシェルスクリプトを配置してください。

/usr/bin/certbot renew --quiet --max-log-backups 10 --post-hook "systemctl reload httpd;systemctl reload postfix; systemctl reload dovecot"

オプションの意味は以下の通りです。
  • renew
    更新が必要な証明書だけ更新します。
    更新が必要なドメイン、サブドメインなどの情報は、ドメインを申請したときの情報が/etc/letsencrypt/renewal/ドメイン名.confに保存されていますので、この情報をもとに更新を行ってくれます。
    有効期限が差し迫らない限り(現状では30日前)更新しません。一日二回がお勧めとのことですが、まあ、1回でいいでしょう。
  • --quiet
    エラー時を除いてメッセージを出力しません。
  • --max-log-backups 10
    デフォルトだと/var/log/letsencrypt/letsencrypt.log.*が最大1000個できちゃうので、せめて10個くらいにしてくださいというお願いです。
  • --post-hook
    更新がうまくいった際に実行するコマンドを定義します。
    この場合はhttpdとpostfixとdovecotに証明書をリロードさせています。
このコマンドをテストする場合は、--dry-runをオプションに加えてください。
そうすると、実際には更新を行いませんが、更新がかかったものとして--post-hookの内容を含めて実行してくれますので、一回は手動でやっておいたほうがいいと思います。

以上です。
お読みいただいてありがとうございました。

2019年4月12日金曜日

ローカル環境にgoogle謹製SDKをインストールせずにGoogle App Engineにプロジェクトをdeployしてみる

たった数年前なのに、Google App Engine(以下GAE)というサービスがいろいろなものと統合されちゃって最近ではGoogle Cloud Platform(以下GCP)とかいうことにしたんですって奥様。

で、今も稼働中の、とあるサーバ機能を持つアプリはeclipseでdeployしてた覚えがあるんですが、もう既に安定稼働につきメンテナンス不要モードに入って何年もたっていて、GAEにアプリをdeployする方法も忘れちゃいました。

そこで、GAEの知識を更新したくなったので、一つなんかやってみるべえ、と思い立ちました。

今ではプロジェクトに選択できる言語が増えていますね。
無償枠で使える言語中から選ぶとすると、javaでのプロジェクトはdeployも運用もすでに経験済みというか運用中だからパスするとして、pythonはインデント縛りのおかげで私にとってメンテがだるい言語だし(インデントを忘れているのか意図しているのかわかりづらいケースがイヤ)、goは開発環境を整えるところから始めなくちゃいけないし、ということで、結局、残っている言語はphpとなりました。

そこで、phpで、アクセスされたら問答無用で四字熟語を送信するサーバを作って、それをGAEにdeployして運用してみようと思います。

余談ですが、以前と大きく変わった点としては、「組織」ってのが必須になっていることです。
これは私のアカウントがG Suiteのせいかもしれません(東日本大震災時に計画停電のせいで自宅サーバがぶっ飛んだ時にgoogle様に独自ドメイン用MXをお願いしたらなぜか今ではG Suiteというサービスになってしまったんですよねぇ)。
そして、めったやたらに権限が細かい。
何をするためにどんな権限が必要なのかがとってもわかりづらくて、プロジェクトを追加する権限は一体どれなんだ、と片っ端から権限を与えてはプロジェクト作成を試すという頭の悪いことをしてしまいましたが、結局どれが「組織なし」へプロジェクトの新規作成ができる権限なのかさっぱりわかりませんでした。
まあ、この辺りは本題から外れますのでいい加減にするとして、本題に戻ります。

ともかくGCP上でプロジェクトを作成してしまいましょう。
さすがにこの手順の解説は端折ります。
ブラウザ画面左上にプロジェクト名が表示が表示されていますから、そこからちゃちゃっと追加してください。

プロジェクトが無事生成出来たら、今度は画面右上の
というアイコンボタンをクリックしますと、bash(Cloud-shell)が立ち上がっちゃいます。

こうなったら、とりあえず自分のプログラムをこのコンソールが立ち上がっているところにダウンロードします。
とりあえず、まずはgitでhttps経由でcloneしてみます。
gitコマンドの使い方は、あまりに文献が豊富なのでここでは説明しませんが、オレオレサーバからhttps経由でcloneしてみます。
無論、別にhttpsでgitじゃなくてもいいんですけど、私が構築してあるgitのbareなリポジトリはgitbucketを利用させていただいている関係でhttpd経由でアクセスするお約束なんですが、そのhttpdがhttps経由でしかgitbucketにアクセスさせない定義になっているというだけの理由です。
あとで触れますがgitである必要性は全くありませんがともあれ説明手順が楽なので一通りこの前提で説明します。

1) オレオレサーバなのでhttps経由の場合は証明書が証明になってねぇよと怒られるので、gitさんに検証をしないで黙って持って来いとお願いする設定にします。
$ git config --global http.sslVerify false
2) オレオレサーバからデプロイしたいプロジェクトをcloneします。
3) cloneしたディレクトリにおります。
4) deployします。
$ gcloud app deploy

以上で終了です。
たったこんだけ。
app.yamlを書き忘れていても3)の段階で書いちゃえばOK。Cloud-shellからはemacsもvimも使えますので宗教論争も無縁です。え?edですか? それはないみたいですね。

んー。gitって何それ?という方に朗報です。
実はscpもsftpもwgetもcurlもCloud-shellから使えるんです。
ですので、開発したプロジェクト一式をzipかなんかで圧縮して、それをCloud-shell上でダウンロードして、展開するだけでいいわけです。
書庫の展開にはunzipもtarも、その他めぼしいところはそろっています。

方法はどうあれ、ソースをgetして展開してdeployしておしまい。
ちょっと前までが信じられないほど極めてシンプルです。

自分の管理下にあるサーバにSDKだのなんだの、得体のしれないものを一切インストールする必要がありません。Cloud-shell上に一式そろっています。

わしの環境じゃあSDKの実行にゃあpython2.7以上が必要じゃけえのう、OSのパッケージ管理システムから外してmake installせんならんけえのう、とか細かいことは何も考えずにデプロイできます。

一度デプロイしちゃえばこっちのものなので、あとはgit cloneしたり tar zxvfしたディレクトリをrm -frしちゃってください。まあ、記念に残しといてもいいですけど。

いやあ、httpsをしゃべれてプログラムも実行できるんですからインスタンス時間が28時間まで無料なGAEってホントにまいっちゃいますね。

で、何をdeployしたのかというと、アクセスすると約7千件あるデータベースの中から四字熟語を1つだけあなたに提案する「提案型四字熟語供給システム」です。大げさでいいでしょ。
インタフェイスはJSONです。供給されるのは四字熟語と、その読み。
どこからでも使えるようにオリジン間リソース共有に対応しています。
一日一語で覚えてゆきましょう。

以上です。
こんな記事をお読みいただいてありがとうございました。

Webアプリのユーザ認証でsaslauthdを利用してみる

まあ、別にWebアプリじゃなくてもいいんですけど、ユーザー名とパスワードが正しいかを判定する仕組みを実装するのって結構面倒くさいじゃないですか。

あるいは、すでにログインアカウント名に使われているのと同じアカウント名とパスワードを使いまわしている場合に、自分のアプリケーションでは設定を許さずに違ったパスワードじゃないとエラーにしたいとか。こういったケースでも入力されたユーザ名とパスワードが使われているかどうかの判定が必要になります。

ですが、アプリから/etc/passwdやら/etc/shadowを直接参照するなんて背筋が凍ります。それに、これ以外の認証機構にはまた別のコードを書かなくちゃなりません。

そこで、saslauthdにお願いして、ユーザ名とパスワードが正しいかを判定してもらっちゃおうというのが本記事の目論見です。

saslauthdの説明はそれこそweb上で検索すれば山ほど情報が出てきますが、実際にはほとんどpostfix+dovecotの設定の仕方ばっかりで意外に思われるかもしれませんが、postfixやdovecot以外からでも簡単に使えるんです。

使い方は単純。testsaslauthdコマンドだけでOK。
戻り値が0だったら認証が通ったという単純明快さです。
C言語で各種APIを使ったライブラリを作る必要もありません。

・・・と、言いたいところですが、困ったことにtestsaslauthdコマンドはパスワードを引数で指定しなければなりません。
これだと手動で打ったらシェルのhistoryにばっちり残ってしまいますし、psコマンドでパスワードが見えちゃいます。
極めて遺憾かつ慚愧の念に堪えません。

ですので、別の方法を採用したいと思います。
といっても、これまた単純明快です。

実は、saslauthdが用意してくれているunixドメインソケットにユーザ名とパスワードとサービス名とrealm名をのっけた電文を突っ込むだけで、そのユーザ名とパスワードが正当なものか判定した結果を返してくれます。
testsaslauthdコマンドも、実際にはこの方法を使って実装されています。

認証依頼電文そのものも超シンプル。
フォーマットは、
ユーザ名文字列長(ネットワークバイトオーダーでWORD)
ユーザ名文字列
パスワード文字列長(ネットワークバイトオーダーでWORD)
パスワード文字列
サービス名文字列長(ネットワークバイトオーダーでWORD)
サービス名文字列
realm名文字列長(ネットワークバイトオーダーでWORD)
realm名文字列
というシンプルさです。

結果応答電文も素っ気ないほどシンプル。
ユーザ名とパスワードが正しかった場合:
\000\002("OK"の長さ。ネットワークバイトオーダーでWORD)
OK(文字列。2byte)
だけ。正しくない場合はOKの代わりにNOとなり、その後に理由文字列がついてきます。

こんなに簡単なのだから利用しない手はありません。
そこで、webアプリと言えばみんな大好き(私は嫌い)なphpでユーザ認証関数を作ると以下のような感じになります。
関数の使い方は・・・見ればわかりますよね。例によってエラーチェックが長いですが、Saslauthd::auth()からtrueが返ってきたらユーザ名とパスワードが認証を通った、というだけの関数です。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
<?php
class Saslauthd
{
  static public function auth( $user, $pass, $service_name = "password-auth", $realm = "", $socket_name = "/run/saslauthd/mux", &$errorMessage = null )
  {
    if( $user === null || $user == "" || strlen( $user ) > 100 ){
      if( $errorMessage !== null ){
        $errorMessage = "invalid user";
      }
      return false;
    }
    if( $pass === null || strlen( $pass ) > 100 ){
      if( $errorMessage !== null ){
        $errorMessage = "invalid password";
      }
      return false;
    }
    if( $socket_name === null || $socket_name === "" ){
      if( $errorMessage !== null ){
        $errorMessage = "invalid socket name";
      }
      return false;
    }
    if( $service_name === null || $service_name == "" || strlen( $service_name ) > 100 ){
      if( $errorMessage !== null ){
        $errorMessage = "invalid service name";
      }
      return false;
    }
     
    if( $realm === null || strlen( $realm ) > 100){
      if( $errorMessage !== null ){
        $errorMessage = "invalid realm";
      }
      return false;
    }
 
    $sock = socket_create( AF_UNIX, SOCK_STREAM, 0 );
    if( $sock === false ){
      if( $errorMessage !== null ){
        $errorMessage = socket_strerror( socket_last_error() );
      }
      return false;
    }
     
    $ret = socket_connect( $sock, $socket_name );
    if( $ret === false ){
      if( $errorMessage !== null ){
        $errorMessage = socket_strerror( socket_last_error() );
      }
      socket_close( $sock );
      return false;
    }
     
    $sendbuf = "";
     
    $sendbuf .= pack("n", strlen( $user )). $user ;
    $sendbuf .= pack("n", strlen( $pass )). $pass ;
    $sendbuf .= pack("n", strlen( $service_name )).$service_name;
    $sendbuf .= pack("n", strlen( $realm )).$realm;
 
    $ret = self::sockwrite( $sock, $sendbuf );
     
    if( true !== $ret ){
      socket_close( $sock );
      if( $errorMessage !== null ){
        $errorMessage = "socket_write error: ".$ret;
      }
      return false;
    }
    $readlenstr = socket_read( $sock, 2, PHP_BINARY_READ );
    if( $readlenstr === false ){
      if( $errorMessage !== null ){
        $errorMessage = "socket_read error: ".socket_strerror( socket_last_error() );
      }
      socket_close( $sock );
      return false;
    }
    $readlen = unpack( "n", $readlenstr )[1];
    $ret = socket_read( $sock, $readlen, PHP_BINARY_READ );
    if( $ret === false ){
      if( $errorMessage !== null ){
        $errorMessage = "socket_read error: ".socket_strerror( socket_last_error() );
      }
      socket_close( $sock );
      return false;
    }
    socket_close( $sock );
     
    if( $ret === "OK" ){
      return true;
    }
    if( $errorMessage !== null ){
      $errorMessage = $ret;
    }
    return false;
  }
  static private function sockwrite( $sock, $buf )
  {
    $spos = 0;
    $buflen = strlen( $buf );
    if( $buflen === 0 ){
      return true;
    }
    while( true ){
      $ret = socket_write( $sock, substr( $buf, $spos ) );
      if( $ret === false ){
        return socket_strerror( socket_last_error() );
      }
      $spos += $ret;
      if( $spos <= $buflen ){
        break;
      }
    }
    return true;
  }
}
?>
パスワードをhttpsを使わないで通信して全世界に公開したり連続飽和攻撃を考慮しないプログラムを作ったりして頭を抱えるようなことがありませんようにご注意ください。

お読みいただいてありがとうございました。