jami-service.scm 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588
  1. ;;; GNU Guix --- Functional package management for GNU
  2. ;;; Copyright © 2021 Maxim Cournoyer <maxim.cournoyer@gmail.com>
  3. ;;;
  4. ;;; This file is part of GNU Guix.
  5. ;;;
  6. ;;; GNU Guix is free software; you can redistribute it and/or modify it
  7. ;;; under the terms of the GNU General Public License as published by
  8. ;;; the Free Software Foundation; either version 3 of the License, or (at
  9. ;;; your option) any later version.
  10. ;;;
  11. ;;; GNU Guix is distributed in the hope that it will be useful, but
  12. ;;; WITHOUT ANY WARRANTY; without even the implied warranty of
  13. ;;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. ;;; GNU General Public License for more details.
  15. ;;;
  16. ;;; You should have received a copy of the GNU General Public License
  17. ;;; along with GNU Guix. If not, see <http://www.gnu.org/licenses/>.
  18. ;;; Commentary:
  19. ;;;
  20. ;;; This module contains helpers used as part of the jami-service-type
  21. ;;; definition.
  22. ;;;
  23. ;;; Code:
  24. (define-module (gnu build jami-service)
  25. #:use-module (ice-9 format)
  26. #:use-module (ice-9 match)
  27. #:use-module (ice-9 peg)
  28. #:use-module (ice-9 rdelim)
  29. #:use-module (ice-9 regex)
  30. #:use-module (rnrs io ports)
  31. #:autoload (shepherd service) (fork+exec-command)
  32. #:use-module (srfi srfi-1)
  33. #:use-module (srfi srfi-26)
  34. #:export (account-fingerprint?
  35. account-details->recutil
  36. get-accounts
  37. get-usernames
  38. set-account-details
  39. add-account
  40. account->username
  41. username->account
  42. username->contacts
  43. enable-account
  44. disable-account
  45. add-contact
  46. remove-contact
  47. set-all-moderators
  48. set-moderator
  49. username->all-moderators?
  50. username->moderators
  51. dbus-available-services
  52. dbus-service-available?
  53. %send-dbus-binary
  54. %send-dbus-bus
  55. %send-dbus-user
  56. %send-dbus-group
  57. %send-dbus-debug
  58. send-dbus
  59. with-retries))
  60. ;;;
  61. ;;; Utilities.
  62. ;;;
  63. (define-syntax-rule (with-retries n delay body ...)
  64. "Retry the code in BODY up to N times until it doesn't raise an exception
  65. nor return #f, else raise an error. A delay of DELAY seconds is inserted
  66. before each retry."
  67. (let loop ((attempts 0))
  68. (catch #t
  69. (lambda ()
  70. (let ((result (begin body ...)))
  71. (if (not result)
  72. (error "failed attempt" attempts)
  73. result)))
  74. (lambda args
  75. (if (< attempts n)
  76. (begin
  77. (sleep delay) ;else wait and retry
  78. (loop (+ 1 attempts)))
  79. (error "maximum number of retry attempts reached"
  80. body ... args))))))
  81. (define (alist->list alist)
  82. "Flatten ALIST into a list."
  83. (append-map (match-lambda
  84. (() '())
  85. ((key . value)
  86. (list key value)))
  87. alist))
  88. (define account-fingerprint-rx (make-regexp "[0-9A-f]{40}"))
  89. (define (account-fingerprint? val)
  90. "A Jami account fingerprint is 40 characters long and only contains
  91. hexadecimal characters."
  92. (and (string? val)
  93. (regexp-exec account-fingerprint-rx val)))
  94. ;;;
  95. ;;; D-Bus reply parser.
  96. ;;;
  97. (define (parse-dbus-reply reply)
  98. "Return the parse tree of REPLY, a string returned by the 'dbus-send'
  99. command."
  100. ;; Refer to 'man 1 dbus-send' for the grammar reference. Note that the
  101. ;; format of the replies doesn't match the format of the input, which is the
  102. ;; one documented, but it gives an idea. For an even better reference, see
  103. ;; the `print_iter' procedure of the 'dbus-print-message.c' file from the
  104. ;; 'dbus' package sources.
  105. (define-peg-string-patterns
  106. "contents <- header (item / container (item / container*)?)
  107. item <-- WS type WS value NL
  108. container <- array / dict / variant
  109. array <-- array-start (item / container)* array-end
  110. dict <-- array-start dict-entry* array-end
  111. dict-entry <-- dict-entry-start item item dict-entry-end
  112. variant <-- variant-start item
  113. type <-- 'string' / 'int16' / 'uint16' / 'int32' / 'uint32' / 'int64' /
  114. 'uint64' / 'double' / 'byte' / 'boolean' / 'objpath'
  115. value <-- (!NL .)* NL
  116. header < (!NL .)* NL
  117. variant-start < WS 'variant'
  118. array-start < WS 'array [' NL
  119. array-end < WS ']' NL
  120. dict-entry-start < WS 'dict entry(' NL
  121. dict-entry-end < WS ')' NL
  122. DQ < '\"'
  123. WS < ' '*
  124. NL < '\n'*")
  125. (peg:tree (match-pattern contents reply)))
  126. (define (strip-quotes text)
  127. "Strip the leading and trailing double quotes (\") characters from TEXT."
  128. (let* ((text* (if (string-prefix? "\"" text)
  129. (string-drop text 1)
  130. text))
  131. (text** (if (string-suffix? "\"" text*)
  132. (string-drop-right text* 1)
  133. text*)))
  134. text**))
  135. (define (deserialize-item item)
  136. "Return the value described by the ITEM parse tree as a Guile object."
  137. ;; Strings are printed wrapped in double quotes (see the print_iter
  138. ;; procedure in dbus-print-message.c).
  139. (match item
  140. (('item ('type "string") ('value value))
  141. (strip-quotes value))
  142. (('item ('type "boolean") ('value value))
  143. (if (string=? "true" value)
  144. #t
  145. #f))
  146. (('item _ ('value value))
  147. value)))
  148. (define (serialize-boolean bool)
  149. "Return the serialized format expected by dbus-send for BOOL."
  150. (format #f "boolean:~:[false~;true~]" bool))
  151. (define (dict->alist dict-parse-tree)
  152. "Translate a dict parse tree to an alist."
  153. (define (tuples->alist tuples)
  154. (map (lambda (x) (apply cons x)) tuples))
  155. (match dict-parse-tree
  156. ('dict
  157. '())
  158. (('dict ('dict-entry keys values) ...)
  159. (let ((keys* (map deserialize-item keys))
  160. (values* (map deserialize-item values)))
  161. (tuples->alist (zip keys* values*))))))
  162. (define (array->list array-parse-tree)
  163. "Translate an array parse tree to a list."
  164. (match array-parse-tree
  165. ('array
  166. '())
  167. (('array items ...)
  168. (map deserialize-item items))))
  169. ;;;
  170. ;;; Low-level, D-Bus-related procedures.
  171. ;;;
  172. ;;; The following parameters are used in the jami-service-type service
  173. ;;; definition to conveniently customize the behavior of the send-dbus helper,
  174. ;;; even when called indirectly.
  175. (define %send-dbus-binary (make-parameter "dbus-send"))
  176. (define %send-dbus-bus (make-parameter #f))
  177. (define %send-dbus-user (make-parameter #f))
  178. (define %send-dbus-group (make-parameter #f))
  179. (define %send-dbus-debug (make-parameter #f))
  180. (define* (send-dbus #:key service path interface method
  181. bus
  182. dbus-send
  183. user group
  184. timeout
  185. arguments)
  186. "Return the response of DBUS-SEND, else raise an error. Unless explicitly
  187. provided, DBUS-SEND takes the value of the %SEND-DBUS-BINARY parameter. BUS
  188. can be used to specify the bus address, such as 'unix:path=/var/run/jami/bus'.
  189. Alternatively, the %SEND-DBUS-BUS parameter can be used. ARGUMENTS can be
  190. used to pass input values to a D-Bus method call. TIMEOUT is the amount of
  191. time to wait for a reply in milliseconds before giving up with an error. USER
  192. and GROUP allow choosing under which user/group the DBUS-SEND command is
  193. executed. Alternatively, the %SEND-DBUS-USER and %SEND-DBUS-GROUP parameters
  194. can be used instead."
  195. (let* ((command `(,(if dbus-send
  196. dbus-send
  197. (%send-dbus-binary))
  198. ,@(if (or bus (%send-dbus-bus))
  199. (list (string-append "--bus="
  200. (or bus (%send-dbus-bus))))
  201. '())
  202. "--print-reply"
  203. ,@(if timeout
  204. (list (format #f "--reply-timeout=~d" timeout))
  205. '())
  206. ,(string-append "--dest=" service) ;e.g., cx.ring.Ring
  207. ,path ;e.g., /cx/ring/Ring/ConfigurationManager
  208. ,(string-append interface "." method)
  209. ,@(or arguments '())))
  210. (temp-port (mkstemp! (string-copy "/tmp/dbus-send-output-XXXXXXX")))
  211. (temp-file (port-filename temp-port)))
  212. (dynamic-wind
  213. (lambda ()
  214. (let* ((uid (or (and=> (or user (%send-dbus-user))
  215. (compose passwd:uid getpwnam)) -1))
  216. (gid (or (and=> (or group (%send-dbus-group))
  217. (compose group:gid getgrnam)) -1)))
  218. (chown temp-port uid gid)))
  219. (lambda ()
  220. (let ((pid (fork+exec-command command
  221. #:user (or user (%send-dbus-user))
  222. #:group (or group (%send-dbus-group))
  223. #:log-file temp-file)))
  224. (match (waitpid pid)
  225. ((_ . status)
  226. (let ((exit-status (status:exit-val status))
  227. (output (call-with-port temp-port get-string-all)))
  228. (if (= 0 exit-status)
  229. output
  230. (error "the send-dbus command exited with: "
  231. command exit-status output)))))))
  232. (lambda ()
  233. (false-if-exception (delete-file temp-file))))))
  234. (define (parse-account-ids reply)
  235. "Return the Jami account IDs from REPLY, which is assumed to be the output
  236. of the Jami D-Bus `getAccountList' method."
  237. (array->list (parse-dbus-reply reply)))
  238. (define (parse-account-details reply)
  239. "Parse REPLY, which is assumed to be the output of the Jami D-Bus
  240. `getAccountDetails' method, and return its content as an alist."
  241. (dict->alist (parse-dbus-reply reply)))
  242. (define (parse-contacts reply)
  243. "Parse REPLY, which is assumed to be the output of the Jamid D-Bus
  244. `getContacts' method, and return its content as an alist."
  245. (match (parse-dbus-reply reply)
  246. ('array
  247. '())
  248. (('array dicts ...)
  249. (map dict->alist dicts))))
  250. ;;;
  251. ;;; Higher-level, D-Bus-related procedures.
  252. ;;;
  253. (define (validate-fingerprint fingerprint)
  254. "Validate that fingerprint is 40 characters long."
  255. (unless (account-fingerprint? fingerprint)
  256. (error "Account fingerprint is not valid:" fingerprint)))
  257. (define (dbus-available-services)
  258. "Return the list of available (acquired) D-Bus services."
  259. (let ((reply (parse-dbus-reply
  260. (send-dbus #:service "org.freedesktop.DBus"
  261. #:path "/org/freedesktop/DBus"
  262. #:interface "org.freedesktop.DBus"
  263. #:method "ListNames"))))
  264. ;; Remove entries such as ":1.7".
  265. (remove (cut string-prefix? ":" <>)
  266. (array->list reply))))
  267. (define (dbus-service-available? service)
  268. "Predicate to check for the D-Bus SERVICE availability."
  269. (member service (dbus-available-services)))
  270. (define* (send-dbus/configuration-manager #:key method arguments timeout)
  271. "Query the Jami D-Bus ConfigurationManager service."
  272. (send-dbus #:service "cx.ring.Ring"
  273. #:path "/cx/ring/Ring/ConfigurationManager"
  274. #:interface "cx.ring.Ring.ConfigurationManager"
  275. #:method method
  276. #:arguments arguments
  277. #:timeout timeout))
  278. ;;; The following methods are for internal use; they make use of the account
  279. ;;; ID, an implementation detail of Jami the user should not need to be
  280. ;;; concerned with.
  281. (define (get-account-ids)
  282. "Return the available Jami account identifiers (IDs). Account IDs are an
  283. implementation detail used to identify the accounts in Jami."
  284. (parse-account-ids
  285. (send-dbus/configuration-manager #:method "getAccountList")))
  286. (define (id->account-details id)
  287. "Retrieve the account data associated with the given account ID."
  288. (parse-account-details
  289. (send-dbus/configuration-manager
  290. #:method "getAccountDetails"
  291. #:arguments (list (string-append "string:" id)))))
  292. (define (id->volatile-account-details id)
  293. "Retrieve the account data associated with the given account ID."
  294. (parse-account-details
  295. (send-dbus/configuration-manager
  296. #:method "getVolatileAccountDetails"
  297. #:arguments (list (string-append "string:" id)))))
  298. (define (id->account id)
  299. "Retrieve the complete account data associated with the given account ID."
  300. (append (id->volatile-account-details id)
  301. (id->account-details id)))
  302. (define %username-to-id-cache #f)
  303. (define (invalidate-username-to-id-cache!)
  304. (set! %username-to-id-cache #f))
  305. (define (username->id username)
  306. "Return the first account ID corresponding to USERNAME."
  307. (unless (assoc-ref %username-to-id-cache username)
  308. (set! %username-to-id-cache
  309. (append-map
  310. (lambda (id)
  311. (let* ((account (id->account id))
  312. (username (assoc-ref account "Account.username"))
  313. (registered-name (assoc-ref account
  314. "Account.registeredName")))
  315. `(,@(if username
  316. (list (cons username id))
  317. '())
  318. ,@(if registered-name
  319. (list (cons registered-name id))
  320. '()))))
  321. (get-account-ids))))
  322. (or (assoc-ref %username-to-id-cache username)
  323. (let ((message (format #f "Could not retrieve a local account ID\
  324. for ~:[username~;fingerprint~]" (account-fingerprint? username))))
  325. (error message username))))
  326. (define (account->username account)
  327. "Return USERNAME, the registered username associated with ACCOUNT, else its
  328. public key fingerprint."
  329. (or (assoc-ref account "Account.registeredName")
  330. (assoc-ref account "Account.username")))
  331. (define (id->username id)
  332. "Return USERNAME, the registered username associated with ID, else its
  333. public key fingerprint, else #f."
  334. (account->username (id->account id)))
  335. (define (get-accounts)
  336. "Return the list of all accounts, as a list of alists."
  337. (map id->account (get-account-ids)))
  338. (define (get-usernames)
  339. "Return the list of the usernames associated with the present accounts."
  340. (map account->username (get-accounts)))
  341. (define (username->account username)
  342. "Return the first account associated with USERNAME, else #f.
  343. USERNAME can be either the account 40 characters public key fingerprint or a
  344. registered username."
  345. (find (lambda (account)
  346. (member username
  347. (list (assoc-ref account "Account.username")
  348. (assoc-ref account "Account.registeredName"))))
  349. (get-accounts)))
  350. (define (add-account archive)
  351. "Import the Jami account ARCHIVE and return its account ID. The archive
  352. should *not* be encrypted with a password. Return the username associated
  353. with the account."
  354. (invalidate-username-to-id-cache!)
  355. (let ((reply (send-dbus/configuration-manager
  356. #:method "addAccount"
  357. #:arguments (list (string-append
  358. "dict:string:string:Account.archivePath,"
  359. archive
  360. ",Account.type,RING")))))
  361. ;; The account information takes some time to be populated.
  362. (let ((id (deserialize-item (parse-dbus-reply reply))))
  363. (with-retries 20 1
  364. (let ((username (id->username id)))
  365. (if (string-null? username)
  366. #f
  367. username))))))
  368. (define (remove-account username)
  369. "Delete the Jami account associated with USERNAME, the account 40 characters
  370. fingerprint or a registered username."
  371. (let ((id (username->id username)))
  372. (send-dbus/configuration-manager
  373. #:method "removeAccount"
  374. #:arguments (list (string-append "string:" id))))
  375. (invalidate-username-to-id-cache!))
  376. (define* (username->contacts username)
  377. "Return the contacts associated with the account of USERNAME as two values;
  378. the first one being the regular contacts and the second one the banned
  379. contacts. USERNAME can be either the account 40 characters public key
  380. fingerprint or a registered username. The contacts returned are represented
  381. using their 40 characters fingerprint."
  382. (let* ((id (username->id username))
  383. (reply (send-dbus/configuration-manager
  384. #:method "getContacts"
  385. #:arguments (list (string-append "string:" id))))
  386. (all-contacts (parse-contacts reply))
  387. (banned? (lambda (contact)
  388. (and=> (assoc-ref contact "banned")
  389. (cut string=? "true" <>))))
  390. (banned (filter banned? all-contacts))
  391. (not-banned (filter (negate banned?) all-contacts))
  392. (fingerprint (cut assoc-ref <> "id")))
  393. (values (map fingerprint not-banned)
  394. (map fingerprint banned))))
  395. (define* (remove-contact contact username #:key ban?)
  396. "Remove CONTACT, the 40 characters public key fingerprint of a contact, from
  397. the account associated with USERNAME (either a fingerprint or a registered
  398. username). When BAN? is true, also mark the contact as banned."
  399. (validate-fingerprint contact)
  400. (let ((id (username->id username)))
  401. (send-dbus/configuration-manager
  402. #:method "removeContact"
  403. #:arguments (list (string-append "string:" id)
  404. (string-append "string:" contact)
  405. (serialize-boolean ban?)))))
  406. (define (add-contact contact username)
  407. "Add CONTACT, the 40 characters public key fingerprint of a contact, to the
  408. account of USERNAME (either a fingerprint or a registered username)."
  409. (validate-fingerprint contact)
  410. (let ((id (username->id username)))
  411. (send-dbus/configuration-manager
  412. #:method "addContact"
  413. #:arguments (list (string-append "string:" id)
  414. (string-append "string:" contact)))))
  415. (define* (set-account-details details username #:key timeout)
  416. "Set DETAILS, an alist containing the key value pairs to set for the account
  417. of USERNAME, a registered username or account fingerprint. The value of the
  418. parameters not provided are unchanged. TIMEOUT is a value in milliseconds to
  419. pass to the `send-dbus/configuration-manager' procedure."
  420. (let* ((id (username->id username))
  421. (current-details (id->account-details id))
  422. (updated-details (map (match-lambda
  423. ((key . value)
  424. (or (and=> (assoc-ref details key)
  425. (cut cons key <>))
  426. (cons key value))))
  427. current-details))
  428. ;; dbus-send does not permit sending null strings (it throws a
  429. ;; "malformed dictionary" error). Luckily they seem to have the
  430. ;; semantic of "default account value" in Jami; so simply drop them.
  431. (updated-details* (remove (match-lambda
  432. ((_ . value)
  433. (string-null? value)))
  434. updated-details)))
  435. (send-dbus/configuration-manager
  436. #:timeout timeout
  437. #:method "setAccountDetails"
  438. #:arguments
  439. (list (string-append "string:" id)
  440. (string-append "dict:string:string:"
  441. (string-join (alist->list updated-details*)
  442. ","))))))
  443. (define (set-all-moderators enabled? username)
  444. "Set the 'AllModerators' property to enabled? for the account of USERNAME, a
  445. registered username or account fingerprint."
  446. (let ((id (username->id username)))
  447. (send-dbus/configuration-manager
  448. #:method "setAllModerators"
  449. #:arguments
  450. (list (string-append "string:" id)
  451. (serialize-boolean enabled?)))))
  452. (define (username->all-moderators? username)
  453. "Return the 'AllModerators' property for the account of USERNAME, a
  454. registered username or account fingerprint."
  455. (let* ((id (username->id username))
  456. (reply (send-dbus/configuration-manager
  457. #:method "isAllModerators"
  458. #:arguments
  459. (list (string-append "string:" id)))))
  460. (deserialize-item (parse-dbus-reply reply))))
  461. (define (username->moderators username)
  462. "Return the moderators for the account of USERNAME, a registered username or
  463. account fingerprint."
  464. (let* ((id (username->id username))
  465. (reply (send-dbus/configuration-manager
  466. #:method "getDefaultModerators"
  467. #:arguments
  468. (list (string-append "string:" id)))))
  469. (array->list (parse-dbus-reply reply))))
  470. (define (set-moderator contact enabled? username)
  471. "Set the moderator flag to ENABLED? for CONTACT, the 40 characters public
  472. key fingerprint of a contact for the account of USERNAME, a registered
  473. username or account fingerprint."
  474. (validate-fingerprint contact)
  475. (let* ((id (username->id username)))
  476. (send-dbus/configuration-manager #:method "setDefaultModerator"
  477. #:arguments
  478. (list (string-append "string:" id)
  479. (string-append "string:" contact)
  480. (serialize-boolean enabled?)))))
  481. (define (disable-account username)
  482. "Disable the account known by USERNAME, a registered username or account
  483. fingerprint."
  484. (set-account-details '(("Account.enable" . "false")) username
  485. ;; Waiting for the reply on this command takes a very
  486. ;; long time that trips the default D-Bus timeout value
  487. ;; (25 s), for some reason.
  488. #:timeout 60000))
  489. (define (enable-account username)
  490. "Enable the account known by USERNAME, a registered username or account
  491. fingerprint."
  492. (set-account-details '(("Account.enable" . "true")) username))
  493. ;;;
  494. ;;; Presentation procedures.
  495. ;;;
  496. (define (.->_ text)
  497. "Map each period character to underscore characters."
  498. (string-map (match-lambda
  499. (#\. #\_)
  500. (c c))
  501. text))
  502. (define (account-details->recutil account-details)
  503. "Serialize the account-details alist into a recutil string. Period
  504. characters in the keys are normalized to underscore to meet Recutils' format
  505. requirements."
  506. (define (pair->recutil-property pair)
  507. (match pair
  508. ((key . value)
  509. (string-append (.->_ key) ": " value))))
  510. (define sorted-account-details
  511. ;; Have the account username, display name and alias appear first, for
  512. ;; convenience.
  513. (let ((first-items '("Account.username"
  514. "Account.displayName"
  515. "Account.alias")))
  516. (append (map (cut assoc <> account-details) first-items)
  517. (fold alist-delete account-details first-items))))
  518. (string-join (map pair->recutil-property sorted-account-details) "\n"))
  519. ;; Local Variables:
  520. ;; eval: (put 'with-retries 'scheme-indent-function 2)
  521. ;; End: