From 5a897344c61d5381f12dbcd8ee7540f9d81b1394 Mon Sep 17 00:00:00 2001 From: RyuJongJin <266630912+RyuJongJin@users.noreply.github.com> Date: Thu, 22 Oct 2026 16:48:27 +0900 Subject: [PATCH] =?UTF-8?q?=EB=82=98=EB=88=84=EA=B8=B0=20core=20C:=20sftp?= =?UTF-8?q?=20(612)=20=EB=A5=BC=20lib/core/sftp/=20=EB=AA=A8=EB=93=88=202?= =?UTF-8?q?=EA=B0=9C=EB=A1=9C=20(=E2=86=92=20=EC=A0=95=EB=A9=B4=20sftp)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sftp_server (설정 · 실패 종류 · 오류 · 개인 키 읽기 · 호스트 키 지문 · 연결 꼴) - sftp_remote (SftpRemote 원격 저장소 · 연결 · 소켓 다리 · SftpRegistry) - sftp.dart 는 export 만 (부르는 쪽은 바꾸지 않음) - 약속 시험: test/sftp_modules_contract_test.dart (실제 paramiko 서버 · 시험 폴더) Co-Authored-By: Claude Opus 5.5 --- lib/core/sftp.dart | 616 +-------------------------- lib/core/sftp/sftp_remote.dart | 463 ++++++++++++++++++++ lib/core/sftp/sftp_server.dart | 167 ++++++++ test/sftp_modules_contract_test.dart | 59 +++ 4 files changed, 693 insertions(+), 612 deletions(-) create mode 100644 lib/core/sftp/sftp_remote.dart create mode 100644 lib/core/sftp/sftp_server.dart create mode 100644 test/sftp_modules_contract_test.dart diff --git a/lib/core/sftp.dart b/lib/core/sftp.dart index d6b0ab7..13017cc 100644 --- a/lib/core/sftp.dart +++ b/lib/core/sftp.dart @@ -1,612 +1,4 @@ -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; -import 'remote_idle.dart'; - -/// 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 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) -/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림) -enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other } - -/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true. -/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다 -bool checkPrivateKey(String text) { - if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat); - try { - return SSHKeyPair.isEncryptedPem(text); - } catch (_) { - throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); - } -} - -/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다 -List parsePrivateKey(String text, String passphrase) { - final encrypted = checkPrivateKey(text); - if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase); - try { - return SSHKeyPair.fromPem(text, encrypted ? passphrase : null); - } catch (_) { - // 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다) - if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase); - throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); - } -} - -/// 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 = parsePrivateKey(s.privateKey, s.keyPassphrase); - } catch (_) { - sock.destroy(); - rethrow; - } - } - 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) { - // 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다 - if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected); - if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth); - 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; - } - - /// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 - - /// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다" - Future _cmd(Future f) => idleCall(f, onTimeout: close); - - /// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다 - Never _fail(Object e, String rel) { - if (e is RemoteIdleTimeout) throw e; - 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 _cmd(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 _cmd(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 _cmd(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 _cmd(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 _cmd(c.sftp.rmdir(_remote(c, rel))); - } else { - await _cmd(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 _cmd(c.sftp.remove(_remote(c, to))); - } catch (e) { - _fail(e, to); - } - } - } - try { - await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to))); - } catch (e) { - _fail(e, from); - } - } - - @override - Future>> openRead(String rel) => openReadFrom(rel); - - /// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next) - Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { - final c = await _open(); - final SftpFile f; - try { - f = await _cmd(c.sftp.open(_remote(c, rel))); - } catch (e) { - _fail(e, rel); - } - late StreamController> out; - StreamSubscription? sub; - out = StreamController>( - onListen: () { - // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다 - sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) { - try { - _fail(e, rel); - } catch (x) { - out.addError(x); - } - }, onDone: () async { - await _cmd(f.close()).catchError((_) {}); - await out.close(); - }); - }, - onPause: () => sub?.pause(), - onResume: () => sub?.resume(), - onCancel: () async { - await sub?.cancel(); - await _cmd(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 _cmd(c.sftp.open(_remote(c, rel), - mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write)); - } catch (e) { - _fail(e, rel); - } - try { - // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음) - await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close); - } catch (e) { - _fail(e, rel); - } finally { - await _cmd(f.close()).catchError((_) {}); - } - } - - @override - Future<(int?, int?)> quota() async { - final c = await _open(); - try { - final v = await _cmd(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)); - } -} +// 약속 (나누기 sftp): SFTP (dartssh2) - 부르는 곳은 이 파일 하나만 가져온다. +// 일은 lib/core/sftp/ 의 모듈이 한다: sftp_server (설정 · 오류 · 키 · 지문) · sftp_remote (원격 저장소 · 클라이언트 목록). +export 'sftp/sftp_server.dart'; +export 'sftp/sftp_remote.dart'; diff --git a/lib/core/sftp/sftp_remote.dart b/lib/core/sftp/sftp_remote.dart new file mode 100644 index 0000000..7987858 --- /dev/null +++ b/lib/core/sftp/sftp_remote.dart @@ -0,0 +1,463 @@ +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 DavItem, DavLockedException; +import '../remote_idle.dart'; +import 'sftp_server.dart'; + +// 약속 (나누기 sftp): SFTP 원격 저장소 ([RemoteClient], dartssh2) 와 서버마다 하나인 클라이언트 목록 ([SftpRegistry]). +// 입력: 서버 기준 경로 ("/" 로 시작). 출력: 목록 · 정보 ([DavItem]) · 부분 읽기 흐름 · 쓰기 · 남은 공간 (모르면 null). +// 오류: [SftpException] (연결 못함 · 로그인 · 키 · 권한 · 없는 경로 · 끊김) · 처음 보는 / 바뀐 호스트 키 [SftpHostKeyException] · +// 잠금 [DavLockedException] · 명령 · 조각 사이 시간 넘김 [RemoteIdleTimeout]. 끊긴 연결은 다음에 다시 연다. + +/// 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 = parsePrivateKey(s.privateKey, s.keyPassphrase); + } catch (_) { + sock.destroy(); + rethrow; + } + } + 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) { + // 관리자 지시 (10/21): 키만으로 들어가다 거부되면 서버가 그 키를 모르는 것 - 비밀번호와 따로 알린다 + if (s.privateKey.isNotEmpty && s.password.isEmpty) return SftpException(tr('서버가 이 키를 받지 않습니다. 서버의 ~/.ssh/authorized_keys 에 짝이 되는 공개 키 (.pub) 가 있는지, 아이디가 맞는지 보세요'), SftpFailure.keyRejected); + if (s.privateKey.isNotEmpty) return SftpException(tr('아이디 · 비밀번호 · 키가 모두 서버에서 거부되었습니다'), SftpFailure.auth); + 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; + } + + /// 관리자 판단 (10/20): SFTP 명령 하나 (목록 · 열기 · 닫기 · 지우기 등) 를 정한 시간 ([RemoteIdle]) 까지만 기다린다 - + /// 서버가 멈추면 이 연결을 닫고 (다음에 다시 연다) "서버가 답하지 않습니다" + Future _cmd(Future f) => idleCall(f, onTimeout: close); + + /// 파일 다루다 난 오류 (SFTP 상태) → 사람 말. 끊긴 연결은 다음에 다시 연다 + Never _fail(Object e, String rel) { + if (e is RemoteIdleTimeout) throw e; + 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 _cmd(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 _cmd(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 _cmd(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 _cmd(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 _cmd(c.sftp.rmdir(_remote(c, rel))); + } else { + await _cmd(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 _cmd(c.sftp.remove(_remote(c, to))); + } catch (e) { + _fail(e, to); + } + } + } + try { + await _cmd(c.sftp.rename(_remote(c, from), _remote(c, to))); + } catch (e) { + _fail(e, from); + } + } + + @override + Future>> openRead(String rel) => openReadFrom(rel); + + /// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next) + Future>> openReadFrom(String rel, {int offset = 0, int? length}) async { + final c = await _open(); + final SftpFile f; + try { + f = await _cmd(c.sftp.open(_remote(c, rel))); + } catch (e) { + _fail(e, rel); + } + late StreamController> out; + StreamSubscription? sub; + out = StreamController>( + onListen: () { + // 관리자 판단 (10/20): 조각 사이에 정한 시간 동안 아무것도 오지 않으면 실패 - 그 연결은 닫고 다음에 다시 연다 + sub = idleRead(f.read(offset: offset, length: length), onTimeout: close).listen(out.add, onError: (Object e) { + try { + _fail(e, rel); + } catch (x) { + out.addError(x); + } + }, onDone: () async { + await _cmd(f.close()).catchError((_) {}); + await out.close(); + }); + }, + onPause: () => sub?.pause(), + onResume: () => sub?.resume(), + onCancel: () async { + await sub?.cancel(); + await _cmd(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 _cmd(c.sftp.open(_remote(c, rel), + mode: SftpFileOpenMode.create | SftpFileOpenMode.truncate | SftpFileOpenMode.write)); + } catch (e) { + _fail(e, rel); + } + try { + // 관리자 판단 (10/20): 서버가 받지 않아 정한 시간 동안 한 조각도 더 보내지 못하면 실패 (그 연결을 닫음) + await idleWrite(data, (x) => f.write(x.map((y) => y is Uint8List ? y : Uint8List.fromList(y))).done, onTimeout: close); + } catch (e) { + _fail(e, rel); + } finally { + await _cmd(f.close()).catchError((_) {}); + } + } + + @override + Future<(int?, int?)> quota() async { + final c = await _open(); + try { + final v = await _cmd(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/sftp/sftp_server.dart b/lib/core/sftp/sftp_server.dart new file mode 100644 index 0000000..1f139eb --- /dev/null +++ b/lib/core/sftp/sftp_server.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:dartssh2/dartssh2.dart'; + +import '../../l10n/tr.dart'; +import '../webdav.dart' show DavException; + +// 약속 (나누기 sftp): SFTP 서버 설정 (저장 꼴) · 실패 종류 · 오류 · 개인 키 읽기 · 호스트 키 지문. +// 입력: 설정 JSON (비밀 값 없음) · 개인 키 글 + 키 암호 · dartssh2 가 넘긴 지문 바이트. 출력: [SftpServer] · 키 · "SHA256:…" 지문. +// 오류: 키 형식 · 암호 없음 · 암호 틀림 → [SftpException] ([SftpFailure.keyFormat] · [SftpFailure.keyPassphrase]). 그 밖은 던지지 않는다. + +/// 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 실패의 종류 (안내 글 · 할 일 단추를 고르는 데) +/// [keyFormat] · [keyPassphrase] · [keyRejected]: SSH 키 로그인의 실패를 따로 (관리자 지시 10/21 - 연결 확인에서 키 형식 · 암호 틀림 · 서버 거부를 나눠 알림) +enum SftpFailure { unreachable, auth, noSecret, hostKeyUnknown, hostKeyChanged, permission, notFound, keyFormat, keyPassphrase, keyRejected, other } + +/// 개인 키 글이 쓸 수 있는 꼴인지 본다 (OpenSSH · PEM RSA · PEM EC - dartssh2 가 읽는 것). 암호가 걸려 있으면 true. +/// 쓸 수 없으면 [SftpException] ([SftpFailure.keyFormat]) - PuTTY (.ppk) · PKCS#8 (BEGIN PRIVATE KEY) · 공개 키는 바꾸는 법을 알린다 +bool checkPrivateKey(String text) { + if (text.contains('PuTTY-User-Key-File')) throw SftpException(tr('PuTTY 키 (.ppk) 는 쓸 수 없습니다. PuTTYgen 의 [Conversions] > [Export OpenSSH key] 로 바꾼 파일을 고르세요'), SftpFailure.keyFormat); + try { + return SSHKeyPair.isEncryptedPem(text); + } catch (_) { + throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); + } +} + +/// 개인 키 글 + 키 암호 → 로그인에 쓸 키. 키 형식 · 암호 없음 · 암호 틀림을 따로 알린다 +List parsePrivateKey(String text, String passphrase) { + final encrypted = checkPrivateKey(text); + if (encrypted && passphrase.isEmpty) throw SftpException(tr('개인 키에 암호가 걸려 있습니다. 키 암호를 넣으세요'), SftpFailure.keyPassphrase); + try { + return SSHKeyPair.fromPem(text, encrypted ? passphrase : null); + } catch (_) { + // 암호가 걸린 키를 풀지 못함 = 암호 틀림 (PEM 은 틀린 암호로 풀면 다른 오류가 나기도 한다) + if (encrypted) throw SftpException(tr('개인 키의 암호가 맞지 않습니다'), SftpFailure.keyPassphrase); + throw SftpException(tr('개인 키를 읽을 수 없습니다. OpenSSH 형식이나 PEM (RSA · EC) 형식의 비밀 키 (ed25519 · rsa · ecdsa) 를 고르세요 (.pub 은 공개 키)'), SftpFailure.keyFormat); + } +} + +/// 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); diff --git a/test/sftp_modules_contract_test.dart b/test/sftp_modules_contract_test.dart new file mode 100644 index 0000000..99b7e01 --- /dev/null +++ b/test/sftp_modules_contract_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:jj_mkvmaker/core/sftp/sftp_remote.dart'; +import 'package:jj_mkvmaker/core/sftp/sftp_server.dart'; +import 'package:path/path.dart' as p; + +import 'support/sftp_server.dart'; + +/// 나누기 sftp 약속 시험: lib/core/sftp/ 모듈마다 맨 위 약속 - 실제 서버 (tool/sftp_test_server.py, paramiko) · 실제 시험 폴더. +/// 시험용 파이썬이 없으면 서버 시험은 건너뛴다 (sftp_test 와 같음) +void main() { + test('sftp_server: JSON 에 비밀 값 없음 · 지문 꼴 · 키 형식 · 암호 오류 종류', () { + const s = SftpServer(id: 'a', name: '', host: 'nas', password: 'secret', privateKey: 'k'); + final j = s.toJson(); + expect(jsonEncode(j), isNot(contains('secret'))); + expect((j['hasPassword'], j['hasKey'], s.label), (true, true, 'nas')); + expect(SftpServer.fromJson({'id': 'x', 'port': 99999}).port, 22); + expect(sshFingerprint(Uint8List.fromList(utf8.encode('SHA256:abc'))), 'SHA256:abc'); + expect(sshFingerprint(Uint8List.fromList([1, 2, 3])), 'SHA256:AQID'); + expect(() => checkPrivateKey('PuTTY-User-Key-File-3: ssh-ed25519'), + throwsA(isA().having((e) => e.kind, 'kind', SftpFailure.keyFormat))); + expect(() => parsePrivateKey('not a key', ''), throwsA(isA().having((e) => e.kind, 'kind', SftpFailure.keyFormat))); + }); + + test('sftp_remote: 처음 보는 호스트 키는 지문과 함께 막음 → 믿으면 쓰기 · 목록 · 부분 읽기 · 옮기기 · 지우기 · 틀린 비밀번호는 auth', () async { + final tmp = Directory.systemTemp.createTempSync('jj_sftpm_'); + final root = Directory(p.join(tmp.path, 'root'))..createSync(); + final srv = await SftpTestServer.start(root: root.path, hostKeyFile: p.join(tmp.path, 'host.key')); + addTearDown(() async { + await srv.stop(); + tmp.deleteSync(recursive: true); + }); + SftpServer conf({String fp = '', String pw = 'pw'}) => + SftpServer(id: 'm1', name: '', host: '127.0.0.1', port: srv.port, user: 'jj', password: pw, hostKeyFingerprint: fp, root: root.path); + final first = SftpRemote(conf()); + final e = await first.list('/').then((_) => null, onError: (Object x) => x); + first.close(); + expect(e, isA().having((x) => x.fingerprint, 'fp', srv.fingerprint).having((x) => x.changed, 'changed', isFalse)); + final c = SftpRemote(conf(fp: srv.fingerprint)); + addTearDown(c.close); + await c.mkdirs('/가/나'); + await c.write('/가/나/a.txt', Stream.value(utf8.encode('0123456789'))); + expect(File(p.join(root.path, '가', '나', 'a.txt')).readAsStringSync(), '0123456789'); + expect((await c.list('/가/나')).map((x) => (x.rel, x.size)), [('/가/나/a.txt', 10)]); + expect(await c.stat('/없음'), isNull); + final part = await (await c.openReadFrom('/가/나/a.txt', offset: 4, length: 3)).expand((x) => x).toList(); + expect(utf8.decode(part), '456'); + await c.move('/가/나/a.txt', '/가/b.txt'); + expect(File(p.join(root.path, '가', 'b.txt')).existsSync(), isTrue); + await c.delete('/가'); + expect(Directory(p.join(root.path, '가')).existsSync(), isFalse); + final bad = SftpRemote(conf(fp: srv.fingerprint, pw: 'wrong')); + addTearDown(bad.close); + await expectLater(bad.list('/'), throwsA(isA().having((x) => x.kind, 'kind', SftpFailure.auth))); + }, skip: sftpTestServerAvailable ? false : '시험용 파이썬 없음', timeout: const Timeout(Duration(minutes: 3))); +}