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