SFTP.php 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502
  1. <?php
  2. /**
  3. * SPDX-FileCopyrightText: 2017-2024 Nextcloud GmbH and Nextcloud contributors
  4. * SPDX-FileCopyrightText: 2016 ownCloud, Inc.
  5. * SPDX-License-Identifier: AGPL-3.0-only
  6. */
  7. namespace OCA\Files_External\Lib\Storage;
  8. use Icewind\Streams\CountWrapper;
  9. use Icewind\Streams\IteratorDirectory;
  10. use Icewind\Streams\RetryWrapper;
  11. use OC\Files\Storage\Common;
  12. use OC\Files\View;
  13. use OCP\Constants;
  14. use OCP\Files\FileInfo;
  15. use OCP\Files\IMimeTypeDetector;
  16. use phpseclib\Net\SFTP\Stream;
  17. /**
  18. * Uses phpseclib's Net\SFTP class and the Net\SFTP\Stream stream wrapper to
  19. * provide access to SFTP servers.
  20. */
  21. class SFTP extends Common {
  22. private $host;
  23. private $user;
  24. private $root;
  25. private $port = 22;
  26. private $auth = [];
  27. /**
  28. * @var \phpseclib\Net\SFTP
  29. */
  30. protected $client;
  31. private IMimeTypeDetector $mimeTypeDetector;
  32. public const COPY_CHUNK_SIZE = 8 * 1024 * 1024;
  33. /**
  34. * @param string $host protocol://server:port
  35. * @return array [$server, $port]
  36. */
  37. private function splitHost(string $host): array {
  38. $input = $host;
  39. if (!str_contains($host, '://')) {
  40. // add a protocol to fix parse_url behavior with ipv6
  41. $host = 'http://' . $host;
  42. }
  43. $parsed = parse_url($host);
  44. if (is_array($parsed) && isset($parsed['port'])) {
  45. return [$parsed['host'], $parsed['port']];
  46. } elseif (is_array($parsed)) {
  47. return [$parsed['host'], 22];
  48. } else {
  49. return [$input, 22];
  50. }
  51. }
  52. public function __construct(array $parameters) {
  53. // Register sftp://
  54. Stream::register();
  55. $parsedHost = $this->splitHost($parameters['host']);
  56. $this->host = $parsedHost[0];
  57. $this->port = $parsedHost[1];
  58. if (!isset($parameters['user'])) {
  59. throw new \UnexpectedValueException('no authentication parameters specified');
  60. }
  61. $this->user = $parameters['user'];
  62. if (isset($parameters['public_key_auth'])) {
  63. $this->auth[] = $parameters['public_key_auth'];
  64. }
  65. if (isset($parameters['password']) && $parameters['password'] !== '') {
  66. $this->auth[] = $parameters['password'];
  67. }
  68. if ($this->auth === []) {
  69. throw new \UnexpectedValueException('no authentication parameters specified');
  70. }
  71. $this->root
  72. = isset($parameters['root']) ? $this->cleanPath($parameters['root']) : '/';
  73. $this->root = '/' . ltrim($this->root, '/');
  74. $this->root = rtrim($this->root, '/') . '/';
  75. $this->mimeTypeDetector = \OC::$server->get(IMimeTypeDetector::class);
  76. }
  77. /**
  78. * Returns the connection.
  79. *
  80. * @return \phpseclib\Net\SFTP connected client instance
  81. * @throws \Exception when the connection failed
  82. */
  83. public function getConnection(): \phpseclib\Net\SFTP {
  84. if (!is_null($this->client)) {
  85. return $this->client;
  86. }
  87. $hostKeys = $this->readHostKeys();
  88. $this->client = new \phpseclib\Net\SFTP($this->host, $this->port);
  89. // The SSH Host Key MUST be verified before login().
  90. $currentHostKey = $this->client->getServerPublicHostKey();
  91. if (array_key_exists($this->host, $hostKeys)) {
  92. if ($hostKeys[$this->host] !== $currentHostKey) {
  93. throw new \Exception('Host public key does not match known key');
  94. }
  95. } else {
  96. $hostKeys[$this->host] = $currentHostKey;
  97. $this->writeHostKeys($hostKeys);
  98. }
  99. $login = false;
  100. foreach ($this->auth as $auth) {
  101. /** @psalm-suppress TooManyArguments */
  102. $login = $this->client->login($this->user, $auth);
  103. if ($login === true) {
  104. break;
  105. }
  106. }
  107. if ($login === false) {
  108. throw new \Exception('Login failed');
  109. }
  110. return $this->client;
  111. }
  112. public function test(): bool {
  113. if (
  114. !isset($this->host)
  115. || !isset($this->user)
  116. ) {
  117. return false;
  118. }
  119. return $this->getConnection()->nlist() !== false;
  120. }
  121. public function getId(): string {
  122. $id = 'sftp::' . $this->user . '@' . $this->host;
  123. if ($this->port !== 22) {
  124. $id .= ':' . $this->port;
  125. }
  126. // note: this will double the root slash,
  127. // we should not change it to keep compatible with
  128. // old storage ids
  129. $id .= '/' . $this->root;
  130. return $id;
  131. }
  132. public function getHost(): string {
  133. return $this->host;
  134. }
  135. public function getRoot(): string {
  136. return $this->root;
  137. }
  138. public function getUser(): string {
  139. return $this->user;
  140. }
  141. private function absPath(string $path): string {
  142. return $this->root . $this->cleanPath($path);
  143. }
  144. private function hostKeysPath(): string|false {
  145. try {
  146. $userId = \OC_User::getUser();
  147. if ($userId === false) {
  148. return false;
  149. }
  150. $view = new View('/' . $userId . '/files_external');
  151. return $view->getLocalFile('ssh_hostKeys');
  152. } catch (\Exception $e) {
  153. }
  154. return false;
  155. }
  156. protected function writeHostKeys(array $keys): bool {
  157. try {
  158. $keyPath = $this->hostKeysPath();
  159. if ($keyPath && file_exists($keyPath)) {
  160. $fp = fopen($keyPath, 'w');
  161. foreach ($keys as $host => $key) {
  162. fwrite($fp, $host . '::' . $key . "\n");
  163. }
  164. fclose($fp);
  165. return true;
  166. }
  167. } catch (\Exception $e) {
  168. }
  169. return false;
  170. }
  171. protected function readHostKeys(): array {
  172. try {
  173. $keyPath = $this->hostKeysPath();
  174. if (file_exists($keyPath)) {
  175. $hosts = [];
  176. $keys = [];
  177. $lines = file($keyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
  178. if ($lines) {
  179. foreach ($lines as $line) {
  180. $hostKeyArray = explode('::', $line, 2);
  181. if (count($hostKeyArray) === 2) {
  182. $hosts[] = $hostKeyArray[0];
  183. $keys[] = $hostKeyArray[1];
  184. }
  185. }
  186. return array_combine($hosts, $keys);
  187. }
  188. }
  189. } catch (\Exception $e) {
  190. }
  191. return [];
  192. }
  193. public function mkdir(string $path): bool {
  194. try {
  195. return $this->getConnection()->mkdir($this->absPath($path));
  196. } catch (\Exception $e) {
  197. return false;
  198. }
  199. }
  200. public function rmdir(string $path): bool {
  201. try {
  202. $result = $this->getConnection()->delete($this->absPath($path), true);
  203. // workaround: stray stat cache entry when deleting empty folders
  204. // see https://github.com/phpseclib/phpseclib/issues/706
  205. $this->getConnection()->clearStatCache();
  206. return $result;
  207. } catch (\Exception $e) {
  208. return false;
  209. }
  210. }
  211. public function opendir(string $path) {
  212. try {
  213. $list = $this->getConnection()->nlist($this->absPath($path));
  214. if ($list === false) {
  215. return false;
  216. }
  217. $id = md5('sftp:' . $path);
  218. $dirStream = [];
  219. foreach ($list as $file) {
  220. if ($file !== '.' && $file !== '..') {
  221. $dirStream[] = $file;
  222. }
  223. }
  224. return IteratorDirectory::wrap($dirStream);
  225. } catch (\Exception $e) {
  226. return false;
  227. }
  228. }
  229. public function filetype(string $path): string|false {
  230. try {
  231. $stat = $this->getConnection()->stat($this->absPath($path));
  232. if (!is_array($stat) || !array_key_exists('type', $stat)) {
  233. return false;
  234. }
  235. if ((int)$stat['type'] === NET_SFTP_TYPE_REGULAR) {
  236. return 'file';
  237. }
  238. if ((int)$stat['type'] === NET_SFTP_TYPE_DIRECTORY) {
  239. return 'dir';
  240. }
  241. } catch (\Exception $e) {
  242. }
  243. return false;
  244. }
  245. public function file_exists(string $path): bool {
  246. try {
  247. return $this->getConnection()->stat($this->absPath($path)) !== false;
  248. } catch (\Exception $e) {
  249. return false;
  250. }
  251. }
  252. public function unlink(string $path): bool {
  253. try {
  254. return $this->getConnection()->delete($this->absPath($path), true);
  255. } catch (\Exception $e) {
  256. return false;
  257. }
  258. }
  259. public function fopen(string $path, string $mode) {
  260. try {
  261. $absPath = $this->absPath($path);
  262. $connection = $this->getConnection();
  263. switch ($mode) {
  264. case 'r':
  265. case 'rb':
  266. $stat = $this->stat($path);
  267. if (!$stat) {
  268. return false;
  269. }
  270. SFTPReadStream::register();
  271. $context = stream_context_create(['sftp' => ['session' => $connection, 'size' => $stat['size']]]);
  272. $handle = fopen('sftpread://' . trim($absPath, '/'), 'r', false, $context);
  273. return RetryWrapper::wrap($handle);
  274. case 'w':
  275. case 'wb':
  276. SFTPWriteStream::register();
  277. // the SFTPWriteStream doesn't go through the "normal" methods so it doesn't clear the stat cache.
  278. $connection->_remove_from_stat_cache($absPath);
  279. $context = stream_context_create(['sftp' => ['session' => $connection]]);
  280. return fopen('sftpwrite://' . trim($absPath, '/'), 'w', false, $context);
  281. case 'a':
  282. case 'ab':
  283. case 'r+':
  284. case 'w+':
  285. case 'wb+':
  286. case 'a+':
  287. case 'x':
  288. case 'x+':
  289. case 'c':
  290. case 'c+':
  291. $context = stream_context_create(['sftp' => ['session' => $connection]]);
  292. $handle = fopen($this->constructUrl($path), $mode, false, $context);
  293. return RetryWrapper::wrap($handle);
  294. }
  295. } catch (\Exception $e) {
  296. }
  297. return false;
  298. }
  299. public function touch(string $path, ?int $mtime = null): bool {
  300. try {
  301. if (!is_null($mtime)) {
  302. return false;
  303. }
  304. if (!$this->file_exists($path)) {
  305. $this->getConnection()->put($this->absPath($path), '');
  306. } else {
  307. return false;
  308. }
  309. } catch (\Exception $e) {
  310. return false;
  311. }
  312. return true;
  313. }
  314. /**
  315. * @throws \Exception
  316. */
  317. public function getFile(string $path, string $target): void {
  318. $this->getConnection()->get($path, $target);
  319. }
  320. public function rename(string $source, string $target): bool {
  321. try {
  322. if ($this->file_exists($target)) {
  323. $this->unlink($target);
  324. }
  325. return $this->getConnection()->rename(
  326. $this->absPath($source),
  327. $this->absPath($target)
  328. );
  329. } catch (\Exception $e) {
  330. return false;
  331. }
  332. }
  333. /**
  334. * @return array{mtime: int, size: int, ctime: int}|false
  335. */
  336. public function stat(string $path): array|false {
  337. try {
  338. $stat = $this->getConnection()->stat($this->absPath($path));
  339. $mtime = isset($stat['mtime']) ? (int)$stat['mtime'] : -1;
  340. $size = isset($stat['size']) ? (int)$stat['size'] : 0;
  341. return [
  342. 'mtime' => $mtime,
  343. 'size' => $size,
  344. 'ctime' => -1
  345. ];
  346. } catch (\Exception $e) {
  347. return false;
  348. }
  349. }
  350. public function constructUrl(string $path): string {
  351. // Do not pass the password here. We want to use the Net_SFTP object
  352. // supplied via stream context or fail. We only supply username and
  353. // hostname because this might show up in logs (they are not used).
  354. $url = 'sftp://' . urlencode($this->user) . '@' . $this->host . ':' . $this->port . $this->root . $path;
  355. return $url;
  356. }
  357. public function file_put_contents(string $path, mixed $data): int|float|false {
  358. /** @psalm-suppress InternalMethod */
  359. $result = $this->getConnection()->put($this->absPath($path), $data);
  360. if ($result) {
  361. return strlen($data);
  362. } else {
  363. return false;
  364. }
  365. }
  366. public function writeStream(string $path, $stream, ?int $size = null): int {
  367. if ($size === null) {
  368. $stream = CountWrapper::wrap($stream, function (int $writtenSize) use (&$size): void {
  369. $size = $writtenSize;
  370. });
  371. if (!$stream) {
  372. throw new \Exception('Failed to wrap stream');
  373. }
  374. }
  375. /** @psalm-suppress InternalMethod */
  376. $result = $this->getConnection()->put($this->absPath($path), $stream);
  377. fclose($stream);
  378. if ($result) {
  379. if ($size === null) {
  380. throw new \Exception('Failed to get written size from sftp storage wrapper');
  381. }
  382. return $size;
  383. } else {
  384. throw new \Exception('Failed to write steam to sftp storage');
  385. }
  386. }
  387. public function copy(string $source, string $target): bool {
  388. if ($this->is_dir($source) || $this->is_dir($target)) {
  389. return parent::copy($source, $target);
  390. } else {
  391. $absSource = $this->absPath($source);
  392. $absTarget = $this->absPath($target);
  393. $connection = $this->getConnection();
  394. $size = $connection->size($absSource);
  395. if ($size === false) {
  396. return false;
  397. }
  398. for ($i = 0; $i < $size; $i += self::COPY_CHUNK_SIZE) {
  399. /** @psalm-suppress InvalidArgument */
  400. $chunk = $connection->get($absSource, false, $i, self::COPY_CHUNK_SIZE);
  401. if ($chunk === false) {
  402. return false;
  403. }
  404. /** @psalm-suppress InternalMethod */
  405. if (!$connection->put($absTarget, $chunk, \phpseclib\Net\SFTP::SOURCE_STRING, $i)) {
  406. return false;
  407. }
  408. }
  409. return true;
  410. }
  411. }
  412. public function getPermissions(string $path): int {
  413. $stat = $this->getConnection()->stat($this->absPath($path));
  414. if (!$stat) {
  415. return 0;
  416. }
  417. if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) {
  418. return Constants::PERMISSION_ALL;
  419. } else {
  420. return Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE;
  421. }
  422. }
  423. public function getMetaData(string $path): ?array {
  424. $stat = $this->getConnection()->stat($this->absPath($path));
  425. if (!$stat) {
  426. return null;
  427. }
  428. if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) {
  429. $stat['permissions'] = Constants::PERMISSION_ALL;
  430. } else {
  431. $stat['permissions'] = Constants::PERMISSION_ALL - Constants::PERMISSION_CREATE;
  432. }
  433. if ($stat['type'] === NET_SFTP_TYPE_DIRECTORY) {
  434. $stat['size'] = -1;
  435. $stat['mimetype'] = FileInfo::MIMETYPE_FOLDER;
  436. } else {
  437. $stat['mimetype'] = $this->mimeTypeDetector->detectPath($path);
  438. }
  439. $stat['etag'] = $this->getETag($path);
  440. $stat['storage_mtime'] = $stat['mtime'];
  441. $stat['name'] = basename($path);
  442. $keys = ['size', 'mtime', 'mimetype', 'etag', 'storage_mtime', 'permissions', 'name'];
  443. return array_intersect_key($stat, array_flip($keys));
  444. }
  445. }