2019-06-04 追記
いくつかコメントいただいたので、いろいろ試してみた内容を追記します
私がShellの仕様をきちんと理解せずに投稿していた部分もあり、勉強になりました
結論からいうと
-
base64
なんて使わなくてもssh先のサーバにコマンドは流せます -
cat encoded.txt | ssh HOSTNAME "$(base64 -d)"
の"$(base64 -d)"
の部分はssh先ではなくローカルでdecodeされます(リモートでされると勘違いしてました)
ただ、実際に私が開発している過程でbase64
を使って、上記のShellのテクニックや複雑なエスケープ処理を正しく動作させるまでの手間を回避していたことも事実なので、コードの実例を交えて紹介しておきます
問題は、たんにssh先のホストにコマンドが流せればよいわけではなくNode.jsの文字列をsshコマンドを通してリモートホストで流す必要があったということです
Node.jsからsshを経由して、リモートサーバ上で複雑なShellSctiptを実行するいくつかの方法
以下のようなコマンドをNode.jsからsshを経由してリモートのサーバ上で実行したいとします
cat /etc/hosts \
|grep "^192" \
|awk '{print $2}' \
|sed -e "s/^target\([1-3]\)$/updated\1/g" \
> ~/tmp/results.hosts
/etc/hosts
の中から192
で始まる行を抜き出し、更にそのホスト名だけを抜き出します。そしてホスト名に'target'
という文字列が含まれる場合は'updated'
に置き換え、~/tmp/results.hosts
に結果を出力します。出力先のファイルパスはリモートサーバ上のものです
そんな処理が必要になるような状況は想定しづらいですが開発しているソフトウェアの特性上、Shell上で正しく動作するコマンドは全てリモートサーバでも動作するようサポートしなければならないので、あえて複雑な例でソフトウェアの動作を確認しておいて損はないでしょう
案1: 普通にssh HOSTNAME COMMAND
const exec=require('child_process').exec;
const cmd=String.raw`
cat /etc/hosts \
|grep "^192" \
|awk '{print $2}' \
|sed -e "s/^target\([1-3]\)$/updated\1/g" \
> ~/tmp/results.hosts
`;
const ssh=`
ssh target1 ${cmd}
`;
console.log(ssh);
exec(ssh, console.log);
`
で囲まれている部分はテンプレート構文で、文字列中に変数や定数を埋め込むことができます。"
や'
が含まれる文字列もこの構文を使えばエスケープなしで使えます
String.raw
というのは文字列をそのまま扱ってくれるものです。これがないとテンプレート構文中の\1
がうまく解釈されずに、SyntaxErrorとなります(Javascriptの細かい仕様は割愛)
そして実際このコードは終了しません。cmd定数の1行目が改行なのでssh target1
のあとに改行が入ってしまい、ただtarget1にログインするだけで、コマンドは実行されません
\n
を削除すればよいのですが、そうすると今度はあえて改行を入れているコマンドに対応できなくなります
案2: echo COMMAND | ssh HOSTNAME sh
このパターンだと今度は、コマンド内の'
ないしは"
をエスケープしなければなりません
const exec=require('child_process').exec;
const cmd=String.raw`
cat /etc/hosts \
|grep "^192" \
|awk '\''{print $2}'\'' \
|sed -e "s/^target\([1-3]\)$/updated\\1/g" \
> ~/tmp/results.hosts
`;
const ssh=`
echo '${cmd}' | ssh target1 sh
`;
console.log(ssh);
exec(ssh, console.log);
テンプレート構文とShellの文字列が混在しているので、どうしてもどこかでエスケープが必要になります
案3: cat EOS ~ EOS
を使う
この方法は問題なく動作しました
const exec=require('child_process').exec;
const cmd=String.raw`
cat /etc/hosts \
|grep "^192" \
|awk '{print $2}' \
|sed -e "s/^target\([1-3]\)$/updated\1/g" \
> ~/tmp/results.hosts
`;
const ssh=`
cat << 'EOS' | ssh target1 sh
${cmd}
EOS
`;
console.log(ssh);
exec(ssh, console.log);
ただし終わりのEOS
はインデントすることができません。EOS
の前に空白を入れると、コマンドとして認識されエラーになります。見栄えがよくないので、このことを忘れていて何かの拍子にインデントしてしまい「あれ?動かなくなった?」ということもあるかもしれません
案4: base64
を使う
私なりに考えた結果、コマンドをbase64
でエンコードしてしまえば、そもそもエスケープから解放されるのでは?との結論にいたり、ソフトウェア上でもShellコマンドは全てbase64
でエンコードし、実行時にbase64 -d
でデコードするよう実装することにしました
今のところ自分で利用している分には、問題なく動作しています
const exec=require('child_process').exec;
const cmd=String.raw`
cat /etc/hosts \
|grep "^192" \
|awk '{print $2}' \
|sed -e "s/^target\([1-3]\)$/updated\1/g" \
> ~/tmp/results.hosts
`;
const enc=Buffer.from(cmd)
.toString('base64');
const ssh=`
echo ${enc} | ssh target1 "$(base64 -d)"
`;
console.log(ssh);
exec(ssh, console.log);
以上です。何か良い方法があれば、ぜひ教えていただければと思います
ありがとうございました
※以降、追記前の記事
記事: https://qiita.com/mjusui/items/bce4566ffdb54ba7039a
ツール: Submarine - https://gitlab.com/mjusui/submarine
↑で紹介しているSubmarineというツールを作成しているときに、ひらめいた!
通常ssh先でコマンドを実行したい場合は以下のように書くと思います
ssh HOSTNAME COMMAND
ところが、実際にコマンドを実行してみるとコマンドの一部が、ssh元の方で評価されたり ¥
エスケープがうまくいかずにエラーになることがあります
そんなときにはssh先で実行したいコマンドをパイプで渡してやると、うまく行くことがあります
echo COMMAND | ssh HOSTNAME "$(cat -)"
しかし、それでも何らかの理由で失敗する場合はbase64でエンコードして、ssh先でデコードしてあげると、エスケープを気にせず、生のコマンドをssh先で実行できます
### エンコード
echo command.txt | base64 > encoded.txt
### 実行
cat encoded.txt | ssh HOSTNAME "$(base64 -d)"