SFTP.php 13 KB

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