at 25.11-pre 21 kB view raw
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()