You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

SFTP.php 14KB

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