command.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555
  1. """module to handle command files
  2. @contact: Debian FTP Master <ftpmaster@debian.org>
  3. @copyright: 2012, Ansgar Burchardt <ansgar@debian.org>
  4. @copyright: 2023 Emilio Pozuelo Monfort <pochu@debian.org>
  5. @license: GPL-2+
  6. """
  7. # This program is free software; you can redistribute it and/or modify
  8. # it under the terms of the GNU General Public License as published by
  9. # the Free Software Foundation; either version 2 of the License, or
  10. # (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU General Public License along
  18. # with this program; if not, write to the Free Software Foundation, Inc.,
  19. # 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
  20. import os
  21. import tempfile
  22. import apt_pkg
  23. from daklib.config import Config
  24. from daklib.dak_exceptions import ParseMaintError
  25. from daklib.dbconn import (
  26. ACL,
  27. ACLPerSource,
  28. ACLPerSuite,
  29. DBChange,
  30. DBConn,
  31. DBSource,
  32. Fingerprint,
  33. Keyring,
  34. PolicyQueueUpload,
  35. SignatureHistory,
  36. )
  37. from daklib.gpg import SignedFile
  38. from daklib.regexes import re_field_package
  39. from daklib.textutils import fix_maintainer
  40. from daklib.utils import TemplateSubst, gpg_get_key_addresses, send_mail
  41. class CommandError(Exception):
  42. pass
  43. class CommandFile:
  44. def __init__(self, filename: str, data: bytes, log=None):
  45. if log is None:
  46. from daklib.daklog import Logger
  47. log = Logger()
  48. self.cc: list[str] = []
  49. self.result = []
  50. self.log = log
  51. self.filename: str = filename
  52. self.data = data
  53. self.uploader = None
  54. def _check_replay(self, signed_file: SignedFile, session):
  55. """check for replays
  56. .. note::
  57. Will commit changes to the database.
  58. :param session: database session
  59. """
  60. # Mark commands file as seen to prevent replays.
  61. signature_history = SignatureHistory.from_signed_file(signed_file)
  62. session.add(signature_history)
  63. session.commit()
  64. def _quote_section(self, section) -> str:
  65. lines = [f"> {line}" for line in str(section).splitlines()]
  66. return "\n".join(lines)
  67. def _evaluate_sections(self, sections, session):
  68. session.rollback()
  69. try:
  70. while True:
  71. next(sections)
  72. section = sections.section
  73. self.result.append(self._quote_section(section))
  74. action = section.get("Action", None)
  75. if action is None:
  76. raise CommandError("Encountered section without Action field")
  77. if action == "dm":
  78. self.action_dm(self.fingerprint, section, session)
  79. elif action == "dm-remove":
  80. self.action_dm_remove(self.fingerprint, section, session)
  81. elif action == "dm-migrate":
  82. self.action_dm_migrate(self.fingerprint, section, session)
  83. elif action == "break-the-archive":
  84. self.action_break_the_archive(self.fingerprint, section, session)
  85. elif action == "process-upload":
  86. self.action_process_upload(self.fingerprint, section, session)
  87. else:
  88. raise CommandError("Unknown action: {0}".format(action))
  89. self.result.append("")
  90. except StopIteration:
  91. pass
  92. finally:
  93. session.rollback()
  94. def _notify_uploader(self):
  95. cnf = Config()
  96. bcc = "X-DAK: dak process-command"
  97. if "Dinstall::Bcc" in cnf:
  98. bcc = "{0}\nBcc: {1}".format(bcc, cnf["Dinstall::Bcc"])
  99. maint_to = None
  100. addresses = gpg_get_key_addresses(self.fingerprint.fingerprint)
  101. if len(addresses) > 0:
  102. maint_to = addresses[0]
  103. if self.uploader:
  104. try:
  105. maint_to = fix_maintainer(self.uploader)[1]
  106. except ParseMaintError:
  107. self.log.log("ignoring malformed uploader", self.filename)
  108. cc = set()
  109. for address in self.cc:
  110. try:
  111. cc.add(fix_maintainer(address)[1])
  112. except ParseMaintError:
  113. self.log.log("ignoring malformed cc", self.filename)
  114. subst = {
  115. "__DAK_ADDRESS__": cnf["Dinstall::MyEmailAddress"],
  116. "__MAINTAINER_TO__": maint_to,
  117. "__CC__": ", ".join(cc),
  118. "__BCC__": bcc,
  119. "__RESULTS__": "\n".join(self.result),
  120. "__FILENAME__": self.filename,
  121. }
  122. message = TemplateSubst(
  123. subst, os.path.join(cnf["Dir::Templates"], "process-command.processed")
  124. )
  125. send_mail(message)
  126. def evaluate(self) -> bool:
  127. """evaluate commands file
  128. :return: :const:`True` if the file was processed sucessfully,
  129. :const:`False` otherwise
  130. """
  131. result = True
  132. session = DBConn().session()
  133. keyrings = (
  134. session.query(Keyring).filter_by(active=True).order_by(Keyring.priority)
  135. )
  136. keyring_files = [k.keyring_name for k in keyrings]
  137. signed_file = SignedFile(self.data, keyring_files)
  138. if not signed_file.valid:
  139. self.log.log(["invalid signature", self.filename])
  140. return False
  141. self.fingerprint = (
  142. session.query(Fingerprint)
  143. .filter_by(fingerprint=signed_file.primary_fingerprint)
  144. .one()
  145. )
  146. if self.fingerprint.keyring is None:
  147. self.log.log(["signed by key in unknown keyring", self.filename])
  148. return False
  149. assert self.fingerprint.keyring.active
  150. self.log.log(
  151. [
  152. "processing",
  153. self.filename,
  154. "signed-by={0}".format(self.fingerprint.fingerprint),
  155. ]
  156. )
  157. with tempfile.TemporaryFile() as fh:
  158. fh.write(signed_file.contents)
  159. fh.seek(0)
  160. sections = apt_pkg.TagFile(fh)
  161. try:
  162. next(sections)
  163. section = sections.section
  164. if "Uploader" in section:
  165. self.uploader = section["Uploader"]
  166. if "Cc" in section:
  167. self.cc.append(section["Cc"])
  168. # TODO: Verify first section has valid Archive field
  169. if "Archive" not in section:
  170. raise CommandError("No Archive field in first section.")
  171. # TODO: send mail when we detected a replay.
  172. self._check_replay(signed_file, session)
  173. self._evaluate_sections(sections, session)
  174. self.result.append("")
  175. except Exception as e:
  176. self.log.log(["ERROR", e])
  177. self.result.append(
  178. "There was an error processing this section. No changes were committed.\nDetails:\n{0}".format(
  179. e
  180. )
  181. )
  182. result = False
  183. self._notify_uploader()
  184. session.close()
  185. return result
  186. def _split_packages(self, value: str) -> list[str]:
  187. names = value.split()
  188. for name in names:
  189. if not re_field_package.match(name):
  190. raise CommandError('Invalid package name "{0}"'.format(name))
  191. return names
  192. def action_dm(self, fingerprint, section, session) -> None:
  193. cnf = Config()
  194. if (
  195. "Command::DM::AdminKeyrings" not in cnf
  196. or "Command::DM::ACL" not in cnf
  197. or "Command::DM::Keyrings" not in cnf
  198. ):
  199. raise CommandError("DM command is not configured for this archive.")
  200. allowed_keyrings = cnf.value_list("Command::DM::AdminKeyrings")
  201. if fingerprint.keyring.keyring_name not in allowed_keyrings:
  202. raise CommandError(
  203. "Key {0} is not allowed to set DM".format(fingerprint.fingerprint)
  204. )
  205. acl_name = cnf.get("Command::DM::ACL", "dm")
  206. acl = session.query(ACL).filter_by(name=acl_name).one()
  207. fpr_hash = section["Fingerprint"].replace(" ", "")
  208. fpr = session.query(Fingerprint).filter_by(fingerprint=fpr_hash).first()
  209. if fpr is None:
  210. raise CommandError("Unknown fingerprint {0}".format(fpr_hash))
  211. if fpr.keyring is None or fpr.keyring.keyring_name not in cnf.value_list(
  212. "Command::DM::Keyrings"
  213. ):
  214. raise CommandError("Key {0} is not in DM keyring.".format(fpr.fingerprint))
  215. addresses = gpg_get_key_addresses(fpr.fingerprint)
  216. if len(addresses) > 0:
  217. self.cc.append(addresses[0])
  218. self.log.log(["dm", "fingerprint", fpr.fingerprint])
  219. self.result.append("Fingerprint: {0}".format(fpr.fingerprint))
  220. if len(addresses) > 0:
  221. self.log.log(["dm", "uid", addresses[0]])
  222. self.result.append("Uid: {0}".format(addresses[0]))
  223. for source in self._split_packages(section.get("Allow", "")):
  224. # Check for existance of source package to catch typos
  225. if session.query(DBSource).filter_by(source=source).first() is None:
  226. raise CommandError(
  227. "Tried to grant permissions for unknown source package: {0}".format(
  228. source
  229. )
  230. )
  231. if (
  232. session.query(ACLPerSource)
  233. .filter_by(acl=acl, fingerprint=fpr, source=source)
  234. .first()
  235. is None
  236. ):
  237. aps = ACLPerSource()
  238. aps.acl = acl
  239. aps.fingerprint = fpr
  240. aps.source = source
  241. aps.created_by = fingerprint
  242. aps.reason = section.get("Reason")
  243. session.add(aps)
  244. self.log.log(["dm", "allow", fpr.fingerprint, source])
  245. self.result.append("Allowed: {0}".format(source))
  246. else:
  247. self.result.append("Already-Allowed: {0}".format(source))
  248. session.flush()
  249. for source in self._split_packages(section.get("Deny", "")):
  250. count = (
  251. session.query(ACLPerSource)
  252. .filter_by(acl=acl, fingerprint=fpr, source=source)
  253. .delete()
  254. )
  255. if count == 0:
  256. raise CommandError(
  257. "Tried to remove upload permissions for package {0}, "
  258. "but no upload permissions were granted before.".format(source)
  259. )
  260. self.log.log(["dm", "deny", fpr.fingerprint, source])
  261. self.result.append("Denied: {0}".format(source))
  262. session.commit()
  263. def _action_dm_admin_common(self, fingerprint, section, session) -> None:
  264. cnf = Config()
  265. if (
  266. "Command::DM-Admin::AdminFingerprints" not in cnf
  267. or "Command::DM::ACL" not in cnf
  268. ):
  269. raise CommandError("DM admin command is not configured for this archive.")
  270. allowed_fingerprints = cnf.value_list("Command::DM-Admin::AdminFingerprints")
  271. if fingerprint.fingerprint not in allowed_fingerprints:
  272. raise CommandError(
  273. "Key {0} is not allowed to admin DM".format(fingerprint.fingerprint)
  274. )
  275. def action_dm_remove(self, fingerprint, section, session) -> None:
  276. self._action_dm_admin_common(fingerprint, section, session)
  277. cnf = Config()
  278. acl_name = cnf.get("Command::DM::ACL", "dm")
  279. acl = session.query(ACL).filter_by(name=acl_name).one()
  280. fpr_hash = section["Fingerprint"].replace(" ", "")
  281. fpr = session.query(Fingerprint).filter_by(fingerprint=fpr_hash).first()
  282. if fpr is None:
  283. self.result.append(
  284. "Unknown fingerprint: {0}\nNo action taken.".format(fpr_hash)
  285. )
  286. return
  287. self.log.log(["dm-remove", fpr.fingerprint])
  288. count = 0
  289. for entry in session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr):
  290. self.log.log(
  291. ["dm-remove", fpr.fingerprint, "source={0}".format(entry.source)]
  292. )
  293. count += 1
  294. session.delete(entry)
  295. self.result.append(
  296. "Removed: {0}.\n{1} acl entries removed.".format(fpr.fingerprint, count)
  297. )
  298. session.commit()
  299. def action_dm_migrate(self, fingerprint, section, session) -> None:
  300. self._action_dm_admin_common(fingerprint, section, session)
  301. cnf = Config()
  302. acl_name = cnf.get("Command::DM::ACL", "dm")
  303. acl = session.query(ACL).filter_by(name=acl_name).one()
  304. fpr_hash_from = section["From"].replace(" ", "")
  305. fpr_from = (
  306. session.query(Fingerprint).filter_by(fingerprint=fpr_hash_from).first()
  307. )
  308. if fpr_from is None:
  309. self.result.append(
  310. "Unknown fingerprint (From): {0}\nNo action taken.".format(
  311. fpr_hash_from
  312. )
  313. )
  314. return
  315. fpr_hash_to = section["To"].replace(" ", "")
  316. fpr_to = session.query(Fingerprint).filter_by(fingerprint=fpr_hash_to).first()
  317. if fpr_to is None:
  318. self.result.append(
  319. "Unknown fingerprint (To): {0}\nNo action taken.".format(fpr_hash_to)
  320. )
  321. return
  322. if fpr_to.keyring is None or fpr_to.keyring.keyring_name not in cnf.value_list(
  323. "Command::DM::Keyrings"
  324. ):
  325. self.result.append(
  326. "Key (To) {0} is not in DM keyring.\nNo action taken.".format(
  327. fpr_to.fingerprint
  328. )
  329. )
  330. return
  331. self.log.log(
  332. [
  333. "dm-migrate",
  334. "from={0}".format(fpr_hash_from),
  335. "to={0}".format(fpr_hash_to),
  336. ]
  337. )
  338. sources = []
  339. for entry in session.query(ACLPerSource).filter_by(
  340. acl=acl, fingerprint=fpr_from
  341. ):
  342. self.log.log(
  343. [
  344. "dm-migrate",
  345. "from={0}".format(fpr_hash_from),
  346. "to={0}".format(fpr_hash_to),
  347. "source={0}".format(entry.source),
  348. ]
  349. )
  350. entry.fingerprint = fpr_to
  351. sources.append(entry.source)
  352. self.result.append(
  353. "Migrated {0} to {1}.\n{2} acl entries changed: {3}".format(
  354. fpr_hash_from, fpr_hash_to, len(sources), ", ".join(sources)
  355. )
  356. )
  357. session.commit()
  358. def action_break_the_archive(self, fingerprint, section, session) -> None:
  359. name = "Dave"
  360. uid = fingerprint.uid
  361. if uid is not None and uid.name is not None:
  362. name = uid.name.split()[0]
  363. self.result.append(
  364. "DAK9000: I'm sorry, {0}. I'm afraid I can't do that.".format(name)
  365. )
  366. def _sourcename_from_dbchanges(self, changes: DBChange) -> str:
  367. source = changes.source
  368. # in case the Source contains spaces, e.g. in binNMU .changes
  369. source = source.split(" ")[0]
  370. return source
  371. def _process_upload_add_command_file(
  372. self, upload: PolicyQueueUpload, command
  373. ) -> None:
  374. source = self._sourcename_from_dbchanges(upload.changes)
  375. filename = f"{command}.{source}_{upload.changes.version}"
  376. content = "OK" if command == "ACCEPT" else "NOTOK"
  377. with open(
  378. os.path.join(upload.policy_queue.path, "COMMENTS", filename), "x"
  379. ) as f:
  380. f.write(content + "\n")
  381. def _action_process_upload_common(self, fingerprint, section, session) -> None:
  382. cnf = Config()
  383. if "Command::ProcessUpload::ACL" not in cnf:
  384. raise CommandError(
  385. "Process Upload command is not configured for this archive."
  386. )
  387. def action_process_upload(self, fingerprint, section, session) -> None:
  388. self._action_process_upload_common(fingerprint, section, session)
  389. cnf = Config()
  390. acl_name = cnf.get("Command::ProcessUpload::ACL", "process-upload")
  391. acl = session.query(ACL).filter_by(name=acl_name).one()
  392. source = section["Source"].replace(" ", "")
  393. version = section["Version"].replace(" ", "")
  394. command = section["Command"].replace(" ", "")
  395. if command not in ("ACCEPT", "REJECT"):
  396. raise CommandError("Invalid ProcessUpload command: {0}".format(command))
  397. uploads = (
  398. session.query(PolicyQueueUpload)
  399. .join(PolicyQueueUpload.changes)
  400. .filter_by(version=version)
  401. .all()
  402. )
  403. # we don't filter_by(source=source) because a source in a DBChange can
  404. # contain more than the source, e.g. 'source (version)' for binNMUs
  405. uploads = [
  406. upload
  407. for upload in uploads
  408. if self._sourcename_from_dbchanges(upload.changes) == source
  409. ]
  410. if not uploads:
  411. raise CommandError(
  412. "Could not find upload for {0} {1}".format(source, version)
  413. )
  414. upload = uploads[0]
  415. # we consider all uploads except those for NEW, and take into account the
  416. # target suite when checking for permissions
  417. if upload.policy_queue.queue_name == "new":
  418. raise CommandError(
  419. "Processing uploads from NEW not allowed ({0} {1})".format(
  420. source, version
  421. )
  422. )
  423. suite = upload.target_suite
  424. self.log.log(
  425. [
  426. "process-upload",
  427. fingerprint.fingerprint,
  428. source,
  429. version,
  430. upload.policy_queue.queue_name,
  431. suite.suite_name,
  432. ]
  433. )
  434. allowed = False
  435. for entry in session.query(ACLPerSource).filter_by(
  436. acl=acl, fingerprint=fingerprint, source=source
  437. ):
  438. allowed = True
  439. if not allowed:
  440. for entry in session.query(ACLPerSuite).filter_by(
  441. acl=acl, fingerprint=fingerprint, suite=suite
  442. ):
  443. allowed = True
  444. self.log.log(
  445. [
  446. "process-upload",
  447. fingerprint.fingerprint,
  448. source,
  449. version,
  450. upload.policy_queue.queue_name,
  451. suite.suite_name,
  452. allowed,
  453. ]
  454. )
  455. if allowed:
  456. self._process_upload_add_command_file(upload, command)
  457. self.result.append(
  458. "ProcessUpload: processed fp {0}: {1}_{2}/{3}".format(
  459. fingerprint.fingerprint, source, version, suite.codename
  460. )
  461. )