routine.py 18.5 KB
Newer Older
Valentin Samir's avatar
Valentin Samir committed
1
#!/usr/bin/env python
2
# -*- coding: utf8 -*-
Valentin Samir's avatar
Valentin Samir committed
3
4
5
6

import os
import sys
import datetime
Valentin Samir's avatar
style    
Valentin Samir committed
7
import subprocess
Valentin Samir's avatar
Valentin Samir committed
8
import argparse
Valentin Samir's avatar
Valentin Samir committed
9
10
from functools import total_ordering

11

Valentin Samir's avatar
Valentin Samir committed
12
BASE = "/etc/bind/keys"
13
14
15
# Interval entre 2 opérations sur les clefs dns.
# Par exemple si vous avec la clef1 d'utilisé,
# clef2 est publié INTERVAL avant la désactivation de clef1.
Valentin Samir's avatar
Valentin Samir committed
16
# clef1 est désactivé quand clef2 est activé,
17
18
# clef2 est supprimé INTRERVAL après sa désactivation.
# INTERVAL DOIT être supérieur aux plus long TTL que les enregistrement DS peuvent avoir.
Valentin Samir's avatar
Valentin Samir committed
19
# INTERVAL DOIT egalement être supérieur a l'intervale de signature de bind (défaut de 22.5 jours)
20
21
# Cela dépent essentiellement de la configuration de la zone parente et vous n'avez pas forcement
# de controle dessus.
Valentin Samir's avatar
Valentin Samir committed
22
INTERVAL = datetime.timedelta(days=23)
23
24
25
26
27
28
29
30
31
32
33
34
# Durée au bout de laquelle une ZSK est remplacé par une nouvelle ZSK.
# La génération des ZSK et leur activation/désactivation/suppression est géré
# automatiquement tant que routine.py -c est appelé au moins une fois par
# jour.
ZSK_VALIDITY = datetime.timedelta(days=30)  # ~1 mois
# Temps au bout duquelle une nouvelle KSK est généré et publié pour la zone
# (et activé après INTERVAL). L'ancienne clef n'est retiré que INTERVAL après que la nouvelle
# clef a été routine.py --ds-seen. Cela demande en général une opération
# manuelle avec le registrar (publier le DS de la nouvelle clef dans la zone parente)
# et routine.py -c affiche un message tant que cela n'a pas été fait.
KSK_VALIDITY = datetime.timedelta(days=366)  # ~1 an

Valentin Samir's avatar
Valentin Samir committed
35
36
37
38
39

def get_zones(zone_names=None):
    l = []
    if zone_names is None:
        for f in os.listdir(BASE):
Valentin Samir's avatar
Valentin Samir committed
40
            if os.path.isdir(os.path.join(BASE, f)) and not f.startswith('.'):
Valentin Samir's avatar
Valentin Samir committed
41
42
43
44
45
46
                l.append(Zone(f))
    else:
        for name in zone_names:
            l.append(Zone(name))
    return l

Valentin Samir's avatar
style    
Valentin Samir committed
47

Valentin Samir's avatar
Valentin Samir committed
48
def settime(path, flag, date):
Valentin Samir's avatar
style    
Valentin Samir committed
49
50
51
52
53
    cmd = [
        "/usr/sbin/dnssec-settime",
        "-i", str(int(INTERVAL.total_seconds())),
        "-%s" % flag, date, path
    ]
54
    p = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
Valentin Samir's avatar
Valentin Samir committed
55
56
57
58
    err = p.communicate()[1]
    if p.returncode != 0:
        raise ValueError("err %s: %s" % (p.returncode, err))
    if err:
59
        sys.stderr.write("%s\n" % err)
Valentin Samir's avatar
Valentin Samir committed
60

Valentin Samir's avatar
style    
Valentin Samir committed
61

Valentin Samir's avatar
Valentin Samir committed
62
63
def bind_chown(path):
    os.chown(path, 104, -1)
Valentin Samir's avatar
style    
Valentin Samir committed
64
65
    for root, dirs, files in os.walk(path):
        for momo in dirs:
Valentin Samir's avatar
Valentin Samir committed
66
67
68
69
            os.chown(os.path.join(root, momo), 104, -1)
        for momo in files:
            os.chown(os.path.join(root, momo), 104, -1)

Valentin Samir's avatar
style    
Valentin Samir committed
70

Valentin Samir's avatar
Valentin Samir committed
71
72
73
74
75
def bind_reload():
    cmd = ["/usr/sbin/rndc", "reload"]
    p = subprocess.Popen(cmd)
    p.wait()

Valentin Samir's avatar
style    
Valentin Samir committed
76

Valentin Samir's avatar
Valentin Samir committed
77
78
79
80
81
82
83
84
85
def nsec3(zone, salt="-"):
    cmd = ["rndc", "signing", "-nsec3param", "1", "0", "10", salt, zone]
    sys.stdout.write("Enabling nsec3 for zone %s: " % zone)
    p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
    out = p.communicate()[0]
    sys.stdout.write(out)
    p.wait()


Valentin Samir's avatar
Valentin Samir committed
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
class Zone(object):
    ZSK = None
    KSK = None
    _path = None
    name = None

    def __str__(self):
        return self.name

    def __unicode__(self):
        return self.name.decode("utf-8")

    def __repr__(self):
        return "Zone %s" % self.name

    @classmethod
    def create(cls, name):
        path = os.path.join(BASE, name)
        if os.path.isdir(path):
            raise ValueError("%s existe" % path)
        os.mkdir(path)
        bind_chown(path)
        return cls(name)

    def do_zsk(self):
        for zsk in self.ZSK:
            if zsk.is_activate:
113
114
                zsk.inactive = zsk.activate + ZSK_VALIDITY
                zsk.delete = zsk.inactive + INTERVAL
115
        now = datetime.datetime.utcnow()
Valentin Samir's avatar
Valentin Samir committed
116
        if zsk.is_activate:
117
118
            zsk.inactive = max(zsk.inactive, now + INTERVAL)
            zsk.delete = zsk.inactive + INTERVAL
Valentin Samir's avatar
Valentin Samir committed
119
120
121
            zsk.gen_successor()
            bind_reload()

122
123
124
125
126
127
    def do_ksk(self):
        ksk = self.KSK[-1]
        if ksk.need_renew:
            now = datetime.datetime.utcnow()
            new_ksk = Key.create("KSK", self.name)
            new_ksk.publish = now
128
129
            # do not activate the new key until ds-seen
            new_ksk.activate = None
130
            bind_reload()
Valentin Samir's avatar
style    
Valentin Samir committed
131
132
133
134
135
136
137
138
        active_ksk = [key for key in self.KSK if key.is_publish and key.delete is None]
        if len(active_ksk) >= 2:
            sys.stderr.write(
                (
                    "New KSK needs DS seen and/or old KSK needs "
                    "inactivate/remove for zone %s\n"
                ) % self.name
            )
139
140
141
142
143
144
145
146
147
148
149
150
151

    def ds_seen(self, keyid):
        old_ksks = []
        for ksk in self.KSK:
            if ksk.keyid == keyid:
                seen_ksk = ksk
                break
            old_ksks.append(ksk)
        else:
            sys.stderr.write("Key not found\n")
            return
        print "Key %s found" % keyid
        now = datetime.datetime.utcnow()
152
153
        if seen_ksk.activate is None:
            seen_ksk.activate = (now + INTERVAL)
154
155
156
        for ksk in old_ksks:
            print " * program key %s removal" % ksk.keyid
            # set inactive in at least INTERVAL
157
158
159
            ksk.inactive = seen_ksk.activate
            # delete INTERVAL after being inactive
            ksk.delete = ksk.inactive + INTERVAL
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
        bind_reload()

    def remove_deleted(self):
        deleted_path = os.path.join(self._path, "deleted")
        try:
            os.mkdir(deleted_path)
        except OSError as error:
            if error.errno != 17:  # File exists
                raise
        for key in self.ZSK + self.KSK:
            if key.is_delete:
                for path in [key._path, key._path_private]:
                    basename = os.path.basename(path)
                    new_path = os.path.join(deleted_path, basename)
                    os.rename(path, new_path)

Valentin Samir's avatar
Valentin Samir committed
176
177
178
179
180
    def ds(self):
        for ksk in self.KSK:
            cmd = ["/usr/sbin/dnssec-dsfromkey", ksk._path]
            p = subprocess.Popen(cmd)
            p.wait()
Valentin Samir's avatar
style    
Valentin Samir committed
181

Valentin Samir's avatar
Valentin Samir committed
182
183
184
185
186
187
188
189
190
191
192
193
194
195
    def key(self):
        for ksk in self.KSK:
            print ksk

    def __init__(self, name):
        path = os.path.join(BASE, name)
        if not os.path.isdir(path):
            raise ValueError("%s n'est pas un dossier" % path)
        self.name = name
        self._path = path
        self.ZSK = []
        self.KSK = []
        for file in os.listdir(path):
            file_path = os.path.join(path, file)
196
            if os.path.isfile(file_path) and file_path.endswith(".private"):
Valentin Samir's avatar
style    
Valentin Samir committed
197
198
199
200
201
202
203
204
205
206
                try:
                    key = Key(file_path)
                    if key.type == "ZSK":
                        self.ZSK.append(key)
                    elif key.type == "KSK":
                        self.KSK.append(key)
                    else:
                        raise RuntimeError("impossible")
                except ValueError as error:
                    sys.stderr.write("%s\n" % error)
Valentin Samir's avatar
Valentin Samir committed
207
208
209
210
211
212
213
        self.ZSK.sort()
        self.KSK.sort()
        if not self.ZSK:
            self.ZSK.append(Key.create("ZSK", name))
        if not self.KSK:
            self.KSK.append(Key.create("KSK", name))

Valentin Samir's avatar
style    
Valentin Samir committed
214

Valentin Samir's avatar
Valentin Samir committed
215
216
217
218
219
220
221
222
223
224
225
@total_ordering
class Key(object):
    _created = None
    _publish = None
    _activate = None
    _inactive = None
    _delete = None
    _data = None
    _path = None
    type = None
    keyid = None
226
227
    flag = None
    zone_name = None
Valentin Samir's avatar
Valentin Samir committed
228
229
230
231
232
233
234

    def __str__(self):
        return self._data

    def __repr__(self):
        r = os.path.basename(self._path)
        return r
Valentin Samir's avatar
style    
Valentin Samir committed
235

Valentin Samir's avatar
Valentin Samir committed
236
237
238
    def _date_from_key(self, date):
        if date is not None:
            return datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
Valentin Samir's avatar
style    
Valentin Samir committed
239

Valentin Samir's avatar
Valentin Samir committed
240
    def _date_to_key(self, date):
241
242
243
244
        if date is None:
            return 'none'
        else:
            return datetime.datetime.strftime(date, "%Y%m%d%H%M%S")
Valentin Samir's avatar
Valentin Samir committed
245

246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
    def _date_check(self, value, needed_date, value_name, needed_date_name):
        if value is not None:
            if needed_date is None or value < needed_date:
                raise RuntimeError(
                    "Cannot set %s date before %s date on key %s on zone %s" % (
                        value_name,
                        needed_date_name,
                        self.keyid,
                        self.zone_name
                     )
                )

    def _date_check2(self, value, needed_date, value_name, needed_date_name):
        msg = "Cannot set %s date after %s date on key %s on zone %s" % (
                  value_name,
                  needed_date_name,
                  self.keyid,
                  self.zone_name
              )
        if value is None and needed_date is not None:
            raise RuntimeError(msg)
        elif value is not None and needed_date is not None:
            if value > needed_date:
                raise RuntimeError(msg)

Valentin Samir's avatar
Valentin Samir committed
271
    @classmethod
272
273
274
    def create(cls, typ, name, options=None):
        if options is None:
            options = []
Valentin Samir's avatar
Valentin Samir committed
275
        path = os.path.join(BASE, name)
276
        cmd = ["/usr/sbin/dnssec-keygen", "-a", "RSASHA256"]
Valentin Samir's avatar
Valentin Samir committed
277
        if typ == "KSK":
278
            cmd.extend(["-b", "2048", "-f", "KSK"])
Valentin Samir's avatar
Valentin Samir committed
279
        elif typ == "ZSK":
280
            cmd.extend(["-b", "1024"])
281
282
        else:
            raise ValueError("typ must be KSK or ZSK")
283
284
        cmd.extend(options)
        cmd.extend(["-K", path,  name])
Valentin Samir's avatar
Valentin Samir committed
285
286
287
288
289
290
        p = subprocess.Popen(cmd, stdout=subprocess.PIPE)
        p.wait()
        if p.returncode != 0:
            raise ValueError("La creation de la clef a echoue")
        keyname = p.communicate()[0].strip()
        bind_chown(path)
291
        return cls(os.path.join(path, "%s.private" % keyname))
Valentin Samir's avatar
style    
Valentin Samir committed
292

Valentin Samir's avatar
Valentin Samir committed
293
    def gen_successor(self):
Valentin Samir's avatar
style    
Valentin Samir committed
294
295
296
297
        cmd = [
            "/usr/sbin/dnssec-keygen", "-i", str(int(INTERVAL.total_seconds())),
            "-S", self._path, "-K", os.path.dirname(self._path)
        ]
Valentin Samir's avatar
Valentin Samir committed
298
299
300
301
302
303
304
305
306
307
        p = subprocess.Popen(cmd, stderr=subprocess.PIPE)
        err = p.communicate()[1]
        if p.returncode != 0:
            raise ValueError("err %s: %s" % (p.returncode, err))
        if err:
            print err
        bind_chown(os.path.dirname(self._path))

    @property
    def created(self):
308
309
        if self._created is not None:
            return self._date_from_key(self._created)
Valentin Samir's avatar
Valentin Samir committed
310
311
312

    @property
    def publish(self):
313
314
        if self._publish is not None:
            return self._date_from_key(self._publish)
Valentin Samir's avatar
Valentin Samir committed
315
316
    @publish.setter
    def publish(self, value):
317
318
        self._date_check(value, self.created, "publish", "created")
        self._date_check2(value, self.activate, "publish", "activate")
Valentin Samir's avatar
Valentin Samir committed
319
320
321
322
        date = self._date_to_key(value)
        if date != self._publish:
            settime(self._path, 'P', date)
            self._publish = date
Valentin Samir's avatar
style    
Valentin Samir committed
323
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
324
325
326
327
                self._data = f.read()

    @property
    def activate(self):
328
329
        if self._activate is not None:
            return self._date_from_key(self._activate)
Valentin Samir's avatar
Valentin Samir committed
330
331
    @activate.setter
    def activate(self, value):
332
333
        self._date_check(value, self.publish, "active", "publish")
        self._date_check2(value, self.inactive, "activate", "inactive")
Valentin Samir's avatar
Valentin Samir committed
334
335
336
337
        date = self._date_to_key(value)
        if date != self._activate:
            settime(self._path, 'A', date)
            self._activate = date
Valentin Samir's avatar
style    
Valentin Samir committed
338
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
339
340
341
342
                self._data = f.read()

    @property
    def inactive(self):
343
344
        if self._inactive is not None:
            return self._date_from_key(self._inactive)
Valentin Samir's avatar
Valentin Samir committed
345
346
    @inactive.setter
    def inactive(self, value):
347
348
        self._date_check(value, self.activate, "inactive", "activate")
        self._date_check2(value, self.delete, "inactive", "delete")
Valentin Samir's avatar
Valentin Samir committed
349
350
351
352
        date = self._date_to_key(value)
        if date != self._inactive:
            settime(self._path, 'I', date)
            self._inactive = date
Valentin Samir's avatar
style    
Valentin Samir committed
353
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
354
355
356
357
                self._data = f.read()

    @property
    def delete(self):
358
359
        if self._delete:
            return self._date_from_key(self._delete)
Valentin Samir's avatar
Valentin Samir committed
360
361
    @delete.setter
    def delete(self, value):
362
        self._date_check(value, self.inactive, "delete", "inactive")
Valentin Samir's avatar
Valentin Samir committed
363
364
365
366
        date = self._date_to_key(value)
        if date != self._delete:
            settime(self._path, 'D', date)
            self._delete = date
Valentin Samir's avatar
style    
Valentin Samir committed
367
            with open(self._path, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
368
369
                self._data = f.read()

370
371
372
    @property
    def is_publish(self):
        return self.publish is not None and self.publish <= datetime.datetime.utcnow()
Valentin Samir's avatar
style    
Valentin Samir committed
373

374
375
376
    @property
    def is_activate(self):
        return self.activate is not None and self.activate <= datetime.datetime.utcnow()
Valentin Samir's avatar
style    
Valentin Samir committed
377

378
379
380
    @property
    def is_inactive(self):
        return self.inactive is not None and self.inactive <= datetime.datetime.utcnow()
Valentin Samir's avatar
style    
Valentin Samir committed
381

382
383
384
385
386
387
388
389
390
391
392
393
394
    @property
    def is_delete(self):
        return self.delete is not None and self.delete <= datetime.datetime.utcnow()

    @property
    def need_renew(self):
        if self.type == "KSK":
            return (self.activate + KSK_VALIDITY) <= (datetime.datetime.utcnow() + INTERVAL)
        elif self.type == "ZSK":
            return (self.activate + ZSK_VALIDITY) <= (datetime.datetime.utcnow() + INTERVAL)
        else:
            raise RuntimeError("impossible")

Valentin Samir's avatar
Valentin Samir committed
395
    def __init__(self, path):
396
397
398
399
400
401
402
403
        if not path.endswith(".private"):
            raise ValueError("%s n'est pas une clef valide" % path)
        if not os.path.isfile(path):
            raise ValueError("%s n'existe pas" % path)
        ppath = "%s.key" % path[:-8]
        if not os.path.isfile(ppath):
            raise ValueError("la clef publique (%s) de %s n'existe pas" % (ppath, path))
        with open(ppath, 'r') as f:
Valentin Samir's avatar
Valentin Samir committed
404
            self._data = f.read()
405
406
407
408
409
410
411
412
        with open(path, 'r') as f:
            private_data = f.read()
        for line in self._data.split("\n"):
            if line.startswith(";") or not line:
                continue
            line = line.split(";", 1)[0].strip()
            line = line.split()
            if len(line) < 7:
Valentin Samir's avatar
style    
Valentin Samir committed
413
414
415
                raise ValueError(
                    "La clef publique %s devrait avoir au moins 7 champs: %r" % (ppath, line)
                )
416
            if not line[0].endswith('.'):
Valentin Samir's avatar
style    
Valentin Samir committed
417
418
419
420
421
422
                raise ValueError(
                    (
                        "La clef publique %s devrait commencer par le fqdn "
                        "(finissant par un .) de la zone"
                    ) % ppath
                )
423
424
425
426
            self.zone_name = line[0][:-1]
            try:
                self.flag = int(line[3])
            except ValueError:
Valentin Samir's avatar
style    
Valentin Samir committed
427
428
429
                raise ValueError(
                    "Le flag %s de la clef publique %s devrait être un entier" % (line[3], ppath)
                )
430
        if self.flag == 256:
Valentin Samir's avatar
Valentin Samir committed
431
            self.type = "ZSK"
432
        elif self.flag == 257:
Valentin Samir's avatar
Valentin Samir committed
433
434
            self.type = "KSK"
        else:
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
            raise ValueError("%s n'est pas une clef valide: flag %s inconnu" % (ppath, self.flag))
        self._path = ppath
        self._path_private = path
        keyid = path.split('.')[-2].split('+')[-1]
        try:
            self.keyid = int(keyid)
        except ValueError:
            raise ValueError("Le keyid %s de la clef %s devrait être un entier" % (keyid, path))
        for line in private_data.split("\n"):
            if line.startswith("Created:"):
                self._created = line[8:].strip()
                self._date_from_key(self._created)
            elif line.startswith("Publish:"):
                self._publish = line[8:].strip()
                self._date_from_key(self._publish)
            elif line.startswith("Activate:"):
                self._activate = line[9:].strip()
                self._date_from_key(self._activate)
            elif line.startswith("Inactive:"):
                self._inactive = line[9:].strip()
                self._date_from_key(self._inactive)
            elif line.startswith("Delete:"):
                self._delete = line[7:].strip()
                self._date_from_key(self._delete)
        if self.created is None:
            raise ValueError("La clef %s doit au moins avoir le champs Created de définit" % path)
Valentin Samir's avatar
Valentin Samir committed
461
462

    def __lt__(self, y):
Valentin Samir's avatar
style    
Valentin Samir committed
463
464
465
466
467
468
469
470
        if not isinstance(y, Key):
            raise ValueError("can only compare two Keys")
        if self.activate is not None and y.activate is not None:
            return self.activate < y.activate
        elif self.publish is not None and y.publish is not None:
            return self.publish < y.publish
        else:
            return self.created < y.created
Valentin Samir's avatar
Valentin Samir committed
471
472
473
474
475
476

    def __eq__(self, y):
        return isinstance(y, Key) and y._path == self._path

if __name__ == '__main__':
    try:
Valentin Samir's avatar
Valentin Samir committed
477
478
        parser = argparse.ArgumentParser()
        parser.add_argument('zone', nargs='*', help='zone name')
Valentin Samir's avatar
style    
Valentin Samir committed
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
        parser.add_argument(
            '--make', '-m',
            action='store_true',
            help='Create keys for each supplied zone'
        )
        parser.add_argument(
            '--cron', '-c',
            action='store_true',
            help='Perform maintenance for each supplied zone or for all zones if no zone supplied'
        )
        parser.add_argument(
            '-ds',
            action='store_true',
            help='Show DS for each supplied zone or for all zones if no zone supplied'
        )
        parser.add_argument(
            '-key',
            action='store_true',
            help='Show DNSKEY for each zone supplied zone or for all zones if no zone supplied'
        )
        parser.add_argument(
            '--ds-seen',
            metavar='KEYID',
            type=int,
            help=(
                'To call with the ID of a new KSK published in the parent zone. '
                'Programs old KSK removal'
            )
        )
        parser.add_argument(
            '--nsec3',
            action='store_true',
            help='Enable NSEC3 for the zones, using a random salt'
        )
Valentin Samir's avatar
Valentin Samir committed
513
514
515
        args = parser.parse_args()
        zones = args.zone
        if args.make:
Valentin Samir's avatar
Valentin Samir committed
516
517
518
            for zone in zones:
                Zone.create(zone)
        zones = get_zones(zones if zones else None)
Valentin Samir's avatar
Valentin Samir committed
519
520
521
        if args.nsec3:
            for zone in zones:
                nsec3(zone.name, os.urandom(24).encode("hex"))
522
523
524
525
526
527
        if args.ds_seen:
            if len(zones) != 1:
                sys.stderr.write("Please specify exactly ONE zone name\n")
                sys.exit(1)
            for zone in zones:
                zone.ds_seen(args.ds_seen)
Valentin Samir's avatar
Valentin Samir committed
528
        if args.cron:
Valentin Samir's avatar
Valentin Samir committed
529
530
            for zone in zones:
                zone.do_zsk()
531
532
                zone.do_ksk()
                zone.remove_deleted()
Valentin Samir's avatar
Valentin Samir committed
533
        if args.ds:
Valentin Samir's avatar
Valentin Samir committed
534
535
            for zone in zones:
                zone.ds()
Valentin Samir's avatar
Valentin Samir committed
536
        if args.key:
Valentin Samir's avatar
Valentin Samir committed
537
538
            for zone in zones:
                zone.key()
Valentin Samir's avatar
Valentin Samir committed
539
        if not any([args.make, args.cron, args.ds, args.key, args.ds_seen, args.nsec3]):
Valentin Samir's avatar
Valentin Samir committed
540
            parser.print_help()
Valentin Samir's avatar
Valentin Samir committed
541
542
543
    except ValueError as error:
        sys.stderr.write("%s\n" % error)
        sys.exit(1)