JJ_MKVMaker/lib/core/ftp/ftp_session.dart

256 lines
10 KiB
Dart
Raw Permalink Blame History

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'dart:typed_data';
import '../../l10n/tr.dart';
import 'ftp_server.dart';
// 약속 (나누기 ftp): FTP 명령 채널 하나 - 연결 · TLS (명시 · 암시, 믿은 지문만) · 로그인 · 명령 주고받기 · 자료 채널 (EPSV / PASV).
// 입력: [FtpServer] · 시간 제한 · 이름 풀기 ([FtpResolve]). 출력: 응답 (코드, 글) · 자료 소켓.
// 오류: 끊김 · 로그인 실패 · 권한 · 없는 경로 → [FtpException] (종류 [FtpFailure]) · 처음 보는 / 바뀐 인증서 → [FtpCertException] ·
// 답 없음 → [TimeoutException]. 줄바꿈이 든 명령은 보내지 않고 [FtpException].
/// FTP 명령 채널 하나 (로그인까지). 응답은 줄 단위 ("123-…" 여러 줄 → "123 …" 끝)
class FtpSession {
final FtpServer server;
final Duration timeout;
final InternetAddress addr;
RawSocket _raw;
StreamSubscription<RawSocketEvent>? _sub;
final _buf = <int>[];
final _lines = <String>[];
Completer<void>? _wake;
bool _closed = false;
bool secure = false;
bool mlsd = false;
bool _protP = false;
/// 로그인 뒤 서버의 지금 폴더 (시작 폴더가 비었을 때 맨 위)
String home = '/';
FtpSession._(this.server, this.timeout, this.addr, this._raw);
static Future<FtpSession> open(FtpServer s, Duration timeout, FtpResolve resolve) async {
final addr = await resolve(s.host.trim()) ?? (await InternetAddress.lookup(s.host.trim())).first;
final raw = await RawSocket.connect(addr, s.port, timeout: timeout);
final x = FtpSession._(s, timeout, addr, raw);
if (s.security == 'implicit') {
x._raw = await x._secure(raw, null);
x.secure = true;
}
x._listen();
return x;
}
void _listen() {
_sub = _raw.listen((ev) {
if (ev == RawSocketEvent.read) {
final d = _raw.read();
if (d != null) _feed(d);
} else if (ev == RawSocketEvent.readClosed || ev == RawSocketEvent.closed) {
_closed = true;
_poke();
}
}, onError: (_) {
_closed = true;
_poke();
});
}
void _feed(Uint8List d) {
_buf.addAll(d);
while (true) {
final i = _buf.indexOf(10);
if (i < 0) break;
var line = utf8.decode(_buf.sublist(0, i), allowMalformed: true);
if (line.endsWith('\r')) line = line.substring(0, line.length - 1);
_buf.removeRange(0, i + 1);
debugLog?.call('< $line');
_lines.add(line);
}
_poke();
}
void _poke() {
final w = _wake;
_wake = null;
w?.complete();
}
Future<String> _line() async {
final end = DateTime.now().add(timeout);
while (_lines.isEmpty) {
if (_closed) throw FtpException(trf('서버 ({0}) 와의 연결이 끊겼습니다', [server.host]), FtpFailure.unreachable);
final left = end.difference(DateTime.now());
if (left <= Duration.zero) throw TimeoutException(tr('답이 없습니다'));
_wake = Completer<void>();
await _wake!.future.timeout(left, onTimeout: () {});
}
return _lines.removeAt(0);
}
/// 응답 하나 (여러 줄이면 합침): (코드, 글)
Future<(int, String)> reply() async {
final first = await _line();
final code = int.tryParse(first.length >= 3 ? first.substring(0, 3) : '') ?? 0;
final text = StringBuffer(first.length > 4 ? first.substring(4) : '');
if (first.length > 3 && first[3] == '-') {
while (true) {
final l = await _line();
if (l.startsWith('$code ')) {
text.write('\n${l.substring(4)}');
break;
}
text.write('\n${l.startsWith(' ') ? l.substring(1) : l}');
}
}
return (code, text.toString());
}
/// 시험: 명령 채널 주고받기 (비밀번호 줄은 가림)
static void Function(String line)? debugLog;
void send(String cmd) {
// 보안 검토 8: 경로 · 이름에 줄바꿈이 있으면 명령이 하나 더 끼어든다 - 보내지 않는다
if (cmd.contains('\r') || cmd.contains('\n') || cmd.contains('\u0000')) {
throw FtpException(tr('이름에 줄바꿈이 들어 있어 FTP 로 보낼 수 없습니다'), FtpFailure.other);
}
debugLog?.call('> ${cmd.startsWith('PASS ') ? 'PASS ***' : cmd}');
_raw.write(utf8.encode('$cmd\r\n'));
}
Future<(int, String)> cmd(String c) async {
send(c);
return reply();
}
/// [c] 를 보내고 [ok] 코드 (2xx 등) 가 아니면 [FtpException]
Future<String> must(String c, {bool Function(int code)? ok, String? path}) async {
final (code, text) = await cmd(c);
if (ok != null ? ok(code) : code >= 200 && code < 300) return text;
throw failure(code, text, path);
}
FtpException failure(int code, String text, String? path) {
if (code == 530) return FtpException(tr('아이디나 비밀번호가 맞지 않습니다'), FtpFailure.auth);
if (code == 550 || code == 450) {
final t = text.toLowerCase();
if (t.contains('permission') || t.contains('denied') || t.contains('access')) {
return FtpException(trf('이 폴더 · 파일을 다룰 권한이 없습니다: {0}', [path ?? '']), FtpFailure.permission);
}
return FtpException(trf('없는 경로입니다: {0}', [path ?? '']), FtpFailure.notFound);
}
return FtpException('$code $text${path == null ? '' : ' ($path)'}', FtpFailure.other);
}
/// TLS 로 바꾼다 (명시: AUTH TLS 뒤 · 암시: 연결하자마자). 자체 서명이면 믿은 지문만
Future<RawSecureSocket> _secure(RawSocket raw, StreamSubscription<RawSocketEvent>? sub) async {
X509Certificate? bad;
try {
return await RawSecureSocket.secure(raw, subscription: sub, host: server.host.trim(), onBadCertificate: (c) {
bad = c;
return server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint;
}).timeout(timeout);
} on HandshakeException {
final c = bad;
if (c == null) rethrow;
final fp = certSha256(c);
throw FtpCertException(
server.certFingerprint.isEmpty
? tr('처음 보는 서버 인증서입니다 (자체 서명). 지문을 확인한 뒤 믿을지 정하세요')
: tr('서버 인증서가 바뀌었습니다. 다른 서버로 연결되었을 수 있습니다'),
server.certFingerprint.isEmpty ? FtpFailure.certUnknown : FtpFailure.certChanged,
fingerprint: fp,
subject: c.subject,
previous: server.certFingerprint.isEmpty ? null : server.certFingerprint);
}
}
/// 인사 · TLS · 로그인 · UTF-8 · 이진 · 시작 폴더
Future<void> login() async {
final (hello, text) = await reply();
if (hello != 220) throw failure(hello, text, null);
if (server.security == 'explicit') {
final (code, t) = await cmd('AUTH TLS');
if (code != 234) {
// FTPS 를 못 하는 서버: 몰래 평문으로 내려가지 않는다 (사용자가 "평문 FTP" 를 골라야)
throw FtpException(trf('서버가 암호화 (FTPS) 를 하지 않습니다 ({0} {1}). 서버 설정에서 FTPS 를 켜거나, 위험을 알고 "평문 FTP" 를 고르세요', [code, t]),
FtpFailure.noTls);
}
// 보안 검토 2: 234 뒤에 평문으로 끼워 넣은 줄이 있으면 (중간자가 TLS 뒤의 답을 꾸미려는 것) 끊는다
if (_lines.isNotEmpty || _buf.isNotEmpty) {
throw FtpException(tr('서버가 암호화 (FTPS) 를 시작하기 전에 예상하지 못한 답을 보냈습니다. 연결을 끊었습니다'), FtpFailure.noTls);
}
final sub = _sub;
_sub = null;
_raw = await _secure(_raw, sub);
secure = true;
// RawSecureSocket.secure 가 넘겨받은 구독을 이어 쓰지 않으므로 다시 듣는다
_listen();
}
if (secure) {
await must('PBSZ 0');
await must('PROT P');
_protP = true;
}
if (server.user.isNotEmpty || server.password.isNotEmpty) {
final (code, t) = await cmd('USER ${server.user.isEmpty ? 'anonymous' : server.user}');
if (code == 331 || code == 332) {
await must('PASS ${server.password}', ok: (c) => c == 230 || c == 202);
} else if (code != 230) {
throw failure(code, t, null);
}
} else {
await must('USER anonymous', ok: (c) => c == 230 || c == 331);
}
final feat = await cmd('FEAT');
// MLST 를 알리면 MLSD 도 한다 (RFC 3659 - pyftpdlib 등은 FEAT 에 MLST 만 적는다)
mlsd = feat.$1 == 211 && RegExp(r'^\s*MLS[TD]', multiLine: true).hasMatch(feat.$2.toUpperCase());
if (feat.$2.toUpperCase().contains('UTF8')) await cmd('OPTS UTF8 ON');
await must('TYPE I');
final pwd = await cmd('PWD');
final m = RegExp(r'"((?:[^"]|"")*)"').firstMatch(pwd.$2);
if (pwd.$1 == 257 && m != null) home = m[1]!.replaceAll('""', '"');
}
/// 자료 채널 (EPSV, 안 되면 PASV). 주소는 명령 채널의 서버 주소로 (서버가 알려 준 다른 주소로는 가지 않는다 - FTP 바운스 막기)
Future<Socket> data() async {
int? port;
final (c1, t1) = await cmd('EPSV');
if (c1 == 229) {
final m = RegExp(r'\(\|\|\|(\d+)\|\)').firstMatch(t1);
if (m != null) port = int.parse(m[1]!);
}
if (port == null) {
final t = await must('PASV', ok: (c) => c == 227);
final m = RegExp(r'(\d+),(\d+),(\d+),(\d+),(\d+),(\d+)').firstMatch(t);
if (m == null) throw FtpException(trf('서버의 자료 연결 응답을 읽지 못했습니다: {0}', [t]), FtpFailure.other);
port = int.parse(m[5]!) * 256 + int.parse(m[6]!);
}
return Socket.connect(addr, port, timeout: timeout);
}
/// 자료 연결을 TLS 로 (PROT P). 명령 (RETR · STOR · MLSD) 을 보내고 150/125 를 받은 뒤에 한다 - 서버는 명령을 받고서야 TLS 를 시작한다 (pyftpdlib 등)
Future<Socket> secureData(Socket sock) async {
if (!_protP) return sock;
final x = await SecureSocket.secure(sock,
host: server.host.trim(),
onBadCertificate: (c) => server.certFingerprint.isNotEmpty && certSha256(c) == server.certFingerprint).timeout(timeout);
return x;
}
void close() {
if (_closed) return;
_closed = true;
try {
send('QUIT');
} catch (_) {}
_sub?.cancel();
_raw.close();
}
}
/// 시험: 명령 채널 기록 (비밀번호 가림)
void debugFtpLog(void Function(String line)? f) => FtpSession.debugLog = f;