at 16.09-beta 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 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()