C# - FTP/FTPS/SFTPクライアント（+ Ubuntu 24.04 でサーバ構築）

## はじめに

昔は、ファイル転送として、大変お世話になった FTP ですが、WEB API で事足りるので、しばらく利用する機会がありませんでした。
しかし、数年前、案件対応として、FTPS でのファイル転送を C# で実装するケースがありました。
今回は、FTP / FTPS / SFTP クライアントについて記載したいと思います。

### テスト環境
ここに記載した情報/ソースコードは、Visual Studio Community 2022 を利用した下記プロジェクトで生成したモジュールを Windows 11 24H2 で動作確認しています。

- Windows Forms - .NET Framework 4.8
- Windows Forms - .NET 8
- WPF - .NET Framework 4.8
- WPF - .NET 8

::: note
Visual Studio 2022 - .NET Framework 4.8 は、C# 7.3 が既定です。
このため、サンプルコードは、C# 7.3 機能範囲で記述しています。
:::

FTP / FTPS / SFTP サーバは、Ubuntu 24.04 上に構築しました。

::: note
こちらは主題ではないので、ペアでの確認用に、メモ程度に残しておきます。
と言いながら、こちらの情報量のほうが多いかも、、、
※テスト用の設定なので、実運用としては、相応しくない可能性があります。
:::

## FTP/FTPS/SFTP
> [FTP、FTPS、SFTP、SCP：ファイル転送プロトコルを解説](https://www.integrate.io/jp/blog/the-complete-guide-to-ftp-ftps-sftp-and-scp-ja/)

まずは、FTP / FTPS / SFTP の概要説明をします。

- FTP
  - FTP（File Transfer Protocol）は、1970年代から利用されている、ファイル転送プロトコルで、制御チャネルとデータ転送チャネルの 2つのチャネル（デュアルチャネル）を利用します。
  - FTP には、Active（アクティブ：能動的）モードと Passive（パッシブ：受動的）モードが存在します。双方ともに、最初は、クライアントからサーバのポート番号 21 に接続して制御チャネルを確立します。
    - Active モードでは、クライアントからサーバに接続ポート番号を伝えて、サーバがポート番号 20 からクライアントの対象ポートに接続して、データチャネルを確立します。
    - Passive モードでは、サーバからクライアントに、サーバで割り当てらた範囲内のポート番号を伝えて、クライアントからサーバの対象ポートに接続して、データ転送チャネルを確立します。
  - メリット
    - 迅速、かつ、シンプルです。
  - デメリット
    - 暗号化されないため、セキュリティリスクがあります。
- FTPS
  - FTPS（FTP Secure、もしくは、FTP over SSL/TLS）は、FTP で送受信するデータを、SSL/TLS で暗号化する通信プロトコルで、FTP と同様に、制御チャネルとデータ転送チャネルの 2つのチャネルを利用します。（Active モードと Passive モードが存在）
  - FTPS には、暗号化モードとして、Explicit（エクスプリシット：明示的）モード と Implicit（インプリシット：暗黙的）モードが存在します。
    - Explicit モードは、クライアントからサーバのポート番号 21 に接続して制御チャネルを確立します。通信開始時は暗号化せず、クライアントが AUTH コマンドを実行することで、途中から通信内容を暗号化します。データ転送チャネルの利用ポート番号については、Active モード / Passive モードともに FTP と同様です。
    - Implicit モードは、クライアントからサーバのポート番号 990 に接続して制御チャネルを確立して、初めから暗号化通信を行います。データ転送チャネルの利用ポート番号については、Active モードの場合、サーバはポート番号 20 ではなく 989 を利用します。Passive モードの場合、FTP と同様です。
  - メリット
    - 既存の FTP インフラを活用しながらセキュリティを強化できるため、システムの大幅な変更が不要です。
    - SSL/TLS で、認証情報、および、転送データの暗号化を行うことで、セキュリティリスクが軽減されます。
  - デメリット
    - ファイアウォールに妨げられることがあるので、ポート番号、もしくは、FTPS サーバ / クライアントソフトの例外登録が必要となります。
- SFTP
  - SFTP（SSH File Transfer Protocol）は、SSH（Secure Shell）を利用したファイル転送プロトコルです。SSH の暗号化を利用することで、高いセキュリティを確保します。また、FTP とは異なり、シングルチャネル（制御とデータ転送を同じチャネルで実施）実装となり、ポート番号 22 を利用します。
  - メリット
    - すべての通信が暗号化され、認証も SSH の強力な仕組みに基づいて行われます。
    - シングルチャネルのため、設定やファイアウォールの構成が容易です。
  - デメリット
    - SSH が標準利用できる環境でない場合には、各種ソフトの導入が必要となります。

::: note
上記に記載したポート番号は一般的なポート番号です。
システムによっては、異なるポート番号を利用するケースもあります。
:::

::: note
上記以外に、SSH を使用したファイル転送プロトコル SCP（Secure Copy Protocol）もありますが、今回は割愛させて頂きます。
:::


## 事前準備
### Ubuntu

下記目的で ftpusers というグループを作成します。

- FTP / FTPS で vsftpd.conf - umask を 002 として、対象グループで更新可とする
- SFTP 接続を対象グループで許可して umask を 002 として、対象グループで更新可とする

```shell-session
$ sudo groupadd ftpusers
```

ftpuser1, ftpuser2 というユーザを ftpusers グループで作成して、パスワードを設定します。

```shell-session
$ sudo useradd -m -g ftpusers ftpuser1
$ sudo passwd sftpuser1
新しい パスワード: 
新しい パスワードを再入力してください: 
passwd: パスワードは正しく更新されました
$ sudo useradd -m -g ftpusers ftpuser2
$ sudo passwd sftpuser2
新しい パスワード: 
新しい パスワードを再入力してください: 
passwd: パスワードは正しく更新されました
```

共用公開ディレクトリとして /var/ftproot を作成して、サンプルとして hoge1.txt を配置します。

```shell-session
$ sudo -i
# mkdir /var/ftproot
# chown root:ftpusers /var/ftproot
# chmod 0775 /var/ftproot
# echo "HogeHoge" > /var/ftproot/hoge1.txt
# chown ftpuser2:ftpusers /var/ftproot/hoge1.txt
# chmod 0664 /var/ftproot/hoge1.txt
$
```

### C# クライアント

FTP / FTPS は、[FtpWebRequest](https://learn.microsoft.com/ja-jp/dotnet/api/system.net.ftpwebrequest) を用いた実装が可能でしたが、FtpWebRequest 基底クラス WebRequest が .NET 6 で非推奨となり、FtpWebRequest マニュアルに「新しい開発に FtpWebRequest を使用することはお勧めしません」との記載がされました。
このため、[NuGet Gallery | FluentFTP](https://www.nuget.org/packages/FluentFTP/) を利用することとします。

SFTP については、[NuGet Gallery | SSH.NET](https://www.nuget.org/packages/SSH.NET) を利用します。

```
PM> NuGet\Install-Package FluentFTP
PM> NuGet\Install-Package SSH.NET
```

## FTP

FTP - Passive をサンプルとします。

### Ubuntu
> [FTPサーバ vsftpdの設定(vsftpd.conf)](http://park12.wakwak.com/~eslab/pcmemo/linux/vsftpd/vsftpd2.html)

vsftpd（3.0.5）をインストールして、起動されていることを確認します。

```shell-session
$ sudo apt update
$ sudo apt install vsftpd

$ sudo systemctl status vsftpd
```

vsftpd の設定ファイルで、接続時のルートディレクトリ、Passive モードなどを設定します。

```shell-session
$ sudo vi /etc/vsftpd.conf
```

```
# vsftpd.conf 既存項目
listen=YES
listen_ipv6=NO
anonymous_enable=NO
local_enable=YES
write_enable=YES
local_umask=002
use_localtime=YES
connect_from_port_20=NO
ascii_upload_enable=YES
ascii_donwload_enable=YES
chroot_local_user=YES

# 接続可能ユーザ制限
userlist_enable=YES
userlist_deny=NO
userlist_file=/etc/vsftpd.user_list

# 接続時のルートディレクトリ
local_root=/var/ftproot

# 書き込み権限があるとchroot出来ない機能を無効
allow_writeable_chroot=YES

# Passive モード関係
pasv_enable=YES
pasv_min_port=50000
pasv_max_port=50050
```

接続可能ユーザを vsftpd.user_list に記載します。

```shell-session
$ sudo vi /etc/vsftpd.user_list
```
```
ftpuser1
ftpuser2
```

FireWall の状態を確認、非アクティブでしたので FireWall 設定はスキップします。
※本記事は Ubuntu サーバ構築ではなく、FTP/FTPS/SFTP クライアントが主題のため。

```shell-session
$ sudo ufw status
状態：非アクティブ
```

vsftpd を再起動します。

```shell-session
$ sudo systemctl restart vsftpd
```

### C# クライアント

FluentFTP は GitHub で公開されていて、CSharpExamples もあります。

https://github.com/robinrodricks/FluentFTP

以前、利用した時は 37.0 でしたが、[RELEASES.md](https://github.com/robinrodricks/FluentFTP/blob/master/RELEASES.md#4000) を見ると、40.0 で大幅な改修がされて、設定情報が Config に集約されたようです。

FtpClient（同期）、AsyncFtpClient（非同期）が用意されています。
FtpClient での 接続 / 一覧取得 / ダウンロード / アップロード / 切断 サンプルコードを記載します。

```CSharp
using FluentFTP;
```

```CSharp
// FtpClient は、IDisposable なので、using もしくは Dispose() が必要
// IP_OR_HOST：接続先、ftpuser1：ユーザ名、PASSWORD：パスワード、21：接続ポート
using (var client = new FluentFTP.FtpClient("IP_OR_HOST", "ftpuser1", "PASSWORD", 21))
{
  // FTP - Passive
  client.Config.EncryptionMode = FtpEncryptionMode.None;
  client.Config.DataConnectionType = FtpDataConnectionType.AutoPassive;

  // タイムアウト値 (msec)
  client.Config.ConnectTimeout = 5000;
  // 必要に応じて下記も設定
  // client.Config.DataConnectionConnectTimeout
  // client.Config.DataConnectionReadTimeout

  // 接続
  try
  {
    client.Connect();
  }
  catch (System.TimeoutException)
  {
    // タイムアウト - TODO
  }
  catch (FluentFTP.Exceptions.FtpAuthenticationException)
  {
    // 認証情報不正、および、FTP接続許可されていないユーザ - TODO
  }

  // 公開フォルダの一覧取得
  FtpListItem[] items = client.GetListing();
  foreach (var item in items)
  {
    var target = item.Name;
    // TODO
  }

  // ダウンロード
  string localPath = System.IO.Path.GetTempPath() + "hoge1.txt";
  string remotePath = "hoge1.txt";
  FtpStatus sts = client.DownloadFile(localPath, remotePath, FtpLocalExists.Overwrite);
  if (sts != FtpStatus.Success)
  {
    // ERROR - TODO
  }

  // アップロード
  remotePath = "hoge2.txt";
  sts = client.UploadFile(localPath, remotePath, FtpRemoteExists.Overwrite);
  if (sts != FtpStatus.Success)
  {
    // ERROR - TODO
  }

  // 切断
  client.Disconnect();
}
```

FluentFTP.FtpClient、上記以外の主な機能を記載します。

| メソッド                  | 内容                       |
| ------------------------ | -------------------------- |
| FileExists               | リモートファイル存在確認     |
| DeleteFile               | リモートファイル削除         |
| DirectoryExists          | リモートディレクトリ存在確認 |
| CreateDirectory          | リモートディレクトリ作成     |
| Rename                   | リモートファイル名称変更     |


## FTPS

FTPS - Implicit Passive をサンプルとします。

### Ubuntu

> [【vsftpd】FTPをSSL/TLSで暗号化する設定 #ftps - Qiita](https://qiita.com/aqmr-kino/items/ad80258eacd69a78064c)
> [vsftpdでchrootを使って複数ユーザでFTPS接続させる](https://beyondjapan.com/blog/2023/10/vsftpd_ftps/)
> [vsftpd.conf(5) — Arch manual pages](https://man.archlinux.org/man/vsftpd.conf.5.en)


前述、FTP サーバとして構築した環境を FTPS - Implicit に再構築します。
まず、自己署名証明書を作成します。
vsftpd.key 作成で、画面表示に従いパスワードを設定します。

```shell-session
$ sudo -i
# cd /etc/ssl/private
# openssl genrsa -aes256 4096 > vsftpd.key
Generating RSA private key, 4096 bit long modulus (2 primes)
........++++
.......................++++
e is 65537 (0x010001)
Enter pass phrase:
Verifying - Enter pass phrase:
```

vsftpd.conf では、rsa_private_key_file に対するパスワード設定項目がないので、パスワードを削除した後、vsftpd.pem を作成して、ファイルモードを変更します。

```shell-session
# openssl rsa -in vsftpd.key -out vsftpd.key
Enter pass phrase for vsftpd.key:
writing RSA key
# openssl req -new -key vsftpd.key -x509 -days 365 -out vsftpd.pem
You are about to be asked to enter information that will be incorporated
into your certificate request.
What you are about to enter is what is called a Distinguished Name or a DN.
There are quite a few fields but you can leave some blank
For some fields there will be a default value,
If you enter '.', the field will be left blank.
-----
Country Name (2 letter code) [AU]:JP
State or Province Name (full name) [Some-State]:Tokyo
Locality Name (eg, city) []:Tokyo
Organization Name (eg, company) [Internet Widgits Pty Ltd]:hoge
Organizational Unit Name (eg, section) []:hoge
Common Name (e.g. server FQDN or YOUR name) []:hoge
Email Address []:hoge@example.com
# chmod 0666 vsftpd.key vsftpd.pem
# exit
$
```

::: note
自己署名証明書なので、 vsftpd.key と vsftpd.pem から、Windows 用の vsftpd.pfx 作成は割愛させて頂きます。
:::

vsftpd の設定ファイルに下記内容を更新します。

```shell-session
$ sudo vi /etc/vsftpd.conf
```

```
# vsftpd.conf 既存項目をコメントアウト
#rsa_cert_file=/etc/ssl/certs/ssl-cert-snakeoil.pem
#rsa_private_key_file=/etc/ssl/private/ssl-cert-snakeoil.key
#ssl_enable=NO

# SSL/TLSを有効化
ssl_enable=YES

# 使用プロトコル
ssl_sslv2=NO
ssl_sslv3=NO
ssl_tlsv1=NO
ssl_tlsv11=NO
ssl_tlsv12=YES
ssl_tlsv13=YES

# 暗号化方式
ssl_ciphers=HIGH

# サーバ証明書、秘密鍵
rsa_cert_file=/etc/ssl/private/vsftpd.pem
rsa_private_key_file=/etc/ssl/private/vsftpd.key

# Implicit モード設定
implicit_ssl=YES
listen_port=990
```

::: note
vsftpd.conf 記載項目でつまずいたら、[Ubuntu – Ubuntu パッケージ検索](https://packages.ubuntu.com/ja/) で vsftpd を検索、vsftpd_3.0.5.orig.tar.gz をダウンロード、解凍した parseconf.c を確認という手もあります。
:::

vsftpd を再起動します。

```shell-session
$ sudo systemctl restart vsftpd
```

### C# クライアント

FTP サンプルコードの一部を更新して、FTPS - Implicit Passive とします。
- ポート番号 990 利用
- FtpEncryptionMode.Implicit 指定
- ValidateCertificate コールバック

```CSharp
// FtpClient は、IDisposable なので、using もしくは Dispose() が必要
// IP_OR_HOST：接続先、ftpuser1：ユーザ名、PASSWORD：パスワード、990：接続ポート
using (var client = new FluentFTP.FtpClient("IP_OR_HOST", "ftpuser1", "PASSWORD", 990))
{
  // FTPS - Implicit Passive
  client.Config.EncryptionMode = FtpEncryptionMode.Implicit;
  client.Config.DataConnectionType = FtpDataConnectionType.AutoPassive;

  // 証明書の内容を確認しない（自己署名証明書なので）
  client.ValidateCertificate += (control, e) => { e.Accept = true; };

  // タイムアウト値 (msec)
  client.Config.ConnectTimeout = 5000;
  
＜以降は FTP と同一＞
```

正規の認証局が発行する証明書がある場合には、下記手順で証明書を利用します。

```CSharp
// 証明書
string pfxPath = "PATH_TO_PFX";                              // TODO - pfxファイルのパス
var certificate = new X509Certificate2(pfxPath, "PASSWORD"); // TODO - pfxのパスワード
client.Config.ClientCertificates.Add(certificate);
client.ValidateCertificate += (control, e) => {
  if (e.PolicyErrors == System.Net.Security.SslPolicyErrors.None)
  {
    e.Accept = true;
  }
  else
  {
    // TODO 
    e.Accept = false;
  }
};
```


## SFTP

パスワード認証と公開鍵認証がありますが、今回は、パスワード認証とします。


### Ubuntu

> [【Ubuntu】Linuxでsftpサーバー構築し、sftpユーザーのディレクトリ制限を行う方法](https://choppydays.com/ubuntu-linux-sftp-server/)

SSHサーバとしてOpen SSH Serverをインストールして、起動されていることを確認します。

```shell-session
$ sudo apt update
$ sudo apt install openssh-server

$ sudo systemctl status ssh
```

sshd_config を修正します。

```shell-session
$ sudo vi /etc/ssh/sshd_config
```
```
# ssh 無効、sftp のみ有効
#Subsystem      sftp    /usr/lib/openssh/sftp-server
Subsystem       sftp    internal-sftp

# パスワード認証無効、公開鍵認証有効
PasswordAuthentication no
PubkeyAuthentication yes

# ftpusers グループはパスワード認証有効、/var/ftproot を初期ディレクトリ
Match Group ftpusers
        X11Forwarding no
        AllowTcpForwarding no
        PasswordAuthentication yes
        ForceCommand internal-sftp -d /var/ftproot -u 002
```

::: note
ForceCommand internal-sftp 引数は、[sftp-server(8) - Linux manual page](https://www.man7.org/linux/man-pages/man8/sftp-server.8.html) 参照してください。
:::

FireWall の状態を確認、非アクティブでしたので FireWall 設定はスキップします。
※本記事は Ubuntu サーバ構築ではなく、FTP/FTPS/SFTP クライアントが主題のため。

```shell-session
$ sudo ufw status
状態：非アクティブ
```

SSHサーバを再起動します。

```shell-session
$ sudo systemctl restart ssh
```

### C# クライアント

> [GitHub - sshnet/SSH.NET: SSH.NET is a Secure Shel](https://github.com/sshnet/SSH.NET)
> [C#でSFTPでファイル転送やってみる](https://yoshinorin.net/articles/2016/12/08/csharp-sftp/)


SftpClient での 接続 / 一覧取得 / ダウンロード / アップロード / 切断 サンプルコードを記載します。

```CSharp
// SftpClient は、IDisposable なので、using もしくは Dispose() が必要
// IP_OR_HOST：接続先、22：接続ポート、ftpuser1：ユーザ名、PASSWORD：パスワード
using (var client = new SftpClient("IP_OR_HOST", 22, "ftpuser1", "PASSWORD"))
{
  // タイムアウト値 (TimeSpan)
  client.ConnectionInfo.Timeout = TimeSpan.FromSeconds(5);
  // 必要に応じて下記も設定
  // client.OperationTimeout

  // 接続
  try
  {
    client.Connect();
  }
  catch (Renci.SshNet.Common.SshOperationTimeoutException)
  {
    // タイムアウト - TODO
  }
  catch(Renci.SshNet.Common.SshAuthenticationException)
  {
    // 認証情報不正、および、対象ユーザはパスワード認証無効 - TODO
  }

  // 公開フォルダの一覧取得
  foreach (ISftpFile file in client.ListDirectory("."))
  {
    var name = file.Name;
    if (name.StartsWith("."))
    {
      // "."、".." などをスキップ
      continue;
    }
    // TODO
    var size = file.Length;
  }

  // ダウンロード
  string localPath = System.IO.Path.GetTempPath() + "hoge1.txt";
  string remotePath = "hoge1.txt";
  using (var fs = System.IO.File.OpenWrite(localPath))
  {
    client.DownloadFile(remotePath, fs);
  }

  // アップロード
  remotePath = "hoge2.txt";
  using (var fs = System.IO.File.OpenRead(localPath))
  {
    client.UploadFile(fs, remotePath, true);  // true:上書き
  }

  // 切断
  client.Disconnect();
}
```

SftpClient、および、ISftpFile、上記以外の主な機能を記載します。

| メソッド                     | 内容              |
| ----------------------------| ----------------- |
| SftpClient.CreateDirectory  | ディレクトリ作成   |
| SftpClient.Exist            | 存在確認           |
| ISftpFile.Delete            | 削除              |
| ISftpFile.MoveTo            | リネーム           |

対象がディレクトリかの確認は、プロパティで可能です。

| プロパティ             | 内容                 |
| ----------------------| -------------------- |
| ISftpFile.IsDirectory | ディレクトリ確認      |