JJ_MKVMaker/lib/core/sftp/sftp_remote.dart

440 lines
17 KiB
Dart

import 'dart:async';
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 '../webdav.dart' show DavItem;
import '../remote_idle.dart';
import 'sftp_server.dart';
import '../remote_registry.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;
await requireSecretGate(hasSecret);
// 키도 비밀번호도 없으면 (저장하지 않음) 시도하지 않고 정확히 알린다 (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<SSHKeyPair>? 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<T> _cmd<T>(Future<T> 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';
@override
Future<List<DavItem>> list(String rel) async {
final c = await _open();
try {
final out = <DavItem>[];
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<DavItem?> 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<void> mkdirs(String rel) => remoteMkdirs(this, rel, _mkdirOne);
Future<void> _mkdirOne(String rel) async {
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<void> 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<void> 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<Stream<List<int>>> openRead(String rel) => openReadFrom(rel);
/// [offset] 부터 [length] 바이트 (null 이면 끝까지) 만 읽는다 - 동영상 스트리밍 다리의 Range (sftp_next)
Future<Stream<List<int>>> 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<List<int>> out;
StreamSubscription<Uint8List>? sub;
out = StreamController<List<int>>(
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<void> write(String rel, Stream<List<int>> 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<void>((_) {}, onError: (Object _) {}));
}
/// 소켓 오류 (상대가 끊음 · 쓰기 실패) 는 끊김으로 (dartssh2 는 읽는 쪽의 오류를 받지 않는다 - 처리되지 않은 오류가 되었다)
@override
late final Stream<Uint8List> stream =
_s.transform(StreamTransformer<Uint8List, Uint8List>.fromHandlers(handleError: (e, st, out) => out.close()));
@override
StreamSink<List<int>> get sink => _s;
// 닫기 · 비우기의 쓰기 실패도 같은 끊김이다 (dartssh2 가 기다리지 않는 Future - 처리되지 않은 오류가 되지 않게)
@override
Future<void> close() => _s.close().then<void>((_) {}, onError: (Object _) {});
@override
Future<void> get done => _s.done;
@override
void destroy() => _s.destroy();
@override
Future<void> flush() => _s.flush().then<void>((_) {}, onError: (Object _) {});
}
/// 설정의 SFTP 서버 목록과 클라이언트 (서버마다 하나)
class SftpRegistry {
/// 연결 방법 (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]));
};
// F11: 몸통은 RemoteRegistry 하나 - 비밀 값 (비밀번호 · 개인 키 · 키 암호) 은 toJson 에 없으므로 따로 비교
static final _r = RemoteRegistry<SftpServer, SftpRemote>(
idOf: (s) => s.id,
toJson: (s) => s.toJson(),
secretsOf: (s) => [s.password, s.privateKey, s.keyPassphrase],
create: SftpRemote.new,
missing: (id) => SftpException(trf('SFTP 서버 설정이 없습니다 ({0})', [id]), SftpFailure.other));
static void configure(List<SftpServer> servers) => _r.configure(servers);
static List<SftpServer> get servers => _r.servers;
static SftpServer? server(String id) => _r.server(id);
static SftpRemote client(String id) => _r.client(id);
}