NTEmacs 24.x から TRAMP で多段 ssh 接続するただ一つの方法（たぶん）

# TL;DR
一段目だけ plink を使い、後は ssh すべし

# 経緯
業務上、踏み台経由でリモートホストのファイルを編集する必要に迫られ、Emacs なら TRAMP があるから簡単にできるだろうと思って着手したものの、案外手間取ったのでメモ。探し方が悪いのか、そのものズバリの記事を見つけられませんでした。

皆さんスイスイ接続できてるのか、はたまた Windows から多段 ssh のニーズが乏しいのか、はて。

# 必要なもの

以下の構成で動作確認してます。

- plink 0.65
- GNU Emacs 24.4.1 (x86_64-pc-mingw32) of 2014-11-10 on NTEMACS64

NTEmacs の TRAMP は PuTTY と連携するので、あらかじめ plink へのパスを通しておいてください。ssh そのものは、インストールしておかなくても大丈夫です（あっても害はありませんが）。

# 多段構成の想定

ここでは仮に、以下のような多段接続を想定します。

```
localhost(windows) -> proxy1 -> proxy2 -> target
```

さて、target のファイルを手元で編集するにはどうすればいいでしょうか？

# ファイル名のルール（おさらい）

TRAMP 知ってる人には釈迦に説法ですが、ファイル名は以下の形式に従います。

`/method:user@host#port:path`

- method: 接続方法を指定する。ftp, ssh, scp, plink, pscp, su など。
- user: 接続ユーザーを指定する。
- host: 接続先ホストを指定する。
- port: 接続先ポート番号を指定する。のっぴきならない場合に。
- path: 編集するファイル名を指定する。

接続方法やユーザーなどを省略するとデフォルト値が使われたり、入力を促されたりします。

これで、ローカルファイルを開くのと同じように、`C-x C-f /ssh:foo@bar:.zshrc` でリモートファイルを開くことができます。この透過性を一度でも体験すれば Emacs から離れられなくなること請け合いの魅力的な機能です。その昔は Ange-FTP で・・・なんて話はどうでもいいですね。

蛇足ながら、多段接続しない場合には、`method` に scp や pscp などの外部メソッドを指定するとパフォーマンスが向上します。多段接続をサポートする ssh や plink などの内部メソッドはファイル転送時にエンコード処理がはさまるため、パフォーマンス面では不利です。

# 多段接続の設定例

以下の設定を追加しておくことで、`C-x C-f /user@target:path` で `target` のファイルを開けるようになります。

```el:~/.emacs.d/init.el
(with-eval-after-load 'tramp
  (setq tramp-default-method "ssh")
  (add-to-list 'tramp-default-proxies-alist
               '("proxy2" nil "/plink:proxy1:"))
  (add-to-list 'tramp-default-proxies-alist
               '("target" nil "/proxy2:"))
  nil)
```

`tramp-default-method` はファイル名に method が指定されなかった場合のデフォルト値になります。`tramp-default-proxies-alist` は、`(HOST USER PROXY)` のリストです。これは、`USER@HOST` に接続する場合は `PROXY` を経由しろ、という指定になります。

ポイントは、**一段目にだけ `/plink:` を明示的に指定し、二段目以降は未指定**にすることです。

わざわざ設定を書かずに多段したいものぐさな人は、`C-x C-f /plink:proxy1|proxy2|user@target:path` でファイルを開けば OK です。この時にも、一段目にだけ `/plink` を指定する必要があります。

# 多段接続の仕組み

TRAMP の多段接続は結局のところ、コマンドラインで

```
$ ssh -t user@host1 ssh -t user@host2
```

としているのと同じです。Windows では ssh のかわりに plink を使って

```
C:\>plink -t user@host1 ssh -t user@host2
```

とできればいいわけですね。踏み台に接続した後はそのホスト上で `ssh` を普通に叩きます。Windows だからと言って安直に

```elisp
(setq tramp-default-method "plink")
```

と設定してしまうと、いざ多段接続するときに

```
C:\>plink -t user@host1 plink -t user@host2
```

と残念なことになってしまいます。`plink` が踏み台である `host1` に存在することはまずないでしょうから、これでは繋がりません。

そこで、`tramp-default-method` には `ssh` を指定しておき、一段目の踏み台もしくは、直接 ssh する時だけ `/plink:` を指定すれば良いのです。 

あるいは、`tramp-default-method` を `plink` にしておき、接続方法として常に `/ssh:` を指定する、という手もあります。この場合には直接接続時に `/plink:` を省略できます。

多段が多いのか、はたまた直接が多いのかによって、どちらをデフォルトにするか決めれば良いでしょう。

# はまりポイント - 多段で無限ループ

`tramp-default-proxies-alist` で、`HOST` と `USER` に `PROXY` と一致する正規表現を指定した場合には無限ループします。私はこれではまりました。例えば、`(nil nil "/plink:foo@bar")` などは、無限ループ確定です。

その理由は、多段接続用のリストを作る `tramp-compute-multi-hops` を見ればわかります。

```el:tramp-sh.elから抜粋
    ;; Look for proxy hosts to be passed.
    (setq choices tramp-default-proxies-alist)
    (while choices
      (setq item (pop choices)
            proxy (eval (nth 2 item)))
      (when (and
             ;; Host.
             (string-match (or (eval (nth 0 item)) "")
                           (or (tramp-file-name-host (car target-alist)) ""))
             ;; User.
             (string-match (or (eval (nth 1 item)) "")
                           (or (tramp-file-name-user (car target-alist)) "")))
        (if (null proxy)
            ;; No more hops needed.
            (setq choices nil)
          ;; Replace placeholders.
          (setq proxy
                (format-spec
                 proxy
                 (format-spec-make
                  ?u (or (tramp-file-name-user (car target-alist)) "")
                  ?h (or (tramp-file-name-host (car target-alist)) ""))))
          (with-parsed-tramp-file-name proxy l
            ;; Add the hop.
            (add-to-list 'target-alist l)
            ;; Start next search.
            (setq choices tramp-default-proxies-alist)))))
```

処理はまず、接続先リストの先頭からホストとユーザーを取り出し、それらが経由すべき踏み台の条件と一致するかを調べます。もし一致した場合には、その踏み台を接続先リストの先頭に追加し、もう一度踏み台リストの先頭から調べ直します。

つまり、`(nil nil "/plink:foo@bar")` のような条件はどんな `PROXY` とも一致するので、自分で自分にヒットしてまた自分を追加する･･･という一人上手を延々と続けることになるわけです。わーお！

# 考察 - 汎用的な多段接続を実現するには

無限ループに関連して、現状の TRAMP では、どう頑張ってもできない設定があります。ドメイン名を利用した、汎用的な多段接続設定です（まあ、そんなニーズはそもそもないのかもしれませんが）。仮に、以下のような多段接続が必要だとします。

```
localhost -> *.local.domain
localhost -> proxy@gw.local.domain -> *.hop.domain
localhost -> proxy@gw.local.domain -> proxy@gw.hop.domain -> *.step.domain
localhost -> proxy@gw.local.domain -> proxy@gw.hop.domain -> proxy@gw.step.domain -> *.jump.domain
```

これを、以下のように設定できれば直観的です。

```el
(setq tramp-default-method "ssh")
(add-to-list 'tramp-default-proxies-alist
             '("local\\.domain" nil "/plink:%u@%h:"))
(add-to-list 'tramp-default-proxies-alist
             '("hop\\.domain" nil "/proxy@gw.local.domain:"))
(add-to-list 'tramp-default-proxies-alist
             '("step\\.domain" nil "/proxy@gw.hop.domain:"))
(add-to-list 'tramp-default-proxies-alist
             '("jump\\.domain" nil "/proxy@gw.step.domain:"))
```

しかし、これは無限ループします。なぜなら、`/plink:%u@%h:` が `*.local.domain` と一致してしまうからです。せっかくなので（≒現実逃避）、どうすればこういった設定が可能になるかを考察してみます。

要件としては、一段目のルールに対して「これ以上多段生成しなくて良い」ということを指定できれば良いはずです。TRAMP では `PROXY` に `nil` を指定することで多段生成を抑制することができますが、残念ながら今回のようなケースには全く無力です。例えばこう書いてみたとしても：

```el
(add-to-list 'tramp-default-proxies-alist
             '("local\\.domain" nil "/plink:%u@%h:"))
(add-to-list 'tramp-default-proxies-alist
             '("hop\\.domain" nil "/proxy@gw.local.domain:"))
(add-to-list 'tramp-default-proxies-alist
             '("step\\.domain" nil "/proxy@gw.hop.domain:"))
(add-to-list 'tramp-default-proxies-alist
             '("jump\\.domain" nil "/proxy@gw.step.domain:"))
(add-to-list 'tramp-default-proxies-alist
             '("local\\.domain" nil nil)) ;; prevent multi-hops
```

`*.local.domain` への接続は `PROXY` に `nil` を指定したルールが最初にヒットしてしまうため、常に失敗することは明らかです。

そこでシンプルに考えて、`tramp-default-proxies-alist` の各要素を `(HOST USER PROXY NO-MORE-HOPS)` という風に拡張し、`NO-MORE-HOPS` が `t` なら `PROXY` を接続先リストの先頭に追加して多段生成ループを抜ける、というロジックを考えてみます。

```el
    ;; Look for proxy hosts to be passed.
    (setq choices tramp-default-proxies-alist)
    (while choices
      (setq item (pop choices)
            proxy (eval (nth 2 item))
            no-more-hops (eval (nth 3 item)))
      (when (and
             ;; Host.
             (string-match (or (eval (nth 0 item)) "")
                           (or (tramp-file-name-host (car target-alist)) ""))
             ;; User.
             (string-match (or (eval (nth 1 item)) "")
                           (or (tramp-file-name-user (car target-alist)) "")))
        (if (null proxy)
            ;; No more hops needed.
            (setq choices nil)
          ;; Replace placeholders.
          (setq proxy
                (format-spec
                 proxy
                 (format-spec-make
                  ?u (or (tramp-file-name-user (car target-alist)) "")
                  ?h (or (tramp-file-name-host (car target-alist)) ""))))
          (with-parsed-tramp-file-name proxy l
            ;; Add the hop.
            (add-to-list 'target-alist l)
            ;; Start next search or break if no more hops needed
            (setq choices (if no-more-hops nil
                            tramp-default-proxies-alist))))))
```

これで設定は以下のように書けます。

```el
(add-to-list 'tramp-default-proxies-alist
             '("local\\.domain" nil "/plink:%u@%h:" t)) ;; prevent multi-hops any more
(add-to-list 'tramp-default-proxies-alist
             '("hop\\.domain" nil "/proxy@gw.local.domain:"))
(add-to-list 'tramp-default-proxies-alist
             '("step\\.domain" nil "/proxy@gw.hop.domain:"))
(add-to-list 'tramp-default-proxies-alist
             '("jump\\.domain" nil "/proxy@gw.step.domain:"))
```

既存の挙動と互換性を維持したまま、機能拡張できそうです。しかも、一段目に接続する時も `/plink:` を指定する必要がなくなり、利便性が向上しています。

でも、`tramp-default-proxies-alist` をこんなふうに拡張してしまうのは、バッドノウハウを増やすだけのような罪悪感があり、気が引けます。パッチを送るべきかどうか、悩むところです。

# まとめ

NTEmacs で多段 ssh 接続する方法をまとめました。無限ループにはまって、ずいぶん寄り道してしまいましたが、モヤモヤを解消できてスッキリしました。Emacs との腐れ縁はまだまだ続きそうです。

# 参考

- [TRAMP User Manual](https://www.gnu.org/software/tramp/)