本文へ移動
購読
GUIDE#security更新 2026.10.05

SFTPの使い方:sftpコマンドの基本と専用ユーザーをchrootで作る手順

OpenSSH 10.2p1を2台で検証。sftpのput・getと転送の再開、-bのバッチとcron、chroot付きの転送専用ユーザーの作り方、sshログインやポート転送が拒否されることの確認、転送ログの残し方まで。

level
中級
time
約75分
stack
Ubuntu 26.04.1 LTS / OpenSSH 10.2p1 (openssh-server・openssh-client・openssh-sftp-server 1:10.2p1-2ubuntu3.6) / rsyslog 8.2512.0 (8.2512.0-1ubuntu4.2) / rsync 3.4.1 / cron 3.0pl1
author
@ryoma_t
// 前提条件
sudoが使えるUbuntuサーバー1台と接続元のマシン1台(またはDockerコンテナ2つ) / 2台の間でSSH(TCP 22番)が通ること / SSHの鍵認証でサーバーにログインできること
転送専用アカウントへsftpのバッチ送信とscpが成功し、sshログイン・ポート転送・scp -Oが拒否される様子を示すターミナル画面
画像:nagoya.dev(実際の画面・実行結果をもとに作成)

SFTPとは、SSHの接続(既定ではTCP 22番)の中でファイルの送受信と一覧・削除・改名を行うプロトコルで、FTPをTLSで包んだFTPSとは仕組みもポートも別物です。使う道具は、接続する側がsftpコマンド、サーバー側がsshdで、どちらもOpenSSHに入っています。Ubuntu 26.04.1 の OpenSSH 10.2p1 を2台の検証機(Dockerコンテナ)で動かし、putとget、中断した転送の再開、cronからのバッチ実行、そして「シェルには入れず、決めたディレクトリの外は見えない」転送専用アカウントの作成までを、実際の出力で確かめました。

対象は、取引先や外注先とのファイル受け渡し口を作ってほしいと頼まれた情シス・インフラ担当の方です。読み終えると、鍵認証のみ・chroot付き・SFTP限定のアカウントを作る手順と、そのアカウントで何ができて何ができないかを自分で叩いて確かめるコマンド、転送の記録をログに残す設定が手元に残ります。

この記事でわかること

  • sftpコマンドのput・get・-rと、手元側に効くコマンドとサーバー側に効くコマンドの見分け方
  • 中断した転送をreput・regetで再開する手順と、終了コード0のまま中身の違うファイルができる条件
  • Match Group・ChrootDirectory・ForceCommand internal-sftpで作る転送専用アカウントと、1行ずつの理由
  • そのアカウントに対するsshログイン、ポート転送、scp(既定と-O)、rsyncの実測結果
  • internal-sftp -l INFOの転送ログがchrootの中で消える理由と直し方、-uでそろえる権限、FTPS・scp・rsyncとの使い分け

検証環境:2台のUbuntu 26.04とOpenSSH 10.2p1

検証はDockerのubuntu:26.04コンテナ2つで行いました。接続する側のホスト名は nagoyadevlab01、サーバー側は nagoyadevlab02、作業ユーザーはどちらも ryoma、取引先に渡す想定の転送専用アカウントは partner01 です。どれも検証用に付けた名前なので、コマンドや出力に出てくるryoma、/home/ryoma、nagoyadevlab01、nagoyadevlab02、partner01、グループ名のsftponlyとintake、置き場の/srv/sftp、ssh-ed25519 AAAA…の公開鍵は、自分の環境の値に読み替えてください。コンテナは--privilegedなし・ポート公開なしで、同じユーザー定義ネットワークに置いています。起動にはdocker run --initを使いました。--initなしのコンテナでは、止めたrsyslogdがゾンビとして残り、後半で行う再起動がpidfile '/run/rsyslogd.pid' … already existで失敗したためです。

コンテナの中ではsystemdが動かないため、sshdとrsyslogdはバイナリを直接起動しました。実機のUbuntuではどちらもsystemdのユニットとして動いていて、設定の反映にはsystemctlを使います。systemctlによる再起動は、この環境では実行していません(該当するブロックの名前に「未実行」と書いています)。

サーバー側(例では nagoyadevlab02)で root として実行したのは次のコマンドです。rsyslogはSSHのログを/var/log/auth.logに書かせるため、sudoは以降の手順を実機と同じsudo付きのコマンドで進めるために入れました。/etc/sudoers.dにパスワードなしの行を置くのは検証コンテナだけの措置で、実機に持ち込むものではありません。

terminal(サーバー・root)
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-server rsyslog sudo >/dev/null 2>&1; echo "exit=$?"
exit=0
useradd -m -s /bin/bash ryoma && echo "ryoma ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ryoma && chmod 440 /etc/sudoers.d/ryoma && id ryoma
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma)
grep PRETTY_NAME /etc/os-release; sshd -V; dpkg-query -W openssh-server openssh-sftp-server rsyslog
PRETTY_NAME="Ubuntu 26.04.1 LTS"
OpenSSH_10.2p1 Ubuntu-2ubuntu3.6, OpenSSL 3.5.5 27 Jan 2026
openssh-server	1:10.2p1-2ubuntu3.6
openssh-sftp-server	1:10.2p1-2ubuntu3.6
rsyslog	8.2512.0-1ubuntu4.2

デーモンは2つとも直接起動します。rsyslogdが出すimklogの行は、コンテナにカーネルログ(/proc/kmsg)を読む権限がないことを知らせるもので、SSHのログ収集には関係しません。

terminal(サーバー・root・抜粋)
rsyslogd; /usr/sbin/sshd; echo "exit=$?"
rsyslogd: imklog: cannot open kernel log (/proc/kmsg): Operation not permitted.
exit=0

この環境にはjournaldがいないので、sshdのログはrsyslogdが/var/log/auth.logに書きます。以降のログ確認はすべてこのファイルで行いました。実機でrsyslogを入れていない場合、sshd本体の行はjournalctl -u sshで読むことになりますが、そちらの表示は確認できていません。

参考に、パッケージが入れたsystemdのユニットも見ておきます。サービス名はsshで、ExecReloadはsshd -tで設定を検査してからHUPシグナルを送る定義でした。パッケージを入れた直後のコンテナでis-enabledを引くと、ssh.socketが enabled、ssh.serviceが disabled と表示されます。

terminal(サーバー・root)
systemctl is-enabled ssh.socket ssh.service; grep -E '^Exec' /usr/lib/systemd/system/ssh.service
enabled
disabled
ExecStartPre=/usr/sbin/sshd -t
ExecStart=/usr/sbin/sshd -D $SSHD_OPTS
ExecReload=/usr/sbin/sshd -t
ExecReload=/bin/kill -HUP $MAINPID

接続する側(例では nagoyadevlab01。以下クライアントと呼びます)には、SSHクライアントのほか、後半の検査で使うrsyncとcronを入れます。

terminal(クライアント・root)
apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -y -qq openssh-client rsync cron sudo >/dev/null 2>&1; echo "exit=$?"
exit=0
useradd -m -s /bin/bash ryoma && echo "ryoma ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/ryoma && chmod 440 /etc/sudoers.d/ryoma && id ryoma
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma)
ssh -V; dpkg-query -W openssh-client rsync cron
OpenSSH_10.2p1 Ubuntu-2ubuntu3.6, OpenSSL 3.5.5 27 Jan 2026
cron	3.0pl1-200ubuntu1
openssh-client	1:10.2p1-2ubuntu3.6
rsync	3.4.1+ds1-7ubuntu0.3

ここからは作業ユーザーで進めます。まずクライアントで鍵を作り、公開鍵をサーバーの管理用アカウントに登録します。-N ""でパスフレーズを空にしたのは検証を非対話で回すためで、人が日常的に使う鍵にはパスフレーズを付けます(鍵の種類とssh-agentの扱いはssh-keygenでSSH鍵を作る記事にまとめています)。

terminal(クライアント)
ssh-keygen -q -t ed25519 -N "" -C "ryoma@nagoyadevlab01" -f ~/.ssh/id_ed25519 && cat ~/.ssh/id_ed25519.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZR/2f3VY6r5cyknM5x4Lg0d2PLLU27hbjfNkFrB0Jv ryoma@nagoyadevlab01

表示された1行を、サーバーの同じユーザーの~/.ssh/authorized_keysに追記します。ssh-ed25519 AAAA…の部分は必ず自分の画面に出た1行に置き換えてください。続けて、サーバーのホスト鍵の指紋を表示しておきます。

terminal(サーバー)
install -d -m 700 ~/.ssh && echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZR/2f3VY6r5cyknM5x4Lg0d2PLLU27hbjfNkFrB0Jv ryoma@nagoyadevlab01" >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys
ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub
256 SHA256:2UuClrtE5cwgGSbdEBHoYXqYdl3HjzKEU+c8EE/ORNA root@nagoyadevlab02 (ED25519)

クライアントに戻り、サーバーのホスト鍵をknown_hostsに登録します。ssh-keyscanが取ってきた鍵の指紋が、いまサーバーで表示したSHA256:以降と同じであることを目で確かめてから先へ進みます。この登録がないと、初回接続で出るホスト鍵の確認にcronは答えられないので、無人転送は1回目から止まります。

terminal(クライアント)
ssh-keyscan -t ed25519 nagoyadevlab02 >> ~/.ssh/known_hosts; ssh-keygen -lf ~/.ssh/known_hosts
256 SHA256:2UuClrtE5cwgGSbdEBHoYXqYdl3HjzKEU+c8EE/ORNA nagoyadevlab02 (ED25519)
ssh ryoma@nagoyadevlab02 hostname
nagoyadevlab02

sftpコマンドの基本:putとget、手元側とサーバー側の見分け方

sftp ユーザー名@ホスト名で接続するとsftp>というプロンプトに変わり、そこで打つコマンドは「サーバー側に効くもの」と「手元側に効くもの」に分かれます。頭にl(local)が付くものが手元側です。putは手元からサーバーへ、getはサーバーから手元へ送り、ディレクトリごと送るときは-rを付けます。

操作サーバー側手元側
いまいる場所を表示pwdlpwd
一覧ls -llls -l
移動cdlcd
ディレクトリ作成mkdirlmkdir

送るファイルを手元に用意します。

terminal(クライアント)
mkdir -p ~/work/reports/2026-09 && cd ~/work
printf 'id,amount\n1,1200\n2,980\n' > orders.csv
printf 'September summary\n' > reports/2026-09/summary.txt
printf 'monthly reports\n' > reports/readme.txt

記録を取るために、プロンプトで打つ内容をヒアドキュメントで標準入力から流しました。手で操作するときはsftp ryoma@nagoyadevlab02だけを実行し、sftp>に続けてpwdから1行ずつ入力します(終了はbye)。以降のコマンドはクライアントの~/workで実行しています。

terminal(クライアント)
sftp ryoma@nagoyadevlab02 <<'EOT'
pwd
lpwd
mkdir inbox
cd inbox
put orders.csv
put -r reports
ls -l
ls -l reports
lmkdir dl
lcd dl
get orders.csv orders-from-server.csv
get -r reports
lls -l
EOT
出力
Connected to nagoyadevlab02.
sftp> pwd
Remote working directory: /home/ryoma
sftp> lpwd
Local working directory: /home/ryoma/work
sftp> mkdir inbox
sftp> cd inbox
sftp> put orders.csv
Uploading orders.csv to /home/ryoma/inbox/orders.csv
sftp> put -r reports
Uploading reports/ to /home/ryoma/inbox/reports
Entering reports/
Entering reports/2026-09
sftp> ls -l
-rw-r--r--    ? ryoma    ryoma          23 Oct  5 05:17 orders.csv
drwxr-xr-x    ? ryoma    ryoma        4096 Oct  5 05:17 reports
sftp> ls -l reports
drwxr-xr-x    ? ryoma    ryoma        4096 Oct  5 05:17 reports/2026-09
-rw-r--r--    ? ryoma    ryoma          16 Oct  5 05:17 reports/readme.txt
sftp> lmkdir dl
sftp> lcd dl
sftp> get orders.csv orders-from-server.csv
Fetching /home/ryoma/inbox/orders.csv to orders-from-server.csv
sftp> get -r reports
Fetching /home/ryoma/inbox/reports/ to reports
Retrieving /home/ryoma/inbox/reports
Retrieving /home/ryoma/inbox/reports/2026-09
sftp> lls -l
total 8
-rw-r--r-- 1 ryoma ryoma   23 Oct  5 05:17 orders-from-server.csv
drwxr-xr-x 3 ryoma ryoma 4096 Oct  5 05:17 reports

標準入力が端末でないとき、sftpは受け取ったコマンドをsftp>付きで出力に写すので、上の並びは対話したときの画面とほぼ同じです。端末から使った場合は、Uploadingの行の下に、ファイル名・進み具合・転送済みサイズ・速度を並べた進捗メーターが1行加わります(scriptコマンドで擬似端末を割り当てて確認しました)。

出力から読み取れることを順に挙げます。ログイン直後のサーバー側の現在地はホームディレクトリ(/home/ryoma)、手元側の現在地はsftpを起動したディレクトリです。put -r reportsは、送り先に同名のディレクトリがなくてもEntering reports/と表示しながら中身ごと作りました。getは2つ目の引数で保存名を変えられ、orders.csvがorders-from-server.csvとしてdlに届いています。ls -lの2列目(リンク数)は?と表示されますが、これはこのバージョンのsftpの表示で、壊れているわけではありません。

中断した転送をreput・regetで再開する

大きなファイルの途中で回線が切れたとき、最初から送り直す代わりにreput(アップロードの続き)とreget(ダウンロードの続き)が使えます。検証では50,331,648バイトのファイルを作り、-l 40000で帯域を40,000 Kbit/sに絞って送り始め、timeoutで2秒後にsftpを止めて中断を再現しました。

terminal(クライアント)
head -c 48M /dev/urandom > backup.tar && ls -l backup.tar
-rw-r--r-- 1 ryoma ryoma 50331648 Oct  5 05:17 backup.tar
echo 'put backup.tar inbox/backup.tar' > up.batch
timeout 2 sftp -l 40000 -b up.batch ryoma@nagoyadevlab02; echo "exit=$?"
sftp> put backup.tar inbox/backup.tar
exit=124
ssh ryoma@nagoyadevlab02 'ls -l inbox/backup.tar'
-rw-r--r-- 1 ryoma ryoma 9661440 Oct  5 05:18 inbox/backup.tar

exit=124はtimeoutが時間切れで止めた印です。サーバーには9,661,440バイトだけが残りました。この状態からreputを実行します。

terminal(クライアント)
sftp ryoma@nagoyadevlab02 <<'EOT'
cd inbox
reput backup.tar
ls -l backup.tar
EOT
Connected to nagoyadevlab02.
sftp> cd inbox
sftp> reput backup.tar
Resuming upload of backup.tar to /home/ryoma/inbox/backup.tar
sftp> ls -l backup.tar
-rw-r--r--    ? ryoma    ryoma    50331648 Oct  5 05:18 backup.tar
sha256sum backup.tar; ssh ryoma@nagoyadevlab02 'sha256sum inbox/backup.tar'
40addcd5843dcc6732ab80d6ee9972b35ebc587a30fe382a27d97f35e5b7bf0c  backup.tar
40addcd5843dcc6732ab80d6ee9972b35ebc587a30fe382a27d97f35e5b7bf0c  inbox/backup.tar

Resuming upload ofの行が出て、サイズは50,331,648バイトにそろい、sha256sumも両側で一致しました。reputはサーバーにあるファイルのサイズを調べ、手元のファイルをその位置から読んで後ろに書き足します。途中まで届いた分を捨てずに済むのはこのためです。

reputとregetが比べるのはファイルのサイズだけで、中身は見ません。中断したあとで手元のファイルを作り直してからreputすると、終了コード0・サイズ一致のまま、前半と後半が別のデータという壊れたファイルができます(下の実行結果)。毎回作り直されるエクスポートファイルにはreputを使わずputで送り直し、再開したときは必ず両側でsha256sumを比べてください。

terminal(クライアント)
head -c 16M /dev/urandom > export.bin
echo 'put export.bin inbox/export.bin' > up2.batch
timeout 2 sftp -l 20000 -b up2.batch ryoma@nagoyadevlab02; echo "exit=$?"
sftp> put export.bin inbox/export.bin
exit=124
ssh ryoma@nagoyadevlab02 'ls -l inbox/export.bin'
-rw-r--r-- 1 ryoma ryoma 4961280 Oct  5 05:18 inbox/export.bin
head -c 16M /dev/urandom > export.bin
echo 'reput export.bin inbox/export.bin' > re2.batch
sftp -b re2.batch ryoma@nagoyadevlab02; echo "exit=$?"
sftp> reput export.bin inbox/export.bin
exit=0
sha256sum export.bin; ssh ryoma@nagoyadevlab02 'ls -l inbox/export.bin; sha256sum inbox/export.bin'
45cd142dc0385b5fa8c74b55bee66bfc78bd63b1b4622bf346d666fba6507d93  export.bin
-rw-r--r-- 1 ryoma ryoma 16777216 Oct  5 05:18 inbox/export.bin
7918b9ed4640b117eda634982bd90b93d67f6138a1190a31abeb1dde773b25e6  inbox/export.bin

2回目のheadでexport.binの中身を入れ替えてからreputした結果、exit=0でサイズも16,777,216バイトにそろったのに、sha256sumは手元が45cd…、サーバーが7918…と食い違っています。なお、送り先がすでに同じサイズになっているときにreputを重ねると、何も送らずにエラーで終わります。

terminal(クライアント)
sftp -b re2.batch ryoma@nagoyadevlab02; echo "exit=$?"
sftp> reput export.bin inbox/export.bin
resume "export.bin": destination file same size or larger
exit=1

regetも同じ考え方です。手元のdlディレクトリで、ダウンロードを2秒で止めてから続きを取りました。途中まで届いていた9,139,200バイトの後ろが埋まり、sha256sumは元のファイルと同じ40ad…になっています。

terminal(クライアント・~/work/dl)
echo 'get inbox/backup.tar' > down.batch
timeout 2 sftp -l 40000 -b down.batch ryoma@nagoyadevlab02; echo "exit=$?"
sftp> get inbox/backup.tar
exit=124
ls -l backup.tar
-rw-r--r-- 1 ryoma ryoma 9139200 Oct  5 05:18 backup.tar
echo 'reget inbox/backup.tar' > reget.batch
sftp -b reget.batch ryoma@nagoyadevlab02; echo "exit=$?"
sftp> reget inbox/backup.tar
exit=0
ls -l backup.tar; sha256sum backup.tar
-rw-r--r-- 1 ryoma ryoma 50331648 Oct  5 05:18 backup.tar
40addcd5843dcc6732ab80d6ee9972b35ebc587a30fe382a27d97f35e5b7bf0c  backup.tar

転送専用ユーザーをchrootで作る:Match GroupとChrootDirectory

取引先に渡すアカウントに求める条件は、シェルに入れないこと、決めたディレクトリの外が見えないこと、鍵でしか認証できないこと、サーバーを踏み台にできないことです。OpenSSHは追加のソフトなしでこれを満たせます。sshdに内蔵されたSFTPサーバー(internal-sftp)だけを動かし、ログイン後のルートをChrootDirectoryで付け替えます。

先にディレクトリの形を決めます。/srv/sftp/partner01をchrootの根にして root 所有・755 とし、その1段下のuploadだけをアカウントの持ち物にします。2段にするのは、sshdがChrootDirectoryに指定されたパスのすべての階層について「root 所有で、グループにも他人にも書き込めないこと」をセッション開始時に検査するからです。根そのものに書けるアカウントは、根の直下に好きな名前のファイルやディレクトリを置けてしまい、「中に何があるかは管理者が決める」というchrootの前提が崩れます。だから書き込み用の場所は必ず1段下に作ります。

terminal(サーバー)
sudo groupadd sftponly
sudo useradd -g sftponly -d /srv/sftp/partner01 -M -s /usr/sbin/nologin partner01
id partner01; sudo passwd -S partner01
uid=1002(partner01) gid=1002(sftponly) groups=1002(sftponly)
partner01 L 2026-10-05 0 99999 7 -1

-g sftponlyでこのあと設定を当てるグループに入れ、-Mでホームディレクトリを作らせず、シェルは/usr/sbin/nologinにしました。passwd -Sの2列目のLは、パスワードがロックされている(パスワードではログインできない)状態を表します。続けてディレクトリを作ります。

terminal(サーバー)
sudo mkdir -p /srv/sftp/partner01/upload
sudo chown root:root /srv/sftp /srv/sftp/partner01
sudo chmod 755 /srv/sftp /srv/sftp/partner01
sudo chown partner01:sftponly /srv/sftp/partner01/upload
sudo chmod 750 /srv/sftp/partner01/upload
ls -ld /srv/sftp /srv/sftp/partner01 /srv/sftp/partner01/upload
drwxr-xr-x 3 root      root     4096 Oct  5 05:18 /srv/sftp
drwxr-xr-x 3 root      root     4096 Oct  5 05:18 /srv/sftp/partner01
drwxr-x--- 2 partner01 sftponly 4096 Oct  5 05:18 /srv/sftp/partner01/upload

公開鍵は、取引先から受け取ったものを root 所有のファイルとして置きます(検証では nagoyadevlab01 の ryoma の鍵を取引先の鍵に見立てました)。置き場をアカウントのホーム配下の.ssh/authorized_keysにしない理由は2つあります。アカウントの持ち主がSFTP経由で自分の鍵を足したり差し替えたりできないようにするため、そしてchrootの根に.sshを置かずに済ませるためです。ssh-keygen -lfが表示する指紋は、取引先に電話など別の経路で読み上げてもらった値と照合してから登録します。

terminal(サーバー)
sudo install -d -m 755 /etc/ssh/sftp_keys
echo "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZR/2f3VY6r5cyknM5x4Lg0d2PLLU27hbjfNkFrB0Jv ryoma@nagoyadevlab01" | sudo tee /etc/ssh/sftp_keys/partner01
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPZR/2f3VY6r5cyknM5x4Lg0d2PLLU27hbjfNkFrB0Jv ryoma@nagoyadevlab01
sudo chmod 644 /etc/ssh/sftp_keys/partner01
ssh-keygen -lf /etc/ssh/sftp_keys/partner01
256 SHA256:vJLKgl/c35ceklFtD5pY1YC/AAguhY07OdRY1+/UmKg ryoma@nagoyadevlab01 (ED25519)

設定は/etc/ssh/sshd_config.d/に1ファイルで置きます。Ubuntuのsshd_configは、コメントを除いた最初の行でこのディレクトリの*.confを読み込むので、本体のファイルは編集しません。

terminal(サーバー)
grep -vE '^\s*(#|$)' /etc/ssh/sshd_config
Include /etc/ssh/sshd_config.d/*.conf
KbdInteractiveAuthentication no
UsePAM yes
X11Forwarding yes
PrintMotd no
AcceptEnv LANG LC_* COLORTERM NO_COLOR
Subsystem	sftp	/usr/lib/openssh/sftp-server

Subsystem sftpの行は外部プログラムのsftp-serverを指していますが、これもそのままにします。追加するファイルは次のとおりです。

terminal(サーバー)
sudo tee /etc/ssh/sshd_config.d/60-sftponly.conf >/dev/null <<'EOT'
Match Group sftponly
    ChrootDirectory /srv/sftp/%u
    ForceCommand internal-sftp -d /upload
    AuthorizedKeysFile /etc/ssh/sftp_keys/%u
    AuthenticationMethods publickey
    PasswordAuthentication no
    DisableForwarding yes
    PermitTTY no
EOT
sudo sshd -t; echo "exit=$?"
exit=0
設定行役割と、入れる理由
Match Group sftponly以下の行を、このグループのユーザーにだけ適用する。ユーザー名ではなくグループで括っておくと、取引先が増えてもuseradd -g sftponlyだけで同じ制限がかかる
ChrootDirectory /srv/sftp/%u認証のあと、そのユーザーから見えるルートを/srv/sftp/ユーザー名に付け替える。%uはユーザー名に置き換わる
ForceCommand internal-sftp -d /uploadクライアントが何を要求しても、sshd内蔵のSFTPサーバーだけを動かす。内蔵なので、chrootの中にシェルやライブラリを置かなくてよい。-d /uploadは接続直後の現在地
AuthorizedKeysFile /etc/ssh/sftp_keys/%u公開鍵を探す場所を、root 所有のファイルに固定する
AuthenticationMethods publickey
PasswordAuthentication no
公開鍵認証だけを通す。サーバー全体でパスワード認証が有効のままでも、このグループには効かなくなる
DisableForwarding yesTCPのポート転送、エージェント転送、X11転送をまとめて禁止する。ForceCommandはポート転送までは止めないので、この行がないと踏み台にされる(次の節で実測)
PermitTTY no端末の割り当てを拒否する

あえて使わなかった書き方もあります。ChrootDirectory %hでホームディレクトリをそのまま根にする方法は、ホームをアカウントの所有にした時点で上の検査に落ちます(「つまずきポイント」で再現します)。sshd_config本体のSubsystem sftp /usr/lib/openssh/sftp-serverの行は書き換えていません。ForceCommand internal-sftpが効くので、そのままで動きました。パスワード認証を残す設定は、総当たりの的になるうえ、相手がパスワードを使い回していても気づけないので選びません。

sshd -tがexit=0で通ったら反映します。コンテナでは、ユニットのExecReloadと同じHUPシグナルをsshdに直接送りました。

terminal(サーバー・コンテナでの反映)
sudo kill -HUP $(cat /run/sshd.pid)

実機では、Ubuntuの公式ドキュメントが案内しているとおり、検査してからサービスを再起動します。下のブロックはこの環境では実行していません。

terminal(実機での反映・未実行)
sudo sshd -t && sudo systemctl restart ssh.service

反映するときは、いま使っているSSHのセッションを閉じないでください。別の端末から管理用アカウントで新しくログインできることを確かめてから閉じます。sshd -tは文法しか見ないので、Matchの条件を自分の所属グループにしてしまう、といった間違いは通ってしまいます。管理用アカウントに制限がかかっていないことは、次の節のsshd -T -C user=…で事前に確かめられます。

準備ができたので、クライアントから転送専用アカウントで接続します。

terminal(クライアント)
sftp partner01@nagoyadevlab02 <<'EOT'
pwd
put orders.csv
ls -l
cd /
ls -la
put orders.csv
EOT
Connected to nagoyadevlab02.
sftp> pwd
Remote working directory: /upload
sftp> put orders.csv
Uploading orders.csv to /upload/orders.csv
sftp> ls -l
-rw-r--r--    ? 1002     1002           23 Oct  5 05:18 orders.csv
sftp> cd /
sftp> ls -la
drwxr-xr-x    ? root     root         4096 Oct  5 05:18 .
drwxr-xr-x    ? root     root         4096 Oct  5 05:18 ..
drwxr-x---    ? 1002     1002         4096 Oct  5 05:18 upload
sftp> put orders.csv
Uploading orders.csv to /orders.csv
dest open "/orders.csv": Permission denied

接続直後の現在地は-dで指定した/uploadで、cd /で上がった先に見えるのはuploadだけです。サーバーの/etcや他人のディレクトリは、このアカウントからは存在しません。所有者の列が1002という数字になっているのは、chrootの中に/etc/passwdがなく、番号を名前に直せないためです。最後のputは根に書こうとしてPermission deniedになりました。根は root 所有なので、これが正しい挙動です。

動作確認:このアカウントでできること・できないこと

まずサーバー側で、設定が狙ったユーザーにだけ当たっているかを見ます。sshd -Tは実効設定を出力し、-C user=…を付けるとそのユーザーが接続してきたときの値になります。

terminal(サーバー)
sudo sshd -T -C user=partner01 | grep -iE '^(chrootdirectory|forcecommand|authorizedkeysfile|authenticationmethods|passwordauthentication|disableforwarding|permittty) '
passwordauthentication no
permittty no
disableforwarding yes
forcecommand internal-sftp -d /upload
chrootdirectory /srv/sftp/%u
authorizedkeysfile /etc/ssh/sftp_keys/%u
authenticationmethods publickey
sudo sshd -T -C user=ryoma | grep -iE '^(chrootdirectory|forcecommand|authorizedkeysfile|authenticationmethods|passwordauthentication|disableforwarding|permittty) '
passwordauthentication yes
permittty yes
disableforwarding no
forcecommand none
chrootdirectory none
authorizedkeysfile .ssh/authorized_keys .ssh/authorized_keys2
authenticationmethods any

転送専用アカウントには設定した7つの値が並び、管理用アカウントはchrootdirectory none・forcecommand none・passwordauthentication yesと既定のままです。制限が狙ったグループの外へ漏れていないことを、反映の前後にこのコマンドで確かめます。

次にクライアントから、このアカウントで試せる操作を1つずつ叩きます。出力は端末を割り当てないセッションで取ったものです。端末からsshを打った場合は、1行目がPTY allocation request failed on channel 0に変わり、最後にConnection to nagoyadevlab02 closed.が加わります(擬似端末で確認)。

terminal(クライアント)
ssh -o BatchMode=yes partner01@nagoyadevlab02; echo "exit=$?"
Pseudo-terminal will not be allocated because stdin is not a terminal.
This service allows sftp connections only.
exit=1
ssh -o BatchMode=yes partner01@nagoyadevlab02 'cat /etc/passwd'; echo "exit=$?"
This service allows sftp connections only.
exit=1
ssh -o BatchMode=yes -W 127.0.0.1:22 partner01@nagoyadevlab02 </dev/null; echo "exit=$?"
channel 0: open failed: administratively prohibited: open failed
stdio forwarding failed
exit=255
scp orders.csv partner01@nagoyadevlab02:/upload/orders-scp.csv; echo "exit=$?"
exit=0
scp -O orders.csv partner01@nagoyadevlab02:/upload/orders-legacy.csv; echo "exit=$?"
This service allows sftp connections only.
exit=1
rsync -a orders.csv partner01@nagoyadevlab02:/upload/; echo "exit=$?"
protocol version mismatch -- is your shell clean?
(see the rsync manpage for an explanation)
rsync error: protocol incompatibility (code 2) at compat.c(622) [sender=3.4.1]
exit=2
sftp -o BatchMode=yes -o PubkeyAuthentication=no partner01@nagoyadevlab02; echo "exit=$?"
partner01@nagoyadevlab02: Permission denied (publickey).

Connection closed.  
Connection closed
exit=255

最後のコマンドのConnection closed.の行(ピリオドが付くほう)は、同じコマンドを繰り返すと、出ないこともConnection closedの後ろに回ることもありました。Permission denied (publickey).とConnection closedの2行は毎回出ています。

試した操作結果効いている設定
sftp(対話・バッチ)成功。書けるのは/uploadの中だけChrootDirectoryと、root 所有の根
scp(既定)成功(exit=0)。OpenSSH 9.0以降のscpは中身がSFTPなので通るForceCommand internal-sftp
scp -O(旧プロトコル)拒否。This service allows sftp connections only.でexit=1ForceCommand internal-sftp
sshでログイン、コマンド実行拒否。同じメッセージでexit=1ForceCommand internal-sftp、PermitTTY no
rsync失敗。protocol version mismatchでexit=2ForceCommand internal-sftp(サーバー側でrsyncを起動できない)
ssh -Wによるポート転送拒否。administratively prohibitedでexit=255DisableForwarding yes
鍵を使わない接続拒否。Permission denied (publickey).でexit=255AuthenticationMethods publickey、PasswordAuthentication no

ssh -W ホスト:ポートは、ログイン先のサーバーに別のホスト・ポートへのTCP接続を中継させる指定です。踏み台として使えるかどうかを1行で検査できるので、ここでは127.0.0.1:22(サーバー自身のSSH)を相手にしました。

ChrootDirectoryとForceCommand internal-sftpだけでは、踏み台は防げません。検証のためにDisableForwarding yesの行だけを消して再読み込みし、同じssh -Wを実行すると、サーバー自身のSSHのバナーが返ってきました(下の結果)。シェルに入れないアカウントのままで、サーバーのlocalhostや、サーバーから届く社内LANのポートへ接続できるということです。SFTP専用アカウントにはDisableForwarding yesを必ず入れてください。

行を消して再読み込みしたコマンドと、その時点の実効設定は次のとおりです。disableforwarding no・allowtcpforwarding yesに変わっています。確認のための一時的な操作で、本番のサーバーで行うものではありません。

terminal(サーバー・検証のために行を外す)
sudo sed -i '/DisableForwarding/d' /etc/ssh/sshd_config.d/60-sftponly.conf && sudo sshd -t && sudo kill -HUP $(cat /run/sshd.pid); sudo sshd -T -C user=partner01 | grep -iE '^(disableforwarding|allowtcpforwarding|forcecommand|chrootdirectory) '
allowtcpforwarding yes
disableforwarding no
forcecommand internal-sftp -d /upload
chrootdirectory /srv/sftp/%u
terminal(クライアント・DisableForwardingを外した状態)
ssh -o BatchMode=yes -W 127.0.0.1:22 partner01@nagoyadevlab02 </dev/null; echo "exit=$?"
SSH-2.0-OpenSSH_10.2p1 Ubuntu-2ubuntu3.6
exit=0

確認が済んだら、設定ファイルを最初と同じ内容で書き直して行を戻し、再読み込みします。そのうえでクライアントから同じssh -Wをもう一度実行すると、拒否に戻ります。

terminal(サーバー・行を戻す)
sudo tee /etc/ssh/sshd_config.d/60-sftponly.conf >/dev/null <<'EOT'
Match Group sftponly
    ChrootDirectory /srv/sftp/%u
    ForceCommand internal-sftp -d /upload
    AuthorizedKeysFile /etc/ssh/sftp_keys/%u
    AuthenticationMethods publickey
    PasswordAuthentication no
    DisableForwarding yes
    PermitTTY no
EOT
sudo sshd -t && sudo kill -HUP $(cat /run/sshd.pid); echo "exit=$?"
exit=0
terminal(クライアント・行を戻したあと)
ssh -o BatchMode=yes -W 127.0.0.1:22 partner01@nagoyadevlab02 </dev/null; echo "exit=$?"
channel 0: open failed: administratively prohibited: open failed
stdio forwarding failed
exit=255

この拒否は、サーバーのログにも残ります。Accepted publickeyの行の末尾にあるSHA256:は接続に使われた鍵の指紋で、鍵を登録したときにssh-keygen -lfが表示した値と同じです。どの鍵で入ってきたかは、この行で追えます。

terminal(サーバー)
sudo grep -E 'sshd-session\[[0-9]+\]: (Accepted|refused)' /var/log/auth.log | tail -n 2
2026-10-05T05:18:56.427477+00:00 nagoyadevlab02 sshd-session[4667]: Accepted publickey for partner01 from 172.18.0.3 port 34370 ssh2: ED25519 SHA256:vJLKgl/c35ceklFtD5pY1YC/AAguhY07OdRY1+/UmKg
2026-10-05T05:18:56.452948+00:00 nagoyadevlab02 sshd-session[4679]: refused local port forward: originator 127.0.0.1 port 65535, target 127.0.0.1 port 22

バッチモード(-b)でcronから無人転送する

sftp -b ファイルは、ファイルに書いたコマンドを上から順に実行して終了します。対話モードと違うのは、パスワードやパスフレーズを尋ねなくなること(sftpが内部で起動するsshにBatchModeが渡されます)、put・get・rename・rm・mkdir・lsなどが失敗した時点で残りを実行せずに終わること、そして出力が静かになることです。Connected toやUploadingの行は出ず、sftp>付きのコマンドとlsの結果だけが表示されます。

terminal(クライアント)
cat > daily.batch <<'EOT'
put orders.csv orders.csv.part
rename orders.csv.part orders.csv
ls -l
EOT
sftp -b daily.batch partner01@nagoyadevlab02; echo "exit=$?"
sftp> put orders.csv orders.csv.part
sftp> rename orders.csv.part orders.csv
sftp> ls -l
-rw-r--r--    ? 1002     1002           23 Oct  5 05:18 orders-scp.csv
-rw-r--r--    ? 1002     1002           23 Oct  5 05:19 orders.csv
exit=0

いったんorders.csv.partという名前で置いてからrenameしているのは、受け取る側の取り込み処理が書きかけのファイルを拾わないようにするためです。このバッチは何度流しても通りました。renameは、同じ名前のorders.csvがすでにあっても置き換えます。

失敗したときの終了コードを見ておきます。次のバッチは、1行目が存在しないファイルの削除、2行目が手元にないファイルの送信です。

terminal(クライアント)
cat > broken.batch <<'EOT'
-rm yesterday.csv
put missing.csv
ls -l
EOT
sftp -b broken.batch partner01@nagoyadevlab02; echo "exit=$?"
sftp> -rm yesterday.csv
remote delete /upload/yesterday.csv: No such file or directory
sftp> put missing.csv
stat missing.csv: No such file or directory
exit=1

1行目は先頭に-を付けてあるので、失敗しても次へ進みます。2行目のputが失敗したところでバッチは打ち切られ、3行目のls -lは実行されないままexit=1で終わりました。認証や接続そのものが失敗した場合は、コマンドを1つも実行せずにexit=255です。

terminal(クライアント)
sftp -b daily.batch -o PubkeyAuthentication=no partner01@nagoyadevlab02; echo "exit=$?"
partner01@nagoyadevlab02: Permission denied (publickey).
Connection closed
exit=255

つまり、cronに載せるスクリプトが見る値は3つです。0は全コマンド成功、1はバッチの途中で失敗、255はサーバーに入れていない。これを記録するだけの小さなスクリプトを用意しました。

terminal(クライアント)
cat > sftp-put.sh <<'EOT'
#!/bin/sh
cd /home/ryoma/work || exit 1
sftp -b daily.batch partner01@nagoyadevlab02 >> sftp-put.log 2>&1
rc=$?
echo "$(date '+%F %T') sftp exit=$rc" >> sftp-put.log
exit $rc
EOT
chmod +x sftp-put.sh

検証では毎分実行する行をcrontabに入れ、次の実行を待ってログを確認しました。コンテナにはsystemdがないので、cronデーモンもsudo cronで直接起動しています。実運用の時刻の書き方や、動かないときの調べ方はcrontabの書き方の記事を参照してください。

terminal(クライアント)
sudo cron
echo '* * * * * /home/ryoma/work/sftp-put.sh' | crontab -
crontab -l
* * * * * /home/ryoma/work/sftp-put.sh
terminal(クライアント・cronが実行したあと)
cat sftp-put.log
sftp> put orders.csv orders.csv.part
sftp> rename orders.csv.part orders.csv
sftp> ls -l
-rw-r--r--    ? 1002     1002           23 Oct  5 05:18 orders-scp.csv
-rw-r--r--    ? 1002     1002           23 Oct  5 05:20 orders.csv
2026-10-05 05:20:01 sftp exit=0
crontab -r

ログの最後の行はスクリプトが書いた時刻と終了コードで、cronから起動されたバッチがexit=0で終わっています。確認できたらcrontab -rで検証用の行を消しました。人の手を介さずに動いたのは、鍵が~/.ssh/id_ed25519にパスフレーズなしで置かれ、known_hostsも登録済みだからです。パスフレーズなしの鍵を置いてよいのは、相手側のアカウントを前の節までの設定で絞ってあるときだけです。この鍵が漏れた場合に持ち主ができるのは、/upload以下のファイル操作(置く・読む・消す・改名する)までで、シェル、ポート転送、chrootの外の読み書きはできません。ただし「/uploadの中だけなら無害」とは言えません。置いてあるファイルを消すことも、空き容量がなくなるまで置き続けることも、シンボリックリンクを作ることもできます。受け取る側で必要になる決まりは、後半の「仕上げ」の節にまとめます。

転送ログを残す:internal-sftp -l INFOとchrootの中の/dev/log

誰がいつ何を置いたかを残すには、internal-sftpに-l INFOを付けます。既定のログレベルはERRORで、ファイルの読み書きは記録されません。sshd_configのLogLevelはinternal-sftpには効かないので、ForceCommandの行に直接書きます。

terminal(サーバー)
sudo sed -i 's|internal-sftp -d /upload|internal-sftp -d /upload -l INFO|' /etc/ssh/sshd_config.d/60-sftponly.conf
grep ForceCommand /etc/ssh/sshd_config.d/60-sftponly.conf
    ForceCommand internal-sftp -d /upload -l INFO
sudo sshd -t && sudo kill -HUP $(cat /run/sshd.pid); echo "exit=$?"
exit=0

ところが、この状態でクライアントからdaily.batchを流しても、internal-sftpの行は1行も出ません。auth.logに見えるのは、sshd本体が出すAccepted publickeyやReceived disconnectの行です。

terminal(サーバー)
sudo awk '/internal-sftp\[/{n++} END{print n+0}' /var/log/auth.log
0
sudo grep -E 'sshd-session\[[0-9]+\]: (Accepted|Received)' /var/log/auth.log | tail -n 2
2026-10-05T05:20:34.263886+00:00 nagoyadevlab02 sshd-session[4794]: Accepted publickey for partner01 from 172.18.0.3 port 49772 ssh2: ED25519 SHA256:vJLKgl/c35ceklFtD5pY1YC/AAguhY07OdRY1+/UmKg
2026-10-05T05:20:34.298244+00:00 nagoyadevlab02 sshd-session[4806]: Received disconnect from 172.18.0.3 port 49772:11: disconnected by user

原因はchrootです。internal-sftpは、ルートを付け替えたあとのプロセスの中からsyslogに書こうとします。syslogの送り先は/dev/logというソケットで、chrootのあとでは/srv/sftp/partner01/dev/logを指します。そこには何もないので、行はどこにも届かずに捨てられます。sshd本体の行も、すべてが残るわけではありません。同じ手順を別のコンテナでやり直して数えたところ、Accepted publickeyとReceived disconnectは記録されていましたが、接続の終わりに出るDisconnected from user partner01 …の行は0件でした(chrootしない管理用アカウントの接続では同じ行が残っています)。この行は、次のソケットを足したあとの接続から記録されるようになりました。試しにsshdを-E(ログをファイルへ書くオプション)付きで別のポートに起動し、そこへ転送してみましたが、そのファイルに入ったのもsshd本体の行だけでした。

直し方は、syslogデーモンにchrootの中へもう1つソケットを作らせることです。rsyslogでは設定を1行足します。先に断っておくと、ここからauth.logに行が出るところまでは、検証コンテナでの結果です。実機のUbuntuではAppArmorがこのソケットの作成を止めるので、追加の作業が要ります(この節の後半で扱います)。

terminal(サーバー)
echo 'input(type="imuxsock" Socket="/srv/sftp/partner01/dev/log" CreatePath="on")' | sudo tee /etc/rsyslog.d/40-sftp-chroot.conf
input(type="imuxsock" Socket="/srv/sftp/partner01/dev/log" CreatePath="on")
sudo rsyslogd -N1
rsyslogd: version 8.2512.0, config validation run (level 1), master config /etc/rsyslog.conf
rsyslogd: End of config validation run. Bye.

rsyslogd -N1は設定の検査だけを行うオプションです。通ったらrsyslogを再起動します。コンテナではプロセスを止めて起動し直しました(--init付きで起動したコンテナであることが前提です)。実機ではsystemctlで再起動しますが、そちらはこの環境では実行していません。

terminal(サーバー・コンテナでの再起動・抜粋)
sudo pkill rsyslogd; sleep 1; sudo rsyslogd
rsyslogd: imklog: cannot open kernel log (/proc/kmsg): Operation not permitted.
terminal(実機での再起動・未実行)
sudo systemctl restart rsyslog
terminal(サーバー)
pgrep -a rsyslogd; ls -ld /srv/sftp/partner01/dev /srv/sftp/partner01/dev/log
5020 rsyslogd
drwxr-xr-x 2 root root 4096 Oct  5 05:20 /srv/sftp/partner01/dev
srw-rw-rw- 1 root root    0 Oct  5 05:20 /srv/sftp/partner01/dev/log

CreatePath="on"を付けたので、検証コンテナではdevディレクトリごと root 所有で作られました。クライアントからもう一度daily.batchを流すと、今度はauth.logに転送の記録が並びました。

terminal(サーバー)
sudo grep 'internal-sftp\[' /var/log/auth.log
2026-10-05T05:20:57.967986+00:00 nagoyadevlab02 internal-sftp[5055]: session opened for local user partner01 from [172.18.0.3]
2026-10-05T05:20:57.968949+00:00 nagoyadevlab02 internal-sftp[5055]: sent status No such file
2026-10-05T05:20:57.969465+00:00 nagoyadevlab02 internal-sftp[5055]: open "/upload/orders.csv.part" flags WRITE,CREATE,TRUNCATE mode 0644
2026-10-05T05:20:57.969935+00:00 nagoyadevlab02 internal-sftp[5055]: close "/upload/orders.csv.part" bytes read 0 written 23
2026-10-05T05:20:57.970176+00:00 nagoyadevlab02 internal-sftp[5055]: posix-rename old "/upload/orders.csv.part" new "/upload/orders.csv"
2026-10-05T05:20:57.970720+00:00 nagoyadevlab02 internal-sftp[5055]: opendir "/upload"
2026-10-05T05:20:57.971689+00:00 nagoyadevlab02 internal-sftp[5055]: closedir "/upload"
2026-10-05T05:20:57.972685+00:00 nagoyadevlab02 internal-sftp[5055]: session closed for local user partner01 from [172.18.0.3]

1つの接続は、角括弧の中の同じ番号(プロセスID)で束ねられます。session opened for local user partner01 from [172.18.0.3]でアカウントと接続元、openとcloseの組でファイル名と書き込んだバイト数(ここでは23バイト)、posix-renameで改名、opendirで一覧の取得がわかります。2行目のsent status No such fileは、その時点で送り先のorders.csv.partがまだ無いことをクライアントへ返した記録で、転送の失敗ではありません(直後にopenとcloseが続いています)。パスはchrootの中から見た表記なので、実際の場所は頭に/srv/sftp/partner01を足して読みます。

この方法の副作用と限界も書いておきます。アカウントから見える根に/devが1つ増えます(中のlogはソケットで、読み出せるファイルではありません)。chrootをユーザーごとに分けているので、アカウントを足すたびにinput(…)の行も1行足します。また、ここで確かめたのはrsyslogdを直接起動した構成だけです。実機のjournald側(journalctl)にこれらの行が出るかどうかは確認できていません。

実機のUbuntuではAppArmorがソケットの作成を止める

Ubuntuのrsyslogパッケージには、rsyslogdの動作を制限するAppArmorのプロファイル(/etc/apparmor.d/usr.sbin.rsyslogd)が入っています。パッケージのユニット(rsyslog.service)は起動の前に/usr/lib/rsyslog/reload-apparmor-profileを実行する定義で、この検証を動かしているUbuntu 26.04.1のDockerホストでも、rsyslogdのプロファイルは enforce の状態で読み込まれていました。プロファイルがログ用のソケットとして許しているパスは、次の出力の52・58・59行目の3つだけです。/srv以下は入っていません。

terminal(サーバー)
grep -nE 'dev/log|local/|^profile|^/usr/sbin/rsyslogd|include' /etc/apparmor.d/usr.sbin.rsyslogd
2:#include <tunables/global>
11:profile rsyslogd /usr/sbin/rsyslogd flags=(attach_disconnected) {
12:  #include <abstractions/base>
13:  #include <abstractions/nameservice>
52:  /dev/log                      rwl,
58:  /var/lib/*/dev/log            rwl,
59:  /var/spool/postfix/dev/log    rwl,
69:  include if exists <rsyslog.d>
71:  # Site-specific additions and overrides. See local/README for details.
72:  #include <local/usr.sbin.rsyslogd>

ここまでの検証コンテナはDockerの既定のプロファイルで動いていて、rsyslogd用のプロファイルは当たっていませんでした。そこで、rsyslogと上のinput(…)の1行、/srv/sftp/partner01ディレクトリだけを入れた使い捨てのコンテナを--security-opt apparmor=rsyslogd付きで起動し、/usr/sbin/rsyslogd -nにこのプロファイルを当てて確かめました。ホストに読み込み済みのプロファイルをそのコンテナに適用しただけで、プロファイル自体には手を加えていません。1行目が記事の設定そのまま、2行目がdevディレクトリを先に手で作っておいた場合のメッセージです。

出力(プロファイルを当てたコンテナでのrsyslogdのメッセージ・抜粋)
rsyslogd: cannot create '/srv/sftp/partner01/dev/log': No such file or directory [v8.2512.0 try https://www.rsyslog.com/e/2176 ]
rsyslogd: cannot create '/srv/sftp/partner01/dev/log': Permission denied [v8.2512.0 try https://www.rsyslog.com/e/2176 ]

どちらの場合もソケットは作られませんでした。プロファイルなしで同じイメージを起動した対照では、devとソケットの両方が作られています。設定の検査(rsyslogd -N1)は書式を見るだけなので、この問題には気づけません。検査が通った同じ設定が、起動のときに失敗しています。つまり、標準のプロファイルが有効な実機では、上の手順のままだとソケットができず、internal-sftpの行も出ないはずです。

実機で/srv/sftpの下にソケットを作らせるには、プロファイルにローカルのルールを足します。パッケージのプロファイルは72行目でlocal/usr.sbin.rsyslogdを読み込む作りになっていて、/etc/apparmor.d/local/usr.sbin.rsyslogdは空のファイルとして用意されています。Ubuntuの公式ドキュメントも、パッケージの更新とぶつからない追記場所として/etc/apparmor.d/local/を案内しています。下のブロックは、追記、プロファイルの再読み込み、rsyslogの再起動の順です。ルールの書き方は標準プロファイルの/var/lib/*/dev/logの行にならった、執筆時点のものです。AppArmorのプロファイルを書き換えると本番機の設定が変わるため、このブロックはこの環境では実行していません。

terminal(実機でのAppArmorルール追加・未実行)
printf '%s\n' '/srv/sftp/*/dev/ rw,' '/srv/sftp/*/dev/log rwl,' | sudo tee -a /etc/apparmor.d/local/usr.sbin.rsyslogd
sudo apparmor_parser -r /etc/apparmor.d/usr.sbin.rsyslogd
sudo systemctl restart rsyslog

うまくいったかどうかは、この節ですでに使った2つのコマンドで確かめられます。ls -ld /srv/sftp/partner01/dev /srv/sftp/partner01/dev/logで先頭がsの行(ソケット)が出ること、転送を1回流したあとにsudo grep 'internal-sftp\[' /var/log/auth.logで行が出ることです。ソケットがないままなら、rsyslogの起動時のメッセージに上のcannot createが出ていないかを見ます。

プロファイルに手を入れたくない場合の逃げ道も1つ確かめました。chrootの根を/var/lib/名前の直下に置く配置です。標準のプロファイルが/var/lib/*/dev/logを許しているので、/var/lib/sftp-partner01/devを先に root で作っておくと、プロファイルを当てたコンテナでもソケットが作られました。CreatePathに任せた場合は、devの作成で同じNo such file or directoryになります。この配置で確かめたのはrsyslogdがソケットを作れるところまでで、sshdのchrootと転送をその配置で通す検証はしていません。

アップロードされたファイルの権限をumask(-u)でそろえる

置かれたファイルは、社内の取り込み処理が読みに来ます。誰に読ませるかを、相手のクライアントが付けてくる権限まかせにしないための設定がinternal-sftp -uです。まず何も指定しない状態を見ます。手元で権限を666にしたファイルを送ると、サーバーでは644になり、作ったディレクトリは755でした。

terminal(クライアント)
printf 'shared memo\n' > memo.txt && chmod 666 memo.txt
printf 'draft\n' > private.txt && chmod 600 private.txt
ls -l memo.txt private.txt
-rw-rw-rw- 1 ryoma ryoma 12 Oct  5 05:21 memo.txt
-rw------- 1 ryoma ryoma  6 Oct  5 05:21 private.txt
sftp partner01@nagoyadevlab02 <<'EOT'
put memo.txt before-memo.txt
mkdir before-dir
ls -l
EOT
Connected to nagoyadevlab02.
sftp> put memo.txt before-memo.txt
Uploading memo.txt to /upload/before-memo.txt
sftp> mkdir before-dir
sftp> ls -l
drwxr-xr-x    ? 1002     1002         4096 Oct  5 05:21 before-dir
-rw-r--r--    ? 1002     1002           12 Oct  5 05:21 before-memo.txt
-rw-r--r--    ? 1002     1002           23 Oct  5 05:18 orders-scp.csv
-rw-r--r--    ? 1002     1002           23 Oct  5 05:20 orders.csv

ここに-u 0027を足し、あわせて取り込み用のグループ(例ではintake)を作ってuploadの所有グループにします。chmod 2750の先頭の2はsetgidで、このディレクトリの中に作られるファイルのグループをintakeにそろえます。検証では管理用の ryoma をintakeに入れて、取り込み処理のユーザーの代わりにしました。実機でログイン中のユーザーを追加した場合、そのグループが効くのはログインし直したあとです(動いているシェルのidには、追加したグループが出ませんでした)。社内のユーザーをsftponlyグループのほうに入れてはいけません。Match Group sftponlyの条件に当たり、そのユーザーまでchrootの対象になります。

terminal(サーバー)
sudo sed -i 's|-l INFO|-l INFO -u 0027|' /etc/ssh/sshd_config.d/60-sftponly.conf
grep ForceCommand /etc/ssh/sshd_config.d/60-sftponly.conf
    ForceCommand internal-sftp -d /upload -l INFO -u 0027
sudo sshd -t && sudo kill -HUP $(cat /run/sshd.pid); echo "exit=$?"
exit=0
sudo groupadd intake
sudo chgrp intake /srv/sftp/partner01/upload
sudo chmod 2750 /srv/sftp/partner01/upload
sudo usermod -aG intake ryoma
ls -ld /srv/sftp/partner01/upload; id ryoma
drwxr-s--- 3 partner01 intake 4096 Oct  5 05:21 /srv/sftp/partner01/upload
uid=1001(ryoma) gid=1001(ryoma) groups=1001(ryoma),1003(intake)
terminal(クライアント)
sftp partner01@nagoyadevlab02 <<'EOT'
put memo.txt after-memo.txt
put -p memo.txt after-memo-p.txt
put -p private.txt after-private-p.txt
mkdir after-dir
EOT
Connected to nagoyadevlab02.
sftp> put memo.txt after-memo.txt
Uploading memo.txt to /upload/after-memo.txt
sftp> put -p memo.txt after-memo-p.txt
Uploading memo.txt to /upload/after-memo-p.txt
sftp> put -p private.txt after-private-p.txt
Uploading private.txt to /upload/after-private-p.txt
sftp> mkdir after-dir
terminal(サーバー・2つ目は読めない)
ls -l /srv/sftp/partner01/upload
total 32
drwxr-s--- 2 partner01 intake   4096 Oct  5 05:21 after-dir
-rw-rw-rw- 1 partner01 intake     12 Oct  5 05:21 after-memo-p.txt
-rw-r----- 1 partner01 intake     12 Oct  5 05:21 after-memo.txt
-rw------- 1 partner01 intake      6 Oct  5 05:21 after-private-p.txt
drwxr-xr-x 2 partner01 sftponly 4096 Oct  5 05:21 before-dir
-rw-r--r-- 1 partner01 sftponly   12 Oct  5 05:21 before-memo.txt
-rw-r--r-- 1 partner01 sftponly   23 Oct  5 05:18 orders-scp.csv
-rw-r--r-- 1 partner01 sftponly   23 Oct  5 05:20 orders.csv
cat /srv/sftp/partner01/upload/after-memo.txt
shared memo
cat /srv/sftp/partner01/upload/after-private-p.txt
cat: /srv/sftp/partner01/upload/after-private-p.txt: Permission denied

after-memo.txtは640・グループintakeになり、intakeに入れたユーザーから読めました。ディレクトリは750で、setgidも引き継がれています。設定を変える前に置かれたbefore-の2つは、元のグループと権限のまま残ります。

-uが効くのは、ファイルやディレクトリが新しく作られるときだけです。put -pは転送のあとで手元と同じ権限を付け直すので、umaskを素通りします。上の一覧では、手元で666だったファイルが666のまま、600だったファイルが600のまま届き、後者はintakeのユーザーからPermission deniedで読めませんでした。相手に渡す手順書には「権限を保持するオプションは使わない」と書き、取り込み側では読めないファイルを検知して知らせる作りにしておくと、原因の切り分けが早くなります。もう1つ、アカウントは自分が所有するuploadディレクトリそのものの権限も変えられます。アカウントはintakeグループに入っていないので、chmodするたびにsetgidが外れ、そのあとのアップロードはintakeグループにならず、取り込み側から読めなくなります(実行結果と、取り込み側での備えは次の節)。

仕上げ:取り込み側の決まりと、リクエストの絞り込み(任意)

ここまでの設定で、アカウントはchrootの外には出られず、書けるのは/uploadの中だけです。それでも/uploadの中に置かれるものは、相手(あるいは鍵を拾った誰か)が自由に作れる入力です。受け取る側が何に気をつけるかを、実際に試した結果で示します。この節の出力は、アカウントと設定を別のコンテナで作り直し、/uploadにorders.csvだけがある状態から取ったものです。ファイルの時刻が前後の節とつながっていないのはそのためです。前の節までを同じサーバーで続けて実行している場合は、それまでに置いたbefore-・after-で始まるファイルやディレクトリ、orders-scp.csvも一覧に並びます。headの出力にはorders-scp.csvの見出しと1行目(id,amount)が加わり、findの結果にもorders-scp.csvが出ます。ls -lの先頭のtotalの値も変わり、uploadのリンク数もここより多くなります。

アカウントはシンボリックリンクを置ける

転送専用アカウントで、サーバー上のパスを指すシンボリックリンクを作ってみます。

terminal(クライアント)
sftp partner01@nagoyadevlab02 <<'EOT'
put orders.csv
symlink /etc/passwd passwd-link
symlink /etc/shadow shadow-link
symlink /etc etc-link
symlink /etc/passwd orders-link.csv
ls -l
get passwd-link /tmp/passwd-via-link
ls etc-link
df -h
EOT
Connected to nagoyadevlab02.
sftp> put orders.csv
Uploading orders.csv to /upload/orders.csv
sftp> symlink /etc/passwd passwd-link
sftp> symlink /etc/shadow shadow-link
sftp> symlink /etc etc-link
sftp> symlink /etc/passwd orders-link.csv
sftp> ls -l
lrwxrwxrwx    ? 1002     1003            4 Oct  5 06:19 etc-link
lrwxrwxrwx    ? 1002     1003           11 Oct  5 06:19 orders-link.csv
-rw-r--r--    ? 1002     1002           23 Oct  5 06:19 orders.csv
lrwxrwxrwx    ? 1002     1003           11 Oct  5 06:19 passwd-link
lrwxrwxrwx    ? 1002     1003           11 Oct  5 06:19 shadow-link
sftp> get passwd-link /tmp/passwd-via-link
Fetching /upload/passwd-link to /tmp/passwd-via-link
stat remote: No such file or directory
sftp> ls etc-link
etc-link            
sftp> df -h
    Size     Used    Avail   (root)    %Capacity
   150GB   12.6GB    129GB    137GB           8%

symlinkは4つとも通りました。アカウント自身がリンクの先を読もうとしても、chrootの中には/etc/passwdがないのでstat remote: No such file or directoryで失敗します。chrootの境界は保たれています。最後のdf -hには、サーバーのファイルシステム全体の容量と空きが表示されました。問題は、同じリンクをサーバー側から見たときです。

terminal(サーバー・intakeのユーザー。shadowは読めない)
ls -l /srv/sftp/partner01/upload
head -n 1 /srv/sftp/partner01/upload/passwd-link
ls /srv/sftp/partner01/upload/etc-link/ | head -n 3
wc -l /srv/sftp/partner01/upload/shadow-link
total 4
lrwxrwxrwx 1 partner01 intake    4 Oct  5 06:19 etc-link -> /etc
lrwxrwxrwx 1 partner01 intake   11 Oct  5 06:19 orders-link.csv -> /etc/passwd
-rw-r--r-- 1 partner01 sftponly 23 Oct  5 06:19 orders.csv
lrwxrwxrwx 1 partner01 intake   11 Oct  5 06:19 passwd-link -> /etc/passwd
lrwxrwxrwx 1 partner01 intake   11 Oct  5 06:19 shadow-link -> /etc/shadow
root:x:0:0:root:/root:/bin/bash
X11
adduser.conf
alternatives
wc: /srv/sftp/partner01/upload/shadow-link: Permission denied

サーバーから見ると、リンクの先はchrootの外の本物の/etc/passwdや/etcです。取り込み用グループのユーザーは、リンク経由でサーバーの/etc/passwdの1行目を読み、/etcの中身を一覧できました。/etc/shadowはこのユーザーではPermission deniedですが、root で同じリンクを読むと/etc/shadowと同じ行数が返りました。取り込み処理のユーザーが読めるファイルなら、どれでもリンクで指せるということです。複数の相手のuploadを同じグループで読ませていれば、ほかの相手のディレクトリもその範囲に入ります。

ここから決まる取り込み側の規則は3つです。アップロードされたものは信頼しない入力として扱うこと、通常のファイルだけを対象にしてリンクをたどらないこと、取り込み処理を root で動かさないこと。違いは次の2つのコマンドではっきり出ます。ワイルドカードで開くとリンク(orders-link.csv)の先の/etc/passwdまで読んでしまい、find -type fで通常のファイルだけを拾えばリンクは対象から外れます。

terminal(サーバー・intakeのユーザー)
head -n 1 /srv/sftp/partner01/upload/*.csv
==> /srv/sftp/partner01/upload/orders-link.csv <==
root:x:0:0:root:/root:/bin/bash

==> /srv/sftp/partner01/upload/orders.csv <==
id,amount
find /srv/sftp/partner01/upload -maxdepth 1 -type f -name '*.csv'
/srv/sftp/partner01/upload/orders.csv

容量についても同じ考え方です。アカウントごとの容量を制限する設定はOpenSSHにないので、上限はサーバー側で決めます。置き場を専用のファイルシステム(別のボリューム)にするか、ファイルシステムのクォータを使い、あふれてもほかのサービスを巻き込まない配置にしておきます。

アカウントはuploadの権限を変えられる

uploadの所有者はアカウント自身なので、SFTPのchmodでディレクトリの権限を書き換えられます。

terminal(クライアントで実行し、サーバーで確認)
echo 'chmod 2777 /upload' | sftp partner01@nagoyadevlab02
Connected to nagoyadevlab02.
sftp> chmod 2777 /upload
Changing mode on /upload
ls -ld /srv/sftp/partner01/upload
drwxrwxrwx 2 partner01 intake 4096 Oct  5 06:19 /srv/sftp/partner01/upload

2777を指定した結果はdrwxrwxrwxで、サーバーのどのユーザーからも読み書きできる状態になり、setgidは付いていません。元の値に戻すつもりでchmod 2750を実行しても、setgidは外れたままでした。その状態で置かれたファイルを見ます。

terminal(クライアントで実行し、サーバーで確認・2つ目のcatは読めない)
sftp partner01@nagoyadevlab02 <<'EOT'
chmod 2750 /upload
put memo.txt after-chmod.txt
EOT
Connected to nagoyadevlab02.
sftp> chmod 2750 /upload
Changing mode on /upload
sftp> put memo.txt after-chmod.txt
Uploading memo.txt to /upload/after-chmod.txt
ls -ld /srv/sftp/partner01/upload; ls -l /srv/sftp/partner01/upload/after-chmod.txt
drwxr-x--- 2 partner01 intake 4096 Oct  5 06:20 /srv/sftp/partner01/upload
-rw-r----- 1 partner01 sftponly 12 Oct  5 06:20 /srv/sftp/partner01/upload/after-chmod.txt
sudo cat /srv/sftp/partner01/upload/after-chmod.txt; cat /srv/sftp/partner01/upload/after-chmod.txt
shared memo
cat: /srv/sftp/partner01/upload/after-chmod.txt: Permission denied

新しいファイルのグループはintakeではなくsftponlyになり、取り込み用グループのユーザーからはPermission deniedです。直すには root でsudo chmod 2750 /srv/sftp/partner01/uploadを実行します(実行後はdrwxr-s---に戻りました)。取り込み処理に「uploadの権限が想定どおりか」の確認を入れておくと、読めないファイルが出たときにすぐ原因がわかります。

リンクの作成をinternal-sftpの側で断る(任意)

internal-sftpには、指定したリクエストを拒否する-Pオプションがあります。リクエストの名前はsftp-server -Q requestsで一覧できます。今回試したのは次の5つです。symlinkとhardlinkはリンクの作成にあたるリクエストで、この項で断ります。名前にstatの付く残りの3つは、次の項の「使わなかった設定」で扱います。

terminal(サーバー・抜粋)
/usr/lib/openssh/sftp-server -Q requests
setstat
fsetstat
symlink
hardlink
lsetstat

先に結論を書くと、この記事で入れるのはsymlinkとhardlinkの2つを断る設定です。ForceCommandの行の末尾に-P symlink,hardlinkを足して、再読み込みします。

terminal(サーバー)
sudo sed -i 's|-u 0027|-u 0027 -P symlink,hardlink|' /etc/ssh/sshd_config.d/60-sftponly.conf
grep ForceCommand /etc/ssh/sshd_config.d/60-sftponly.conf
    ForceCommand internal-sftp -d /upload -l INFO -u 0027 -P symlink,hardlink
sudo sshd -t && sudo kill -HUP $(cat /run/sshd.pid); echo "exit=$?"
exit=0

通常の操作とリンクの作成を、クライアントから試します。

terminal(クライアント)
sftp partner01@nagoyadevlab02 <<'EOT'
put memo.txt f-put.txt
mkdir f-dir
rename f-put.txt f-renamed.txt
rm f-renamed.txt
put -p memo.txt f-put-p.txt
ls -l f-put-p.txt
symlink /etc/passwd f-link
ln f-put-p.txt f-hard
EOT
Connected to nagoyadevlab02.
sftp> put memo.txt f-put.txt
Uploading memo.txt to /upload/f-put.txt
sftp> mkdir f-dir
sftp> rename f-put.txt f-renamed.txt
sftp> rm f-renamed.txt
Removing /upload/f-renamed.txt
sftp> put -p memo.txt f-put-p.txt
Uploading memo.txt to /upload/f-put-p.txt
sftp> ls -l f-put-p.txt
-rw-rw-rw-    ? 1002     1003           12 Oct  5 07:09 f-put-p.txt
sftp> symlink /etc/passwd f-link
remote symlink file "/etc/passwd" to "/upload/f-link": Permission denied
sftp> ln f-put-p.txt f-hard
Server does not support [email protected] extension

put・mkdir・rename・rm・put -pはエラーなく通り、本文のdaily.batchもこの設定のもとでexit=0でした。symlinkはPermission denied、ln(ハードリンク)はServer does not support [email protected] extensionで断られています。put -pが通るので、umaskを素通りする点は前の節のままです。届いたf-put-p.txtは手元と同じ666になっています。

この設定はchmodを止めません。アカウントがuploadの権限を書き換えられる点は変わらないので、同じ操作をもう一度試し、root で元に戻します。

terminal(クライアントで実行し、サーバーで確認して元に戻す)
echo 'chmod 2750 /upload' | sftp partner01@nagoyadevlab02
Connected to nagoyadevlab02.
sftp> chmod 2750 /upload
Changing mode on /upload
ls -ld /srv/sftp/partner01/upload
drwxr-x--- 3 partner01 intake 4096 Oct  5 07:10 /srv/sftp/partner01/upload
sudo chmod 2750 /srv/sftp/partner01/upload; ls -ld /srv/sftp/partner01/upload
drwxr-s--- 3 partner01 intake 4096 Oct  5 07:10 /srv/sftp/partner01/upload

chmod 2750 /uploadは通り、setgidがまた外れました(drwxr-x---)。前の項で書いた「uploadの権限が想定どおりか」という取り込み側の確認は、-P symlink,hardlinkを入れても必要です。

既定のscpも試します。3,893バイトのファイルを新しい名前で送り、続けて同じ名前へ23バイトのファイルを送り直して、サーバー側のバイト数を見ます。

terminal(クライアントで実行し、サーバーで確認)
seq 1 1000 > long.txt; wc -c long.txt orders.csv
3893 long.txt
  23 orders.csv
3916 total
scp long.txt partner01@nagoyadevlab02:/upload/f-scp.txt; echo "exit=$?"
exit=0
wc -c /srv/sftp/partner01/upload/f-scp.txt
3893 /srv/sftp/partner01/upload/f-scp.txt
scp orders.csv partner01@nagoyadevlab02:/upload/f-scp.txt; echo "exit=$?"
exit=0
wc -c /srv/sftp/partner01/upload/f-scp.txt; cat /srv/sftp/partner01/upload/f-scp.txt
23 /srv/sftp/partner01/upload/f-scp.txt
id,amount
1,1200
2,980

scpは2回ともメッセージなしのexit=0で、送り直したあとのファイルは23バイト、中身も送り直したファイルと同じ3行です。

使わなかった設定:setstat・fsetstat・lsetstatまで断ると、scpの上書きで新旧が混ざる

リンクに加えてsetstat・fsetstat・lsetstatまで断る設定も試しましたが、採用しませんでした。理由を、いまと同じscpの操作で示します。リストを差し替えて再読み込みします。

terminal(サーバー・採用しない設定に切り替える)
sudo sed -i 's|-P symlink,hardlink|-P symlink,hardlink,setstat,fsetstat,lsetstat|' /etc/ssh/sshd_config.d/60-sftponly.conf
grep ForceCommand /etc/ssh/sshd_config.d/60-sftponly.conf
    ForceCommand internal-sftp -d /upload -l INFO -u 0027 -P symlink,hardlink,setstat,fsetstat,lsetstat
sudo sshd -t && sudo kill -HUP $(cat /run/sshd.pid); echo "exit=$?"
exit=0
terminal(クライアントで実行し、サーバーで確認・採用しない設定)
scp long.txt partner01@nagoyadevlab02:/upload/f-scp2.txt; echo "exit=$?"
scp: remote fsetstat: Permission denied
exit=0
wc -c /srv/sftp/partner01/upload/f-scp2.txt
3893 /srv/sftp/partner01/upload/f-scp2.txt
scp orders.csv partner01@nagoyadevlab02:/upload/f-scp2.txt; echo "exit=$?"
scp: remote fsetstat: Permission denied
exit=0
wc -c /srv/sftp/partner01/upload/f-scp2.txt; head -n 5 /srv/sftp/partner01/upload/f-scp2.txt
3893 /srv/sftp/partner01/upload/f-scp2.txt
id,amount
1,1200
2,980

12

scpは2回ともscp: remote fsetstat: Permission deniedを表示し、それでもexit=0で終わりました。1回目は新しい名前なので、3,893バイトがそのまま届いています。2回目が問題です。23バイトのファイルを送り直したのに、サーバーのファイルは3,893バイトのままでした。先頭の3行が新しい内容に置き換わり、そのあとに古い内容の残りが続いています。fsetstatを断った状態では、scpは終了コード0を返しながら、新旧の混ざったファイルをサーバーに残しました。取り込み処理が読むのは、その混ざったファイルです。相手がscpで同じ名前に送り直すことがあるなら、この設定は使えません。

確認が済んだので、リストをsymlink,hardlinkに戻して再読み込みします。サーバーに残すのはこの行です。

terminal(サーバー・symlink,hardlinkに戻す)
sudo sed -i 's|-P symlink,hardlink,setstat,fsetstat,lsetstat|-P symlink,hardlink|' /etc/ssh/sshd_config.d/60-sftponly.conf
grep ForceCommand /etc/ssh/sshd_config.d/60-sftponly.conf
    ForceCommand internal-sftp -d /upload -l INFO -u 0027 -P symlink,hardlink
sudo sshd -t && sudo kill -HUP $(cat /run/sshd.pid); echo "exit=$?"
exit=0

-P symlink,hardlinkを入れても、すでに置かれているリンクは消えず、chmodも止まりません。-Pは取り込み側の規則の代わりにはならないので、両方を重ねて使います。OpenSSH以外のクライアントでの動きは確認していません。次の「つまずきポイント」の出力は、この-Pを入れる前の状態で取ったものです(この節でf-dirを作ったあとは、uploadのリンク数が1つ増えます)。

つまずきポイント

dest open "/orders.csv": Permission denied

接続はできるのにputがPermission deniedで失敗するときは、chrootの根(アカウントから見た/)に書こうとしています。最初の接続の出力にあったとおり、根は root 所有なので書けません。cd uploadしてから送るか、ForceCommand internal-sftp -d /uploadで接続直後の現在地を書き込み用ディレクトリにしておきます。ここで「書けるようにしよう」と根の所有者を変えると、次のエラーに進みます。

bad ownership or modes for chroot directory

根をアカウントの所有にした状態を作って、接続してみます。

terminal(サーバー・誤った変更)
sudo chown partner01:sftponly /srv/sftp/partner01
ls -ld /srv/sftp/partner01
drwxr-xr-x 4 partner01 sftponly 4096 Oct  5 05:20 /srv/sftp/partner01
terminal(クライアント・エラー)
sftp -b daily.batch partner01@nagoyadevlab02; echo "exit=$?"
Read from remote host nagoyadevlab02: Connection reset by peer
client_loop: send disconnect: Broken pipe
Connection closed
exit=255

クライアント側に出るのはConnection reset by peerとBroken pipeだけで、理由はわかりません。exit=255なので、cronのログでは「サーバーに入れていない」と同じ扱いになります。原因はサーバーのログにだけ出ます。

terminal(サーバー)
sudo grep -E 'sshd-session\[[0-9]+\]: (Accepted|fatal)' /var/log/auth.log | tail -n 2
2026-10-05T05:21:25.324920+00:00 nagoyadevlab02 sshd-session[5242]: Accepted publickey for partner01 from 172.18.0.3 port 58532 ssh2: ED25519 SHA256:vJLKgl/c35ceklFtD5pY1YC/AAguhY07OdRY1+/UmKg
2026-10-05T05:21:25.351764+00:00 nagoyadevlab02 sshd-session[5254]: fatal: bad ownership or modes for chroot directory "/srv/sftp/partner01"

認証は通った(Accepted publickey)直後に、fatal: bad ownership or modes for chroot directory "/srv/sftp/partner01"で切られています。所有者が root でも、権限を775にしてグループに書き込みを許すと同じ行が出ました。1つ上の/srv/sftpを775にした場合は、bad ownership or modes for chroot directory component "/srv/sftp/"と、問題の階層がcomponent付きで示されます。直し方は、根までのすべての階層を root 所有・755に戻すことです。

terminal(サーバー)
sudo chown root:root /srv/sftp /srv/sftp/partner01
sudo chmod 755 /srv/sftp /srv/sftp/partner01
ls -ld /srv/sftp /srv/sftp/partner01
drwxr-xr-x 3 root root 4096 Oct  5 05:18 /srv/sftp
drwxr-xr-x 5 root root 4096 Oct  5 05:21 /srv/sftp/partner01

もう1つ後始末があります。根がアカウントの所有になっていたあいだの接続で、根の直下に.cacheというディレクトリが作られていました。中身は空のmotd.legal-displayedです。根はアカウントのホームディレクトリでもあるので、書ける状態だとログイン時の処理がファイルを置いてしまいます。所有者を戻したあとで一覧を確認し、残っていれば消します。

terminal(サーバー)
sudo ls -la /srv/sftp/partner01 /srv/sftp/partner01/.cache
/srv/sftp/partner01:
total 20
drwxr-xr-x 5 root      root     4096 Oct  5 05:21 .
drwxr-xr-x 3 root      root     4096 Oct  5 05:18 ..
drwx------ 2 partner01 sftponly 4096 Oct  5 05:21 .cache
drwxr-xr-x 2 root      root     4096 Oct  5 05:20 dev
drwxr-s--- 4 partner01 intake   4096 Oct  5 05:21 upload

/srv/sftp/partner01/.cache:
total 8
drwx------ 2 partner01 sftponly 4096 Oct  5 05:21 .
drwxr-xr-x 5 root      root     4096 Oct  5 05:21 ..
-rw-r--r-- 1 partner01 sftponly    0 Oct  5 05:21 motd.legal-displayed
sudo rm -r /srv/sftp/partner01/.cache
sudo ls -la /srv/sftp/partner01
total 16
drwxr-xr-x 4 root      root   4096 Oct  5 05:21 .
drwxr-xr-x 3 root      root   4096 Oct  5 05:18 ..
drwxr-xr-x 2 root      root   4096 Oct  5 05:20 dev
drwxr-s--- 4 partner01 intake 4096 Oct  5 05:21 upload

This service allows sftp connections only.

動作確認の節で見たとおり、sshでのログイン、scp -O、rsyncはこのアカウントでは通りません。旧来のscpプロトコルとrsyncは、サーバー側でシェルを通じて相手役のプログラムを起動する方式です。ForceCommandがその起動をinternal-sftpに差し替えるので、会話が成立しません。scpが既定でSFTPを使うようになったのはOpenSSH 9.0からなので、それより古いscpしか入っていない相手は、-Oを付けていなくても同じメッセージになるはずです(古いクライアントは手元になく、未検証です)。相手にはsftpコマンドか、SFTPに対応したクライアントを使ってもらいます。

SFTP・FTPS・scp・rsyncの使い分け

受け渡しの方式を選ぶときに効いてくるのは、ファイアウォールで開けるポート、相手側に求めるもの、無人実行のしやすさです。SFTP・scp・rsyncの列は今回の実測、FTPSの列は規格(RFC 4217)とIANAのポート登録にもとづく整理で、FTPSサーバーはこの環境では動かしていません。

観点SFTPFTPSscprsync(SSH経由)
ファイアウォールで開けるポートTCP 22の1本制御用のTCP 21(最初からTLSで始める方式は990)に加え、データ用の接続を別のポートで張る。サーバー側で使うポート範囲も開けるTCP 22の1本TCP 22の1本
相手側に求めるものSFTPクライアントと鍵ペアFTPS対応クライアントと、サーバー証明書の確認OpenSSH 9.0以降のscprsync本体と、サーバー側でそれを起動できるシェル
今回の転送専用アカウントで使えるか使える対象外(別のサーバーソフトが要る)既定は使える。-Oは拒否使えない
無人実行-bのバッチと終了コード(0・1・255)クライアントのスクリプト機能しだい1コマンドで1回のコピー差分だけを送る定期同期
中断からの再開reput・reget(サイズだけを比較)クライアントしだいなし--partial
一覧・削除・改名できるできるできない同期の一部としてのみ

外部の相手との受け渡し口を新しく作るなら、SFTPを選びます。開けるポートが22番の1本で済み、鍵認証とここまでのアカウント制限がOpenSSHだけで完結するからです。FTPSを選ぶのは、相手のシステムがFTP系しか話せないと仕様や契約で決まっている場合に限ります。そのときは別のサーバーソフトの導入と、証明書の更新、データ用ポートの開放が運用に加わります。rsyncは相手側にシェルが必要なので、外部には渡さず、自分たちが管理するサーバー同士の同期に使います(手順はrsyncの使い方の記事)。

相手がWindowsの場合について、執筆時点の公式ドキュメントで確認できた範囲だけ書きます。WinSCPはSFTPに対応しています。Microsoftが提供するOpenSSH for Windows(Windows 10の1809以降とWindows 11が対象)にはsftpコマンドが含まれます。ただし、Windowsのクライアントからこのアカウントへ接続する検証は、この環境では実行していません。鍵の形式や画面の操作は、相手が使うクライアントの手順で確認してもらってください。

よくある質問

SFTPとFTPの違いは何ですか?

FTPは制御用とデータ用で別々の接続を使い、暗号化を持たないので、パスワードもファイルの中身もそのまま流れます。SFTPはSSHの1本の接続の中ですべてをやり取りするため通信は暗号化され、ファイアウォールで開けるのもTCP 22番だけです。名前は似ていますが互換性はなく、クライアントもサーバーも別のものを使います。

SFTPの欠点は何ですか?

SSHと同じ仕組みに乗っているので、設定を絞らないと転送用のアカウントがほかの用途にも使えてしまう点です。本文の検証でも、DisableForwardingを入れる前は転送専用アカウント経由でサーバー内のポートに届きました。再開機能がサイズしか比べないこと、chrootすると転送ログ用のソケットを別に用意する必要があることも弱点です。

SFTPにアクセスするにはどうすればいいですか?

接続先のホスト名、ユーザー名、認証に使う秘密鍵の3つを管理者から受け取り、端末でsftp ユーザー名@ホスト名を実行します。プロンプトがsftp>に変わったら、putで送信、getで受信、byeで終了です。画面で操作したい場合は、SFTPに対応したクライアントに同じ3つを設定します。

SFTPのポート番号は何番ですか?

SSHと同じTCP 22番です。SFTP専用のポートはなく、sshdの待ち受けポートを変えているサーバーではその番号になります。sftpコマンドでは大文字の-Pで指定し、検証でも-P 2222で別ポートのsshdに接続できました。小文字の-pは更新時刻と権限を保持するオプションで、sshの-pとは意味が違います。

まとめ

sftpの操作は、put・getと、頭にlが付く手元側コマンドの区別を押さえれば足ります。無人実行は-bと終了コード(0・1・255)で組み立てます。転送専用アカウントの骨格は、root 所有の根と1段下の書き込み用ディレクトリ、そしてMatch Groupの中のChrootDirectory・ForceCommand internal-sftp・DisableForwarding yes・鍵のみの認証です。作ったら、ssh・ssh -W・scp -Oが拒否されることを自分で叩き、auth.logにinternal-sftpの行が出るところまで確かめてください(実機のUbuntuでは、その前にrsyslogdのAppArmorプロファイルへソケットのパスを足す作業が入ります)。受け取ったファイルは、リンクをたどらず通常のファイルだけを取り込みます。最初の一歩は、検証用のサーバーでsudo sshd -T -C user=転送用アカウント名を実行し、出力を本文の7行と見比べることです。

次に読む

$ subscribe --weekly
毎週月曜、企業ITとホームラボの要点を5分で。