Update dependencies, fix compatibility with Sphinx 1.6, ignore PyCharm files
[cacert-infradocs.git] / docs / sphinxext / cacert.py
1 # -*- python -*-
2 # This module provides the following CAcert specific sphinx directives
3 #
4 # sslcert
5 # sslcertlist
6 # sshkeys
7 # sshkeylist
8
9 import re
10 import os.path
11 from ipaddress import ip_address
12
13 from docutils import nodes
14 from docutils.parsers.rst import Directive
15 from docutils.parsers.rst import directives
16
17 from sphinx import addnodes
18 from sphinx.errors import SphinxError
19 from sphinx.util.nodes import set_source_info, make_refnode, traverse_parent
20
21 from dateutil.parser import parse as date_parse
22 from validate_email import validate_email
23
24 __version__ = '0.1.0'
25
26 SUPPORTED_SSH_KEYTYPES = ('RSA', 'DSA', 'ECDSA', 'ED25519')
27
28
29 class sslcert_node(nodes.General, nodes.Element):
30 pass
31
32
33 class sslcertlist_node(nodes.General, nodes.Element):
34 pass
35
36
37 class sshkeys_node(nodes.General, nodes.Element):
38 pass
39
40
41 class sshkeylist_node(nodes.General, nodes.Element):
42 pass
43
44
45 # mapping and validation functions for directive options
46
47 def hex_int(argument):
48 value = int(argument, base=16)
49 return value
50
51
52 def md5_fingerprint(argument):
53 value = argument.strip().lower()
54 if not re.match(r'^([0-9a-f]{2}:){15}[0-9a-f]{2}$', value):
55 raise ValueError('no correctly formatted SHA1 fingerprint')
56 return value
57
58
59 def sha1_fingerprint(argument):
60 value = argument.strip().lower()
61 if not re.match(r'^([0-9a-f]{2}:){19}[0-9a-f]{2}$', value):
62 raise ValueError('no correctly formatted SHA1 fingerprint')
63 return value
64
65
66 def is_valid_hostname(hostname):
67 if len(hostname) > 255:
68 return False
69 if hostname[-1] == ".": # strip exactly one dot from the right, if present
70 hostname = hostname[:-1]
71 allowed = re.compile("(?!-)[A-Z\d-]{1,63}(?<!-)$", re.IGNORECASE)
72 return all(allowed.match(x) for x in hostname.split("."))
73
74
75 def is_valid_ipaddress(content):
76 try:
77 ip_address(content)
78 except ValueError:
79 return False
80 return True
81
82
83 def subject_alternative_names(argument):
84 value = [san.strip().split(':', 1) for san in argument.split(',')]
85 for typ, content in value:
86 if typ == 'DNS':
87 if not is_valid_hostname(content):
88 raise ValueError("%s is no valid DNS name" % content)
89 elif typ == 'EMAIL':
90 if not validate_email(content):
91 raise ValueError("%s is not a valid email address" % content)
92 elif typ == 'IP':
93 if not is_valid_ipaddress(content):
94 raise ValueError("%s is not a valid IP address" % content)
95 else:
96 raise ValueError(
97 "handling of %s subject alternative names (%s) has not been "
98 "implemented" % (typ, content))
99 return value
100
101
102 def expiration_date(argument):
103 return date_parse(directives.unchanged_required(argument))
104
105
106 class CAcertSSLCert(Directive):
107 """
108 The sslcert directive implementation.
109
110 There must only be one instance of a certificate with the same CN and
111 serial number that is not flagged as secondary
112 """
113 final_argument_whitespace = True
114 required_arguments = 1
115 option_spec = {
116 'certfile': directives.path,
117 'keyfile': directives.path,
118 'serial': hex_int,
119 'expiration': expiration_date,
120 'sha1fp': sha1_fingerprint,
121 'altnames': subject_alternative_names,
122 'issuer': directives.unchanged_required,
123 'secondary': directives.flag
124 }
125
126 def run(self):
127 if 'secondary' in self.options:
128 missing = [
129 required for required in ('certfile', 'keyfile', 'serial')
130 if required not in self.options
131 ]
132 else:
133 missing = [
134 required for required in (
135 'certfile', 'keyfile', 'serial', 'expiration', 'sha1fp',
136 'issuer')
137 if required not in self.options
138 ]
139 if missing:
140 raise self.error(
141 "required option(s) '%s' is/are not set for %s." % (
142 "', '".join(missing), self.name))
143 sslcert = sslcert_node()
144 sslcert.attributes['certdata'] = self.options.copy()
145 sslcert.attributes['certdata']['cn'] = self.arguments[0]
146 set_source_info(self, sslcert)
147
148 env = self.state.document.settings.env
149 targetid = 'sslcert-%s' % env.new_serialno('sslcert')
150 targetnode = nodes.target('', '', ids=[targetid])
151 para = nodes.paragraph()
152 para.append(targetnode)
153 para.append(sslcert)
154 return [para]
155
156
157 class CAcertSSLCertList(Directive):
158 """
159 The sslcertlist directive implementation
160 """
161 def run(self):
162 return [sslcertlist_node()]
163
164
165 class CAcertSSHKeys(Directive):
166 """
167 The sshkeys directive implementation that can be used to specify the ssh
168 host keys for a host.
169 """
170 option_spec = {
171 keytype.lower(): md5_fingerprint for keytype in SUPPORTED_SSH_KEYTYPES
172 }
173 def run(self):
174 if len(self.options) == 0:
175 raise self.error(
176 "at least one ssh key fingerprint must be specified. The "
177 "following formats are supported: %s" % ", ".join(
178 SUPPORTED_SSH_KEYTYPES))
179 sshkeys = sshkeys_node()
180 sshkeys.attributes['keys'] = self.options.copy()
181 set_source_info(self, sshkeys)
182
183 env = self.state.document.settings.env
184 secid = 'sshkeys-%s' % env.new_serialno('sshkeys')
185
186 section = nodes.section(ids=[secid])
187 section += nodes.title(text='SSH host keys')
188 section += sshkeys
189 return [section]
190
191
192 class CAcertSSHKeyList(Directive):
193 """
194 The sshkeylist directive implementation
195 """
196 def run(self):
197 return [sshkeylist_node()]
198
199
200 def create_table_row(rowdata):
201 row = nodes.row()
202 for cell in rowdata:
203 entry = nodes.entry()
204 row += entry
205 entry += cell
206 return row
207
208
209 def _sslcert_item_key(item):
210 return "%s-%d" % (item['cn'], item['serial'])
211
212
213 def _sshkeys_item_key(item):
214 return "%s" % os.path.basename(item['docname'])
215
216
217 def _build_cert_anchor_name(cn, serial):
218 return 'cert_%s_%d' % (cn.replace('.', '_'), serial)
219
220
221 def _format_subject_alternative_names(altnames):
222 return nodes.paragraph(text=", ".join(
223 [content for _, content in altnames]
224 ))
225
226
227 def _place_sort_key(place):
228 return "%s-%d" % (place['docname'], place['lineno'])
229
230
231 def _file_ref_paragraph(cert_info, filekey, app, env, docname):
232 para = nodes.paragraph()
233
234 places = [place for place in cert_info['places'] if place['primary']]
235 places.extend(sorted([
236 place for place in cert_info['places'] if not place['primary']],
237 key=_place_sort_key))
238
239 for pos in range(len(places)):
240 place = places[pos]
241 title = env.titles[place['docname']].astext().lower()
242 if place['primary'] and len(places) > 1:
243 reftext = nodes.strong(text=title)
244 else:
245 reftext = nodes.Text(title)
246 para += make_refnode(
247 app.builder, docname, place['docname'], place['target']['ids'][0],
248 reftext)
249 para += nodes.Text(":")
250 para += addnodes.literal_emphasis(text=place[filekey])
251 if pos + 1 < len(places):
252 para += nodes.Text(", ")
253 return para
254
255
256 def _format_serial_number(serial):
257 return nodes.paragraph(text="%d (0x%0x)" % (serial, serial))
258
259
260 def _format_expiration_date(expiration):
261 return nodes.paragraph(text=expiration)
262
263
264 def _format_fingerprint(fingerprint):
265 para = nodes.paragraph()
266 para += nodes.literal(text=fingerprint, classes=['fingerprint'])
267 return para
268
269
270 def _get_cert_index_text(cert_info):
271 return "Certificate; %s" % cert_info['cn']
272
273
274 def _get_formatted_keyentry(keys_info, algorithm):
275 entry = nodes.entry()
276 algkey = algorithm.lower()
277 if algkey in keys_info:
278 para = nodes.paragraph()
279 keyfp = nodes.literal(text=keys_info[algkey])
280 para += keyfp
281 else:
282 para = nodes.paragraph(text="-")
283 entry += para
284 return entry
285
286
287 def process_sslcerts(app, doctree):
288 env = app.builder.env
289 if not hasattr(env, 'cacert_sslcerts'):
290 env.cacert_sslcerts = []
291
292 for node in doctree.traverse(sslcertlist_node):
293 if hasattr(env, 'cacert_certlistdoc'):
294 raise SphinxError(
295 "There must be one sslcertlist directive present in "
296 "the document tree only.")
297 env.cacert_certlistdoc = env.docname
298
299 for node in doctree.traverse(sslcert_node):
300 try:
301 targetnode = node.parent[node.parent.index(node) - 1]
302 if not isinstance(targetnode, nodes.target):
303 raise IndexError
304 except IndexError:
305 targetnode = None
306 certdata = node.attributes['certdata'].copy()
307 existing = [
308 cert_info for cert_info in env.cacert_sslcerts
309 if (cert_info['cn'], cert_info['serial']) ==
310 (certdata['cn'], certdata['serial'])
311 ]
312 place_info = {
313 'docname': env.docname,
314 'lineno': node.line,
315 'certfile': certdata['certfile'],
316 'keyfile': certdata['keyfile'],
317 'primary': 'secondary' not in certdata,
318 'target': targetnode,
319 }
320 if existing:
321 info = existing[0]
322 else:
323 info = {
324 'cn': certdata['cn'],
325 'serial': certdata['serial'],
326 'places': [],
327 }
328 env.cacert_sslcerts.append(info)
329 info['places'].append(place_info)
330 if 'sha1fp' in certdata:
331 info['sha1fp'] = certdata['sha1fp']
332 if 'issuer' in certdata:
333 info['issuer'] = certdata['issuer']
334 if 'expiration' in certdata:
335 info['expiration'] = certdata['expiration']
336 if 'altnames' in certdata:
337 info['altnames'] = certdata['altnames'].copy()
338 indexnode = addnodes.index(entries=[
339 ('pair', _get_cert_index_text(info), targetnode['ids'][0],
340 '', None)
341 ])
342
343 bullets = nodes.bullet_list()
344 certitem = nodes.list_item()
345 bullets += certitem
346 certpara = nodes.paragraph()
347 certpara += nodes.Text('Certificate for CN %s, see ' % certdata['cn'])
348 refid = _build_cert_anchor_name(certdata['cn'], certdata['serial'])
349 detailref = addnodes.pending_xref(
350 reftype='certlistref', refdoc=env.docname, refid=refid,
351 reftarget='certlist'
352 )
353 detailref += nodes.Text("details in the certificate list")
354 certpara += detailref
355 certitem += certpara
356
357 subbullets = nodes.bullet_list()
358 bullets += subbullets
359 item = nodes.list_item()
360 subbullets += item
361 certfile = nodes.paragraph(text="certificate in file ")
362 certfile += addnodes.literal_emphasis(text=certdata['certfile']) #, node.line)
363 item += certfile
364 item = nodes.list_item()
365 subbullets += item
366 keyfile = nodes.paragraph(text="private key in file ")
367 keyfile += addnodes.literal_emphasis(text=certdata['keyfile'])
368 #keyfile += _create_interpreted_file_node(
369 # certdata['keyfile'], node.line)
370 item += keyfile
371
372 node.parent.replace_self([targetnode, indexnode, bullets])
373 #env.note_indexentries_from(env.docname, doctree)
374
375
376 def process_sshkeys(app, doctree):
377 env = app.builder.env
378 if not hasattr(env, 'cacert_sshkeys'):
379 env.cacert_sshkeys = []
380
381 for _ in doctree.traverse(sshkeylist_node):
382 if hasattr(env, 'cacert_sshkeylistdoc'):
383 raise SphinxError(
384 "There must be one sshkeylist directive present in "
385 "the document tree only.")
386 env.cacert_sshkeylistdoc = env.docname
387
388 for node in doctree.traverse(sshkeys_node):
389 # find section
390 section = [s for s in traverse_parent(node, nodes.section)][0]
391 dockeys = {'docname': env.docname, 'secid': section['ids'][0]}
392 dockeys.update(node['keys'])
393 env.cacert_sshkeys.append(dockeys)
394
395 secparent = section.parent
396 pos = secparent.index(section)
397 # add index node for section
398 indextitle = 'SSH host key; %s' % (
399 env.docname in env.titles and env.titles[env.docname].astext()
400 or os.path.basename(env.docname)
401 )
402 secparent.insert(pos, addnodes.index(entries=[
403 ('pair', indextitle, section['ids'][0], '', None),
404 ]))
405
406 # add table
407 content = []
408 table = nodes.table()
409 content.append(table)
410 cols = (1, 4)
411 tgroup = nodes.tgroup(cols=len(cols))
412 table += tgroup
413 for col in cols:
414 tgroup += nodes.colspec(colwidth=col)
415 thead = nodes.thead()
416 tgroup += thead
417 thead += create_table_row([
418 nodes.paragraph(text='Algorithm'),
419 nodes.paragraph(text='Fingerprint'),
420 ])
421 tbody = nodes.tbody()
422 tgroup += tbody
423 for alg in SUPPORTED_SSH_KEYTYPES:
424 if alg.lower() in dockeys:
425 fpparagraph = nodes.paragraph()
426 fpparagraph += nodes.literal(text=dockeys[alg.lower()])
427 else:
428 fpparagraph = nodes.paragraph(text='-')
429 tbody += create_table_row([
430 nodes.paragraph(text=alg),
431 fpparagraph,
432 ])
433 # add pending_xref for link to ssh key list
434 seealso = addnodes.seealso()
435 content.append(seealso)
436 detailref = addnodes.pending_xref(
437 reftype='sshkeyref', refdoc=env.docname, refid='sshkeylist',
438 reftarget='sshkeylist'
439 )
440 detailref += nodes.Text("SSH host key list")
441 seepara = nodes.paragraph()
442 seepara += detailref
443 seealso += seepara
444
445 node.replace_self(content)
446
447
448 def process_sslcert_nodes(app, doctree, docname):
449 env = app.builder.env
450
451 if not hasattr(env, 'cacert_sslcerts'):
452 env.cacert_sslcerts = []
453
454 for node in doctree.traverse(sslcertlist_node):
455 content = []
456
457 for cert_info in sorted(env.cacert_sslcerts, key=_sslcert_item_key):
458 primarycount = len([
459 place for place in cert_info['places'] if place['primary']
460 ])
461 if primarycount != 1:
462 raise SphinxError(
463 "There must be exactly one primary place for a "
464 "certificate, but the certificate for CN %s with "
465 "serial number %d has %d" %
466 (cert_info['cn'], cert_info['serial'], primarycount)
467 )
468 cert_sec = nodes.section()
469 cert_sec['ids'].append(
470 _build_cert_anchor_name(cert_info['cn'],
471 cert_info['serial'])
472 )
473 cert_sec += nodes.title(text=cert_info['cn'])
474 indexnode = addnodes.index(entries=[
475 ('pair', _get_cert_index_text(cert_info),
476 cert_sec['ids'][0], '', None),
477 ])
478 content.append(indexnode)
479 table = nodes.table()
480 cert_sec += table
481 tgroup = nodes.tgroup(cols=2)
482 table += tgroup
483 tgroup += nodes.colspec(colwidth=1)
484 tgroup += nodes.colspec(colwidth=5)
485 tbody = nodes.tbody()
486 tgroup += tbody
487 tbody += create_table_row([
488 nodes.paragraph(text='Common Name'),
489 nodes.paragraph(text=cert_info['cn'])
490 ])
491 if 'altnames' in cert_info:
492 tbody += create_table_row([
493 nodes.paragraph(text='Subject Alternative Names'),
494 _format_subject_alternative_names(
495 cert_info['altnames'])
496 ])
497 tbody += create_table_row([
498 nodes.paragraph(text='Key kept at'),
499 _file_ref_paragraph(cert_info, 'keyfile', app, env, docname)
500 ])
501 tbody += create_table_row([
502 nodes.paragraph(text='Cert kept at'),
503 _file_ref_paragraph(cert_info, 'certfile', app, env, docname)
504 ])
505 tbody += create_table_row([
506 nodes.paragraph(text='Serial number'),
507 _format_serial_number(cert_info['serial'])
508 ])
509 tbody += create_table_row([
510 nodes.paragraph(text='Expiration date'),
511 _format_expiration_date(cert_info['expiration'])
512 ])
513 tbody += create_table_row([
514 nodes.paragraph(text='Issuer'),
515 nodes.paragraph(text=cert_info['issuer'])
516 ])
517 tbody += create_table_row([
518 nodes.paragraph(text='SHA1 fingerprint'),
519 _format_fingerprint(cert_info['sha1fp'])
520 ])
521 content.append(cert_sec)
522
523 node.replace_self(content)
524 #env.note_indexentries_from(docname, doctree)
525
526
527 def process_sshkeys_nodes(app, doctree, docname):
528 env = app.builder.env
529
530 if not hasattr(env, 'cacert_sshkeys'):
531 env.cacert_sslcerts = []
532
533 for node in doctree.traverse(sshkeylist_node):
534 content = []
535 content.append(nodes.target(ids=['sshkeylist']))
536
537 if len(env.cacert_sshkeys) > 0:
538 table = nodes.table()
539 content.append(table)
540 tgroup = nodes.tgroup(cols=3)
541 tgroup += nodes.colspec(colwidth=1)
542 tgroup += nodes.colspec(colwidth=1)
543 tgroup += nodes.colspec(colwidth=4)
544 table += tgroup
545
546 thead = nodes.thead()
547 row = nodes.row()
548 entry = nodes.entry()
549 entry += nodes.paragraph(text="Host")
550 row += entry
551 entry = nodes.entry(morecols=1)
552 entry += nodes.paragraph(text="SSH Host Keys")
553 row += entry
554 thead += row
555 tgroup += thead
556
557 tbody = nodes.tbody()
558 tgroup += tbody
559
560 for keys_info in sorted(env.cacert_sshkeys, key=_sshkeys_item_key):
561 trow = nodes.row()
562 entry = nodes.entry(morerows=len(SUPPORTED_SSH_KEYTYPES))
563 para = nodes.paragraph()
564 para += make_refnode(
565 app.builder, docname, keys_info['docname'],
566 keys_info['secid'],
567 nodes.Text(env.titles[keys_info['docname']].astext())
568 )
569 entry += para
570 trow += entry
571
572 entry = nodes.entry()
573 para = nodes.paragraph()
574 para += nodes.strong(text='Algorithm')
575 entry += para
576 trow += entry
577
578 entry = nodes.entry()
579 para = nodes.paragraph()
580 para += nodes.strong(text='SSH host key MD5 fingerprint')
581 entry += para
582 trow += entry
583
584 tbody += trow
585
586 for algorithm in SUPPORTED_SSH_KEYTYPES:
587 trow = nodes.row()
588
589 entry = nodes.entry()
590 entry += nodes.paragraph(text=algorithm)
591 trow += entry
592
593 trow += _get_formatted_keyentry(keys_info, algorithm)
594 tbody += trow
595 else:
596 content.append(nodes.paragraph(
597 text="No ssh keys have been documented.")
598 )
599
600 node.replace_self(content)
601
602
603 def resolve_missing_reference(app, env, node, contnode):
604 if node['reftype'] == 'certlistref':
605 if hasattr(env, 'cacert_certlistdoc'):
606 return make_refnode(
607 app.builder, node['refdoc'], env.cacert_certlistdoc,
608 node['refid'], contnode)
609 raise SphinxError('No certlist directive found in the document tree')
610 if node['reftype'] == 'sshkeyref' :
611 if hasattr(env, 'cacert_sshkeylistdoc'):
612 return make_refnode(
613 app.builder, node['refdoc'], env.cacert_sshkeylistdoc,
614 node['refid'], contnode)
615 raise SphinxError('No sshkeylist directive found in the document tree')
616
617
618 def purge_sslcerts(app, env, docname):
619 if (
620 hasattr(env, 'cacert_certlistdoc') and
621 env.cacert_certlistdoc == docname
622 ):
623 delattr(env, 'cacert_certlistdoc')
624 if not hasattr(env, 'cacert_sslcerts'):
625 return
626 for cert_info in env.cacert_sslcerts:
627 cert_info['places'] = [
628 place for place in cert_info['places']
629 if place['docname'] != docname
630 ]
631
632
633 def purge_sshkeys(app, env, docname):
634 if (
635 hasattr(env, 'cacert_sshkeylistdoc') and
636 env.cacert_sshkeylistdoc == docname
637 ):
638 delattr(env, 'cacert_sshkeylistdoc')
639 if not hasattr(env, 'cacert_sshkeys'):
640 return
641 env.cacert_sshkeys = [
642 keys for keys in env.cacert_sshkeys if keys['docname'] != docname
643 ]
644
645
646 def setup(app):
647 app.add_node(sslcertlist_node)
648 app.add_node(sslcert_node)
649 app.add_node(sshkeylist_node)
650 app.add_node(sshkeys_node)
651
652 app.add_directive('sslcert', CAcertSSLCert)
653 app.add_directive('sslcertlist', CAcertSSLCertList)
654 app.add_directive('sshkeys', CAcertSSHKeys)
655 app.add_directive('sshkeylist', CAcertSSHKeyList)
656
657 app.connect('doctree-read', process_sslcerts)
658 app.connect('doctree-read', process_sshkeys)
659 app.connect('doctree-resolved', process_sslcert_nodes)
660 app.connect('doctree-resolved', process_sshkeys_nodes)
661 app.connect('missing-reference', resolve_missing_reference)
662 app.connect('env-purge-doc', purge_sslcerts)
663 app.connect('env-purge-doc', purge_sshkeys)
664 return {'version': __version__}