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