Issues (2553)

apps/files_external/lib/Lib/Storage/SFTP.php (7 issues)

1
<?php
2
/**
3
 * @copyright Copyright (c) 2016, ownCloud, Inc.
4
 *
5
 * @author Andreas Fischer <[email protected]>
6
 * @author Bart Visscher <[email protected]>
7
 * @author Christoph Wurst <[email protected]>
8
 * @author hkjolhede <[email protected]>
9
 * @author Joas Schilling <[email protected]>
10
 * @author Jörn Friedrich Dreyer <[email protected]>
11
 * @author Lennart Rosam <[email protected]>
12
 * @author Lukas Reschke <[email protected]>
13
 * @author Morris Jobke <[email protected]>
14
 * @author Robin Appelman <[email protected]>
15
 * @author Robin McCorkell <[email protected]>
16
 * @author Roeland Jago Douma <[email protected]>
17
 * @author Ross Nicoll <[email protected]>
18
 * @author SA <[email protected]>
19
 * @author Senorsen <[email protected]>
20
 * @author Vincent Petry <[email protected]>
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
39
use Icewind\Streams\IteratorDirectory;
40
use Icewind\Streams\RetryWrapper;
41
use phpseclib\Net\SFTP\Stream;
42
43
/**
44
 * Uses phpseclib's Net\SFTP class and the Net\SFTP\Stream stream wrapper to
45
 * provide access to SFTP servers.
46
 */
47
class SFTP extends \OC\Files\Storage\Common {
48
	private $host;
49
	private $user;
50
	private $root;
51
	private $port = 22;
52
53
	private $auth = [];
54
55
	/**
56
	 * @var \phpseclib\Net\SFTP
57
	 */
58
	protected $client;
59
60
	/**
61
	 * @param string $host protocol://server:port
62
	 * @return array [$server, $port]
63
	 */
64
	private function splitHost($host) {
65
		$input = $host;
66
		if (!str_contains($host, '://')) {
67
			// add a protocol to fix parse_url behavior with ipv6
68
			$host = 'http://' . $host;
69
		}
70
71
		$parsed = parse_url($host);
72
		if (is_array($parsed) && isset($parsed['port'])) {
73
			return [$parsed['host'], $parsed['port']];
74
		} elseif (is_array($parsed)) {
75
			return [$parsed['host'], 22];
76
		} else {
77
			return [$input, 22];
78
		}
79
	}
80
81
	/**
82
	 * {@inheritdoc}
83
	 */
84
	public function __construct($params) {
85
		// Register sftp://
86
		Stream::register();
87
88
		$parsedHost = $this->splitHost($params['host']);
89
90
		$this->host = $parsedHost[0];
91
		$this->port = $parsedHost[1];
92
93
		if (!isset($params['user'])) {
94
			throw new \UnexpectedValueException('no authentication parameters specified');
95
		}
96
		$this->user = $params['user'];
97
98
		if (isset($params['public_key_auth'])) {
99
			$this->auth[] = $params['public_key_auth'];
100
		}
101
		if (isset($params['password']) && $params['password'] !== '') {
102
			$this->auth[] = $params['password'];
103
		}
104
105
		if ($this->auth === []) {
106
			throw new \UnexpectedValueException('no authentication parameters specified');
107
		}
108
109
		$this->root
110
			= isset($params['root']) ? $this->cleanPath($params['root']) : '/';
111
112
		$this->root = '/' . ltrim($this->root, '/');
113
		$this->root = rtrim($this->root, '/') . '/';
114
	}
115
116
	/**
117
	 * Returns the connection.
118
	 *
119
	 * @return \phpseclib\Net\SFTP connected client instance
120
	 * @throws \Exception when the connection failed
121
	 */
122
	public function getConnection() {
123
		if (!is_null($this->client)) {
124
			return $this->client;
125
		}
126
127
		$hostKeys = $this->readHostKeys();
128
		$this->client = new \phpseclib\Net\SFTP($this->host, $this->port);
129
130
		// The SSH Host Key MUST be verified before login().
131
		$currentHostKey = $this->client->getServerPublicHostKey();
132
		if (array_key_exists($this->host, $hostKeys)) {
133
			if ($hostKeys[$this->host] !== $currentHostKey) {
134
				throw new \Exception('Host public key does not match known key');
135
			}
136
		} else {
137
			$hostKeys[$this->host] = $currentHostKey;
138
			$this->writeHostKeys($hostKeys);
139
		}
140
141
		$login = false;
142
		foreach ($this->auth as $auth) {
143
			/** @psalm-suppress TooManyArguments */
144
			$login = $this->client->login($this->user, $auth);
145
			if ($login === true) {
146
				break;
147
			}
148
		}
149
150
		if ($login === false) {
151
			throw new \Exception('Login failed');
152
		}
153
		return $this->client;
154
	}
155
156
	/**
157
	 * {@inheritdoc}
158
	 */
159
	public function test() {
160
		if (
161
			!isset($this->host)
162
			|| !isset($this->user)
163
		) {
164
			return false;
165
		}
166
		return $this->getConnection()->nlist() !== false;
167
	}
168
169
	/**
170
	 * {@inheritdoc}
171
	 */
172
	public function getId() {
173
		$id = 'sftp::' . $this->user . '@' . $this->host;
174
		if ($this->port !== 22) {
175
			$id .= ':' . $this->port;
176
		}
177
		// note: this will double the root slash,
178
		// we should not change it to keep compatible with
179
		// old storage ids
180
		$id .= '/' . $this->root;
181
		return $id;
182
	}
183
184
	/**
185
	 * @return string
186
	 */
187
	public function getHost() {
188
		return $this->host;
189
	}
190
191
	/**
192
	 * @return string
193
	 */
194
	public function getRoot() {
195
		return $this->root;
196
	}
197
198
	/**
199
	 * @return mixed
200
	 */
201
	public function getUser() {
202
		return $this->user;
203
	}
204
205
	/**
206
	 * @param string $path
207
	 * @return string
208
	 */
209
	private function absPath($path) {
210
		return $this->root . $this->cleanPath($path);
211
	}
212
213
	/**
214
	 * @return string|false
215
	 */
216
	private function hostKeysPath() {
217
		try {
218
			$storage_view = \OCP\Files::getStorage('files_external');
219
			if ($storage_view) {
0 ignored issues
show
$storage_view is of type OC\Files\View, thus it always evaluated to true.
Loading history...
220
				return \OC::$server->getConfig()->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data') .
221
					$storage_view->getAbsolutePath('') .
222
					'ssh_hostKeys';
223
			}
224
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
225
		}
226
		return false;
227
	}
228
229
	/**
230
	 * @param $keys
231
	 * @return bool
232
	 */
233
	protected function writeHostKeys($keys) {
234
		try {
235
			$keyPath = $this->hostKeysPath();
236
			if ($keyPath && file_exists($keyPath)) {
237
				$fp = fopen($keyPath, 'w');
238
				foreach ($keys as $host => $key) {
239
					fwrite($fp, $host . '::' . $key . "\n");
240
				}
241
				fclose($fp);
242
				return true;
243
			}
244
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
245
		}
246
		return false;
247
	}
248
249
	/**
250
	 * @return array
251
	 */
252
	protected function readHostKeys() {
253
		try {
254
			$keyPath = $this->hostKeysPath();
255
			if (file_exists($keyPath)) {
256
				$hosts = [];
257
				$keys = [];
258
				$lines = file($keyPath, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
259
				if ($lines) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $lines of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.

This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.

Consider making the comparison explicit by using empty(..) or ! empty(...) instead.

Loading history...
260
					foreach ($lines as $line) {
261
						$hostKeyArray = explode("::", $line, 2);
262
						if (count($hostKeyArray) === 2) {
263
							$hosts[] = $hostKeyArray[0];
264
							$keys[] = $hostKeyArray[1];
265
						}
266
					}
267
					return array_combine($hosts, $keys);
268
				}
269
			}
270
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
271
		}
272
		return [];
273
	}
274
275
	/**
276
	 * {@inheritdoc}
277
	 */
278
	public function mkdir($path) {
279
		try {
280
			return $this->getConnection()->mkdir($this->absPath($path));
281
		} catch (\Exception $e) {
282
			return false;
283
		}
284
	}
285
286
	/**
287
	 * {@inheritdoc}
288
	 */
289
	public function rmdir($path) {
290
		try {
291
			$result = $this->getConnection()->delete($this->absPath($path), true);
292
			// workaround: stray stat cache entry when deleting empty folders
293
			// see https://github.com/phpseclib/phpseclib/issues/706
294
			$this->getConnection()->clearStatCache();
295
			return $result;
296
		} catch (\Exception $e) {
297
			return false;
298
		}
299
	}
300
301
	/**
302
	 * {@inheritdoc}
303
	 */
304
	public function opendir($path) {
305
		try {
306
			$list = $this->getConnection()->nlist($this->absPath($path));
307
			if ($list === false) {
308
				return false;
309
			}
310
311
			$id = md5('sftp:' . $path);
0 ignored issues
show
The assignment to $id is dead and can be removed.
Loading history...
312
			$dirStream = [];
313
			foreach ($list as $file) {
314
				if ($file !== '.' && $file !== '..') {
315
					$dirStream[] = $file;
316
				}
317
			}
318
			return IteratorDirectory::wrap($dirStream);
319
		} catch (\Exception $e) {
320
			return false;
321
		}
322
	}
323
324
	/**
325
	 * {@inheritdoc}
326
	 */
327
	public function filetype($path) {
328
		try {
329
			$stat = $this->getConnection()->stat($this->absPath($path));
330
			if (!is_array($stat) || !array_key_exists('type', $stat)) {
331
				return false;
332
			}
333
			if ((int) $stat['type'] === NET_SFTP_TYPE_REGULAR) {
334
				return 'file';
335
			}
336
337
			if ((int) $stat['type'] === NET_SFTP_TYPE_DIRECTORY) {
338
				return 'dir';
339
			}
340
		} catch (\Exception $e) {
341
		}
342
		return false;
343
	}
344
345
	/**
346
	 * {@inheritdoc}
347
	 */
348
	public function file_exists($path) {
349
		try {
350
			return $this->getConnection()->stat($this->absPath($path)) !== false;
351
		} catch (\Exception $e) {
352
			return false;
353
		}
354
	}
355
356
	/**
357
	 * {@inheritdoc}
358
	 */
359
	public function unlink($path) {
360
		try {
361
			return $this->getConnection()->delete($this->absPath($path), true);
362
		} catch (\Exception $e) {
363
			return false;
364
		}
365
	}
366
367
	/**
368
	 * {@inheritdoc}
369
	 */
370
	public function fopen($path, $mode) {
371
		try {
372
			$absPath = $this->absPath($path);
373
			switch ($mode) {
374
				case 'r':
375
				case 'rb':
376
					if (!$this->file_exists($path)) {
377
						return false;
378
					}
379
					SFTPReadStream::register();
380
					$context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
381
					$handle = fopen('sftpread://' . trim($absPath, '/'), 'r', false, $context);
382
					return RetryWrapper::wrap($handle);
383
				case 'w':
384
				case 'wb':
385
					SFTPWriteStream::register();
386
					$context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
387
					return fopen('sftpwrite://' . trim($absPath, '/'), 'w', false, $context);
388
				case 'a':
389
				case 'ab':
390
				case 'r+':
391
				case 'w+':
392
				case 'wb+':
393
				case 'a+':
394
				case 'x':
395
				case 'x+':
396
				case 'c':
397
				case 'c+':
398
					$context = stream_context_create(['sftp' => ['session' => $this->getConnection()]]);
399
					$handle = fopen($this->constructUrl($path), $mode, false, $context);
400
					return RetryWrapper::wrap($handle);
401
			}
402
		} catch (\Exception $e) {
0 ignored issues
show
Coding Style Comprehensibility introduced by
Consider adding a comment why this CATCH block is empty.
Loading history...
403
		}
404
		return false;
405
	}
406
407
	/**
408
	 * {@inheritdoc}
409
	 */
410
	public function touch($path, $mtime = null) {
411
		try {
412
			if (!is_null($mtime)) {
413
				return false;
414
			}
415
			if (!$this->file_exists($path)) {
416
				$this->getConnection()->put($this->absPath($path), '');
417
			} else {
418
				return false;
419
			}
420
		} catch (\Exception $e) {
421
			return false;
422
		}
423
		return true;
424
	}
425
426
	/**
427
	 * @param string $path
428
	 * @param string $target
429
	 * @throws \Exception
430
	 */
431
	public function getFile($path, $target) {
432
		$this->getConnection()->get($path, $target);
433
	}
434
435
	/**
436
	 * {@inheritdoc}
437
	 */
438
	public function rename($source, $target) {
439
		try {
440
			if ($this->file_exists($target)) {
441
				$this->unlink($target);
442
			}
443
			return $this->getConnection()->rename(
444
				$this->absPath($source),
445
				$this->absPath($target)
446
			);
447
		} catch (\Exception $e) {
448
			return false;
449
		}
450
	}
451
452
	/**
453
	 * {@inheritdoc}
454
	 */
455
	public function stat($path) {
456
		try {
457
			$stat = $this->getConnection()->stat($this->absPath($path));
458
459
			$mtime = $stat ? $stat['mtime'] : -1;
460
			$size = $stat ? $stat['size'] : 0;
461
462
			return ['mtime' => $mtime, 'size' => $size, 'ctime' => -1];
463
		} catch (\Exception $e) {
464
			return false;
465
		}
466
	}
467
468
	/**
469
	 * @param string $path
470
	 * @return string
471
	 */
472
	public function constructUrl($path) {
473
		// Do not pass the password here. We want to use the Net_SFTP object
474
		// supplied via stream context or fail. We only supply username and
475
		// hostname because this might show up in logs (they are not used).
476
		$url = 'sftp://' . urlencode($this->user) . '@' . $this->host . ':' . $this->port . $this->root . $path;
477
		return $url;
478
	}
479
}
480