routine.py 15.4 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
7

import os
import sys
import datetime
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
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
# 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.
# clef1 est désactivé INTERVAL après que clef2 est activé,
# clef2 est supprimé INTRERVAL après sa désactivation.
# INTERVAL DOIT être supérieur aux plus long TTL que les enregistrement DS peuvent avoir.
# Cela dépent essentiellement de la configuration de la zone parente et vous n'avez pas forcement
# de controle dessus.
INTERVAL = datetime.timedelta(days=14)
# 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
34
35
36
37
38

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
39
            if os.path.isdir(os.path.join(BASE, f)) and not f.startswith('.'):
Valentin Samir's avatar
Valentin Samir committed
40
41
42
43
44
45
46
                l.append(Zone(f))
    else:
        for name in zone_names:
            l.append(Zone(name))
    return l

def settime(path, flag, date):
47
    cmd = ["/usr/sbin/dnssec-settime", "-i", str(int(INTERVAL.total_seconds())), "-%s" % flag, date, path]
48
    p = subprocess.Popen(cmd, stderr=subprocess.PIPE, stdout=subprocess.PIPE)
Valentin Samir's avatar
Valentin Samir committed
49
50
51
52
    err = p.communicate()[1]
    if p.returncode != 0:
        raise ValueError("err %s: %s" % (p.returncode, err))
    if err:
53
        sys.stderr.write("%s\n" % err)
Valentin Samir's avatar
Valentin Samir committed
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94

def bind_chown(path):
    os.chown(path, 104, -1)
    for root, dirs, files in os.walk(path):  
        for momo in dirs:  
            os.chown(os.path.join(root, momo), 104, -1)
        for momo in files:
            os.chown(os.path.join(root, momo), 104, -1)

def bind_reload():
    cmd = ["/usr/sbin/rndc", "reload"]
    p = subprocess.Popen(cmd)
    p.wait()

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:
95
96
                inactive = zsk.activate + INTERVAL
                zsk.delete = inactive + INTERVAL
Valentin Samir's avatar
Valentin Samir committed
97
98
99
100
101
                zsk.inactive = inactive
        if zsk.is_activate:
            zsk.gen_successor()
            bind_reload()

102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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
            new_ksk.activate = (now + INTERVAL)
            bind_reload()
        active_ksk = [ksk for ksk in self.KSK if ksk.is_publish and ksk.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)

    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()
        for ksk in old_ksks:
            print " * program key %s removal" % ksk.keyid
128
            inactive = max(seen_ksk.activate, now + INTERVAL)
129
            # delete INTERVAL after being inactive
130
            ksk.delete = inactive + INTERVAL
131
            # set inactive in at least INTERVAL
132
            ksk.inactive = inactive
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
        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
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
    def ds(self):
        for ksk in self.KSK:
            cmd = ["/usr/sbin/dnssec-dsfromkey", ksk._path]
            p = subprocess.Popen(cmd)
            p.wait()
    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)
168
            if os.path.isfile(file_path) and file_path.endswith(".private"):
Valentin Samir's avatar
Valentin Samir committed
169
170
171
172
173
174
175
176
               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")
177
178
               except ValueError as error:
                   sys.stderr.write("%s\n" % error)
Valentin Samir's avatar
Valentin Samir committed
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
        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))

@total_ordering
class Key(object):
    _created = None
    _publish = None
    _activate = None
    _inactive = None
    _delete = None
    _data = None
    _path = None
    type = None
    keyid = None
197
198
    flag = None
    zone_name = None
Valentin Samir's avatar
Valentin Samir committed
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219

    def __str__(self):
        return self._data

    def __repr__(self):
        r = os.path.basename(self._path)
        return r
        
    def _date_from_key(self, date):
        if date is not None:
            return datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
    def _date_to_key(self, date):
        return datetime.datetime.strftime(date, "%Y%m%d%H%M%S")

    @classmethod
    def create(cls, typ, name):
        path = os.path.join(BASE, name)
        if typ == "KSK":
            cmd = ["/usr/sbin/dnssec-keygen", "-a", "RSASHA256", "-b", "2048", "-f", "KSK", "-K", path,  name]
        elif typ == "ZSK":
            cmd = ["/usr/sbin/dnssec-keygen", "-a", "RSASHA256", "-b", "1024", "-K", path,  name]
220
221
        else:
            raise ValueError("typ must be KSK or ZSK")
Valentin Samir's avatar
Valentin Samir committed
222
223
224
225
226
227
        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)
228
        return cls(os.path.join(path, "%s.private" % keyname))
Valentin Samir's avatar
Valentin Samir committed
229
230
          
    def gen_successor(self):
231
        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
232
233
234
235
236
237
238
239
240
241
        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):
242
243
        if self._created is not None:
            return self._date_from_key(self._created)
Valentin Samir's avatar
Valentin Samir committed
244
245
246

    @property
    def publish(self):
247
248
        if self._publish is not None:
            return self._date_from_key(self._publish)
Valentin Samir's avatar
Valentin Samir committed
249
250
251
252
253
254
255
256
257
258
259
    @publish.setter
    def publish(self, value):
        date = self._date_to_key(value)
        if date != self._publish:
            settime(self._path, 'P', date)
            self._publish = date
            with open(self._path, 'r') as f:   
                self._data = f.read()

    @property
    def activate(self):
260
261
        if self._activate is not None:
            return self._date_from_key(self._activate)
Valentin Samir's avatar
Valentin Samir committed
262
263
264
265
266
267
268
269
270
271
272
    @activate.setter
    def activate(self, value):
        date = self._date_to_key(value)
        if date != self._activate:
            settime(self._path, 'A', date)
            self._activate = date
            with open(self._path, 'r') as f:   
                self._data = f.read()

    @property
    def inactive(self):
273
274
        if self._inactive is not None:
            return self._date_from_key(self._inactive)
Valentin Samir's avatar
Valentin Samir committed
275
276
277
278
279
280
281
282
283
284
285
    @inactive.setter
    def inactive(self, value):
        date = self._date_to_key(value)
        if date != self._inactive:
            settime(self._path, 'I', date)
            self._inactive = date
            with open(self._path, 'r') as f:   
                self._data = f.read()

    @property
    def delete(self):
286
287
        if self._delete:
            return self._date_from_key(self._delete)
Valentin Samir's avatar
Valentin Samir committed
288
289
290
291
292
293
294
295
296
    @delete.setter
    def delete(self, value):
        date = self._date_to_key(value)
        if date != self._delete:
            settime(self._path, 'D', date)
            self._delete = date
            with open(self._path, 'r') as f:   
                self._data = f.read()

297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
    @property
    def is_publish(self):
        return self.publish is not None and self.publish <= datetime.datetime.utcnow()
    @property
    def is_activate(self):
        return self.activate is not None and self.activate <= datetime.datetime.utcnow()
    @property
    def is_inactive(self):
        return self.inactive is not None and self.inactive <= datetime.datetime.utcnow()
    @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
319
    def __init__(self, path):
320
321
322
323
324
325
326
327
        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
328
            self._data = f.read()
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
        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:
                raise ValueError("La clef publique %s devrait avoir au moins 7 champs: %r" % (ppath, line))
            if not line[0].endswith('.'):
                raise ValueError("La clef publique %s devrait commencer par le fqdn (finissant par un .) de la zone" % ppath)
            self.zone_name = line[0][:-1]
            try:
                self.flag = int(line[3])
            except ValueError:
                raise ValueError("Le flag %s de la clef publique %s devrait être un entier" % (line[3], ppath))
        if self.flag == 256:
Valentin Samir's avatar
Valentin Samir committed
346
            self.type = "ZSK"
347
        elif self.flag == 257:
Valentin Samir's avatar
Valentin Samir committed
348
349
            self.type = "KSK"
        else:
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
            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
376
377
378
379

    def __lt__(self, y):
       if not isinstance(y, Key):
           raise ValueError("can only compare two Keys")
380
381
382
383
384
385
       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
386
387
388
389
390
391

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

if __name__ == '__main__':
    try:
Valentin Samir's avatar
Valentin Samir committed
392
393
394
395
396
397
        parser = argparse.ArgumentParser()
        parser.add_argument('zone', nargs='*', help='zone name')
        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')
398
        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')
Valentin Samir's avatar
Valentin Samir committed
399
400
401
        args = parser.parse_args()
        zones = args.zone
        if args.make:
Valentin Samir's avatar
Valentin Samir committed
402
403
404
            for zone in zones:
                Zone.create(zone)
        zones = get_zones(zones if zones else None)
405
406
407
408
409
410
        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
411
        if args.cron:
Valentin Samir's avatar
Valentin Samir committed
412
413
            for zone in zones:
                zone.do_zsk()
414
415
                zone.do_ksk()
                zone.remove_deleted()
Valentin Samir's avatar
Valentin Samir committed
416
        if args.ds:
Valentin Samir's avatar
Valentin Samir committed
417
418
            for zone in zones:
                zone.ds()
Valentin Samir's avatar
Valentin Samir committed
419
        if args.key:
Valentin Samir's avatar
Valentin Samir committed
420
421
            for zone in zones:
                zone.key()
422
        if not any([args.make, args.cron, args.ds, args.key, args.ds_seen]):
Valentin Samir's avatar
Valentin Samir committed
423
            parser.print_help()
Valentin Samir's avatar
Valentin Samir committed
424
425
426
    except ValueError as error:
        sys.stderr.write("%s\n" % error)
        sys.exit(1)