command.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419
  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 apt_pkg
  21. import os
  22. import tempfile
  23. from daklib.config import Config
  24. from daklib.dak_exceptions import *
  25. from daklib.dbconn import *
  26. from daklib.gpg import SignedFile
  27. from daklib.regexes import re_field_package
  28. from daklib.textutils import fix_maintainer
  29. from daklib.utils import gpg_get_key_addresses, send_mail, TemplateSubst
  30. class CommandError(Exception):
  31. pass
  32. class CommandFile:
  33. def __init__(self, filename: str, data: bytes, log=None):
  34. if log is None:
  35. from daklib.daklog import Logger
  36. log = Logger()
  37. self.cc: list[str] = []
  38. self.result = []
  39. self.log = log
  40. self.filename: str = filename
  41. self.data = data
  42. self.uploader = None
  43. def _check_replay(self, signed_file: SignedFile, session):
  44. """check for replays
  45. .. note::
  46. Will commit changes to the database.
  47. :param session: database session
  48. """
  49. # Mark commands file as seen to prevent replays.
  50. signature_history = SignatureHistory.from_signed_file(signed_file)
  51. session.add(signature_history)
  52. session.commit()
  53. def _quote_section(self, section) -> str:
  54. lines = []
  55. for l in str(section).splitlines():
  56. lines.append("> {0}".format(l))
  57. return "\n".join(lines)
  58. def _evaluate_sections(self, sections, session):
  59. session.rollback()
  60. try:
  61. while True:
  62. next(sections)
  63. section = sections.section
  64. self.result.append(self._quote_section(section))
  65. action = section.get('Action', None)
  66. if action is None:
  67. raise CommandError('Encountered section without Action field')
  68. if action == 'dm':
  69. self.action_dm(self.fingerprint, section, session)
  70. elif action == 'dm-remove':
  71. self.action_dm_remove(self.fingerprint, section, session)
  72. elif action == 'dm-migrate':
  73. self.action_dm_migrate(self.fingerprint, section, session)
  74. elif action == 'break-the-archive':
  75. self.action_break_the_archive(self.fingerprint, section, session)
  76. elif action == 'process-upload':
  77. self.action_process_upload(self.fingerprint, section, session)
  78. else:
  79. raise CommandError('Unknown action: {0}'.format(action))
  80. self.result.append('')
  81. except StopIteration:
  82. pass
  83. finally:
  84. session.rollback()
  85. def _notify_uploader(self):
  86. cnf = Config()
  87. bcc = 'X-DAK: dak process-command'
  88. if 'Dinstall::Bcc' in cnf:
  89. bcc = '{0}\nBcc: {1}'.format(bcc, cnf['Dinstall::Bcc'])
  90. maint_to = None
  91. addresses = gpg_get_key_addresses(self.fingerprint.fingerprint)
  92. if len(addresses) > 0:
  93. maint_to = addresses[0]
  94. if self.uploader:
  95. try:
  96. maint_to = fix_maintainer(self.uploader)[1]
  97. except ParseMaintError:
  98. self.log.log('ignoring malformed uploader', self.filename)
  99. cc = set()
  100. for address in self.cc:
  101. try:
  102. cc.add(fix_maintainer(address)[1])
  103. except ParseMaintError:
  104. self.log.log('ignoring malformed cc', self.filename)
  105. subst = {
  106. '__DAK_ADDRESS__': cnf['Dinstall::MyEmailAddress'],
  107. '__MAINTAINER_TO__': maint_to,
  108. '__CC__': ", ".join(cc),
  109. '__BCC__': bcc,
  110. '__RESULTS__': "\n".join(self.result),
  111. '__FILENAME__': self.filename,
  112. }
  113. message = TemplateSubst(subst, os.path.join(cnf['Dir::Templates'], 'process-command.processed'))
  114. send_mail(message)
  115. def evaluate(self) -> bool:
  116. """evaluate commands file
  117. :return: :const:`True` if the file was processed sucessfully,
  118. :const:`False` otherwise
  119. """
  120. result = True
  121. session = DBConn().session()
  122. keyrings = session.query(Keyring).filter_by(active=True).order_by(Keyring.priority)
  123. keyring_files = [k.keyring_name for k in keyrings]
  124. signed_file = SignedFile(self.data, keyring_files)
  125. if not signed_file.valid:
  126. self.log.log(['invalid signature', self.filename])
  127. return False
  128. self.fingerprint = session.query(Fingerprint).filter_by(fingerprint=signed_file.primary_fingerprint).one()
  129. if self.fingerprint.keyring is None:
  130. self.log.log(['singed by key in unknown keyring', self.filename])
  131. return False
  132. assert self.fingerprint.keyring.active
  133. self.log.log(['processing', self.filename, 'signed-by={0}'.format(self.fingerprint.fingerprint)])
  134. with tempfile.TemporaryFile() as fh:
  135. fh.write(signed_file.contents)
  136. fh.seek(0)
  137. sections = apt_pkg.TagFile(fh)
  138. try:
  139. next(sections)
  140. section = sections.section
  141. if 'Uploader' in section:
  142. self.uploader = section['Uploader']
  143. if 'Cc' in section:
  144. self.cc.append(section['Cc'])
  145. # TODO: Verify first section has valid Archive field
  146. if 'Archive' not in section:
  147. raise CommandError('No Archive field in first section.')
  148. # TODO: send mail when we detected a replay.
  149. self._check_replay(signed_file, session)
  150. self._evaluate_sections(sections, session)
  151. self.result.append('')
  152. except Exception as e:
  153. self.log.log(['ERROR', e])
  154. self.result.append("There was an error processing this section. No changes were committed.\nDetails:\n{0}".format(e))
  155. result = False
  156. self._notify_uploader()
  157. session.close()
  158. return result
  159. def _split_packages(self, value: str) -> list[str]:
  160. names = value.split()
  161. for name in names:
  162. if not re_field_package.match(name):
  163. raise CommandError('Invalid package name "{0}"'.format(name))
  164. return names
  165. def action_dm(self, fingerprint, section, session) -> None:
  166. cnf = Config()
  167. if 'Command::DM::AdminKeyrings' not in cnf \
  168. or 'Command::DM::ACL' not in cnf \
  169. or 'Command::DM::Keyrings' not in cnf:
  170. raise CommandError('DM command is not configured for this archive.')
  171. allowed_keyrings = cnf.value_list('Command::DM::AdminKeyrings')
  172. if fingerprint.keyring.keyring_name not in allowed_keyrings:
  173. raise CommandError('Key {0} is not allowed to set DM'.format(fingerprint.fingerprint))
  174. acl_name = cnf.get('Command::DM::ACL', 'dm')
  175. acl = session.query(ACL).filter_by(name=acl_name).one()
  176. fpr_hash = section['Fingerprint'].replace(' ', '')
  177. fpr = session.query(Fingerprint).filter_by(fingerprint=fpr_hash).first()
  178. if fpr is None:
  179. raise CommandError('Unknown fingerprint {0}'.format(fpr_hash))
  180. if fpr.keyring is None or fpr.keyring.keyring_name not in cnf.value_list('Command::DM::Keyrings'):
  181. raise CommandError('Key {0} is not in DM keyring.'.format(fpr.fingerprint))
  182. addresses = gpg_get_key_addresses(fpr.fingerprint)
  183. if len(addresses) > 0:
  184. self.cc.append(addresses[0])
  185. self.log.log(['dm', 'fingerprint', fpr.fingerprint])
  186. self.result.append('Fingerprint: {0}'.format(fpr.fingerprint))
  187. if len(addresses) > 0:
  188. self.log.log(['dm', 'uid', addresses[0]])
  189. self.result.append('Uid: {0}'.format(addresses[0]))
  190. for source in self._split_packages(section.get('Allow', '')):
  191. # Check for existance of source package to catch typos
  192. if session.query(DBSource).filter_by(source=source).first() is None:
  193. raise CommandError('Tried to grant permissions for unknown source package: {0}'.format(source))
  194. if session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr, source=source).first() is None:
  195. aps = ACLPerSource()
  196. aps.acl = acl
  197. aps.fingerprint = fpr
  198. aps.source = source
  199. aps.created_by = fingerprint
  200. aps.reason = section.get('Reason')
  201. session.add(aps)
  202. self.log.log(['dm', 'allow', fpr.fingerprint, source])
  203. self.result.append('Allowed: {0}'.format(source))
  204. else:
  205. self.result.append('Already-Allowed: {0}'.format(source))
  206. session.flush()
  207. for source in self._split_packages(section.get('Deny', '')):
  208. count = session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr, source=source).delete()
  209. if count == 0:
  210. raise CommandError('Tried to remove upload permissions for package {0}, '
  211. 'but no upload permissions were granted before.'.format(source))
  212. self.log.log(['dm', 'deny', fpr.fingerprint, source])
  213. self.result.append('Denied: {0}'.format(source))
  214. session.commit()
  215. def _action_dm_admin_common(self, fingerprint, section, session) -> None:
  216. cnf = Config()
  217. if 'Command::DM-Admin::AdminFingerprints' not in cnf \
  218. or 'Command::DM::ACL' not in cnf:
  219. raise CommandError('DM admin command is not configured for this archive.')
  220. allowed_fingerprints = cnf.value_list('Command::DM-Admin::AdminFingerprints')
  221. if fingerprint.fingerprint not in allowed_fingerprints:
  222. raise CommandError('Key {0} is not allowed to admin DM'.format(fingerprint.fingerprint))
  223. def action_dm_remove(self, fingerprint, section, session) -> None:
  224. self._action_dm_admin_common(fingerprint, section, session)
  225. cnf = Config()
  226. acl_name = cnf.get('Command::DM::ACL', 'dm')
  227. acl = session.query(ACL).filter_by(name=acl_name).one()
  228. fpr_hash = section['Fingerprint'].replace(' ', '')
  229. fpr = session.query(Fingerprint).filter_by(fingerprint=fpr_hash).first()
  230. if fpr is None:
  231. self.result.append('Unknown fingerprint: {0}\nNo action taken.'.format(fpr_hash))
  232. return
  233. self.log.log(['dm-remove', fpr.fingerprint])
  234. count = 0
  235. for entry in session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr):
  236. self.log.log(['dm-remove', fpr.fingerprint, 'source={0}'.format(entry.source)])
  237. count += 1
  238. session.delete(entry)
  239. self.result.append('Removed: {0}.\n{1} acl entries removed.'.format(fpr.fingerprint, count))
  240. session.commit()
  241. def action_dm_migrate(self, fingerprint, section, session) -> None:
  242. self._action_dm_admin_common(fingerprint, section, session)
  243. cnf = Config()
  244. acl_name = cnf.get('Command::DM::ACL', 'dm')
  245. acl = session.query(ACL).filter_by(name=acl_name).one()
  246. fpr_hash_from = section['From'].replace(' ', '')
  247. fpr_from = session.query(Fingerprint).filter_by(fingerprint=fpr_hash_from).first()
  248. if fpr_from is None:
  249. self.result.append('Unknown fingerprint (From): {0}\nNo action taken.'.format(fpr_hash_from))
  250. return
  251. fpr_hash_to = section['To'].replace(' ', '')
  252. fpr_to = session.query(Fingerprint).filter_by(fingerprint=fpr_hash_to).first()
  253. if fpr_to is None:
  254. self.result.append('Unknown fingerprint (To): {0}\nNo action taken.'.format(fpr_hash_to))
  255. return
  256. if fpr_to.keyring is None or fpr_to.keyring.keyring_name not in cnf.value_list('Command::DM::Keyrings'):
  257. self.result.append('Key (To) {0} is not in DM keyring.\nNo action taken.'.format(fpr_to.fingerprint))
  258. return
  259. self.log.log(['dm-migrate', 'from={0}'.format(fpr_hash_from), 'to={0}'.format(fpr_hash_to)])
  260. sources = []
  261. for entry in session.query(ACLPerSource).filter_by(acl=acl, fingerprint=fpr_from):
  262. self.log.log(['dm-migrate', 'from={0}'.format(fpr_hash_from), 'to={0}'.format(fpr_hash_to), 'source={0}'.format(entry.source)])
  263. entry.fingerprint = fpr_to
  264. sources.append(entry.source)
  265. self.result.append('Migrated {0} to {1}.\n{2} acl entries changed: {3}'.format(fpr_hash_from, fpr_hash_to, len(sources), ", ".join(sources)))
  266. session.commit()
  267. def action_break_the_archive(self, fingerprint, section, session) -> None:
  268. name = 'Dave'
  269. uid = fingerprint.uid
  270. if uid is not None and uid.name is not None:
  271. name = uid.name.split()[0]
  272. self.result.append("DAK9000: I'm sorry, {0}. I'm afraid I can't do that.".format(name))
  273. def _sourcename_from_dbchanges(self, changes: DBChange) -> str:
  274. source = changes.source
  275. # in case the Source contains spaces, e.g. in binNMU .changes
  276. source = source.split(' ')[0]
  277. return source
  278. def _process_upload_add_command_file(self, upload: PolicyQueueUpload, command) -> None:
  279. source = self._sourcename_from_dbchanges(upload.changes)
  280. filename = f"{command}.{source}_{upload.changes.version}"
  281. content = "OK" if command == "ACCEPT" else "NOTOK"
  282. with open(os.path.join(upload.policy_queue.path, "COMMENTS", filename), "x") as f:
  283. f.write(content + "\n")
  284. def _action_process_upload_common(self, fingerprint, section, session) -> None:
  285. cnf = Config()
  286. if 'Command::ProcessUpload::ACL' not in cnf:
  287. raise CommandError('Process Upload command is not configured for this archive.')
  288. def action_process_upload(self, fingerprint, section, session) -> None:
  289. self._action_process_upload_common(fingerprint, section, session)
  290. cnf = Config()
  291. acl_name = cnf.get('Command::ProcessUpload::ACL', 'process-upload')
  292. acl = session.query(ACL).filter_by(name=acl_name).one()
  293. source = section['Source'].replace(' ', '')
  294. version = section['Version'].replace(' ', '')
  295. command = section['Command'].replace(' ', '')
  296. if command not in ('ACCEPT', 'REJECT'):
  297. raise CommandError('Invalid ProcessUpload command: {0}'.format(command))
  298. dbsource = session.query(DBSource).filter_by(source=source, version=version)
  299. uploads = session.query(PolicyQueueUpload).join(PolicyQueueUpload.changes) \
  300. .filter_by(version=version) \
  301. .all()
  302. # we don't filter_by(source=source) because a source in a DBChange can
  303. # contain more than the source, e.g. 'source (version)' for binNMUs
  304. uploads = [upload for upload in uploads if self._sourcename_from_dbchanges(upload.changes) == source]
  305. if not uploads:
  306. raise CommandError('Could not find upload for {0} {1}'.format(source, version))
  307. upload = uploads[0]
  308. # we consider all uploads except those for NEW, and take into account the
  309. # target suite when checking for permissions
  310. if upload.policy_queue.queue_name == 'new':
  311. raise CommandError('Processing uploads from NEW not allowed ({0} {1})'.format(source, version))
  312. suite = upload.target_suite
  313. self.log.log(['process-upload', fingerprint.fingerprint, source, version, upload.policy_queue.queue_name, suite.suite_name])
  314. allowed = False
  315. for entry in session.query(ACLPerSource).filter_by(acl=acl,
  316. fingerprint=fingerprint,
  317. source=source):
  318. allowed = True
  319. if not allowed:
  320. for entry in session.query(ACLPerSuite).filter_by(acl=acl,
  321. fingerprint=fingerprint,
  322. suite=suite):
  323. allowed = True
  324. self.log.log(['process-upload', fingerprint.fingerprint, source, version, upload.policy_queue.queue_name, suite.suite_name, allowed])
  325. if allowed:
  326. self._process_upload_add_command_file(upload, command)
  327. self.result.append('ProcessUpload: processed fp {0}: {1}_{2}/{3}'.format(fingerprint.fingerprint, source, version, suite.codename))