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