256 lines
10 KiB
Dart
256 lines
10 KiB
Dart
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;
|