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)); } }