Apache MINA SSHD を使って Java で SFTP サーバをつくる

https://github.com/apache/mina-sshd
MINA SSHD を使えば Java のプロセスを立ち上げるだけで SFTP サーバが用意できます。
急に SFTP サーバが必要になった時に便利。

Spring Boot で Bean としておけば、 HTTP を提供しながら SFTP を提供とかできます。
試した系の記事はもうありますが、細かいところを色々調べる必要があったのでメモ。

----

全体はこんな感じ。結構公開用に書き直したりメソッドを開いたりしているので参考程度です。

```java
    @Bean
    public SshServer sftpServer(
            String serverKeyPath
    ) throws IOException {
        
        // 1
        SshServer server = SshServer.setUpDefaultServer();
        server.setPort(8021);
        
        // 2
        server.setKeyPairProvider(new FileKeyPairProvider(Path.of(serverKeyPath)));

        // 3
        SftpSubsystemFactory sftpSubsystemFactory = new Builder().build();
        sftpSubsystemFactory.addSftpEventListener(new AbstractSftpEventListenerAdapter() {
            private final Map<ServerSession, String> writtenFilePath = new HashMap<>();

            @Override
            public void written(ServerSession session, String remoteHandle, FileHandle localHandle, long offset,
                                byte[] data, int dataOffset, int dataLen, Throwable thrown) throws IOException {
                super.written(session, remoteHandle, localHandle, offset, data, dataOffset, dataLen, thrown);
                writtenFilePath.put(session, localHandle.getFile().toString());
            }

            @Override
            public void closed(ServerSession session, String remoteHandle, Handle localHandle, Throwable thrown)
                    throws IOException {
                if (writtenFilePath.containsKey(session)) {
                    log.info("[SFTP_EVENT] session:{} written file: {}", session,
                             writtenFilePath.remove(session));
                }
            }

            @Override
            public void removed(ServerSession session, Path path, boolean isDirectory, Throwable thrown)
                    throws IOException {
                super.removed(session, path, isDirectory, thrown);
                log.info("[SFTP_EVENT] session:{} removed {}: {}",
                         session, isDirectory ? "directory" : "file", path);
            }

            @Override
            public void created(ServerSession session, Path path, Map<String, ?> attrs, Throwable thrown)
                    throws IOException {
                super.created(session, path, attrs, thrown);
                log.info("[SFTP_EVENT] session:{} created directory: {}", session, path);
            }

            @Override
            public void moved(ServerSession session, Path srcPath, Path dstPath, Collection<CopyOption> opts,
                              Throwable thrown) throws IOException {
                super.moved(session, srcPath, dstPath, opts, thrown);
                log.info("[SFTP_EVENT] session:{} moved path src:{} dst:{}", session, srcPath, dstPath);
            }
        });
        server.setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory));

        // 4
        // secure "aes128-ctr,aes192-ctr,aes256-ctr"
        server.setCipherFactoriesNames("aes256-ctr");
        // secure "ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521"
        server.setKeyExchangeFactories(
                server.getKeyExchangeFactories().stream()
                      .filter(f -> Arrays
                              .asList("ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521")
                              .contains(f.getName())).collect(Collectors.toList()));
        // secure hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1
        server.setMacFactoriesNames("hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com");

        // 5
        server.setPasswordAuthenticator((username, password, session) -> {
            log.info("* try password login {}:{}", session, username);

            if ("passworduser".equals(username)) {
                if ("password".equals(password)) {
                    log.info("* match pass");
                    return true;
                }
                log.info("* unmatch pass");
                return false;
            }
            log.info("* user unmatch");
            return false;
        });

        // 6
        AuthorizedKeyEntry keyEntry =
                AuthorizedKeyEntry.parseAuthorizedKeyEntry("ecdsa-sha2-nistp521 AAAA....");
        server.setPublickeyAuthenticator((username, key, session) -> {
            log.info("* try key login {}:{}:{}", session, username, key);

            if ("publickeyuser".equals(username)) {
                PublicKey resolvePublicKey;
                try {
                    resolvePublicKey = keyEntry.resolvePublicKey(session, keyEntry.getLoginOptions(), null);
                } catch (IOException | GeneralSecurityException e) {
                    log.error("thrown Exception", e);
                    return false;
                }
                if (KeyUtils.compareKeys(key, resolvePublicKey)) {
                    log.info("* match key");
                    return true;
                }
                log.info("* unmatch key");
            }

            log.info("* user unmatch");
            return false;
        });

        // 7
        VirtualFileSystemFactory vfs = new VirtualFileSystemFactory();
        vfs.setDefaultHomeDir(Path.of("/tmp/sshd"));
        vfs.setUserHomeDir("passworduser",Path.of("/tmp/sshd/passworduser"));
        vfs.setUserHomeDir("publickeyuser",Path.of("/tmp/sshd/publickeyuser"));
        server.setFileSystemFactory(vfs);

        // 8
        server.start();

        return server;
    }
```

# 1 インスタンス立ち上げ

```java
        // 1
        SshServer server = SshServer.setUpDefaultServer();
        server.setPort(8021);
```

# 2 server key の設定

```java
        // 2
        server.setKeyPairProvider(new FileKeyPairProvider(Path.of(serverKeyPath)));
```

SSH接続しに行った時に fingerprint とか出て known_hosts に追加されるやつありますね？
それの設定です。
この書き方だと `serverKeyPath` に PEM 形式の秘密鍵のパスを指定すればOK。
なお、執筆時点で EdDSA は使えませんでした。

この項目を設定しないと、都度ランダムで server key が発行され、実用は厳しい感じになります。
`serverKeyPath` にファイルがない場合は生成されたキーが保存され次回から使われるはずなので、単体で使う場合はそれもアリかも。

# 3 SFTP の設定

```java
        // 3
        SftpSubsystemFactory sftpSubsystemFactory = new Builder().build();
        sftpSubsystemFactory.addSftpEventListener(new AbstractSftpEventListenerAdapter() {
            private final Map<ServerSession, String> writtenFilePath = new HashMap<>();

            @Override
            public void written(ServerSession session, String remoteHandle, FileHandle localHandle, long offset,
                                byte[] data, int dataOffset, int dataLen, Throwable thrown) throws IOException {
                super.written(session, remoteHandle, localHandle, offset, data, dataOffset, dataLen, thrown);
                writtenFilePath.put(session, localHandle.getFile().toString());
            }

            @Override
            public void closed(ServerSession session, String remoteHandle, Handle localHandle, Throwable thrown)
                    throws IOException {
                if (writtenFilePath.containsKey(session)) {
                    log.info("[SFTP_EVENT] session:{} written file: {}", session,
                             writtenFilePath.remove(session));
                }
            }

            @Override
            public void removed(ServerSession session, Path path, boolean isDirectory, Throwable thrown)
                    throws IOException {
                super.removed(session, path, isDirectory, thrown);
                log.info("[SFTP_EVENT] session:{} removed {}: {}",
                         session, isDirectory ? "directory" : "file", path);
            }

            @Override
            public void created(ServerSession session, Path path, Map<String, ?> attrs, Throwable thrown)
                    throws IOException {
                super.created(session, path, attrs, thrown);
                log.info("[SFTP_EVENT] session:{} created directory: {}", session, path);
            }

            @Override
            public void moved(ServerSession session, Path srcPath, Path dstPath, Collection<CopyOption> opts,
                              Throwable thrown) throws IOException {
                super.moved(session, srcPath, dstPath, opts, thrown);
                log.info("[SFTP_EVENT] session:{} moved path src:{} dst:{}", session, srcPath, dstPath);
            }
        });
        server.setSubsystemFactories(Collections.singletonList(sftpSubsystemFactory));
```

SFTP を使いたい時は `SftpSubsystemFactory` をデフォルトで作ってセットすればOK。
`setCommandFactory` でセットしなければシェルは使えないので、 SFTP だけ提供できます。

しかし、せっかくなのでログ取りたいですよね。
イベントリスナで取れるんですが、結構多いのでファイル操作系に絞って出しています。ログインは別であります。
色々実験して、ファイル転送時は

opening -> open -> (writing -> written -> writing ...) -> closing -> closed

の順で呼ばれるとわかったので、 written された後の closed で完了時のみ書くようにしています。
全部のログを出すとちょっとダルそう。

他
created: mkdir
removed: rm rmdir
moved: mv
この辺は操作あたり1回しか呼ばれません。

# 4 secure algorithm 設定系

```java
        // 4
        // secure "aes128-ctr,aes192-ctr,aes256-ctr"
        server.setCipherFactoriesNames("aes256-ctr");
        // secure "ecdh-sha2-nistp256,ecdh-sha2-nistp384,ecdh-sha2-nistp521"
        server.setKeyExchangeFactories(
                server.getKeyExchangeFactories().stream()
                      .filter(f -> Arrays
                              .asList("ecdh-sha2-nistp256", "ecdh-sha2-nistp384", "ecdh-sha2-nistp521")
                              .contains(f.getName())).collect(Collectors.toList()));
        // secure hmac-sha2-256-etm@openssh.com,hmac-sha2-512-etm@openssh.com,hmac-sha1-etm@openssh.com,hmac-sha2-256,hmac-sha2-512,hmac-sha1
        server.setMacFactoriesNames("hmac-sha2-256-etm@openssh.com", "hmac-sha2-512-etm@openssh.com");
```

この記事を参考にして、手元で試しながら設定。
https://qiita.com/aqmr-kino/items/8c3306ea8022b0d5cbe4
デフォルトでも明らかにダメって感じではなさそうでしたが……。
用途に合わせて設定した方が良いと思われます。

# 5 パスワード認証のユーザ設定

```java
        // 5
        server.setPasswordAuthenticator((username, password, session) -> {
            log.info("* try password login {}:{}", session, username);

            if ("passworduser".equals(username)) {
                if ("password".equals(password)) {
                    log.info("* match pass");
                    return true;
                }
                log.info("* unmatch pass");
                return false;
            }
            log.info("* user unmatch");
            return false;
        });
```

パスワード認証、今日日使うかねって感じですが、まぁ開発環境ぐらいなら……。
これは自分で書かなくてもいい感じにやってくれるクラスがあったかもしれません。

# 6 公開鍵認証のユーザ設定

```java
        // 6
        AuthorizedKeyEntry keyEntry =
                AuthorizedKeyEntry.parseAuthorizedKeyEntry("ecdsa-sha2-nistp521 AAAA....");
        server.setPublickeyAuthenticator((username, key, session) -> {
            log.info("* try key login {}:{}:{}", session, username, key);

            if ("publickeyuser".equals(username)) {
                PublicKey resolvePublicKey;
                try {
                    resolvePublicKey = keyEntry.resolvePublicKey(session, keyEntry.getLoginOptions(), null);
                } catch (IOException | GeneralSecurityException e) {
                    log.error("thrown Exception", e);
                    return false;
                }
                if (KeyUtils.compareKeys(key, resolvePublicKey)) {
                    log.info("* match key");
                    return true;
                }
                log.info("* unmatch key");
            }

            log.info("* user unmatch");
            return false;
        });
```

これが結構めんどくさかった。ドキュメントを読んでもよくわからず。
`authorized_keys` にセットする形式と同じ、 OpenSSH 形式の公開鍵を元に認証できます。
先に `AuthorizedKeyEntry` を作り、 `ServerSession` を使って `PublicKey` を作り、それで突合するという流れ。
先に `PublicKey` をそのまま直で作っておけないのか？ 色々と理解できていないんですが……。

この例だと単一ユーザのため微妙ですが、実際は `Map<String,AuthorizedKeyEntry>` みたいな形でユーザごとに管理しています。
これ自分で書かないとダメなのかって感じで、実際ライブラリから色々クラスは用意されているんですが、今ひとつ使い方がわからず……。
ここはもっといい方法があるかもしれません。

# 7 ユーザとファイルシステムの紐付け

```java
        // 7
        VirtualFileSystemFactory vfs = new VirtualFileSystemFactory();
        vfs.setDefaultHomeDir(Path.of("/tmp/sshd"));
        vfs.setUserHomeDir("passworduser",Path.of("/tmp/sshd/passworduser"));
        vfs.setUserHomeDir("publickeyuser",Path.of("/tmp/sshd/publickeyuser"));
        server.setFileSystemFactory(vfs);
```

ユーザごとにホームディレクトリが設定でき、設定しない場合はデフォルトが使用されます。
`VirtualFileSystemFactory` 以外にも実装クラスは用意されていますが、多分これが一番ラクなのではないかと思います。
ディスクトラバーサルもできないようになっていて、影響を限定できます。

また、設置されたファイルはすべて java プロセスの実行ユーザのものになります。

# 8 start

```java
        // 8
        server.start();
```

以上です。