From b5918b7aa2af312c2698b7a68472da7ec0b723a0 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Sat, 17 Oct 2026 22:10:59 +0900 Subject: [PATCH] =?UTF-8?q?WIP=2088=20SFTP=201/3:=20=ED=95=B5=EC=8B=AC=20(?= =?UTF-8?q?dartssh2)=20-=20sftp://=20=EA=B2=BD=EB=A1=9C=20=C2=B7=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20=EC=84=A4=EC=A0=95=20=C2=B7=20=EC=95=88?= =?UTF-8?q?=EC=A0=84=20=EC=A0=80=EC=9E=A5=EC=86=8C=20=C2=B7=20=EC=B2=98?= =?UTF-8?q?=EC=9D=8C=20=ED=82=A4=20=ED=99=95=EC=9D=B8=20(TOFU)=20=C2=B7=20?= =?UTF-8?q?=EC=98=A4=EB=9E=98=EB=90=9C=20ssh-rsa=20=C2=B7=20=EC=8B=9C?= =?UTF-8?q?=ED=97=98=20=EC=84=9C=EB=B2=84=20(=EB=AF=B8=EC=99=84)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 사용자 "진행" (10/17, 관리자 3a 전달) - 88 설계안의 제안값: 1) Windows SMB 는 운영체제 그대로 2) 키 암호는 안전 저장소 3) ssh-rsa 기본 끔 · 서버마다 켬 4) SMB3 암호화 (SMB 때) 5) 서버 rsync 는 다음 판. 이 커밋 (화면은 아직 없음 - 설정 화면 · 탐색기 탭은 다음 커밋): - lib/core/remote_fs.dart: 원격 저장소 공통 동작 (RemoteClient). WebDAV (DavClient) 도 이것으로. - lib/core/sftp.dart: SftpServer (설정, 비밀 값은 toJson 에 없음) · SftpRemote (dartssh2, 서버마다 연결 하나를 다시 씀) · SftpRegistry. - 호스트 키: 믿은 지문이 없으면 연결하지 않고 지문 (SHA256:…) 을 알림 (SftpHostKeyException) → 화면이 묻고 저장. 바뀌면 막고 예전 · 지금 지문을 알림 (중간자 공격 안내). - 비밀번호 · keyboard-interactive · 개인 키 (암호), 키 암호 틀림 · 키를 못 읽음 · 저장된 비밀번호 없음 · 닿지 않음 · 권한 · 없는 경로를 사람 말로. - 오래된 서버: 처음엔 SHA-1 을 쓰지 않고 "오래된 ssh-rsa 허용" 을 안내, 서버마다 켜면 ssh-rsa · SHA-1 키 교환. - DNS 강제를 따라 직접 연결 (집 안 이름 · 주소는 기기 DNS), 시작 폴더 (비우면 홈). - 소켓 끊김의 쓰기 실패가 처리되지 않은 오류로 올라오지 않게. - vfs: sftp:// 를 dav:// 와 같은 원격 길로 (isDav = 원격, isWebDav · isSftp 따로). 화면 "🔑 서버/경로", 받아서 열기는 jj_sftp. - 설정: sftpServers (비밀번호 · 개인 키 · 키 암호는 안전 저장소 sftp.pw/key/kp. 에만, 평문 파일에 쓰지 않음), .bak · 깨진 파일 가림, 최상 비밀번호 초기화의 "저장된 비밀번호 지우기" 에 SFTP 도. - 재생 · 다른 앱: SFTP 는 스트리밍 주소가 없어 고른 파일만 받아서 (진행 · 취소) 틀고 같은 폴더 자막도 받음 (다음 파일로 넘어가지 않음 - 1차). - 시험: test/sftp_test.dart 7개 (설정 · 경로 + paramiko 시험 서버: TOFU · 바뀐 키 · 틀린 비밀번호 · 닿지 않음 · vfs 전체 · 키 로그인 · ssh-rsa). tool/sftp_test_server.py (paramiko 3.5.1 - 5.0 은 SHA-1 을 뺌), venv 는 ..\tools\sftp_test_py (없으면 건너뜀). - 라이선스 고지 · 화면 · Android SMB 는 다음 커밋. Co-Authored-By: Claude Opus 5.5 --- assets/l10n/en.json | 17 + assets/l10n/ja.json | 17 + assets/l10n/zh-Hans.json | 17 + lib/app/app_controller.dart | 3 + lib/app/settings.dart | 36 +- lib/core/remote_fs.dart | 23 + lib/core/sftp.dart | 574 ++++++++++++++++++ lib/core/vfs.dart | 60 +- lib/core/webdav.dart | 12 +- lib/main.dart | 3 + .../common/media_kit_full_player.dart | 5 +- lib/ui/dav_external.dart | 16 +- lib/ui/master_prompt.dart | 2 + lib/ui/player_page.dart | 14 +- pubspec.lock | 40 ++ pubspec.yaml | 1 + test/sftp_test.dart | 257 ++++++++ tool/sftp_test_server.py | 261 ++++++++ 18 files changed, 1331 insertions(+), 27 deletions(-) create mode 100644 lib/core/remote_fs.dart create mode 100644 lib/core/sftp.dart create mode 100644 test/sftp_test.dart create mode 100644 tool/sftp_test_server.py diff --git a/assets/l10n/en.json b/assets/l10n/en.json index e92ab26..65c8e6a 100644 --- a/assets/l10n/en.json +++ b/assets/l10n/en.json @@ -260,6 +260,9 @@ "Rsync 확인 창 ({0}): {1}": "Rsync confirmation ({0}): {1}", "SD 카드": "SD card", "SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.": "Check that the SD card is inserted, the network (NAS · VPN) is connected and you have permission for the folder, then press [Try again].", + "SFTP 서버 설정이 없습니다 ({0})": "There are no settings for this SFTP server ({0})", + "SFTP 서버 주소가 없습니다": "The SFTP server address is empty", + "SFTP 에서 받는 중": "Downloading from SFTP", "SRT 형식이 아니거나 비어 있습니다.": "Not in SRT format or empty.", "Stable Diffusion v1.5": "Stable Diffusion v1.5", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 is licensed under CreativeML OpenRAIL-M. You may use it, including commercially, but not for: breaking the law, harming or exploiting minors, false information meant to harm others, harming people with personal information, discrimination or harassment, replacing medical or legal judgment, etc. (license Attachment A). You are responsible for the images you make.", @@ -587,6 +590,7 @@ "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "Stronger refines more but may drift from the original", "같은 시리즈 (번호만 다른 파일)": "Same series (files that differ only by number)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} files that replaced a file with the same name won't be undone.", + "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "Can't create the folder because a file with the same name exists: {0}", "같은 이름이 이미 있습니다": "An item with the same name already exists", "같은 이름이 이미 있습니다: {0}": "Already exists with the same name: {0}", "같은 이름이 이미 있음": "Name already exists", @@ -606,6 +610,8 @@ "같은 확장자 모두": "All with the same extension", "같은 확장자 모두 (.{0})": "All with the same extension (.{0})", "개수": "Count", + "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "Can't read the private key (OpenSSH or PEM format)", + "개인 키의 암호가 맞지 않습니다": "The private key passphrase is incorrect", "거꾸로": "Reverse", "거꾸로 된 범위: {0}": "Reversed range: {0}", "건너뛰기": "Skip", @@ -1356,6 +1362,9 @@ "새로고침": "Reload", "색 보정": "Color correction", "색온도": "Temperature", + "서버 ({0}) 가 답하지 않습니다": "The server ({0}) isn't responding", + "서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "Can't connect to the server ({0}). Check that the server is on and, if you're away from home, that Tailscale is on", + "서버 ({0}) 와의 연결이 끊겼습니다": "The connection to the server ({0}) was lost", "서버 · 서비스 (기기 밖)": "Servers · services (off device)", "서버 설정 고치기": "Edit server settings", "서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "Deletes the server setting and the password saved on this device. Files on the server stay as they are. Syncs and copies using this server won't connect until you add it again.", @@ -1371,6 +1380,9 @@ "서버에 닿지 않습니다": "Cannot reach the server", "서버에서 이 계정의 권한을 확인하세요.": "Check this account's permissions on the server.", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "You need to be on the same network as the server or have a VPN (e.g. Tailscale) on. Turn it on, then tap [Try again].", + "서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "Couldn't agree on an encryption method with the server. If it's an old server, try turning on \"Allow old ssh-rsa\" in the server settings", + "서버와 암호 방식을 맞추지 못했습니다: {0}": "Couldn't agree on an encryption method with the server: {0}", + "서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}": "The server's key has changed. This could be an interception attack, so the connection was not made. Before: {0} · Now: {1}", "서비스 고치기": "Edit service", "서비스 설정과 저장된 API 키를 지웁니다.": "Deletes the service settings and its saved API key.", "서비스 추가": "Add service", @@ -1506,6 +1518,7 @@ "아이디 · 비밀번호를 확인하세요": "check the user name and password", "아이디 또는 비밀번호가 맞지 않습니다": "The user name or password is wrong", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "You can enter the username · password in Settings (optional, raises the daily download limit).", + "아이디나 비밀번호 (또는 키) 가 맞지 않습니다": "The user name or password (or key) is incorrect", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "Icons · list look: X-plore (thumbnails · two lines) · Windows Explorer (color icons · columns) · Total Commander (dense list · [folders])", "아직 만든 MKV · 자막이 없습니다: {0}": "No MKV · subtitles made yet: {0}", "아직 실행 안 함": "Not run yet", @@ -1579,6 +1592,7 @@ "업데이트로 바뀐 기본값 알림": "Notify about defaults changed by updates", "없는 경로": "path not found", "없는 경로: {0}": "path not found: {0}", + "없는 경로입니다: {0}": "The path doesn't exist: {0}", "없음": "None", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "None: one is created when you first press [Move] in the details", "엔진": "Engine", @@ -1812,6 +1826,7 @@ "이 페이지 번역": "Translate this page", "이 페이지 지우기": "Delete this page", "이 페이지를 {0}(으)로 번역": "Translate this page to {0}", + "이 폴더 · 파일을 다룰 권한이 없습니다: {0}": "You don't have permission to access this folder or file: {0}", "이 폴더 고르기": "Choose this folder", "이 폴더 선택": "Choose this folder", "이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "This folder pair uses its remembered options (edit them in Monitoring > Copy · rsync, not in settings)", @@ -2015,6 +2030,7 @@ "저장된 비밀번호 · API 키를 지웠습니다": "Deleted saved passwords and API keys", "저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "Asked when using saved passwords, settings or features that need a password. If you forget it, reset it with the top password, or delete the saved passwords and reset it.", "저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "It will no longer be asked when using saved passwords or settings.", + "저장된 비밀번호가 없습니다": "No password is saved", "저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "No saved password. Please enter it once more", "저장된 비밀번호를 되살리지 못했습니다": "Couldn't restore the saved passwords", "저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "Enter the master password to use features with saved passwords or open settings. It won't be asked again until the app is closed.", @@ -2219,6 +2235,7 @@ "처음 시작 시": "At app start", "처음 쓸 때 내려받기 (기본)": "Download on first use (default)", "처음 아이콘 (파랑)": "Original icon (blue)", + "처음 연결하는 서버입니다. 서버의 키 지문이 맞는지 확인하세요: {0} ({1})": "This is the first connection to this server. Check that the server's key fingerprint is correct: {0} ({1})", "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "These results are from before the search finished. Search again to find more.", "처음대로": "Reset to default", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "To set it from the start, choose it in \"Source language (spoken in the video)\" in the AI subtitles window.", diff --git a/assets/l10n/ja.json b/assets/l10n/ja.json index ed9d71b..eccb794 100644 --- a/assets/l10n/ja.json +++ b/assets/l10n/ja.json @@ -260,6 +260,9 @@ "Rsync 확인 창 ({0}): {1}": "Rsync 確認ウィンドウ ({0}): {1}", "SD 카드": "SD カード", "SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.": "SD カードが挿さっているか、ネットワーク (NAS・VPN) が接続されているか、フォルダーの権限があるかを確認してから [再試行] を押してください。", + "SFTP 서버 설정이 없습니다 ({0})": "この SFTP サーバーの設定がありません ({0})", + "SFTP 서버 주소가 없습니다": "SFTP サーバーのアドレスがありません", + "SFTP 에서 받는 중": "SFTP から受信中", "SRT 형식이 아니거나 비어 있습니다.": "SRT 形式ではないか、空です。", "Stable Diffusion v1.5": "Stable Diffusion v1.5", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 は CreativeML OpenRAIL-M ライセンスです。商用を含めて使えますが、次の用途には使えません: 法律に違反すること、未成年者を害したり利用したりすること、他人を害する目的の虚偽情報、個人情報で人を害すること、差別・嫌がらせ、医療・法律の判断の代わりにすること など (ライセンス付属書 A)。作成した画像の責任は使う人にあります。", @@ -587,6 +590,7 @@ "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "強いほど大きく整えますが、元と違ってくることがあります", "같은 시리즈 (번호만 다른 파일)": "同じシリーズ(番号だけ違うファイル)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "同じ名前を上書きしたファイル {0} 件は元に戻しません。", + "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "同じ名前のファイルがあるためフォルダーを作成できません: {0}", "같은 이름이 이미 있습니다": "同じ名前のものが既にあります", "같은 이름이 이미 있습니다: {0}": "同じ名前が既にあります: {0}", "같은 이름이 이미 있음": "同じ名前が既にあります", @@ -606,6 +610,8 @@ "같은 확장자 모두": "同じ拡張子をすべて", "같은 확장자 모두 (.{0})": "同じ拡張子をすべて (.{0})", "개수": "枚数", + "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "秘密鍵を読み込めません (OpenSSH・PEM 形式)", + "개인 키의 암호가 맞지 않습니다": "秘密鍵のパスフレーズが正しくありません", "거꾸로": "逆順", "거꾸로 된 범위: {0}": "逆順の範囲: {0}", "건너뛰기": "スキップ", @@ -1356,6 +1362,9 @@ "새로고침": "再読み込み", "색 보정": "カラー補正", "색온도": "色温度", + "서버 ({0}) 가 답하지 않습니다": "サーバー ({0}) が応答しません", + "서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "サーバー ({0}) に接続できません。サーバーの電源が入っているか、外出先なら Tailscale がオンか確認してください", + "서버 ({0}) 와의 연결이 끊겼습니다": "サーバー ({0}) との接続が切れました", "서버 · 서비스 (기기 밖)": "サーバー・サービス (端末外)", "서버 설정 고치기": "サーバー設定を修正", "서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "サーバー設定とこの端末に保存したパスワードを削除します。サーバーのファイルはそのままです。このサーバーを使う同期・コピーは、再度追加するまで接続できません。", @@ -1371,6 +1380,9 @@ "서버에 닿지 않습니다": "サーバーに接続できません", "서버에서 이 계정의 권한을 확인하세요.": "サーバーでこのアカウントの権限を確認してください。", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "サーバーと同じネットワークにいるか、VPN (例: Tailscale) をオンにする必要があります。オンにしてから [再試行] を押してください。", + "서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "サーバーと暗号方式を合わせられませんでした。古いサーバーの場合はサーバー設定の「古い ssh-rsa を許可」をオンにしてみてください", + "서버와 암호 방식을 맞추지 못했습니다: {0}": "サーバーと暗号方式を合わせられませんでした: {0}", + "서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}": "サーバーの鍵が変わりました。中間者攻撃の可能性があるため接続しませんでした。以前: {0} · 現在: {1}", "서비스 고치기": "サービスを編集", "서비스 설정과 저장된 API 키를 지웁니다.": "サービス設定と保存した API キーを削除します。", "서비스 추가": "サービスを追加", @@ -1506,6 +1518,7 @@ "아이디 · 비밀번호를 확인하세요": "ID とパスワードを確認してください", "아이디 또는 비밀번호가 맞지 않습니다": "ユーザー名またはパスワードが正しくありません", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "ユーザー名・パスワードは環境設定で入力できます(任意、1日のダウンロード回数が増えます)。", + "아이디나 비밀번호 (또는 키) 가 맞지 않습니다": "ユーザー名またはパスワード (または鍵) が正しくありません", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "アイコン · 一覧の見た目: X-plore (サムネイル · 2 行) · Windows エクスプローラー (カラーアイコン · 列) · Total Commander (詰めた一覧 · [フォルダー])", "아직 만든 MKV · 자막이 없습니다: {0}": "まだ作成した MKV・字幕がありません: {0}", "아직 실행 안 함": "まだ実行していません", @@ -1579,6 +1592,7 @@ "업데이트로 바뀐 기본값 알림": "アップデートで変わった既定値の通知", "없는 경로": "存在しないパス", "없는 경로: {0}": "存在しないパス: {0}", + "없는 경로입니다: {0}": "存在しないパスです: {0}", "없음": "なし", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "なし: 詳細の[移動]を初めて押したときにフォルダーを選んで作成します", "엔진": "エンジン", @@ -1812,6 +1826,7 @@ "이 페이지 번역": "このページを翻訳", "이 페이지 지우기": "このページを削除", "이 페이지를 {0}(으)로 번역": "このページを{0}に翻訳", + "이 폴더 · 파일을 다룰 권한이 없습니다: {0}": "このフォルダー・ファイルを扱う権限がありません: {0}", "이 폴더 고르기": "このフォルダーを選択", "이 폴더 선택": "このフォルダーを選択", "이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "このフォルダーの組は記憶したオプションを使います (環境設定ではなくモニタリング > コピー・rsync で変更します)", @@ -2015,6 +2030,7 @@ "저장된 비밀번호 · API 키를 지웠습니다": "保存したパスワード・API キーを削除しました", "저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "保存したパスワード・設定・パスワードが必要な機能を使うときに尋ねます。忘れたら最上位パスワードで初期化するか、保存したパスワードを削除して初期化できます。", "저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "保存したパスワード・設定を使うときにもう尋ねません。", + "저장된 비밀번호가 없습니다": "保存されたパスワードがありません", "저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "保存されたパスワードがありません。もう一度だけ入力してください", "저장된 비밀번호를 되살리지 못했습니다": "保存されたパスワードを復元できませんでした", "저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "保存したパスワードを使う機能・設定を開くにはマスターパスワードを入力してください。アプリを終了するまで再度尋ねません。", @@ -2219,6 +2235,7 @@ "처음 시작 시": "起動時", "처음 쓸 때 내려받기 (기본)": "初めて使うときにダウンロード (既定)", "처음 아이콘 (파랑)": "最初のアイコン(青)", + "처음 연결하는 서버입니다. 서버의 키 지문이 맞는지 확인하세요: {0} ({1})": "初めて接続するサーバーです。サーバーの鍵のフィンガープリントが正しいか確認してください: {0} ({1})", "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "最初の検索が終わる前の結果です。さらに探すには検索し直してください。", "처음대로": "既定に戻す", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "最初から決めるには、AI 字幕ウィンドウの「原語(動画内の言語)」で選んでください。", diff --git a/assets/l10n/zh-Hans.json b/assets/l10n/zh-Hans.json index 0d736d4..dd55c24 100644 --- a/assets/l10n/zh-Hans.json +++ b/assets/l10n/zh-Hans.json @@ -260,6 +260,9 @@ "Rsync 확인 창 ({0}): {1}": "Rsync 确认窗口 ({0}): {1}", "SD 카드": "SD 卡", "SD 카드가 꽂혀 있는지, 네트워크 (NAS · VPN) 가 연결되어 있는지, 폴더 권한이 있는지 확인한 뒤 [다시 시도] 를 누르세요.": "请确认 SD 卡已插入、网络 (NAS · VPN) 已连接、拥有文件夹权限,然后按 [重试]。", + "SFTP 서버 설정이 없습니다 ({0})": "没有此 SFTP 服务器的设置 ({0})", + "SFTP 서버 주소가 없습니다": "SFTP 服务器地址为空", + "SFTP 에서 받는 중": "正在从 SFTP 下载", "SRT 형식이 아니거나 비어 있습니다.": "不是 SRT 格式或为空。", "Stable Diffusion v1.5": "Stable Diffusion v1.5", "Stable Diffusion v1.5 는 CreativeML OpenRAIL-M 라이선스입니다. 상업적 사용을 포함해 쓸 수 있지만, 다음 용도로는 쓸 수 없습니다: 법을 어기는 일, 미성년자를 해치거나 이용하는 일, 남을 해치려는 거짓 정보, 개인 정보로 사람을 해치는 일, 차별 · 괴롭힘, 의료 · 법률 판단을 대신하는 일 등 (라이선스 부속서 A). 만든 그림에 대한 책임은 쓰는 사람에게 있습니다.": "Stable Diffusion v1.5 采用 CreativeML OpenRAIL-M 许可证。可用于包括商业用途在内的场合,但不得用于: 违法行为、伤害或利用未成年人、意在伤害他人的虚假信息、利用个人信息伤害他人、歧视 · 骚扰、代替医疗 · 法律判断等 (许可证附件 A)。生成图片的责任由使用者承担。", @@ -587,6 +590,7 @@ "강할수록 많이 다듬지만 원본과 달라질 수 있습니다": "越强优化越多,但可能与原图不同", "같은 시리즈 (번호만 다른 파일)": "同一系列(仅编号不同的文件)", "같은 이름을 덮어쓴 파일 {0}개는 되돌리지 않습니다.": "{0} 个覆盖了同名文件的文件不会撤销。", + "같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}": "存在同名文件,无法创建文件夹: {0}", "같은 이름이 이미 있습니다": "已存在同名项目", "같은 이름이 이미 있습니다: {0}": "已存在同名项目: {0}", "같은 이름이 이미 있음": "同名项已存在", @@ -606,6 +610,8 @@ "같은 확장자 모두": "所有相同扩展名", "같은 확장자 모두 (.{0})": "所有相同扩展名(.{0})", "개수": "数量", + "개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)": "无法读取私钥 (OpenSSH · PEM 格式)", + "개인 키의 암호가 맞지 않습니다": "私钥密码不正确", "거꾸로": "倒序", "거꾸로 된 범위: {0}": "范围顺序颠倒: {0}", "건너뛰기": "跳过", @@ -1356,6 +1362,9 @@ "새로고침": "刷新", "색 보정": "色彩校正", "색온도": "色温", + "서버 ({0}) 가 답하지 않습니다": "服务器 ({0}) 没有响应", + "서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요": "无法连接服务器 ({0})。请确认服务器已开启;如果不在家,请确认 Tailscale 已开启", + "서버 ({0}) 와의 연결이 끊겼습니다": "与服务器 ({0}) 的连接已断开", "서버 · 서비스 (기기 밖)": "服务器 · 服务 (设备外)", "서버 설정 고치기": "修改服务器设置", "서버 설정과 이 기기에 저장된 비밀번호를 지웁니다. 서버의 파일은 그대로입니다. 이 서버를 쓰는 동기화 · 복사는 다시 넣을 때까지 연결되지 않습니다.": "删除服务器设置和本设备上保存的密码。服务器上的文件保持不变。使用此服务器的同步 · 复制在重新添加之前无法连接。", @@ -1371,6 +1380,9 @@ "서버에 닿지 않습니다": "无法连接服务器", "서버에서 이 계정의 권한을 확인하세요.": "请在服务器上检查此账户的权限。", "서버와 같은 네트워크에 있거나 VPN (예: Tailscale) 이 켜져 있어야 합니다. 켠 뒤 [다시 시도] 를 누르세요.": "需要与服务器在同一网络,或打开 VPN (如 Tailscale)。打开后点 [重试]。", + "서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 \"오래된 ssh-rsa 허용\" 을 켜 보세요": "无法与服务器协商加密方式。如果是旧服务器,请在服务器设置中开启\"允许旧的 ssh-rsa\"", + "서버와 암호 방식을 맞추지 못했습니다: {0}": "无法与服务器协商加密方式: {0}", + "서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}": "服务器的密钥已更改。可能是中间人攻击,因此未连接。之前: {0} · 现在: {1}", "서비스 고치기": "编辑服务", "서비스 설정과 저장된 API 키를 지웁니다.": "删除服务设置及保存的 API 密钥。", "서비스 추가": "添加服务", @@ -1506,6 +1518,7 @@ "아이디 · 비밀번호를 확인하세요": "请检查用户名和密码", "아이디 또는 비밀번호가 맞지 않습니다": "用户名或密码错误", "아이디·비밀번호는 환경 설정에서 넣을 수 있습니다 (선택, 하루 받기 횟수가 늘어남).": "可在设置中填写用户名 · 密码(可选,可增加每日下载次数)。", + "아이디나 비밀번호 (또는 키) 가 맞지 않습니다": "用户名或密码 (或密钥) 不正确", "아이콘 · 목록 모양: X-plore (썸네일 · 두 줄) · Windows 탐색기 (컬러 아이콘 · 열) · Total Commander (촘촘한 목록 · [폴더])": "图标 · 列表外观: X-plore (缩略图 · 两行) · Windows 资源管理器 (彩色图标 · 列) · Total Commander (紧凑列表 · [文件夹])", "아직 만든 MKV · 자막이 없습니다: {0}": "尚未制作 MKV · 字幕: {0}", "아직 실행 안 함": "尚未运行", @@ -1579,6 +1592,7 @@ "업데이트로 바뀐 기본값 알림": "更新所更改默认值的通知", "없는 경로": "路径不存在", "없는 경로: {0}": "路径不存在: {0}", + "없는 경로입니다: {0}": "路径不存在: {0}", "없음": "无", "없음: 세부 정보의 [이동] 을 처음 누를 때 폴더를 골라 하나 만듭니다": "无: 首次在详细信息中点击[移动]时选择文件夹并创建", "엔진": "引擎", @@ -1812,6 +1826,7 @@ "이 페이지 번역": "翻译此页", "이 페이지 지우기": "删除此页", "이 페이지를 {0}(으)로 번역": "将此页面翻译为{0}", + "이 폴더 · 파일을 다룰 권한이 없습니다: {0}": "没有访问此文件夹或文件的权限: {0}", "이 폴더 고르기": "选择此文件夹", "이 폴더 선택": "选择此文件夹", "이 폴더 쌍은 기억한 옵션을 씁니다 (환경 설정이 아니라 모니터링 > 복사 · rsync 에서 고칩니다)": "此文件夹组合使用记住的选项 (请在 监视 > 复制 · rsync 中修改,而非设置)", @@ -2015,6 +2030,7 @@ "저장된 비밀번호 · API 키를 지웠습니다": "已删除保存的密码 · API 密钥", "저장된 비밀번호 · 설정 · 비밀번호가 필요한 기능을 쓸 때 묻습니다. 잊으면 최상 비밀번호로 초기화하거나, 저장된 비밀번호를 지우고 초기화할 수 있습니다.": "使用已保存的密码 · 设置 · 需要密码的功能时询问。忘记时可用最高密码重置,或删除已保存的密码后重置。", "저장된 비밀번호 · 설정을 쓸 때 더는 묻지 않습니다.": "使用已保存的密码 · 设置时将不再询问。", + "저장된 비밀번호가 없습니다": "没有保存的密码", "저장된 비밀번호가 없습니다. 한 번만 다시 넣어 주세요": "没有保存的密码。请再输入一次", "저장된 비밀번호를 되살리지 못했습니다": "无法恢复已保存的密码", "저장된 비밀번호를 쓰는 기능 · 설정을 열려면 마스터 비밀번호를 넣으세요. 앱을 끌 때까지 다시 묻지 않습니다.": "要使用保存了密码的功能或打开设置,请输入主密码。在关闭应用前不会再次询问。", @@ -2219,6 +2235,7 @@ "처음 시작 시": "启动时", "처음 쓸 때 내려받기 (기본)": "首次使用时下载 (默认)", "처음 아이콘 (파랑)": "最初的图标(蓝色)", + "처음 연결하는 서버입니다. 서버의 키 지문이 맞는지 확인하세요: {0} ({1})": "这是首次连接此服务器。请确认服务器的密钥指纹正确: {0} ({1})", "처음 찾을 때 끝까지 찾기 전의 결과입니다. 더 찾으려면 다시 찾으세요.": "这些是首次搜索完成前的结果。如需查找更多,请重新搜索。", "처음대로": "恢复默认", "처음부터 정하려면 AI 자막 창의 \"원어 (영상 속 언어)\" 에서 고르세요.": "如需一开始就指定,请在 AI 字幕窗口的“原语言(视频中的语言)”中选择。", diff --git a/lib/app/app_controller.dart b/lib/app/app_controller.dart index dd91ffc..80f924a 100644 --- a/lib/app/app_controller.dart +++ b/lib/app/app_controller.dart @@ -11,6 +11,7 @@ import '../core/encode_options.dart'; import '../core/languages.dart'; import '../core/mkv_command_builder.dart'; import '../core/vfs.dart'; +import '../core/sftp.dart' show SftpRegistry; import '../core/webdav.dart'; import '../core/models.dart'; import '../core/output_paths.dart'; @@ -151,6 +152,8 @@ class AppController extends ChangeNotifier { preferredSubtitleSetting = settings.preferredSubtitleLanguage; // WebDAV 서버 (파일 탐색기 · Rsync 화면 · 복사 · 동기화가 dav:// 경로로 쓴다) DavRegistry.configure(settings.webdavServers); + // 88: SFTP 서버 (sftp:// 경로) + SftpRegistry.configure(settings.sftpServers); outputRootOverride = (settings.mkvOutputRoot?.isNotEmpty ?? false) ? settings.mkvOutputRoot : null; } diff --git a/lib/app/settings.dart b/lib/app/settings.dart index 84a479a..e7e4c23 100644 --- a/lib/app/settings.dart +++ b/lib/app/settings.dart @@ -11,6 +11,7 @@ import '../core/dns_resolver.dart'; import '../core/download_detect.dart'; import '../core/encode_options.dart'; import '../core/playlist.dart'; +import '../core/sftp.dart'; import '../core/webdav.dart'; import '../services/image_ai.dart' show AiService; import '../services/app_shell.dart' show appIconOf; @@ -552,6 +553,9 @@ class AppSettings { /// WebDAV 서버 (파일 탐색기 · Rsync 화면 위쪽 "SD 카드" 옆 탭). 비밀번호는 이 설정 파일에만. List webdavServers = []; + /// 88: SFTP 서버 (WebDAV 옆 탭). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에만 (설정 파일에는 평문으로 쓰지 않는다) + List sftpServers = []; + // ───────── 123 · 121: AI 그림 · 해상도 올리기 ───────── /// 그리는 엔진: 'local' 이 기기 안 (기본) · 아니면 [aiServices] 의 id (사용자가 추가한 서버 · 서비스) @@ -831,6 +835,8 @@ class AppSettings { 'webdavServers': [ for (final x in webdavServers) {...x.toJson(), if (plainSecrets) 'password': x.password}, ], + // 새 비밀 값은 안전 저장소에 못 써도 평문으로 쓰지 않는다 (사용자 결정 54) - plainSecrets 여도 넣지 않음 + 'sftpServers': [for (final x in sftpServers) x.toJson()], 'rsyncOptions': rsyncOptions, 'robocopyOptions': robocopyOptions, 'copyRunMode': copyRunMode, @@ -1073,6 +1079,10 @@ class AppSettings { for (final x in (j['webdavServers'] as List?) ?? const []) if (x is Map) DavServer.fromJson(x), ] + ..sftpServers = [ + for (final x in (j['sftpServers'] as List?) ?? const []) + if (x is Map) SftpServer.fromJson(x), + ] ..rsyncOptions = j['rsyncOptions'] as String? ?? '-avPog' ..robocopyOptions = j['robocopyOptions'] as String? ?? '/E /COPY:DAT /DCOPY:T /R:2 /W:2' ..copyRunMode = j['copyRunMode'] == 'once' ? 'once' : 'each' @@ -1142,6 +1152,12 @@ class AppSettings { for (final x in servers) x is Map ? (Map.of(x)..remove('password')) : x, ]; } + final sftp = r['sftpServers']; + if (sftp is List) { + r['sftpServers'] = [ + for (final x in sftp) x is Map ? (Map.of(x)..remove('password')..remove('privateKey')..remove('keyPassphrase')) : x, + ]; + } final ai = r['aiServices']; if (ai is List) { r['aiServices'] = [ @@ -1259,6 +1275,9 @@ class SettingsStore { 'openSubtitlesPassword': 'os.password', }; static String _davKey(String id) => 'dav.pw.$id'; + static String _sftpPw(String id) => 'sftp.pw.$id'; + static String _sftpKey(String id) => 'sftp.key.$id'; + static String _sftpKp(String id) => 'sftp.kp.$id'; static String _aiKey(String id) => 'ai.key.$id'; static const _davServers = 'dav.servers'; @@ -1276,6 +1295,11 @@ class SettingsStore { if (x.password.isNotEmpty) _davKey(x.id): x.password, for (final x in s.aiServices) if (x.apiKey.isNotEmpty) _aiKey(x.id): x.apiKey, + for (final x in s.sftpServers) ...{ + if (x.password.isNotEmpty) _sftpPw(x.id): x.password, + if (x.privateKey.isNotEmpty) _sftpKey(x.id): x.privateKey, + if (x.keyPassphrase.isNotEmpty) _sftpKp(x.id): x.keyPassphrase, + }, // 서버 목록 (비밀번호 없이): 예전 판이 설정 파일에서 서버 목록을 지워도 되살릴 수 있게 (40) if (s.webdavServers.isNotEmpty) _davServers: jsonEncode([for (final x in s.webdavServers) x.toJson()]), }; @@ -1317,6 +1341,14 @@ class SettingsStore { s.aiServices = [ for (final x in s.aiServices) x.apiKey.isNotEmpty ? x : x.copyWith(apiKey: stored[_aiKey(x.id)] ?? ''), ]; + s.sftpServers = [ + for (final x in s.sftpServers) + x.copyWith( + password: x.password.isNotEmpty ? x.password : stored[_sftpPw(x.id)] ?? '', + privateKey: x.privateKey.isNotEmpty ? x.privateKey : stored[_sftpKey(x.id)] ?? '', + keyPassphrase: x.keyPassphrase.isNotEmpty ? x.keyPassphrase : stored[_sftpKp(x.id)] ?? '', + ), + ]; } /// 읽은 설정에 안전 저장소의 비밀 값을 채운다. 파일에 예전 평문 값이 있으면 저장소로 옮기고 파일에서 지운다 (54). @@ -1383,7 +1415,7 @@ class SettingsStore { /// 깨진 설정 파일의 보관본에서 비밀 값을 지운다 (JSON 으로 읽을 수 없으니 글자로) static String redactSecrets(String text) => text.replaceAllMapped( - RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password|apiKey)"\s*:\s*"(?:[^"\\]|\\.)*"'), + RegExp(r'"(openSubtitlesKey|openSubtitlesUser|openSubtitlesPassword|password|apiKey|privateKey|keyPassphrase)"\s*:\s*"(?:[^"\\]|\\.)*"'), (m) => '"${m[1]}": ""'); /// 읽기 오류를 알릴 글 (파일 내용은 넣지 않음 - JSON 오류 메시지에는 원문 일부가 들어간다) @@ -1521,7 +1553,7 @@ class SettingsStore { if (!_secretsOk) return {...values.keys.where((k) => _known[k] != values[k])}; final unsaved = {}; for (final k in {..._known.keys, ...values.keys}) { - if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.')) continue; + if (!k.startsWith('os.') && !k.startsWith('dav.') && !k.startsWith('ai.') && !k.startsWith('sftp.')) continue; final v = values[k]; if (v == _known[k]) continue; try { diff --git a/lib/core/remote_fs.dart b/lib/core/remote_fs.dart new file mode 100644 index 0000000..baeccb4 --- /dev/null +++ b/lib/core/remote_fs.dart @@ -0,0 +1,23 @@ +import 'webdav.dart' show DavItem; + +/// 원격 저장소 (WebDAV · SFTP) 의 공통 동작. vfs 가 경로의 서버 종류 (dav:// · sftp://) 에 맞는 것을 부른다. +/// 경로 [rel] 은 서버 기준 ("/" 로 시작, 맨 위는 "/"). +abstract class RemoteClient { + Future> list(String rel); + + /// 없으면 null + Future stat(String rel); + Future mkdirs(String rel); + + /// 지우기 (폴더면 안의 것까지) + Future delete(String rel); + + /// 같은 서버 안에서 옮기기 · 이름 바꾸기 + Future move(String from, String to, {bool overwrite = false}); + Future>> openRead(String rel); + Future write(String rel, Stream> data, {int? length}); + + /// (남은 공간, 쓴 공간) - 모르면 null + Future<(int?, int?)> quota(); + void close(); +} diff --git a/lib/core/sftp.dart b/lib/core/sftp.dart new file mode 100644 index 0000000..dd2fe72 --- /dev/null +++ b/lib/core/sftp.dart @@ -0,0 +1,574 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; + +import '../l10n/tr.dart'; +import 'dns_resolver.dart'; +import 'remote_fs.dart'; +import 'secret_gate.dart'; +import 'webdav.dart' show DavException, DavItem, DavLockedException; + +/// 88: SFTP 서버 설정 (환경 설정 > 파일 탐색기 > 네트워크 서버). 비밀번호 · 개인 키 · 키 암호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). +/// 호스트 키 (지문) 는 비밀이 아니라 설정에 둔다 - 처음 연결 때 사용자가 믿은 것 (TOFU). +class SftpServer { + final String id; + final String name; + final String host; + final int port; + final String user; + + /// 시작 폴더 (서버 기준 절대 경로). 비우면 로그인한 사용자의 홈 폴더. 탭의 "맨 위" 가 이 폴더다 + final String root; + final String password; + + /// 개인 키 (OpenSSH · PEM 글). 파일을 고르면 내용을 여기로 옮기고 원래 파일 경로는 저장하지 않는다 + final String privateKey; + + /// 개인 키의 암호 (사용자 결정 88-2: 안전 저장소에 저장) + final String keyPassphrase; + + /// 오래된 ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 허용 (사용자 결정 88-3: 처음 끔, 서버마다 켬 - 예전 NAS 용) + final bool legacyRsa; + + /// 믿은 호스트 키: 종류 ("ssh-ed25519" 등) 와 지문 ("SHA256:…"). 비어 있으면 아직 믿은 키가 없음 + final String hostKeyType; + final String hostKeyFingerprint; + + const SftpServer({ + required this.id, + required this.name, + required this.host, + this.port = 22, + this.user = '', + this.root = '', + this.password = '', + this.privateKey = '', + this.keyPassphrase = '', + this.legacyRsa = false, + this.hostKeyType = '', + this.hostKeyFingerprint = '', + }); + + /// 탐색기 위쪽에 보일 이름 + String get label => name.trim().isNotEmpty ? name.trim() : host; + + SftpServer copyWith({ + String? name, + String? host, + int? port, + String? user, + String? root, + String? password, + String? privateKey, + String? keyPassphrase, + bool? legacyRsa, + String? hostKeyType, + String? hostKeyFingerprint, + }) => + SftpServer( + id: id, + name: name ?? this.name, + host: host ?? this.host, + port: port ?? this.port, + user: user ?? this.user, + root: root ?? this.root, + password: password ?? this.password, + privateKey: privateKey ?? this.privateKey, + keyPassphrase: keyPassphrase ?? this.keyPassphrase, + legacyRsa: legacyRsa ?? this.legacyRsa, + hostKeyType: hostKeyType ?? this.hostKeyType, + hostKeyFingerprint: hostKeyFingerprint ?? this.hostKeyFingerprint, + ); + + /// 비밀 값은 넣지 않는다 (안전 저장소). 저장해 두었는지만 + Map toJson() => { + 'id': id, + 'name': name, + 'host': host, + 'port': port, + 'user': user, + 'root': root, + 'hasPassword': password.isNotEmpty, + 'hasKey': privateKey.isNotEmpty, + 'legacyRsa': legacyRsa, + 'hostKeyType': hostKeyType, + 'hostKeyFingerprint': hostKeyFingerprint, + }; + + factory SftpServer.fromJson(Map j) => SftpServer( + id: '${j['id'] ?? DateTime.now().microsecondsSinceEpoch}', + name: j['name'] as String? ?? '', + host: j['host'] as String? ?? '', + port: switch (j['port']) { final int p when p > 0 && p < 65536 => p, _ => 22 }, + user: j['user'] as String? ?? '', + root: j['root'] as String? ?? '', + legacyRsa: j['legacyRsa'] == true, + hostKeyType: j['hostKeyType'] as String? ?? '', + hostKeyFingerprint: j['hostKeyFingerprint'] as String? ?? '', + ); +} + +/// SFTP 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) +enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, other } + +/// SFTP 오류 - WebDAV 오류와 같은 곳에서 보이도록 [DavException] 을 잇는다 +class SftpException extends DavException { + final SftpFailure kind; + const SftpException(super.message, this.kind); +} + +/// 처음 보는 호스트 키 (또는 바뀐 키): 화면이 지문을 보이고 믿을지 묻는다. 믿으면 [SftpServer.hostKeyFingerprint] 에 저장 +class SftpHostKeyException extends SftpException { + final String type; + final String fingerprint; + + /// 바뀐 경우 예전에 믿은 지문 (처음이면 null) + final String? previous; + const SftpHostKeyException(super.message, super.kind, {required this.type, required this.fingerprint, this.previous}); + bool get changed => previous != null; +} + +/// OpenSSH 와 같은 꼴의 지문 "SHA256:" + base64 (끝 '=' 없음). dartssh2 는 이 글을 바이트로 넘긴다 - +/// 판이 바뀌어 SHA-256 값 그대로 오면 직접 만든다 +String sshFingerprint(Uint8List fp) { + final text = utf8.decode(fp, allowMalformed: true); + return text.startsWith('SHA256:') ? text : 'SHA256:${base64.encode(fp).replaceAll('=', '')}'; +} + +/// 연결할 서버 주소. DNS 강제면 정한 DNS 로 (집 안 이름 · 주소는 기기 DNS) - 앱의 다른 연결과 같게 +typedef SftpConnect = Future Function(String host, int port, Duration timeout); + +/// 88: SFTP 클라이언트 (dartssh2). 서버마다 SSH 연결 하나를 열어 두고 다시 쓴다 (끊기면 다음에 다시 연다). +class SftpRemote implements RemoteClient { + final SftpServer server; + + /// 연결 방법 (DNS 강제 · 시험에서 바꿈). 처음 값: [connectWithDns] 를 쓰지 않으면 기기 DNS + final SftpConnect connect; + final Duration timeout; + SftpRemote(this.server, {SftpConnect? connect, this.timeout = const Duration(seconds: 15)}) + : connect = connect ?? SftpRegistry.connect; + + Future<_Conn>? _conn; + + /// 열린 연결 (없거나 끊겼으면 새로) + Future<_Conn> _open() { + final c = _conn; + if (c != null) return c; + final f = _connect(); + _conn = f; + f.then((x) => x.ssh.done.whenComplete(() { + if (identical(_conn, f)) _conn = null; + }), onError: (_) { + if (identical(_conn, f)) _conn = null; + }); + return f; + } + + Future<_Conn> _connect() async { + final s = server; + if (s.host.trim().isEmpty) throw SftpException(tr('SFTP 서버 주소가 없습니다'), SftpFailure.other); + final hasSecret = s.password.isNotEmpty || s.privateKey.isNotEmpty; + if (hasSecret && !await SecretGate.pass()) throw DavLockedException(tr(secretGateMessage)); + // 키도 비밀번호도 없으면 (저장하지 않음) 시도하지 않고 정확히 알린다 (40 과 같게) + if (!hasSecret) throw SftpException(tr('저장된 비밀번호가 없습니다'), SftpFailure.noSecret); + Socket sock; + try { + sock = await connect(s.host.trim(), s.port, timeout); + } catch (e) { + throw SftpException(trf('서버 ({0}) 에 연결할 수 없습니다. 서버가 켜져 있는지, 집 밖이면 Tailscale 이 켜져 있는지 확인하세요', [s.host]), + SftpFailure.unreachable); + } + // 호스트 키: 처음 (믿은 키 없음) · 바뀜 → 연결을 끊고 화면이 묻게 한다 + (String, String)? seen; + List? keys; + if (s.privateKey.isNotEmpty) { + try { + keys = SSHKeyPair.fromPem(s.privateKey, s.keyPassphrase.isEmpty ? null : s.keyPassphrase); + } on SSHKeyDecryptError { + sock.destroy(); + throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.auth); + } catch (_) { + sock.destroy(); + throw SftpException(tr('개인 키를 읽을 수 없습니다 (OpenSSH · PEM 형식)'), SftpFailure.auth); + } + } + final ssh = SSHClient( + _Socket(sock), + username: s.user, + algorithms: s.legacyRsa ? legacyAlgorithms : const SSHAlgorithms(), + identities: keys, + onVerifyHostKey: (type, fp) { + final f = sshFingerprint(fp); + seen = (type, f); + return s.hostKeyFingerprint.isNotEmpty && s.hostKeyFingerprint == f; + }, + onPasswordRequest: s.password.isEmpty ? null : () => s.password, + // 일부 NAS 는 비밀번호를 keyboard-interactive 로 묻는다 + onUserInfoRequest: s.password.isEmpty ? null : (req) => [for (final _ in req.prompts) s.password], + ); + try { + await ssh.authenticated.timeout(timeout * 2); + final sftp = await ssh.sftp(); + final root = s.root.trim().isEmpty ? await sftp.absolute('.') : _clean(s.root.trim()); + return _Conn(ssh, sftp, root); + } catch (e) { + ssh.close(); + sock.destroy(); + throw _explain(e, seen); + } + } + + /// dartssh2 의 오류 → 사람 말 (무슨 일인지 · 할 일) + SftpException _explain(Object e, (String, String)? seen) { + final s = server; + if (e is SftpException) return e; + // 호스트 키를 믿지 않아 끊었으면 그 뒤의 오류 종류 (인증 중단 등) 와 상관없이 키 문제다 + if (seen != null && s.hostKeyFingerprint != seen.$2) { + final (type, fp) = seen; + if (s.hostKeyFingerprint.isEmpty) { + return SftpHostKeyException(trf('처음 연결하는 서버입니다. 서버의 키 지문이 맞는지 확인하세요: {0} ({1})', [fp, type]), + SftpFailure.hostKeyUnknown, + type: type, fingerprint: fp); + } + return SftpHostKeyException( + trf('서버의 키가 바뀌었습니다 - 중간에서 가로채는 공격일 수 있어 연결하지 않았습니다. 예전: {0} · 지금: {1}', [s.hostKeyFingerprint, fp]), + SftpFailure.hostKeyChanged, + type: type, + fingerprint: fp, + previous: s.hostKeyFingerprint); + } + if (e is TimeoutException) return SftpException(trf('서버 ({0}) 가 답하지 않습니다', [s.host]), SftpFailure.unreachable); + // 호스트 키까지 가지 못함 = 키 교환 · 암호 방식을 맞추지 못함 (오래된 서버면 ssh-rsa 허용을 안내) + if (seen == null || e is SSHHandshakeError) { + return SftpException( + s.legacyRsa + ? trf('서버와 암호 방식을 맞추지 못했습니다: {0}', [e is SSHMessageError ? e.message : '$e']) + : tr('서버와 암호 방식을 맞추지 못했습니다. 오래된 서버면 서버 설정의 "오래된 ssh-rsa 허용" 을 켜 보세요'), + SftpFailure.other); + } + if (e is SSHAuthError) return SftpException(tr('아이디나 비밀번호 (또는 키) 가 맞지 않습니다'), SftpFailure.auth); + return SftpException('$e', SftpFailure.other); + } + + /// 서버 기준 경로 → 서버의 절대 경로 + String _remote(_Conn c, String rel) { + final r = rel == '/' || rel.isEmpty ? '' : rel; + return c.root == '/' ? (r.isEmpty ? '/' : r) : '${c.root}$r'; + } + + static String _clean(String path) { + var x = path.replaceAll('\\', '/').replaceAll(RegExp(r'/+'), '/'); + if (!x.startsWith('/')) x = '/$x'; + if (x.length > 1 && x.endsWith('/')) x = x.substring(0, x.length - 1); + return x; + } + + /// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다 + Never _fail(Object e, String rel) { + if (e is SftpStatusError) { + switch (e.code) { + case SftpStatusCode.noSuchFile: + throw SftpException(trf('없는 경로입니다: {0}', [rel]), SftpFailure.notFound); + case SftpStatusCode.permissionDenied: + throw SftpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [rel]), SftpFailure.permission); + } + throw SftpException('${e.message} ($rel)', SftpFailure.other); + } + if (e is SftpException) throw e; + if (e is SSHError || e is SocketException || e is StateError) { + _conn = null; + throw SftpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), SftpFailure.unreachable); + } + throw e; + } + + DavItem _item(String rel, SftpFileAttrs a) => DavItem(rel, + isDir: a.isDirectory, + size: a.isDirectory ? 0 : (a.size ?? 0), + modified: DateTime.fromMillisecondsSinceEpoch((a.modifyTime ?? 0) * 1000)); + + static String _child(String rel, String name) => rel == '/' ? '/$name' : '$rel/$name'; + static String _parent(String rel) => rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')); + + @override + Future> list(String rel) async { + final c = await _open(); + try { + final out = []; + for (final n in await c.sftp.listdir(_remote(c, rel))) { + if (n.filename == '.' || n.filename == '..') continue; + var a = n.attr; + // 링크는 가리키는 것으로 (폴더 링크를 폴더로) - 따라갈 수 없으면 크기 0 파일로 + if (a.type == SftpFileType.symbolicLink) { + try { + a = await c.sftp.stat(_remote(c, _child(rel, n.filename))); + } catch (_) {} + } + out.add(_item(_child(rel, n.filename), a)); + } + return out; + } catch (e) { + _fail(e, rel); + } + } + + @override + Future stat(String rel) async { + final c = await _open(); + try { + return _item(rel, await c.sftp.stat(_remote(c, rel))); + } on SftpStatusError catch (e) { + if (e.code == SftpStatusCode.noSuchFile) return null; + _fail(e, rel); + } catch (e) { + _fail(e, rel); + } + } + + @override + Future mkdirs(String rel) async { + if (rel == '/' || rel.isEmpty) return; + final st = await stat(rel); + if (st != null) { + if (st.isDir) return; + throw SftpException(trf('같은 이름의 파일이 있어 폴더를 만들 수 없습니다: {0}', [rel]), SftpFailure.other); + } + await mkdirs(_parent(rel)); + final c = await _open(); + try { + await c.sftp.mkdir(_remote(c, rel)); + } catch (e) { + // 그 사이에 다른 곳이 만들었으면 그대로 + if ((await stat(rel))?.isDir == true) return; + _fail(e, rel); + } + } + + @override + Future delete(String rel) async { + final st = await stat(rel); + if (st == null) return; + final c = await _open(); + try { + if (st.isDir) { + for (final x in await list(rel)) { + await delete(x.rel); + } + await c.sftp.rmdir(_remote(c, rel)); + } else { + await c.sftp.remove(_remote(c, rel)); + } + } catch (e) { + _fail(e, rel); + } + } + + @override + Future move(String from, String to, {bool overwrite = false}) async { + final c = await _open(); + // SFTP (v3) 의 이름 바꾸기는 대상이 있으면 실패한다 - 덮어쓰기면 대상을 먼저 지운다 (파일만. 폴더를 통째로 덮지 않음) + if (overwrite) { + final t = await stat(to); + if (t != null && !t.isDir) { + try { + await c.sftp.remove(_remote(c, to)); + } catch (e) { + _fail(e, to); + } + } + } + try { + await c.sftp.rename(_remote(c, from), _remote(c, to)); + } catch (e) { + _fail(e, from); + } + } + + @override + Future>> openRead(String rel) async { + final c = await _open(); + final SftpFile f; + try { + f = await c.sftp.open(_remote(c, rel)); + } catch (e) { + _fail(e, rel); + } + late StreamController> out; + StreamSubscription? sub; + out = StreamController>( + onListen: () { + sub = f.read().listen(out.add, onError: (Object e) { + try { + _fail(e, rel); + } catch (x) { + out.addError(x); + } + }, onDone: () async { + await f.close().catchError((_) {}); + await out.close(); + }); + }, + onPause: () => sub?.pause(), + onResume: () => sub?.resume(), + onCancel: () async { + await sub?.cancel(); + await f.close().catchError((_) {}); + }, + ); + return out.stream; + } + + @override + Future write(String rel, Stream> data, {int? length}) async { + final c = await _open(); + final SftpFile f; + try { + f = await c.sftp.open(_remote(c, rel), + mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write); + } catch (e) { + _fail(e, rel); + } + try { + await f.write(data.map((x) => x is Uint8List ? x : Uint8List.fromList(x))).done; + } catch (e) { + _fail(e, rel); + } finally { + await f.close().catchError((_) {}); + } + } + + @override + Future<(int?, int?)> quota() async { + final c = await _open(); + try { + final v = await c.sftp.statvfs(c.root); + final unit = v.fundamentalBlockSize > 0 ? v.fundamentalBlockSize : v.blockSize; + return (v.freeBlocksForNonRoot * unit, (v.totalBlocks - v.freeBlocks) * unit); + } catch (_) { + // statvfs 를 지원하지 않는 서버 (OpenSSH 확장) - 모름 + return (null, null); + } + } + + @override + void close() { + final c = _conn; + _conn = null; + c?.then((x) => x.ssh.close(), onError: (_) {}); + } + + /// 오래된 서버용 (88-3): 기본 알고리즘 + ssh-rsa (SHA-1) 서명 · SHA-1 키 교환 + static const legacyAlgorithms = SSHAlgorithms( + kex: [ + SSHKexType.x25519Rfc, + SSHKexType.x25519, + SSHKexType.nistp521, + SSHKexType.nistp384, + SSHKexType.nistp256, + SSHKexType.dhGexSha256, + SSHKexType.dh14Sha256, + SSHKexType.dh14Sha1, + SSHKexType.dhGexSha1, + ], + hostkey: [ + SSHHostkeyType.ed25519, + SSHHostkeyType.rsaSha512, + SSHHostkeyType.rsaSha256, + SSHHostkeyType.ecdsa521, + SSHHostkeyType.ecdsa384, + SSHHostkeyType.ecdsa256, + SSHHostkeyType.rsaSha1, + ], + ); +} + +class _Conn { + final SSHClient ssh; + final SftpClient sftp; + + /// 탭의 맨 위 (서버의 절대 경로) + final String root; + _Conn(this.ssh, this.sftp, this.root); +} + +/// dart:io Socket → dartssh2 (연결은 우리가 열어 DNS 강제를 따르게) +class _Socket implements SSHSocket { + final Socket _s; + _Socket(this._s) { + // 서버가 먼저 끊은 뒤의 쓰기 실패 (호스트 키를 믿지 않아 끊음 등) 가 처리되지 않은 오류로 앱에 올라오지 않게. + // dartssh2 는 같은 Future 를 기다려 끊김을 그대로 안다 + unawaited(_s.done.then((_) {}, onError: (Object _) {})); + } + /// 소켓 오류 (상대가 끊음 · 쓰기 실패) 는 끊김으로 (dartssh2 는 읽는 쪽의 오류를 받지 않는다 - 처리되지 않은 오류가 되었다) + @override + late final Stream stream = + _s.transform(StreamTransformer.fromHandlers(handleError: (e, st, out) => out.close())); + @override + StreamSink> get sink => _s; + // 닫기 · 비우기의 쓰기 실패도 같은 끊김이다 (dartssh2 가 기다리지 않는 Future - 처리되지 않은 오류가 되지 않게) + @override + Future close() => _s.close().then((_) {}, onError: (Object _) {}); + @override + Future get done => _s.done; + @override + void destroy() => _s.destroy(); + @override + Future flush() => _s.flush().then((_) {}, onError: (Object _) {}); +} + +/// 설정의 SFTP 서버 목록과 클라이언트 (서버마다 하나) +class SftpRegistry { + static final _clients = {}; + static final _servers = {}; + + /// 연결 방법 (main 에서 DNS 강제를 따르게 바꾼다). 처음 값: 기기 DNS + static SftpConnect connect = (host, port, timeout) => Socket.connect(host, port, timeout: timeout); + + /// 정한 DNS 로 이름을 풀어 연결 (집 안 이름 · 주소는 기기 DNS). [resolver] 가 DNS 강제가 아니면 기기 DNS + static SftpConnect connectWithDns(DnsResolver resolver) => (host, port, timeout) async { + if (InternetAddress.tryParse(host) != null || !resolver.config().forced || isLocalName(host)) { + return Socket.connect(host, port, timeout: timeout); + } + final addrs = await resolver.lookup(host); + Object? last; + for (final a in [...addrs.where((a) => a.type == InternetAddressType.IPv4), ...addrs.where((a) => a.type != InternetAddressType.IPv4)]) { + try { + return await Socket.connect(a, port, timeout: timeout); + } catch (e) { + last = e; + } + } + throw last ?? SocketException(trf('{0} 에 연결할 수 없습니다', [host])); + }; + + static void configure(List servers) { + final keep = {for (final s in servers) s.id: s}; + for (final id in _clients.keys.toList()) { + final old = _servers[id], now = keep[id]; + // 비밀 값은 toJson 에 없으므로 따로 비교 (바꾸면 바로 새 값으로 접속) + if (now == null || + old == null || + old.password != now.password || + old.privateKey != now.privateKey || + old.keyPassphrase != now.keyPassphrase || + jsonEncode(old.toJson()) != jsonEncode(now.toJson())) { + _clients.remove(id)?.close(); + } + } + _servers + ..clear() + ..addAll(keep); + } + + static List get servers => _servers.values.toList(); + static SftpServer? server(String id) => _servers[id]; + + static SftpRemote client(String id) { + final s = _servers[id]; + if (s == null) throw SftpException(trf('SFTP 서버 설정이 없습니다 ({0})', [id]), SftpFailure.other); + return _clients.putIfAbsent(id, () => SftpRemote(s)); + } +} diff --git a/lib/core/vfs.dart b/lib/core/vfs.dart index 928fdfa..2f9da3b 100644 --- a/lib/core/vfs.dart +++ b/lib/core/vfs.dart @@ -3,46 +3,65 @@ import 'dart:io'; import 'package:path/path.dart' as p; +import 'remote_fs.dart'; +import 'sftp.dart'; import 'webdav.dart'; import '../l10n/tr.dart'; -/// 로컬 경로와 WebDAV 경로 (`dav://<서버 id>/<경로>`) 를 같이 다루는 도구. -/// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, dav:// 만 여기서 WebDAV 로 처리한다. +/// 로컬 경로와 원격 경로 (WebDAV `dav://<서버 id>/<경로>` · 88 SFTP `sftp://<서버 id>/<경로>`) 를 같이 다루는 도구. +/// 로컬 경로는 원래 코드 (dart:io) 를 그대로 쓰고, 원격만 여기서 서버 종류에 맞는 클라이언트 ([RemoteClient]) 로 처리한다. const davScheme = 'dav://'; +const sftpScheme = 'sftp://'; -bool isDav(String path) => path.startsWith(davScheme); +/// 원격 (WebDAV · SFTP) 경로인지. 이름은 예전 그대로 (원격이면 같은 길 - 복사 · 동기화 · 임시로 받아 열기) +bool isDav(String path) => path.startsWith(davScheme) || path.startsWith(sftpScheme); -/// dav:// 경로: 서버 id 와 서버 기준 경로 ("/" 로 시작, 끝 / 없음, 맨 위는 "/") +/// WebDAV 만 (HTTP 로 바로 재생 · 서버 고치기 창 등 WebDAV 에만 있는 것) +bool isWebDav(String path) => path.startsWith(davScheme); +bool isSftp(String path) => path.startsWith(sftpScheme); + +/// 원격 경로: 서버 종류 ([scheme]) · 서버 id · 서버 기준 경로 ("/" 로 시작, 끝 / 없음, 맨 위는 "/") class DavPath { final String server; final String rel; - const DavPath(this.server, this.rel); + final String scheme; + const DavPath(this.server, this.rel, [this.scheme = davScheme]); - /// "dav://id/a/b" · "dav://id/a\b" (p.join 이 붙인 \ ) · "dav://id" → 정리 + /// "dav://id/a/b" · "dav://id/a\b" (p.join 이 붙인 \ ) · "dav://id" · "sftp://id/…" → 정리 static DavPath parse(String path) { - var rest = path.substring(davScheme.length).replaceAll('\\', '/'); + final scheme = path.startsWith(sftpScheme) ? sftpScheme : davScheme; + var rest = path.substring(scheme.length).replaceAll('\\', '/'); final slash = rest.indexOf('/'); final server = slash < 0 ? rest : rest.substring(0, slash); var rel = slash < 0 ? '/' : rest.substring(slash); rel = rel.replaceAll(RegExp(r'/+'), '/'); if (rel.length > 1 && rel.endsWith('/')) rel = rel.substring(0, rel.length - 1); if (rel.isEmpty) rel = '/'; - return DavPath(server, rel); + return DavPath(server, rel, scheme); } bool get isRoot => rel == '/'; - String get full => isRoot ? '$davScheme$server/' : '$davScheme$server$rel'; + bool get isSftp => scheme == sftpScheme; + String get full => isRoot ? '$scheme$server/' : '$scheme$server$rel'; String get name => isRoot ? '' : rel.substring(rel.lastIndexOf('/') + 1); - DavPath get parent => isRoot ? this : DavPath(server, rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/'))); - DavPath child(String n) => DavPath(server, isRoot ? '/$n' : '$rel/$n'); - DavClient get client => DavRegistry.client(server); + DavPath get parent => isRoot ? this : DavPath(server, rel.lastIndexOf('/') <= 0 ? '/' : rel.substring(0, rel.lastIndexOf('/')), scheme); + DavPath child(String n) => DavPath(server, isRoot ? '/$n' : '$rel/$n', scheme); + + /// 이 서버의 클라이언트 (WebDAV · SFTP) + RemoteClient get client => isSftp ? SftpRegistry.client(server) : DavRegistry.client(server); + + /// 서버 이름 (화면 표시) + String get serverLabel => isSftp ? SftpRegistry.server(server)?.label ?? 'SFTP' : DavRegistry.server(server)?.label ?? 'WebDAV'; + + /// 설정에 이 서버가 있는지 (지운 서버의 경로 · 즐겨찾기 등) + bool get serverKnown => isSftp ? SftpRegistry.server(server) != null : DavRegistry.server(server) != null; } /// WebDAV 파일을 받지 않고 바로 재생 (스트리밍) 할 주소 · 헤더 (인증은 헤더로만 - 주소에 비밀번호를 넣지 않는다) ({String url, Map headers, bool insecure}) davStream(String path) { final d = DavPath.parse(path); final server = DavRegistry.server(d.server); - final client = d.client; + final client = DavRegistry.client(d.server); final uri = client.uriOf(d.rel); final auth = client.authHeader; return ( @@ -54,7 +73,8 @@ class DavPath { /// 외부 프로그램 · 다른 앱에 넘길 주소: WebDAV 는 스트리밍 주소, 로컬은 그대로. /// 사용자 결정 (55): 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다 (받는 앱이 물어본다) -String vPlayable(String path) => isDav(path) ? davStream(path).url : path; +/// SFTP 는 넘길 주소가 없다 - 부르는 쪽이 [vLocalCopy] 로 받아서 넘긴다 +String vPlayable(String path) => isWebDav(path) ? davStream(path).url : path; /// 경로를 한 모양으로 (dav:// 만 정리, 로컬은 그대로) String vNorm(String path) => isDav(path) ? DavPath.parse(path).full : path; @@ -103,7 +123,7 @@ Future> vList(Str final d = DavPath.parse(dir); return [ for (final i in await d.client.list(d.rel)) - (path: DavPath(d.server, i.rel).full, isDir: i.isDir, size: i.size, modified: i.modified), + (path: DavPath(d.server, i.rel, d.scheme).full, isDir: i.isDir, size: i.size, modified: i.modified), ]; } final out = <({String path, bool isDir, int size, DateTime modified})>[]; @@ -223,7 +243,7 @@ Future vLocalCopy(String path, String tempDir, {void Function(int done, if (!isDav(path)) return path; final d = DavPath.parse(path); final st = await d.client.stat(d.rel); - final dir = Directory(p.join(tempDir, 'jj_webdav', d.server, p.dirname(d.rel.substring(1)))); + final dir = Directory(p.join(tempDir, d.isSftp ? 'jj_sftp' : 'jj_webdav', d.server, p.dirname(d.rel.substring(1)))); await dir.create(recursive: true); final out = File(p.join(dir.path, d.name)); // 이미 받아 둔 것 (크기 · 바뀐 때 같음) 은 다시 받지 않는다 @@ -254,12 +274,12 @@ Future vCountFiles(String path) async { return n; } -/// 화면에 보일 경로: WebDAV 는 내부 id 대신 "☁ 서버 이름/경로" -String vDisplay(String path) => isDav(path) ? '☁ ${vVolumeLabel(path)}' : path; +/// 화면에 보일 경로: 원격은 내부 id 대신 "☁ 서버 이름/경로" (WebDAV) · "🔑 서버 이름/경로" (SFTP) +String vDisplay(String path) => isDav(path) ? '${isSftp(path) ? '🔑' : '☁'} ${vVolumeLabel(path)}' : path; -/// 저장소 이름 (화면 표시): dav 는 서버 이름 +/// 저장소 이름 (화면 표시): 원격은 서버 이름 String vVolumeLabel(String path) { if (!isDav(path)) return path; final d = DavPath.parse(path); - return '${DavRegistry.server(d.server)?.label ?? 'WebDAV'}${d.rel == '/' ? '' : d.rel}'; + return '${d.serverLabel}${d.rel == '/' ? '' : d.rel}'; } diff --git a/lib/core/webdav.dart b/lib/core/webdav.dart index 1d688cd..65f73fb 100644 --- a/lib/core/webdav.dart +++ b/lib/core/webdav.dart @@ -5,6 +5,7 @@ import 'dart:io'; import 'package:xml/xml.dart'; import '../l10n/tr.dart'; +import 'remote_fs.dart'; import 'secret_gate.dart'; /// WebDAV 서버 설정 (환경 설정 > 파일 탐색기 > WebDAV). 비밀번호는 안전 저장소에 (설정 파일 · 보관본에는 넣지 않음). @@ -85,7 +86,7 @@ class DavException implements Exception { } /// WebDAV 클라이언트: PROPFIND (목록) · GET · PUT · MKCOL · DELETE · MOVE · COPY. 기본 인증 (Basic). -class DavClient { +class DavClient implements RemoteClient { final DavServer server; late final HttpClient _http = HttpClient() ..connectionTimeout = const Duration(seconds: 15) @@ -209,6 +210,7 @@ class DavClient { } /// 폴더 안의 항목 (자기 자신은 뺌) + @override Future> list(String rel) async { final self = _norm(rel); final items = _parse(await _propfind(self, depth: 1)); @@ -216,6 +218,7 @@ class DavClient { } /// 항목 정보 (없으면 null) + @override Future stat(String rel) async { try { final items = _parse(await _propfind(_norm(rel), depth: 0)); @@ -236,6 +239,7 @@ class DavClient { } /// 위 폴더까지 차례로 만든다 + @override Future mkdirs(String rel) async { var cur = ''; for (final part in rel.split('/').where((x) => x.isNotEmpty)) { @@ -245,6 +249,7 @@ class DavClient { } } + @override Future delete(String rel) async { final st = await stat(rel); final res = await (await _open('DELETE', uriOf(rel, dir: st?.isDir ?? false))).close(); @@ -265,10 +270,12 @@ class DavClient { if (res.statusCode >= 300) _fail(method == 'MOVE' ? tr('이동') : tr('복사'), res); } + @override Future move(String from, String to, {bool overwrite = false}) => _moveOrCopy('MOVE', from, to, overwrite: overwrite); Future copy(String from, String to, {bool overwrite = false}) => _moveOrCopy('COPY', from, to, overwrite: overwrite); /// 받기: 내용을 조각으로 (끝나면 스트림이 닫힘) + @override Future>> openRead(String rel) async { final res = await (await _open('GET', uriOf(rel))).close(); if (res.statusCode != 200) { @@ -279,6 +286,7 @@ class DavClient { } /// 올리기 ([length] 를 알면 Content-Length 로) + @override Future write(String rel, Stream> data, {int? length}) async { final req = await _open('PUT', uriOf(rel)); if (length != null) req.contentLength = length; @@ -289,6 +297,7 @@ class DavClient { } /// 남은 용량 · 쓴 용량 (바이트, 서버가 알려 주지 않으면 null) + @override Future<(int?, int?)> quota() async { try { final xml = await _propfind('/', depth: 0, @@ -302,6 +311,7 @@ class DavClient { } } + @override void close() => _http.close(force: true); static String _norm(String rel) { diff --git a/lib/main.dart b/lib/main.dart index 9e1b605..9e38b75 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -35,6 +35,7 @@ import 'app/download_manager.dart'; import 'app/settings.dart'; import 'core/app_update.dart'; import 'core/playlist.dart'; +import 'core/sftp.dart' show SftpRegistry; import 'platform/android/android_download_tools.dart'; import 'platform/android/android_keep_alive.dart'; import 'platform/android/android_shell.dart' show applyScreenOrientation; @@ -469,6 +470,8 @@ Future setupNetwork(AppController c, String dataDir, {bool second = false} c.note(trf('DNS 강제 프록시를 시작하지 못했습니다: {0}', [e])); } HttpOverrides.global = JjHttpOverrides(); + // 88: SFTP 는 HTTP 가 아니라 직접 잇는다 - 같은 DNS 설정을 따르게 (집 안 이름 · 주소는 기기 DNS) + SftpRegistry.connect = SftpRegistry.connectWithDns(g.resolver); if (second) return; TorService.dataDir = dataDir; TorService.instance.log = c.note; diff --git a/lib/platform/common/media_kit_full_player.dart b/lib/platform/common/media_kit_full_player.dart index f79e605..f12161e 100644 --- a/lib/platform/common/media_kit_full_player.dart +++ b/lib/platform/common/media_kit_full_player.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:media_kit/media_kit.dart' as mk; @@ -134,7 +135,9 @@ class MediaKitFullPlayer implements MediaPlayer { /// WebDAV 는 받지 않고 바로 스트리밍 (주소 + 인증 헤더, 자체 서명 인증서면 확인 안 함) Future _media(String f) async { final native = _p.platform; - final dav = isDav(f) ? davStream(f) : null; + // 88: SFTP 는 스트리밍 주소가 없어 받아서 튼다 (보통은 playFiles 가 미리 받아 둔다 - 여기는 안전망) + if (isSftp(f)) return mk.Media(await vLocalCopy(f, Directory.systemTemp.path)); + final dav = isWebDav(f) ? davStream(f) : null; if (native is mk.NativePlayer) { _tlsDefault ??= await native.getProperty('tls-verify'); // 56: "인증서 확인 안 함" 서버의 것만 확인을 끄고, 그 밖은 원래 값으로 (한 번 끈 것이 계속 남지 않게) diff --git a/lib/ui/dav_external.dart b/lib/ui/dav_external.dart index 5adb7f5..3ba6f5e 100644 --- a/lib/ui/dav_external.dart +++ b/lib/ui/dav_external.dart @@ -21,7 +21,7 @@ Future fetchDav(BuildContext context, AppController c, String path) asy barrierDismissible: false, builder: (ctx) => AlertDialog( scrollable: true, - title: Text(tr('WebDAV 에서 받는 중')), + title: Text(isSftp(path) ? tr('SFTP 에서 받는 중') : tr('WebDAV 에서 받는 중')), content: ValueListenableBuilder<(int, int)>( valueListenable: progress, builder: (_, v, _) => Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -59,7 +59,7 @@ Future fetchDav(BuildContext context, AppController c, String path) asy /// 로그인이 필요한 WebDAV 서버의 파일인지 (아이디나 비밀번호가 있음) bool davNeedsLogin(String path) { - if (!isDav(path)) return false; + if (!isWebDav(path)) return false; final s = DavRegistry.server(DavPath.parse(path).server); return s != null && (s.user.isNotEmpty || s.password.isNotEmpty); } @@ -68,6 +68,18 @@ bool davNeedsLogin(String path) { /// 55: 다른 앱으로 넘기는 주소에는 아이디 · 비밀번호를 넣지 않는다. 그래서 로그인이 필요한 WebDAV 파일이면 /// 조용히 실패하지 않게 먼저 묻는다: [받아서 열기] (임시로 받은 뒤 열기 - 어느 앱이든 열림) · [주소로 열기] (받는 앱이 아이디를 물어야 열림). Future openExternalPlayable(BuildContext context, AppController c, String program, List files) async { + // 88: SFTP 는 다른 앱에 넘길 주소가 없다 - 늘 받아서 연다 (묻지 않음) + if (files.any(isSftp)) { + final local = []; + for (final f in files) { + if (!context.mounted) return; + final l = isSftp(f) ? await fetchDav(context, c, f) : vPlayable(f); + if (l == null) return; // 취소 · 실패 + local.add(l); + } + await c.services.shell.openExternal(program, local); + return; + } if (!files.any(davNeedsLogin)) { await c.services.shell.openExternal(program, files.map(vPlayable).toList()); return; diff --git a/lib/ui/master_prompt.dart b/lib/ui/master_prompt.dart index 6f184dd..dca8318 100644 --- a/lib/ui/master_prompt.dart +++ b/lib/ui/master_prompt.dart @@ -140,6 +140,8 @@ class _MasterDialogState extends State<_MasterDialog> { /// 저장된 실제 비밀번호 · API 키를 지운다 (서버 목록 · 아이디는 남김) Future clearStoredSecrets(AppController c) => c.updateSettings((s) => s ..webdavServers = [for (final x in s.webdavServers) x.copyWith(password: '')] + // 88: SFTP 의 비밀번호 · 개인 키 · 키 암호도 (믿은 호스트 키는 비밀이 아니라 남김) + ..sftpServers = [for (final x in s.sftpServers) x.copyWith(password: '', privateKey: '', keyPassphrase: '')] ..openSubtitlesKey = '' ..openSubtitlesPassword = ''); diff --git a/lib/ui/player_page.dart b/lib/ui/player_page.dart index f9465b2..85091a7 100644 --- a/lib/ui/player_page.dart +++ b/lib/ui/player_page.dart @@ -11,6 +11,7 @@ import '../app/app_controller.dart'; import '../core/languages.dart'; import '../core/playlist.dart'; import '../core/srt.dart' show formatSrtTime; +import '../core/vfs.dart' show isSftp; import '../services/media_player.dart'; import 'app_actions.dart'; import 'audio_now_playing.dart'; @@ -29,7 +30,18 @@ Future playFiles(BuildContext context, AppController c, List files if (context.mounted) await openExternalPlayable(context, c, program, videos); }); if (plan == null || create == null || !context.mounted) return; - final (list, start) = plan; + var (list, start) = plan; + // 88: SFTP 는 바로 재생 (스트리밍) 할 주소가 없다 - 고른 파일만 받아서 (진행 창 · 취소) 그 파일만 튼다. + // 목록 전체를 받지 않게 다음 파일로 넘어가지 않는다 (1차, 사용자 결정 88 설계안) + if (isSftp(list[start])) { + final remote = list[start]; + final local = await fetchDav(context, c, remote); + if (local == null || !context.mounted) return; + // 같은 폴더의 자막도 받아 둔다 (받은 동영상 옆 같은 임시 폴더에 생겨 플레이어가 찾는다) + await c.externalSubtitlesFor(remote); + if (!context.mounted) return; + (list, start) = ([local], 0); + } await Navigator.of(context).push(MaterialPageRoute( builder: (_) => PlayerPage(c: c, player: create(), files: list, start: start), )); diff --git a/pubspec.lock b/pubspec.lock index 06a276a..53a1567 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -105,6 +113,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" cp949_codec: dependency: "direct main" description: @@ -145,6 +161,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + dartssh2: + dependency: "direct main" + description: + name: dartssh2 + sha256: "39359aceafcf5940b868249e3c9c6d86e4e0f989eec7417d033c943157c84c51" + url: "https://pub.dev" + source: hosted + version: "4.1.0" dbus: dependency: transitive description: @@ -777,6 +801,14 @@ packages: url: "https://pub.dev" source: hosted version: "7.0.2" + pinenacl: + dependency: transitive + description: + name: pinenacl + sha256: "57e907beaacbc3c024a098910b6240758e899674de07d6949a67b52fd984cbdf" + url: "https://pub.dev" + source: hosted + version: "0.6.0" platform: dependency: transitive description: @@ -793,6 +825,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pointycastle: + dependency: transitive + description: + name: pointycastle + sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + url: "https://pub.dev" + source: hosted + version: "4.0.0" posix: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 4cd46d8..e0f6217 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -74,6 +74,7 @@ dependencies: wakelock_plus: ^1.8.0 # 시험판이 사용자 데이터와 다른 폴더를 쓰게 (JJ_MKVMAKER_DATA, Windows) - path_provider 가 이미 쓰는 것 path_provider_platform_interface: ^2.1.2 + dartssh2: ^4.1.0 dependency_overrides: flutter_inappwebview_android: path: packages/flutter_inappwebview_android diff --git a/test/sftp_test.dart b/test/sftp_test.dart new file mode 100644 index 0000000..be92402 --- /dev/null +++ b/test/sftp_test.dart @@ -0,0 +1,257 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/app/settings.dart'; +import 'package:jj_mkvmaker/core/sftp.dart'; +import 'package:jj_mkvmaker/core/vfs.dart'; +import 'package:jj_mkvmaker/services/secret_store.dart'; +import 'package:path/path.dart' as p; + +/// 88: SFTP (dartssh2). 실제 서버 시험은 tool/sftp_test_server.py (paramiko) 를 띄운다 - +/// 파이썬 (JJ_SFTP_PY, 없으면 ..\tools\sftp_test_py 의 venv) 이 없으면 건너뛴다. +void main() { + final py = Platform.environment['JJ_SFTP_PY'] ?? + p.normalize(p.join(Directory.current.path, '..', 'tools', 'sftp_test_py', Platform.isWindows ? 'Scripts/python.exe' : 'bin/python')); + final hasServer = File(py).existsSync(); + final keygen = Platform.isWindows ? r'C:\Windows\System32\OpenSSH\ssh-keygen.exe' : '/usr/bin/ssh-keygen'; + + group('설정 · 경로 (서버 없이)', () { + test('설정 파일에는 비밀 값을 넣지 않고 안전 저장소에 · 다시 읽으면 채움', () async { + final tmp = Directory.systemTemp.createTempSync('jj_sftp88_set_'); + addTearDown(() => tmp.deleteSync(recursive: true)); + final secrets = MemorySecretStore(); + final store = SettingsStore(p.join(tmp.path, 'settings.json'), secrets); + final s = AppSettings() + ..sftpServers = [ + const SftpServer( + id: 's1', + name: 'NAS', + host: 'nas.local', + port: 2222, + user: 'jj', + password: 'pw-secret', + privateKey: '-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END OPENSSH PRIVATE KEY-----', + keyPassphrase: 'kp-secret', + legacyRsa: true, + hostKeyType: 'ssh-ed25519', + hostKeyFingerprint: 'SHA256:abc'), + ]; + await store.save(s); + final text = File(p.join(tmp.path, 'settings.json')).readAsStringSync(); + expect(text, isNot(contains('pw-secret'))); + expect(text, isNot(contains('kp-secret'))); + expect(text, isNot(contains('OPENSSH PRIVATE KEY'))); + expect(secrets.values['sftp.pw.s1'], 'pw-secret'); + expect(secrets.values['sftp.kp.s1'], 'kp-secret'); + expect(secrets.values['sftp.key.s1'], contains('OPENSSH PRIVATE KEY')); + final back = await SettingsStore(p.join(tmp.path, 'settings.json'), secrets).load(); + final x = back.sftpServers.single; + expect((x.host, x.port, x.user, x.legacyRsa, x.hostKeyFingerprint), ('nas.local', 2222, 'jj', true, 'SHA256:abc')); + expect((x.password, x.keyPassphrase), ('pw-secret', 'kp-secret')); + expect(x.privateKey, contains('OPENSSH PRIVATE KEY')); + // .bak · 모르는 항목 보존용 JSON 에서도 빠진다 + final stripped = AppSettings.stripSecrets({ + 'sftpServers': [ + {'id': 'x', 'password': 'a', 'privateKey': 'b', 'keyPassphrase': 'c', 'host': 'h'} + ] + }); + expect(jsonEncode(stripped), isNot(anyOf(contains('"a"'), contains('"b"'), contains('"c"')))); + expect(SettingsStore.redactSecrets('{"privateKey": "k", "keyPassphrase": "p"}'), '{"privateKey": "", "keyPassphrase": ""}'); + }); + + test('sftp:// 경로: 정리 · 위 · 아래 · 화면 이름 (🔑 서버 이름)', () { + SftpRegistry.configure([const SftpServer(id: 's1', name: 'Home NAS', host: 'nas')]); + addTearDown(() => SftpRegistry.configure(const [])); + final d = DavPath.parse(r'sftp://s1/a\b/'); + expect(d.full, 'sftp://s1/a/b'); + expect(d.isSftp, isTrue); + expect(d.parent.full, 'sftp://s1/a'); + expect(d.parent.parent.full, 'sftp://s1/'); + expect(d.child('c').full, 'sftp://s1/a/b/c'); + expect(vJoin('sftp://s1/', 'x'), 'sftp://s1/x'); + expect(vDirname('sftp://s1/a/b'), 'sftp://s1/a'); + expect(isDav('sftp://s1/a'), isTrue, reason: '원격 (복사 · 동기화 · 받아서 열기는 같은 길)'); + expect((isWebDav('sftp://s1/a'), isSftp('sftp://s1/a'), isWebDav('dav://d/a')), (false, true, true)); + expect(vDisplay('sftp://s1/a/b'), '🔑 Home NAS/a/b'); + expect(vPlayable('sftp://s1/a.mkv'), 'sftp://s1/a.mkv', reason: 'SFTP 는 넘길 주소가 없다 - 받아서 연다'); + expect(davSame('sftp://s1/a', 'dav://s1/a'), isFalse); + }); + }); + + group('실제 SFTP 서버 (paramiko)', () { + late Directory tmp; + late Directory root; + Process? server; + late int port; + late String fingerprint; + + Future start({String? password = 'pw', String? pubkey, String keytype = 'ed25519', bool legacyOnly = false}) async { + final pr = await Process.start(py, [ + 'tool/sftp_test_server.py', + '--root', root.path, + '--hostkey', p.join(tmp.path, 'hostkey_$keytype'), + '--keytype', keytype, + '--user', 'jj', + if (password != null) ...['--password', password], + if (pubkey != null) ...['--pubkey', pubkey], + if (legacyOnly) '--legacy-only', + ]); + server = pr; + final lines = pr.stdout.transform(utf8.decoder).transform(const LineSplitter()).asBroadcastStream(); + pr.stderr.drain(); + port = int.parse((await lines.firstWhere((l) => l.startsWith('PORT '))).split(' ')[1]); + fingerprint = (await lines.firstWhere((l) => l.startsWith('FINGERPRINT '))).split(' ')[1]; + } + + setUp(() { + tmp = Directory.systemTemp.createTempSync('jj_sftp88_'); + root = Directory(p.join(tmp.path, 'root'))..createSync(); + }); + tearDown(() async { + SftpRegistry.configure(const []); + final s = server; + server = null; + if (s != null) { + await s.stdin.close().catchError((_) {}); + s.kill(); + await s.exitCode.timeout(const Duration(seconds: 5), onTimeout: () => -1); + } + for (var i = 0;; i++) { + try { + if (tmp.existsSync()) tmp.deleteSync(recursive: true); + break; + } on FileSystemException { + if (i > 30) rethrow; + await Future.delayed(const Duration(milliseconds: 100)); + } + } + }); + + SftpServer srv({String password = 'pw', String fp = '', String key = '', String kp = '', bool legacy = false, String rootDir = ''}) => + SftpServer( + id: 's1', + name: 'test', + host: '127.0.0.1', + port: port, + user: 'jj', + password: password, + privateKey: key, + keyPassphrase: kp, + legacyRsa: legacy, + hostKeyFingerprint: fp, + root: rootDir); + + Future err(Future Function() f) async { + try { + await f(); + return null; + } catch (e) { + return e; + } + } + + test('처음 연결: 지문을 보이고 막음 (TOFU) → 믿으면 연결 · 바뀐 키는 막음', () async { + await start(); + final e = await err(() => SftpRemote(srv()).list('/')); + expect(e, isA()); + final hk = e! as SftpHostKeyException; + expect(hk.kind, SftpFailure.hostKeyUnknown); + expect(hk.fingerprint, fingerprint, reason: 'OpenSSH 와 같은 SHA256 지문'); + expect(hk.type, 'ssh-ed25519'); + expect(hk.changed, isFalse); + // 믿음 → 연결 + final ok = SftpRemote(srv(fp: fingerprint)); + addTearDown(ok.close); + expect(await ok.list('/'), isEmpty); + // 바뀐 키 → 막고 예전 · 지금 지문을 알림 + final e2 = await err(() => SftpRemote(srv(fp: 'SHA256:예전키')).list('/')); + expect(e2, isA()); + expect((e2! as SftpHostKeyException).kind, SftpFailure.hostKeyChanged); + expect((e2 as SftpHostKeyException).previous, 'SHA256:예전키'); + expect('$e2', contains('바뀌었습니다')); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('틀린 비밀번호 · 저장된 비밀번호 없음 · 닿지 않음은 이유를 정확히', () async { + await start(); + final bad = await err(() => SftpRemote(srv(password: 'nope', fp: fingerprint)).list('/')); + expect((bad! as SftpException).kind, SftpFailure.auth); + final none = await err(() => SftpRemote(srv(password: '', fp: fingerprint)).list('/')); + expect((none! as SftpException).kind, SftpFailure.noSecret); + expect('$none', '저장된 비밀번호가 없습니다'); + // 닫힌 포트 + final free = await ServerSocket.bind(InternetAddress.loopbackIPv4, 0); + final closedPort = free.port; + await free.close(); + final off = await err(() => SftpRemote(SftpServer(id: 'x', name: '', host: '127.0.0.1', port: closedPort, user: 'jj', password: 'pw'), + timeout: const Duration(seconds: 3)) + .list('/')); + expect((off! as SftpException).kind, SftpFailure.unreachable); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('vfs 로 폴더 · 쓰기 (덮어쓰기) · 목록 · 읽기 · 옮기기 · 지우기 (안쪽까지) · 시작 폴더', () async { + await start(); + SftpRegistry.configure([srv(fp: fingerprint)]); + const base = 'sftp://s1/'; + await vMkdirs('${base}a/b'); + expect(Directory(p.join(root.path, 'a', 'b')).existsSync(), isTrue); + Stream> bytes(String s) => Stream.value(utf8.encode(s)); + await vWrite('${base}a/b/f.txt', bytes('hello')); + await vWrite('${base}a/b/f.txt', bytes('hello again')); + expect(File(p.join(root.path, 'a', 'b', 'f.txt')).readAsStringSync(), 'hello again', reason: '덮어쓰기 (.jjpart 뒤 이름 바꾸기)'); + expect(Directory(p.join(root.path, 'a', 'b')).listSync().map((e) => p.basename(e.path)), ['f.txt'], reason: '.jjpart 가 남지 않음'); + final list = await vList('${base}a/b'); + expect(list.single.path, 'sftp://s1/a/b/f.txt'); + expect((list.single.isDir, list.single.size), (false, 11)); + final st = await vStat('${base}a'); + expect(st!.isDir, isTrue); + expect(await vStat('$base없음'), isNull); + expect(utf8.decode(await (await vOpenRead('${base}a/b/f.txt')).expand((x) => x).toList()), 'hello again'); + await vRename('${base}a/b/f.txt', '${base}a/g.txt'); + expect(File(p.join(root.path, 'a', 'g.txt')).existsSync(), isTrue); + // 받아서 열기 (임시 폴더) + final local = await vLocalCopy('${base}a/g.txt', tmp.path); + expect(File(local).readAsStringSync(), 'hello again'); + expect(local, contains('jj_sftp')); + await vDelete('${base}a'); + expect(Directory(p.join(root.path, 'a')).existsSync(), isFalse); + // 시작 폴더: 그 안이 탭의 맨 위 + Directory(p.join(root.path, 'home', 'jj')).createSync(recursive: true); + File(p.join(root.path, 'home', 'jj', 'mine.txt')).writeAsStringSync('m'); + SftpRegistry.configure([srv(fp: fingerprint, rootDir: '/home/jj')]); + expect((await vList(base)).map((e) => e.path), ['sftp://s1/mine.txt']); + final (free, _) = await DavPath.parse(base).client.quota(); + expect(free == null || free > 0, isTrue, reason: 'statvfs 를 모르면 null'); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + + test('개인 키 (암호 있음) 로 로그인 · 틀린 키 암호는 그렇다고', () async { + final key = p.join(Directory.systemTemp.path, 'jj_sftp88_key_${DateTime.now().microsecondsSinceEpoch}'); + final r = await Process.run(keygen, ['-q', '-t', 'ed25519', '-N', 'kp', '-f', key]); + if (r.exitCode != 0) return markTestSkipped('ssh-keygen 없음'); + addTearDown(() { + for (final f in [key, '$key.pub']) { + if (File(f).existsSync()) File(f).deleteSync(); + } + }); + await start(password: null, pubkey: '$key.pub'); + final pem = File(key).readAsStringSync(); + final c = SftpRemote(srv(password: '', key: pem, kp: 'kp', fp: fingerprint)); + addTearDown(c.close); + expect(await c.list('/'), isEmpty); + final bad = await err(() => SftpRemote(srv(password: '', key: pem, kp: 'nope', fp: fingerprint)).list('/')); + expect((bad! as SftpException).kind, SftpFailure.auth); + expect('$bad', contains('개인 키의 암호')); + }, skip: hasServer && File(keygen).existsSync() ? false : 'SFTP 시험 서버 · ssh-keygen 없음'); + + test('오래된 서버 (ssh-rsa 만): 처음엔 연결 안 됨 + 켜는 법 안내 → 서버마다 켜면 연결 (88-3)', () async { + await start(keytype: 'rsa', legacyOnly: true); + final e = await err(() => SftpRemote(srv(fp: fingerprint)).list('/')); + expect(e, isA()); + expect('$e', contains('ssh-rsa')); + final c = SftpRemote(srv(fp: fingerprint, legacy: true)); + addTearDown(c.close); + expect(await c.list('/'), isEmpty); + }, skip: hasServer ? false : 'SFTP 시험 서버 (paramiko) 없음'); + }); +} diff --git a/tool/sftp_test_server.py b/tool/sftp_test_server.py new file mode 100644 index 0000000..37f591a --- /dev/null +++ b/tool/sftp_test_server.py @@ -0,0 +1,261 @@ +"""88: SFTP 시험 서버 (paramiko). 단위 시험이 띄워 쓴다 - 앱에는 들어가지 않는다. + +사용: python sftp_test_server.py --root <폴더> --hostkey <키 파일> [--user u] [--password pw] [--pubkey <공개 키 파일>] + [--legacy-only] [--port 0] + - 첫 줄에 "PORT <번호>" 를 찍는다 (port 0 이면 빈 포트). + - --legacy-only: 호스트 키 서명을 ssh-rsa (SHA-1) 만 (오래된 서버 흉내, RSA 호스트 키일 때). + - 호스트 키 파일이 없으면 만든다 (--keytype ed25519 · rsa). +paramiko 3.5.1 이 필요하다 (tools\\sftp_test_py 의 venv: python -m pip install "paramiko==3.5.1"). +5.0 은 ssh-rsa (SHA-1) · SHA-1 키 교환을 빼서 --legacy-only (오래된 서버 흉내) 가 아무 방식도 내놓지 못한다. +""" +import argparse +import os +import socket +import sys +import threading + +import paramiko +from paramiko import SFTPAttributes, SFTPHandle, SFTPServer, SFTPServerInterface, SFTP_OK, SFTP_NO_SUCH_FILE, SFTP_PERMISSION_DENIED + + +def _err(e): + if isinstance(e, FileNotFoundError): + return SFTP_NO_SUCH_FILE + if isinstance(e, PermissionError): + return SFTP_PERMISSION_DENIED + return SFTPServer.convert_errno(e.errno) if getattr(e, 'errno', None) else paramiko.SFTP_FAILURE + + +class _Handle(SFTPHandle): + def stat(self): + try: + return SFTPAttributes.from_stat(os.fstat(self.readfile.fileno() if hasattr(self, 'readfile') else self.writefile.fileno())) + except OSError as e: + return _err(e) + + def chattr(self, attr): + return SFTP_OK + + +class _Sftp(SFTPServerInterface): + ROOT = '.' + + def _real(self, path): + path = self.canonicalize(path) + return os.path.join(self.ROOT, path.lstrip('/').replace('/', os.sep)) + + def canonicalize(self, path): + # 홈 = 맨 위 ("/home/jj" 처럼 보이게 하지 않고 "/" 로) + p = path.replace('\\', '/') + if not p.startswith('/'): + p = '/' + p + parts = [] + for x in p.split('/'): + if x in ('', '.'): + continue + if x == '..': + if parts: + parts.pop() + continue + parts.append(x) + return '/' + '/'.join(parts) + + def list_folder(self, path): + real = self._real(path) + try: + out = [] + for name in os.listdir(real): + a = SFTPAttributes.from_stat(os.lstat(os.path.join(real, name))) + a.filename = name + out.append(a) + return out + except OSError as e: + return _err(e) + + def stat(self, path): + try: + return SFTPAttributes.from_stat(os.stat(self._real(path))) + except OSError as e: + return _err(e) + + def lstat(self, path): + try: + return SFTPAttributes.from_stat(os.lstat(self._real(path))) + except OSError as e: + return _err(e) + + def open(self, path, flags, attr): + real = self._real(path) + try: + binary = getattr(os, 'O_BINARY', 0) + fd = os.open(real, flags | binary, 0o666) + except OSError as e: + return _err(e) + if flags & os.O_CREAT and attr is not None: + attr._flags &= ~attr.FLAG_PERMISSIONS + if flags & os.O_WRONLY: + mode = 'ab' if flags & os.O_APPEND else 'wb' + elif flags & os.O_RDWR: + mode = 'a+b' if flags & os.O_APPEND else 'r+b' + else: + mode = 'rb' + try: + f = os.fdopen(fd, mode) + except OSError as e: + return _err(e) + h = _Handle(flags) + h.filename = real + h.readfile = f + h.writefile = f + return h + + def remove(self, path): + try: + os.remove(self._real(path)) + except OSError as e: + return _err(e) + return SFTP_OK + + def rename(self, old, new): + # SFTP v3: 대상이 있으면 실패 (OpenSSH 와 같게) + if os.path.exists(self._real(new)): + return paramiko.SFTP_FAILURE + try: + os.rename(self._real(old), self._real(new)) + except OSError as e: + return _err(e) + return SFTP_OK + + def posix_rename(self, old, new): + try: + os.replace(self._real(old), self._real(new)) + except OSError as e: + return _err(e) + return SFTP_OK + + def mkdir(self, path, attr): + try: + os.mkdir(self._real(path)) + except OSError as e: + return _err(e) + return SFTP_OK + + def rmdir(self, path): + try: + os.rmdir(self._real(path)) + except OSError as e: + return _err(e) + return SFTP_OK + + def chattr(self, path, attr): + try: + if attr.atime is not None and attr.mtime is not None: + os.utime(self._real(path), (attr.atime, attr.mtime)) + except OSError as e: + return _err(e) + return SFTP_OK + + +class _Server(paramiko.ServerInterface): + def __init__(self, user, password, pubkey): + self.user, self.password, self.pubkey = user, password, pubkey + + def get_allowed_auths(self, username): + auths = [] + if self.password is not None: + auths.append('password') + if self.pubkey is not None: + auths.append('publickey') + return ','.join(auths) + + def check_auth_password(self, username, password): + if username == self.user and self.password is not None and password == self.password: + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def check_auth_publickey(self, username, key): + if username == self.user and self.pubkey is not None and key.get_base64() == self.pubkey.get_base64(): + return paramiko.AUTH_SUCCESSFUL + return paramiko.AUTH_FAILED + + def check_channel_request(self, kind, chanid): + return paramiko.OPEN_SUCCEEDED if kind == 'session' else paramiko.OPEN_FAILED_ADMINISTRATIVELY_PROHIBITED + + +def _load_or_make_key(path, keytype): + if os.path.exists(path): + try: + return paramiko.Ed25519Key(filename=path) + except Exception: + return paramiko.RSAKey(filename=path) + if keytype == 'rsa': + k = paramiko.RSAKey.generate(2048) + k.write_private_key_file(path) + return k + # ed25519 는 paramiko 로 만들 수 없어 cryptography 로 + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric import ed25519 + pk = ed25519.Ed25519PrivateKey.generate() + pem = pk.private_bytes(serialization.Encoding.PEM, serialization.PrivateFormat.OpenSSH, serialization.NoEncryption()) + with open(path, 'wb') as f: + f.write(pem) + return paramiko.Ed25519Key(filename=path) + + +def _public_key(path): + text = open(path, encoding='ascii').read().split() + kind, data = text[0], text[1] + return paramiko.PKey.from_type_string(kind, __import__('base64').b64decode(data)) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--root', required=True) + ap.add_argument('--hostkey', required=True) + ap.add_argument('--keytype', default='ed25519') + ap.add_argument('--user', default='jj') + ap.add_argument('--password') + ap.add_argument('--pubkey') + ap.add_argument('--legacy-only', action='store_true') + ap.add_argument('--port', type=int, default=0) + a = ap.parse_args() + _Sftp.ROOT = os.path.abspath(a.root) + host_key = _load_or_make_key(a.hostkey, a.keytype) + pub = _public_key(a.pubkey) if a.pubkey else None + sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(('127.0.0.1', a.port)) + sock.listen(16) + print('PORT %d' % sock.getsockname()[1], flush=True) + print('FINGERPRINT SHA256:%s %s' % ( + __import__('base64').b64encode(__import__('hashlib').sha256(host_key.asbytes()).digest()).decode().rstrip('='), + host_key.get_name()), flush=True) + + def serve(conn): + t = paramiko.Transport(conn) + if a.legacy_only: + # 오래된 서버: 호스트 키 서명은 ssh-rsa (SHA-1) 만 · 키 교환도 SHA-1 만 + t.disabled_algorithms = {'pubkeys': ['rsa-sha2-256', 'rsa-sha2-512'], + 'keys': ['rsa-sha2-256', 'rsa-sha2-512'], + 'kex': ['curve25519-sha256@libssh.org', 'curve25519-sha256', 'ecdh-sha2-nistp256', + 'ecdh-sha2-nistp384', 'ecdh-sha2-nistp521', + 'diffie-hellman-group-exchange-sha256', 'diffie-hellman-group14-sha256', + 'diffie-hellman-group16-sha512']} + t.add_server_key(host_key) + t.set_subsystem_handler('sftp', SFTPServer, _Sftp) + try: + t.start_server(server=_Server(a.user, a.password, pub)) + while t.is_active(): + t.join(1) + except Exception as e: + print('ERR', e, file=sys.stderr, flush=True) + + # 표준 입력이 닫히면 (시험이 끝나면) 끝난다 + threading.Thread(target=lambda: (sys.stdin.read(), os._exit(0)), daemon=True).start() + while True: + conn, _ = sock.accept() + threading.Thread(target=serve, args=(conn,), daemon=True).start() + + +if __name__ == '__main__': + main()