1import grp
2import json
3import pwd
4import os
5import re
6import string
7import subprocess
8import sys
9
10from contextlib import contextmanager
11from shutil import rmtree
12from tempfile import NamedTemporaryFile
13
14import click
15
16IS_AUTO_CONFIG = @isAutoConfig@ # NOQA
17CERTTOOL_COMMAND = "@certtool@"
18CERT_BITS = "@certBits@"
19CLIENT_EXPIRATION = "@clientExpiration@"
20CRL_EXPIRATION = "@crlExpiration@"
21
22TASKD_COMMAND = "@taskd@"
23TASKD_DATA_DIR = "@dataDir@"
24TASKD_USER = "@user@"
25TASKD_GROUP = "@group@"
26FQDN = "@fqdn@"
27
28CA_KEY = os.path.join(TASKD_DATA_DIR, "keys", "ca.key")
29CA_CERT = os.path.join(TASKD_DATA_DIR, "keys", "ca.cert")
30CRL_FILE = os.path.join(TASKD_DATA_DIR, "keys", "server.crl")
31
32RE_CONFIGUSER = re.compile(r'^\s*user\s*=(.*)$')
33RE_USERKEY = re.compile(r'New user key: (.+)$', re.MULTILINE)
34
35
36def lazyprop(fun):
37 """
38 Decorator which only evaluates the specified function when accessed.
39 """
40 name = '_lazy_' + fun.__name__
41
42 @property
43 def _lazy(self):
44 val = getattr(self, name, None)
45 if val is None:
46 val = fun(self)
47 setattr(self, name, val)
48 return val
49
50 return _lazy
51
52
53class TaskdError(OSError):
54 pass
55
56
57def run_as_taskd_user():
58 uid = pwd.getpwnam(TASKD_USER).pw_uid
59 gid = grp.getgrnam(TASKD_GROUP).gr_gid
60 os.setgid(gid)
61 os.setuid(uid)
62
63
64def taskd_cmd(cmd, *args, **kwargs):
65 """
66 Invoke taskd with the specified command with the privileges of the 'taskd'
67 user and 'taskd' group.
68
69 If 'capture_stdout' is passed as a keyword argument with the value True,
70 the return value are the contents the command printed to stdout.
71 """
72 capture_stdout = kwargs.pop("capture_stdout", False)
73 fun = subprocess.check_output if capture_stdout else subprocess.check_call
74 return fun(
75 [TASKD_COMMAND, cmd, "--data", TASKD_DATA_DIR] + list(args),
76 preexec_fn=run_as_taskd_user,
77 **kwargs
78 )
79
80
81def certtool_cmd(*args, **kwargs):
82 """
83 Invoke certtool from GNUTLS and return the output of the command.
84
85 The provided arguments are added to the certtool command and keyword
86 arguments are added to subprocess.check_output().
87
88 Note that this will suppress all output of certtool and it will only be
89 printed whenever there is an unsuccessful return code.
90 """
91 return subprocess.check_output(
92 [CERTTOOL_COMMAND] + list(args),
93 preexec_fn=lambda: os.umask(0o077),
94 stderr=subprocess.STDOUT,
95 **kwargs
96 )
97
98
99def label(msg):
100 if sys.stdout.isatty() or sys.stderr.isatty():
101 sys.stderr.write(msg + "\n")
102
103
104def mkpath(*args):
105 return os.path.join(TASKD_DATA_DIR, "orgs", *args)
106
107
108def mark_imperative(*path):
109 """
110 Mark the specified path as being imperatively managed by creating an empty
111 file called ".imperative", so that it doesn't interfere with the
112 declarative configuration.
113 """
114 open(os.path.join(mkpath(*path), ".imperative"), 'a').close()
115
116
117def is_imperative(*path):
118 """
119 Check whether the given path is marked as imperative, see mark_imperative()
120 for more information.
121 """
122 full_path = []
123 for component in path:
124 full_path.append(component)
125 if os.path.exists(os.path.join(mkpath(*full_path), ".imperative")):
126 return True
127 return False
128
129
130def fetch_username(org, key):
131 for line in open(mkpath(org, "users", key, "config"), "r"):
132 match = RE_CONFIGUSER.match(line)
133 if match is None:
134 continue
135 return match.group(1).strip()
136 return None
137
138
139@contextmanager
140def create_template(contents):
141 """
142 Generate a temporary file with the specified contents as a list of strings
143 and yield its path as the context.
144 """
145 template = NamedTemporaryFile(mode="w", prefix="certtool-template")
146 template.writelines(map(lambda l: l + "\n", contents))
147 template.flush()
148 yield template.name
149 template.close()
150
151
152def generate_key(org, user):
153 if not IS_AUTO_CONFIG:
154 msg = "Automatic PKI handling is disabled, you need to " \
155 "manually issue a client certificate for user {}.\n"
156 sys.stderr.write(msg.format(user))
157 return
158
159 basedir = os.path.join(TASKD_DATA_DIR, "keys", org, user)
160 if os.path.exists(basedir):
161 raise OSError("Keyfile directory for {} already exists.".format(user))
162
163 privkey = os.path.join(basedir, "private.key")
164 pubcert = os.path.join(basedir, "public.cert")
165
166 try:
167 os.makedirs(basedir, mode=0o700)
168
169 certtool_cmd("-p", "--bits", CERT_BITS, "--outfile", privkey)
170
171 template_data = [
172 "organization = {0}".format(org),
173 "cn = {}".format(FQDN),
174 "expiration_days = {}".format(CLIENT_EXPIRATION),
175 "tls_www_client",
176 "encryption_key",
177 "signing_key"
178 ]
179
180 with create_template(template_data) as template:
181 certtool_cmd(
182 "-c",
183 "--load-privkey", privkey,
184 "--load-ca-privkey", CA_KEY,
185 "--load-ca-certificate", CA_CERT,
186 "--template", template,
187 "--outfile", pubcert
188 )
189 except:
190 rmtree(basedir)
191 raise
192
193
194def revoke_key(org, user):
195 basedir = os.path.join(TASKD_DATA_DIR, "keys", org, user)
196 if not os.path.exists(basedir):
197 raise OSError("Keyfile directory for {} doesn't exist.".format(user))
198
199 pubcert = os.path.join(basedir, "public.cert")
200
201 expiration = "expiration_days = {}".format(CRL_EXPIRATION)
202
203 with create_template([expiration]) as template:
204 oldcrl = NamedTemporaryFile(mode="wb", prefix="old-crl")
205 oldcrl.write(open(CRL_FILE, "rb").read())
206 oldcrl.flush()
207 certtool_cmd(
208 "--generate-crl",
209 "--load-crl", oldcrl.name,
210 "--load-ca-privkey", CA_KEY,
211 "--load-ca-certificate", CA_CERT,
212 "--load-certificate", pubcert,
213 "--template", template,
214 "--outfile", CRL_FILE
215 )
216 oldcrl.close()
217 rmtree(basedir)
218
219
220def is_key_line(line, match):
221 return line.startswith("---") and line.lstrip("- ").startswith(match)
222
223
224def getkey(*args):
225 path = os.path.join(TASKD_DATA_DIR, "keys", *args)
226 buf = []
227 for line in open(path, "r"):
228 if len(buf) == 0:
229 if is_key_line(line, "BEGIN"):
230 buf.append(line)
231 continue
232
233 buf.append(line)
234
235 if is_key_line(line, "END"):
236 return ''.join(buf)
237 raise IOError("Unable to get key from {}.".format(path))
238
239
240def mktaskkey(cfg, path, keydata):
241 heredoc = 'cat > "{}" <<EOF\n{}EOF'.format(path, keydata)
242 cmd = 'task config taskd.{} -- "{}"'.format(cfg, path)
243 return heredoc + "\n" + cmd
244
245
246class User(object):
247 def __init__(self, org, name, key):
248 self.__org = org
249 self.name = name
250 self.key = key
251
252 def export(self):
253 credentials = '/'.join([self.__org, self.name, self.key])
254 allow_unquoted = string.ascii_letters + string.digits + "/-_."
255 if not all((c in allow_unquoted) for c in credentials):
256 credentials = "'" + credentials.replace("'", r"'\''") + "'"
257
258 script = []
259
260 if IS_AUTO_CONFIG:
261 pubcert = getkey(self.__org, self.name, "public.cert")
262 privkey = getkey(self.__org, self.name, "private.key")
263 cacert = getkey("ca.cert")
264
265 keydir = "${TASKDATA:-$HOME/.task}/keys"
266
267 script += [
268 "umask 0077",
269 'mkdir -p "{}"'.format(keydir),
270 mktaskkey("certificate", os.path.join(keydir, "public.cert"),
271 pubcert),
272 mktaskkey("key", os.path.join(keydir, "private.key"), privkey),
273 mktaskkey("ca", os.path.join(keydir, "ca.cert"), cacert)
274 ]
275
276 script.append(
277 "task config taskd.credentials -- {}".format(credentials)
278 )
279
280 return "\n".join(script) + "\n"
281
282
283class Group(object):
284 def __init__(self, org, name):
285 self.__org = org
286 self.name = name
287
288
289class Organisation(object):
290 def __init__(self, name, ignore_imperative):
291 self.name = name
292 self.ignore_imperative = ignore_imperative
293
294 def add_user(self, name):
295 """
296 Create a new user along with a certificate and key.
297
298 Returns a 'User' object or None if the user already exists.
299 """
300 if self.ignore_imperative and is_imperative(self.name):
301 return None
302 if name not in self.users.keys():
303 output = taskd_cmd("add", "user", self.name, name,
304 capture_stdout=True, encoding='utf-8')
305 key = RE_USERKEY.search(output)
306 if key is None:
307 msg = "Unable to find key while creating user {}."
308 raise TaskdError(msg.format(name))
309
310 generate_key(self.name, name)
311 newuser = User(self.name, name, key.group(1))
312 self._lazy_users[name] = newuser
313 return newuser
314 return None
315
316 def del_user(self, name):
317 """
318 Delete a user and revoke its keys.
319 """
320 if name in self.users.keys():
321 user = self.get_user(name)
322 if self.ignore_imperative and \
323 is_imperative(self.name, "users", user.key):
324 return
325
326 # Work around https://bug.tasktools.org/browse/TD-40:
327 rmtree(mkpath(self.name, "users", user.key))
328
329 revoke_key(self.name, name)
330 del self._lazy_users[name]
331
332 def add_group(self, name):
333 """
334 Create a new group.
335
336 Returns a 'Group' object or None if the group already exists.
337 """
338 if self.ignore_imperative and is_imperative(self.name):
339 return None
340 if name not in self.groups.keys():
341 taskd_cmd("add", "group", self.name, name)
342 newgroup = Group(self.name, name)
343 self._lazy_groups[name] = newgroup
344 return newgroup
345 return None
346
347 def del_group(self, name):
348 """
349 Delete a group.
350 """
351 if name in self.users.keys():
352 if self.ignore_imperative and \
353 is_imperative(self.name, "groups", name):
354 return
355 taskd_cmd("remove", "group", self.name, name)
356 del self._lazy_groups[name]
357
358 def get_user(self, name):
359 return self.users.get(name)
360
361 @lazyprop
362 def users(self):
363 result = {}
364 for key in os.listdir(mkpath(self.name, "users")):
365 user = fetch_username(self.name, key)
366 if user is not None:
367 result[user] = User(self.name, user, key)
368 return result
369
370 def get_group(self, name):
371 return self.groups.get(name)
372
373 @lazyprop
374 def groups(self):
375 result = {}
376 for group in os.listdir(mkpath(self.name, "groups")):
377 result[group] = Group(self.name, group)
378 return result
379
380
381class Manager(object):
382 def __init__(self, ignore_imperative=False):
383 """
384 Instantiates an organisations manager.
385
386 If ignore_imperative is True, all actions that modify data are checked
387 whether they're created imperatively and if so, they will result in no
388 operation.
389 """
390 self.ignore_imperative = ignore_imperative
391
392 def add_org(self, name):
393 """
394 Create a new organisation.
395
396 Returns an 'Organisation' object or None if the organisation already
397 exists.
398 """
399 if name not in self.orgs.keys():
400 taskd_cmd("add", "org", name)
401 neworg = Organisation(name, self.ignore_imperative)
402 self._lazy_orgs[name] = neworg
403 return neworg
404 return None
405
406 def del_org(self, name):
407 """
408 Delete and revoke keys of an organisation with all its users and
409 groups.
410 """
411 org = self.get_org(name)
412 if org is not None:
413 if self.ignore_imperative and is_imperative(name):
414 return
415 for user in list(org.users.keys()):
416 org.del_user(user)
417 for group in list(org.groups.keys()):
418 org.del_group(group)
419 taskd_cmd("remove", "org", name)
420 del self._lazy_orgs[name]
421
422 def get_org(self, name):
423 return self.orgs.get(name)
424
425 @lazyprop
426 def orgs(self):
427 result = {}
428 for org in os.listdir(mkpath()):
429 result[org] = Organisation(org, self.ignore_imperative)
430 return result
431
432
433class OrganisationType(click.ParamType):
434 name = 'organisation'
435
436 def convert(self, value, param, ctx):
437 org = Manager().get_org(value)
438 if org is None:
439 self.fail("Organisation {} does not exist.".format(value))
440 return org
441
442ORGANISATION = OrganisationType()
443
444
445@click.group()
446@click.pass_context
447def cli(ctx):
448 """
449 Manage Taskserver users and certificates
450 """
451 if not IS_AUTO_CONFIG:
452 return
453 for path in (CA_KEY, CA_CERT, CRL_FILE):
454 if not os.path.exists(path):
455 msg = "CA setup not done or incomplete, missing file {}."
456 ctx.fail(msg.format(path))
457
458
459@cli.group("org")
460def org_cli():
461 """
462 Manage organisations
463 """
464 pass
465
466
467@cli.group("user")
468def user_cli():
469 """
470 Manage users
471 """
472 pass
473
474
475@cli.group("group")
476def group_cli():
477 """
478 Manage groups
479 """
480 pass
481
482
483@user_cli.command("list")
484@click.argument("organisation", type=ORGANISATION)
485def list_users(organisation):
486 """
487 List all users belonging to the specified organisation.
488 """
489 label("The following users exists for {}:".format(organisation.name))
490 for user in organisation.users.values():
491 sys.stdout.write(user.name + "\n")
492
493
494@group_cli.command("list")
495@click.argument("organisation", type=ORGANISATION)
496def list_groups(organisation):
497 """
498 List all users belonging to the specified organisation.
499 """
500 label("The following users exists for {}:".format(organisation.name))
501 for group in organisation.groups.values():
502 sys.stdout.write(group.name + "\n")
503
504
505@org_cli.command("list")
506def list_orgs():
507 """
508 List available organisations
509 """
510 label("The following organisations exist:")
511 for org in Manager().orgs:
512 sys.stdout.write(org.name + "\n")
513
514
515@user_cli.command("getkey")
516@click.argument("organisation", type=ORGANISATION)
517@click.argument("user")
518def get_uuid(organisation, user):
519 """
520 Get the UUID of the specified user belonging to the specified organisation.
521 """
522 userobj = organisation.get_user(user)
523 if userobj is None:
524 msg = "User {} doesn't exist in organisation {}."
525 sys.exit(msg.format(userobj.name, organisation.name))
526
527 label("User {} has the following UUID:".format(userobj.name))
528 sys.stdout.write(user.key + "\n")
529
530
531@user_cli.command("export")
532@click.argument("organisation", type=ORGANISATION)
533@click.argument("user")
534def export_user(organisation, user):
535 """
536 Export user of the specified organisation as a series of shell commands
537 that can be used on the client side to easily import the certificates.
538
539 Note that the private key will be exported as well, so use this with care!
540 """
541 userobj = organisation.get_user(user)
542 if userobj is None:
543 msg = "User {} doesn't exist in organisation {}."
544 sys.exit(msg.format(user, organisation.name))
545
546 sys.stdout.write(userobj.export())
547
548
549@org_cli.command("add")
550@click.argument("name")
551def add_org(name):
552 """
553 Create an organisation with the specified name.
554 """
555 if os.path.exists(mkpath(name)):
556 msg = "Organisation with name {} already exists."
557 sys.exit(msg.format(name))
558
559 taskd_cmd("add", "org", name)
560 mark_imperative(name)
561
562
563@org_cli.command("remove")
564@click.argument("name")
565def del_org(name):
566 """
567 Delete the organisation with the specified name.
568
569 All of the users and groups will be deleted as well and client certificates
570 will be revoked.
571 """
572 Manager().del_org(name)
573 msg = ("Organisation {} deleted. Be sure to restart the Taskserver"
574 " using 'systemctl restart taskserver.service' in order for"
575 " the certificate revocation to apply.")
576 click.echo(msg.format(name), err=True)
577
578
579@user_cli.command("add")
580@click.argument("organisation", type=ORGANISATION)
581@click.argument("user")
582def add_user(organisation, user):
583 """
584 Create a user for the given organisation along with a client certificate
585 and print the key of the new user.
586
587 The client certificate along with it's public key can be shown via the
588 'user export' subcommand.
589 """
590 userobj = organisation.add_user(user)
591 if userobj is None:
592 msg = "User {} already exists in organisation {}."
593 sys.exit(msg.format(user, organisation))
594 else:
595 mark_imperative(organisation.name, "users", userobj.key)
596
597
598@user_cli.command("remove")
599@click.argument("organisation", type=ORGANISATION)
600@click.argument("user")
601def del_user(organisation, user):
602 """
603 Delete a user from the given organisation.
604
605 This will also revoke the client certificate of the given user.
606 """
607 organisation.del_user(user)
608 msg = ("User {} deleted. Be sure to restart the Taskserver using"
609 " 'systemctl restart taskserver.service' in order for the"
610 " certificate revocation to apply.")
611 click.echo(msg.format(user), err=True)
612
613
614@group_cli.command("add")
615@click.argument("organisation", type=ORGANISATION)
616@click.argument("group")
617def add_group(organisation, group):
618 """
619 Create a group for the given organisation.
620 """
621 groupobj = organisation.add_group(group)
622 if groupobj is None:
623 msg = "Group {} already exists in organisation {}."
624 sys.exit(msg.format(group, organisation))
625 else:
626 mark_imperative(organisation.name, "groups", groupobj.name)
627
628
629@group_cli.command("remove")
630@click.argument("organisation", type=ORGANISATION)
631@click.argument("group")
632def del_group(organisation, group):
633 """
634 Delete a group from the given organisation.
635 """
636 organisation.del_group(group)
637 click("Group {} deleted.".format(group), err=True)
638
639
640def add_or_delete(old, new, add_fun, del_fun):
641 """
642 Given an 'old' and 'new' list, figure out the intersections and invoke
643 'add_fun' against every element that is not in the 'old' list and 'del_fun'
644 against every element that is not in the 'new' list.
645
646 Returns a tuple where the first element is the list of elements that were
647 added and the second element consisting of elements that were deleted.
648 """
649 old_set = set(old)
650 new_set = set(new)
651 to_delete = old_set - new_set
652 to_add = new_set - old_set
653 for elem in to_delete:
654 del_fun(elem)
655 for elem in to_add:
656 add_fun(elem)
657 return to_add, to_delete
658
659
660@cli.command("process-json")
661@click.argument('json-file', type=click.File('rb'))
662def process_json(json_file):
663 """
664 Create and delete users, groups and organisations based on a JSON file.
665
666 The structure of this file is exactly the same as the
667 'services.taskserver.organisations' option of the NixOS module and is used
668 for declaratively adding and deleting users.
669
670 Hence this subcommand is not recommended outside of the scope of the NixOS
671 module.
672 """
673 data = json.load(json_file)
674
675 mgr = Manager(ignore_imperative=True)
676 add_or_delete(mgr.orgs.keys(), data.keys(), mgr.add_org, mgr.del_org)
677
678 for org in mgr.orgs.values():
679 if is_imperative(org.name):
680 continue
681 add_or_delete(org.users.keys(), data[org.name]['users'],
682 org.add_user, org.del_user)
683 add_or_delete(org.groups.keys(), data[org.name]['groups'],
684 org.add_group, org.del_group)
685
686
687if __name__ == '__main__':
688 cli()