10 from contextlib
import contextmanager
11 from shutil
import rmtree
12 from tempfile
import NamedTemporaryFile
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@"
25 TASKD_GROUP
= "@group@"
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
)
38 Decorator which only evaluates the specified function when accessed.
40 name
= '_lazy_' + fun
.__name
__
44 val
= getattr(self
, name
, None)
47 setattr(self
, name
, val
)
53 class TaskdError(OSError):
57 def run_as_taskd_user():
58 uid
= pwd
.getpwnam(TASKD_USER
).pw_uid
59 gid
= grp
.getgrnam(TASKD_GROUP
).gr_gid
64 def run_as_taskd_group():
65 gid
= grp
.getgrnam(TASKD_GROUP
).gr_gid
68 def taskd_cmd(cmd
, *args
, **kwargs
):
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.
76 capture_stdout
= kwargs
.pop("capture_stdout", False)
77 fun
= subprocess
.check_output
if capture_stdout
else subprocess
.check_call
79 [TASKD_COMMAND
, cmd
, "--data", TASKD_DATA_DIR
] + list(args
),
80 preexec_fn
=run_as_taskd_user
,
85 def certtool_cmd(*args
, **kwargs
):
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.
95 return subprocess
.check_output(
96 [CERTTOOL_COMMAND
] + list(args
),
97 preexec_fn
=run_as_taskd_group
,
98 stderr
=subprocess
.STDOUT
,
104 if sys
.stdout
.isatty() or sys
.stderr
.isatty():
105 sys
.stderr
.write(msg
+ "\n")
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.
127 for component
in path
:
128 full_path
.append(component
)
129 if os
.path
.exists(os
.path
.join(mkpath(*full_path
), ".imperative")):
134 def fetch_username(org
, key
):
135 for line
in open(mkpath(org
, "users", key
, "config"), "r"):
136 match
= RE_CONFIGUSER
.match(line
)
139 return match
.group(1).strip()
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
))
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
))
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")
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)
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)
192 "organization = {0}".format(org
),
193 "cn = {}".format(FQDN
),
194 "expiration_days = {}".format(CLIENT_EXPIRATION
),
200 with
create_template(template_data
) as template
:
203 "--load-privkey", privkey
,
204 "--load-ca-privkey", CA_KEY
,
205 "--load-ca-certificate", CA_CERT
,
206 "--template", template
,
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())
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
240 def is_key_line(line
, match
):
241 return line
.startswith("---") and line
.lstrip("- ").startswith(match
)
245 path
= os
.path
.join(TASKD_DATA_DIR
, "keys", *args
)
247 for line
in open(path
, "r"):
249 if is_key_line(line
, "BEGIN"):
255 if is_key_line(line
, "END"):
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
267 def __init__(self
, org
, name
, key
):
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
"'\''") + "'"
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"
289 'mkdir -p "{}"'.format(keydir
),
290 mktaskkey("certificate", os
.path
.join(keydir
, "public.cert"),
292 mktaskkey("key", os
.path
.join(keydir
, "private.key"), privkey
),
293 mktaskkey("ca", os
.path
.join(keydir
, "ca.cert"), cacert
)
297 "task config taskd.credentials -- {}".format(credentials
)
300 return "\n".join(script
) + "\n"
304 def __init__(self
, org
, name
):
309 class Organisation(object):
310 def __init__(self
, name
, ignore_imperative
):
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
):
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
)
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
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
):
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
):
356 Returns a 'Group' object or None if the group already exists.
358 if self
.ignore_imperative
and is_imperative(self
.name
):
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
367 def del_group(self
, name
):
371 if name
in self
.users
.keys():
372 if self
.ignore_imperative
and \
373 is_imperative(self
.name
, "groups", name
):
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
)
384 for key
in os
.listdir(mkpath(self
.name
, "users")):
385 user
= fetch_username(self
.name
, key
)
387 result
[user
] = User(self
.name
, user
, key
)
390 def get_group(self
, name
):
391 return self
.groups
.get(name
)
396 for group
in os
.listdir(mkpath(self
.name
, "groups")):
397 result
[group
] = Group(self
.name
, group
)
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
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
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
426 def del_org(self
, name
):
428 Delete and revoke keys of an organisation with all its users and
431 org
= self
.get_org(name
)
433 if self
.ignore_imperative
and is_imperative(name
):
435 for user
in list(org
.users
.keys()):
437 for group
in list(org
.groups
.keys()):
439 taskd_cmd("remove", "org", name
)
440 del self
._lazy
_orgs
[name
]
442 def get_org(self
, name
):
443 return self
.orgs
.get(name
)
448 for org
in os
.listdir(mkpath()):
449 result
[org
] = Organisation(org
, self
.ignore_imperative
)
453 class OrganisationType(click
.ParamType
):
454 name
= 'organisation'
456 def convert(self
, value
, param
, ctx
):
457 org
= Manager().get_org(value
)
459 self
.fail("Organisation {} does not exist.".format(value
))
462 ORGANISATION
= OrganisationType()
469 Manage Taskserver users and certificates
471 if not IS_AUTO_CONFIG
:
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
))
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")
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
)
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
)
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")
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")
587 Delete the organisation with the specified name.
589 All of the users and groups will be deleted as well and client certificates
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
)
612 msg
= "User {} already exists in organisation {}."
613 sys
.exit(msg
.format(user
, organisation
))
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
)
643 msg
= "Group {} already exists in organisation {}."
644 sys
.exit(msg
.format(group
, organisation
))
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.
671 to_delete
= old_set
- new_set
672 to_add
= new_set
- old_set
673 for elem
in to_delete
:
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
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
):
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__':