vuls: init at 0.27.0
[NixPkgs.git] / nixos / modules / services / misc / taskserver / helper-tool.py
blobb1eebb07686b27c1faa4e532072473e7ddd03968
1 import grp
2 import json
3 import pwd
4 import os
5 import re
6 import string
7 import subprocess
8 import sys
10 from contextlib import contextmanager
11 from shutil import rmtree
12 from tempfile import NamedTemporaryFile
14 import click
16 IS_AUTO_CONFIG = @isAutoConfig@ # NOQA
17 CERTTOOL_COMMAND = "@certtool@"
18 CERT_BITS = "@certBits@"
19 CLIENT_EXPIRATION = "@clientExpiration@"
20 CRL_EXPIRATION = "@crlExpiration@"
22 TASKD_COMMAND = "@taskd@"
23 TASKD_DATA_DIR = "@dataDir@"
24 TASKD_USER = "@user@"
25 TASKD_GROUP = "@group@"
26 FQDN = "@fqdn@"
28 CA_KEY = os.path.join(TASKD_DATA_DIR, "keys", "ca.key")
29 CA_CERT = os.path.join(TASKD_DATA_DIR, "keys", "ca.cert")
30 CRL_FILE = os.path.join(TASKD_DATA_DIR, "keys", "server.crl")
32 RE_CONFIGUSER = re.compile(r'^\s*user\s*=(.*)$')
33 RE_USERKEY = re.compile(r'New user key: (.+)$', re.MULTILINE)
36 def lazyprop(fun):
37 """
38 Decorator which only evaluates the specified function when accessed.
39 """
40 name = '_lazy_' + fun.__name__
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
50 return _lazy
53 class TaskdError(OSError):
54 pass
57 def 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)
64 def run_as_taskd_group():
65 gid = grp.getgrnam(TASKD_GROUP).gr_gid
66 os.setgid(gid)
68 def taskd_cmd(cmd, *args, **kwargs):
69 """
70 Invoke taskd with the specified command with the privileges of the 'taskd'
71 user and 'taskd' group.
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
85 def certtool_cmd(*args, **kwargs):
86 """
87 Invoke certtool from GNUTLS and return the output of the command.
89 The provided arguments are added to the certtool command and keyword
90 arguments are added to subprocess.check_output().
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
103 def label(msg):
104 if sys.stdout.isatty() or sys.stderr.isatty():
105 sys.stderr.write(msg + "\n")
108 def mkpath(*args):
109 return os.path.join(TASKD_DATA_DIR, "orgs", *args)
112 def mark_imperative(*path):
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.
118 open(os.path.join(mkpath(*path), ".imperative"), 'a').close()
121 def is_imperative(*path):
123 Check whether the given path is marked as imperative, see mark_imperative()
124 for more information.
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
134 def 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
143 @contextmanager
144 def create_template(contents):
146 Generate a temporary file with the specified contents as a list of strings
147 and yield its path as the context.
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()
156 def 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
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))
169 privkey = os.path.join(userdir, "private.key")
170 pubcert = os.path.join(userdir, "public.cert")
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(
184 uid=pwd.getpwnam(TASKD_USER).pw_uid,
185 gid=grp.getgrnam(TASKD_GROUP).gr_gid,
188 certtool_cmd("-p", "--bits", CERT_BITS, "--outfile", privkey)
189 os.chmod(privkey, 0o640)
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"
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
209 except:
210 rmtree(userdir)
211 raise
214 def 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))
219 pubcert = os.path.join(basedir, "public.cert")
221 expiration = "expiration_days = {}".format(CRL_EXPIRATION)
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
236 oldcrl.close()
237 rmtree(basedir)
240 def is_key_line(line, match):
241 return line.startswith("---") and line.lstrip("- ").startswith(match)
244 def 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
253 buf.append(line)
255 if is_key_line(line, "END"):
256 return ''.join(buf)
257 raise IOError("Unable to get key from {}.".format(path))
260 def 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
266 class User(object):
267 def __init__(self, org, name, key):
268 self.__org = org
269 self.name = name
270 self.key = key
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"'\''") + "'"
278 script = []
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")
285 keydir = "${TASKDATA:-$HOME/.task}/keys"
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)
296 script.append(
297 "task config taskd.credentials -- {}".format(credentials)
300 return "\n".join(script) + "\n"
303 class Group(object):
304 def __init__(self, org, name):
305 self.__org = org
306 self.name = name
309 class Organisation(object):
310 def __init__(self, name, ignore_imperative):
311 self.name = name
312 self.ignore_imperative = ignore_imperative
314 def add_user(self, name):
316 Create a new user along with a certificate and key.
318 Returns a 'User' object or None if the user already exists.
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))
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
336 def del_user(self, name):
338 Delete a user and revoke its keys.
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
346 # Work around https://bug.tasktools.org/browse/TD-40:
347 rmtree(mkpath(self.name, "users", user.key))
349 revoke_key(self.name, name)
350 del self._lazy_users[name]
352 def add_group(self, name):
354 Create a new group.
356 Returns a 'Group' object or None if the group already exists.
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
367 def del_group(self, name):
369 Delete a group.
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]
378 def get_user(self, name):
379 return self.users.get(name)
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
390 def get_group(self, name):
391 return self.groups.get(name)
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
401 class Manager(object):
402 def __init__(self, ignore_imperative=False):
404 Instantiates an organisations manager.
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.
410 self.ignore_imperative = ignore_imperative
412 def add_org(self, name):
414 Create a new organisation.
416 Returns an 'Organisation' object or None if the organisation already
417 exists.
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
426 def del_org(self, name):
428 Delete and revoke keys of an organisation with all its users and
429 groups.
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]
442 def get_org(self, name):
443 return self.orgs.get(name)
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
453 class OrganisationType(click.ParamType):
454 name = 'organisation'
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
462 ORGANISATION = OrganisationType()
465 @click.group()
466 @click.pass_context
467 def cli(ctx):
469 Manage Taskserver users and certificates
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))
479 @cli.group("org")
480 def org_cli():
482 Manage organisations
484 pass
487 @cli.group("user")
488 def user_cli():
490 Manage users
492 pass
495 @cli.group("group")
496 def group_cli():
498 Manage groups
500 pass
503 @user_cli.command("list")
504 @click.argument("organisation", type=ORGANISATION)
505 def list_users(organisation):
507 List all users belonging to the specified organisation.
509 label("The following users exists for {}:".format(organisation.name))
510 for user in organisation.users.values():
511 sys.stdout.write(user.name + "\n")
514 @group_cli.command("list")
515 @click.argument("organisation", type=ORGANISATION)
516 def list_groups(organisation):
518 List all users belonging to the specified organisation.
520 label("The following users exists for {}:".format(organisation.name))
521 for group in organisation.groups.values():
522 sys.stdout.write(group.name + "\n")
525 @org_cli.command("list")
526 def list_orgs():
528 List available organisations
530 label("The following organisations exist:")
531 for org in Manager().orgs:
532 sys.stdout.write(org.name + "\n")
535 @user_cli.command("getkey")
536 @click.argument("organisation", type=ORGANISATION)
537 @click.argument("user")
538 def get_uuid(organisation, user):
540 Get the UUID of the specified user belonging to the specified organisation.
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))
547 label("User {} has the following UUID:".format(userobj.name))
548 sys.stdout.write(user.key + "\n")
551 @user_cli.command("export")
552 @click.argument("organisation", type=ORGANISATION)
553 @click.argument("user")
554 def export_user(organisation, user):
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.
559 Note that the private key will be exported as well, so use this with care!
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))
566 sys.stdout.write(userobj.export())
569 @org_cli.command("add")
570 @click.argument("name")
571 def add_org(name):
573 Create an organisation with the specified name.
575 if os.path.exists(mkpath(name)):
576 msg = "Organisation with name {} already exists."
577 sys.exit(msg.format(name))
579 taskd_cmd("add", "org", name)
580 mark_imperative(name)
583 @org_cli.command("remove")
584 @click.argument("name")
585 def del_org(name):
587 Delete the organisation with the specified name.
589 All of the users and groups will be deleted as well and client certificates
590 will be revoked.
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)
599 @user_cli.command("add")
600 @click.argument("organisation", type=ORGANISATION)
601 @click.argument("user")
602 def add_user(organisation, user):
604 Create a user for the given organisation along with a client certificate
605 and print the key of the new user.
607 The client certificate along with it's public key can be shown via the
608 'user export' subcommand.
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)
618 @user_cli.command("remove")
619 @click.argument("organisation", type=ORGANISATION)
620 @click.argument("user")
621 def del_user(organisation, user):
623 Delete a user from the given organisation.
625 This will also revoke the client certificate of the given user.
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)
634 @group_cli.command("add")
635 @click.argument("organisation", type=ORGANISATION)
636 @click.argument("group")
637 def add_group(organisation, group):
639 Create a group for the given organisation.
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)
649 @group_cli.command("remove")
650 @click.argument("organisation", type=ORGANISATION)
651 @click.argument("group")
652 def del_group(organisation, group):
654 Delete a group from the given organisation.
656 organisation.del_group(group)
657 click("Group {} deleted.".format(group), err=True)
660 def add_or_delete(old, new, add_fun, del_fun):
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.
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.
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
680 @cli.command("process-json")
681 @click.argument('json-file', type=click.File('rb'))
682 def process_json(json_file):
684 Create and delete users, groups and organisations based on a JSON file.
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.
690 Hence this subcommand is not recommended outside of the scope of the NixOS
691 module.
693 data = json.load(json_file)
695 mgr = Manager(ignore_imperative=True)
696 add_or_delete(mgr.orgs.keys(), data.keys(), mgr.add_org, mgr.del_org)
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)
707 if __name__ == '__main__':
708 cli()