at 23.11-pre 20 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 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()