sftp.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488
  1. // SPDX-FileCopyrightText: GSConnect Developers https://github.com/GSConnect
  2. //
  3. // SPDX-License-Identifier: GPL-2.0-or-later
  4. 'use strict';
  5. const Gio = imports.gi.Gio;
  6. const GLib = imports.gi.GLib;
  7. const GObject = imports.gi.GObject;
  8. const Config = imports.config;
  9. const Lan = imports.service.backends.lan;
  10. const PluginBase = imports.service.plugin;
  11. var Metadata = {
  12. label: _('SFTP'),
  13. id: 'org.gnome.Shell.Extensions.GSConnect.Plugin.SFTP',
  14. description: _('Browse the paired device filesystem'),
  15. incomingCapabilities: ['kdeconnect.sftp'],
  16. outgoingCapabilities: ['kdeconnect.sftp.request'],
  17. actions: {
  18. mount: {
  19. label: _('Mount'),
  20. icon_name: 'folder-remote-symbolic',
  21. parameter_type: null,
  22. incoming: ['kdeconnect.sftp'],
  23. outgoing: ['kdeconnect.sftp.request'],
  24. },
  25. unmount: {
  26. label: _('Unmount'),
  27. icon_name: 'media-eject-symbolic',
  28. parameter_type: null,
  29. incoming: ['kdeconnect.sftp'],
  30. outgoing: ['kdeconnect.sftp.request'],
  31. },
  32. },
  33. };
  34. const MAX_MOUNT_DIRS = 12;
  35. /**
  36. * SFTP Plugin
  37. * https://github.com/KDE/kdeconnect-kde/tree/master/plugins/sftp
  38. * https://github.com/KDE/kdeconnect-android/tree/master/src/org/kde/kdeconnect/Plugins/SftpPlugin
  39. */
  40. var Plugin = GObject.registerClass({
  41. GTypeName: 'GSConnectSFTPPlugin',
  42. }, class Plugin extends PluginBase.Plugin {
  43. _init(device) {
  44. super._init(device, 'sftp');
  45. this._gmount = null;
  46. this._mounting = false;
  47. // A reusable launcher for ssh processes
  48. this._launcher = new Gio.SubprocessLauncher({
  49. flags: (Gio.SubprocessFlags.STDOUT_PIPE |
  50. Gio.SubprocessFlags.STDERR_MERGE),
  51. });
  52. // Watch the volume monitor
  53. this._volumeMonitor = Gio.VolumeMonitor.get();
  54. this._mountAddedId = this._volumeMonitor.connect(
  55. 'mount-added',
  56. this._onMountAdded.bind(this)
  57. );
  58. this._mountRemovedId = this._volumeMonitor.connect(
  59. 'mount-removed',
  60. this._onMountRemoved.bind(this)
  61. );
  62. }
  63. get gmount() {
  64. if (this._gmount === null && this.device.connected) {
  65. const host = this.device.channel.host;
  66. const regex = new RegExp(
  67. `sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`
  68. );
  69. for (const mount of this._volumeMonitor.get_mounts()) {
  70. const uri = mount.get_root().get_uri();
  71. if (regex.test(uri)) {
  72. this._gmount = mount;
  73. this._addSubmenu(mount);
  74. this._addSymlink(mount);
  75. break;
  76. }
  77. }
  78. }
  79. return this._gmount;
  80. }
  81. connected() {
  82. super.connected();
  83. // Only enable for Lan connections
  84. if (this.device.channel instanceof Lan.Channel) {
  85. if (this.settings.get_boolean('automount'))
  86. this.mount();
  87. } else {
  88. this.device.lookup_action('mount').enabled = false;
  89. this.device.lookup_action('unmount').enabled = false;
  90. }
  91. }
  92. handlePacket(packet) {
  93. switch (packet.type) {
  94. case 'kdeconnect.sftp':
  95. if (packet.body.hasOwnProperty('errorMessage'))
  96. this._handleError(packet);
  97. else
  98. this._handleMount(packet);
  99. break;
  100. }
  101. }
  102. _onMountAdded(monitor, mount) {
  103. if (this._gmount !== null || !this.device.connected)
  104. return;
  105. const host = this.device.channel.host;
  106. const regex = new RegExp(`sftp://(${host}):(1739|17[4-5][0-9]|176[0-4])`);
  107. const uri = mount.get_root().get_uri();
  108. if (!regex.test(uri))
  109. return;
  110. this._gmount = mount;
  111. this._addSubmenu(mount);
  112. this._addSymlink(mount);
  113. }
  114. _onMountRemoved(monitor, mount) {
  115. if (this.gmount !== mount)
  116. return;
  117. this._gmount = null;
  118. this._removeSubmenu();
  119. }
  120. async _listDirectories(mount) {
  121. const file = mount.get_root();
  122. const iter = await file.enumerate_children_async(
  123. Gio.FILE_ATTRIBUTE_STANDARD_NAME,
  124. Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
  125. GLib.PRIORITY_DEFAULT,
  126. this.cancellable);
  127. const infos = await iter.next_files_async(MAX_MOUNT_DIRS,
  128. GLib.PRIORITY_DEFAULT, this.cancellable);
  129. iter.close_async(GLib.PRIORITY_DEFAULT, null, null);
  130. const directories = {};
  131. for (const info of infos) {
  132. const name = info.get_name();
  133. directories[name] = `${file.get_uri()}${name}/`;
  134. }
  135. return directories;
  136. }
  137. _onAskQuestion(op, message, choices) {
  138. op.reply(Gio.MountOperationResult.HANDLED);
  139. }
  140. _onAskPassword(op, message, user, domain, flags) {
  141. op.reply(Gio.MountOperationResult.HANDLED);
  142. }
  143. /**
  144. * Handle an error reported by the remote device.
  145. *
  146. * @param {Core.Packet} packet - a `kdeconnect.sftp`
  147. */
  148. _handleError(packet) {
  149. this.device.showNotification({
  150. id: 'sftp-error',
  151. title: _('%s reported an error').format(this.device.name),
  152. body: packet.body.errorMessage,
  153. icon: new Gio.ThemedIcon({name: 'dialog-error-symbolic'}),
  154. priority: Gio.NotificationPriority.HIGH,
  155. });
  156. }
  157. /**
  158. * Mount the remote device using the provided information.
  159. *
  160. * @param {Core.Packet} packet - a `kdeconnect.sftp`
  161. */
  162. async _handleMount(packet) {
  163. try {
  164. // Already mounted or mounting
  165. if (this.gmount !== null || this._mounting)
  166. return;
  167. this._mounting = true;
  168. // Ensure the private key is in the keyring
  169. await this._addPrivateKey();
  170. // Create a new mount operation
  171. const op = new Gio.MountOperation({
  172. username: packet.body.user || null,
  173. password: packet.body.password || null,
  174. password_save: Gio.PasswordSave.NEVER,
  175. });
  176. op.connect('ask-question', this._onAskQuestion);
  177. op.connect('ask-password', this._onAskPassword);
  178. // This is the actual call to mount the device
  179. const host = this.device.channel.host;
  180. const uri = `sftp://${host}:${packet.body.port}/`;
  181. const file = Gio.File.new_for_uri(uri);
  182. await file.mount_enclosing_volume(GLib.PRIORITY_DEFAULT, op,
  183. this.cancellable);
  184. } catch (e) {
  185. // Special case when the GMount didn't unmount properly but is still
  186. // on the same port and can be reused.
  187. if (e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.ALREADY_MOUNTED))
  188. return;
  189. // There's a good chance this is a host key verification error;
  190. // regardless we'll remove the key for security.
  191. this._removeHostKey(this.device.channel.host);
  192. logError(e, this.device.name);
  193. } finally {
  194. this._mounting = false;
  195. }
  196. }
  197. /**
  198. * Add GSConnect's private key identity to the authentication agent so our
  199. * identity can be verified by Android during private key authentication.
  200. *
  201. * @return {Promise} A promise for the operation
  202. */
  203. async _addPrivateKey() {
  204. const ssh_add = this._launcher.spawnv([
  205. Config.SSHADD_PATH,
  206. GLib.build_filenamev([Config.CONFIGDIR, 'private.pem']),
  207. ]);
  208. const [stdout] = await ssh_add.communicate_utf8_async(null,
  209. this.cancellable);
  210. if (ssh_add.get_exit_status() !== 0)
  211. debug(stdout.trim(), this.device.name);
  212. }
  213. /**
  214. * Remove all host keys from ~/.ssh/known_hosts for @host in the port range
  215. * used by KDE Connect (1739-1764).
  216. *
  217. * @param {string} host - A hostname or IP address
  218. */
  219. async _removeHostKey(host) {
  220. for (let port = 1739; port <= 1764; port++) {
  221. try {
  222. const ssh_keygen = this._launcher.spawnv([
  223. Config.SSHKEYGEN_PATH,
  224. '-R',
  225. `[${host}]:${port}`,
  226. ]);
  227. const [stdout] = await ssh_keygen.communicate_utf8_async(null,
  228. this.cancellable);
  229. const status = ssh_keygen.get_exit_status();
  230. if (status !== 0) {
  231. throw new Gio.IOErrorEnum({
  232. code: Gio.io_error_from_errno(status),
  233. message: `${GLib.strerror(status)}\n${stdout}`.trim(),
  234. });
  235. }
  236. } catch (e) {
  237. logError(e, this.device.name);
  238. }
  239. }
  240. }
  241. /*
  242. * Mount menu helpers
  243. */
  244. _getUnmountSection() {
  245. if (this._unmountSection === undefined) {
  246. this._unmountSection = new Gio.Menu();
  247. const unmountItem = new Gio.MenuItem();
  248. unmountItem.set_label(Metadata.actions.unmount.label);
  249. unmountItem.set_icon(new Gio.ThemedIcon({
  250. name: Metadata.actions.unmount.icon_name,
  251. }));
  252. unmountItem.set_detailed_action('device.unmount');
  253. this._unmountSection.append_item(unmountItem);
  254. }
  255. return this._unmountSection;
  256. }
  257. _getFilesMenuItem() {
  258. if (this._filesMenuItem === undefined) {
  259. // Files menu icon
  260. const emblem = new Gio.Emblem({
  261. icon: new Gio.ThemedIcon({name: 'emblem-default'}),
  262. });
  263. const mountedIcon = new Gio.EmblemedIcon({
  264. gicon: new Gio.ThemedIcon({name: 'folder-remote-symbolic'}),
  265. });
  266. mountedIcon.add_emblem(emblem);
  267. // Files menu item
  268. this._filesMenuItem = new Gio.MenuItem();
  269. this._filesMenuItem.set_detailed_action('device.mount');
  270. this._filesMenuItem.set_icon(mountedIcon);
  271. this._filesMenuItem.set_label(_('Files'));
  272. }
  273. return this._filesMenuItem;
  274. }
  275. async _addSubmenu(mount) {
  276. try {
  277. const directories = await this._listDirectories(mount);
  278. // Submenu sections
  279. const dirSection = new Gio.Menu();
  280. const unmountSection = this._getUnmountSection();
  281. for (const [name, uri] of Object.entries(directories))
  282. dirSection.append(name, `device.openPath::${uri}`);
  283. // Files submenu
  284. const filesSubmenu = new Gio.Menu();
  285. filesSubmenu.append_section(null, dirSection);
  286. filesSubmenu.append_section(null, unmountSection);
  287. // Files menu item
  288. const filesMenuItem = this._getFilesMenuItem();
  289. filesMenuItem.set_submenu(filesSubmenu);
  290. // Replace the existing menu item
  291. const index = this.device.removeMenuAction('device.mount');
  292. this.device.addMenuItem(filesMenuItem, index);
  293. } catch (e) {
  294. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.CANCELLED))
  295. debug(e, this.device.name);
  296. // Reset to allow retrying
  297. this._gmount = null;
  298. }
  299. }
  300. _removeSubmenu() {
  301. try {
  302. const index = this.device.removeMenuAction('device.mount');
  303. const action = this.device.lookup_action('mount');
  304. if (action !== null) {
  305. this.device.addMenuAction(
  306. action,
  307. index,
  308. Metadata.actions.mount.label,
  309. Metadata.actions.mount.icon_name
  310. );
  311. }
  312. } catch (e) {
  313. logError(e, this.device.name);
  314. }
  315. }
  316. /**
  317. * Create a symbolic link referring to the device by name
  318. *
  319. * @param {Gio.Mount} mount - A GMount to link to
  320. */
  321. async _addSymlink(mount) {
  322. try {
  323. const by_name_dir = Gio.File.new_for_path(
  324. `${Config.RUNTIMEDIR}/by-name/`
  325. );
  326. try {
  327. by_name_dir.make_directory_with_parents(null);
  328. } catch (e) {
  329. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.EXISTS))
  330. throw e;
  331. }
  332. // Replace path separator with a Unicode lookalike:
  333. let safe_device_name = this.device.name.replace('/', '∕');
  334. if (safe_device_name === '.')
  335. safe_device_name = '·';
  336. else if (safe_device_name === '..')
  337. safe_device_name = '··';
  338. const link_target = mount.get_root().get_path();
  339. const link = Gio.File.new_for_path(
  340. `${by_name_dir.get_path()}/${safe_device_name}`);
  341. // Check for and remove any existing stale link
  342. try {
  343. const link_stat = await link.query_info_async(
  344. 'standard::symlink-target',
  345. Gio.FileQueryInfoFlags.NOFOLLOW_SYMLINKS,
  346. GLib.PRIORITY_DEFAULT,
  347. this.cancellable);
  348. if (link_stat.get_symlink_target() === link_target)
  349. return;
  350. await link.delete_async(GLib.PRIORITY_DEFAULT,
  351. this.cancellable);
  352. } catch (e) {
  353. if (!e.matches(Gio.IOErrorEnum, Gio.IOErrorEnum.NOT_FOUND))
  354. throw e;
  355. }
  356. link.make_symbolic_link(link_target, this.cancellable);
  357. } catch (e) {
  358. debug(e, this.device.name);
  359. }
  360. }
  361. /**
  362. * Send a request to mount the remote device
  363. */
  364. mount() {
  365. if (this.gmount !== null)
  366. return;
  367. this.device.sendPacket({
  368. type: 'kdeconnect.sftp.request',
  369. body: {
  370. startBrowsing: true,
  371. },
  372. });
  373. }
  374. /**
  375. * Remove the menu items, unmount the filesystem, replace the mount item
  376. */
  377. async unmount() {
  378. try {
  379. if (this.gmount === null)
  380. return;
  381. this._removeSubmenu();
  382. this._mounting = false;
  383. await this.gmount.unmount_with_operation(
  384. Gio.MountUnmountFlags.FORCE,
  385. new Gio.MountOperation(),
  386. this.cancellable);
  387. } catch (e) {
  388. debug(e, this.device.name);
  389. }
  390. }
  391. destroy() {
  392. if (this._volumeMonitor) {
  393. this._volumeMonitor.disconnect(this._mountAddedId);
  394. this._volumeMonitor.disconnect(this._mountRemovedId);
  395. this._volumeMonitor = null;
  396. }
  397. super.destroy();
  398. }
  399. });