sftp.js 15 KB

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