#! /usr/bin/env perl

#	facedin - manage user accounts
#
#	Copyright © 2012-2016 Tilburg University https://www.tilburguniversity.edu
#
#	This program is free software: you can redistribute it and/or modify
#	it under the terms of the GNU General Public License as published by
#	the Free Software Foundation, either version 3 of the License, or
#	(at your option) any later version.
#
#	This program is distributed in the hope that it will be useful,
#	but WITHOUT ANY WARRANTY; without even the implied warranty of
#	MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
#	GNU General Public License for more details.
#
#	You should have received a copy of the GNU General Public License
#	along with this program.  If not, see <http://www.gnu.org/licenses/>.

# $Id: facedin 45287 2016-06-22 08:38:03Z wsl $
# $URL: https://svn.uvt.nl/its-id/trunk/sources/facedin/bin/facedin $

use strict;
use warnings FATAL => 'all';

package Facedin::Config;

use POSIX qw(mktime);
use IO::File;
use IO::Dir;

sub validate_uid {
	my $self = shift;
	local $_ = shift;
	die "uid '$_' is not a number\n"
		unless /^\d+$/;
	die "uid '$_' not valid\n"
		unless /^[1-9]\d{3,5}$/;
	$_ = int($_);
	return $_;
}

sub validate_gid {
	my $self = shift;
	local $_ = shift;
	die "gid '$_' is not a number\n"
		unless /^\d+$/;
	die "gid '$_' not valid\n"
		unless /^[1-9]\d\d$/;
	$_ = int($_);
	return $_;
}

sub validate_cidr {
	my $self = shift;
	local $_ = shift;

	die "ip '$_' not valid\n"
		unless /^(
			(
				(?:[1-9]\d?|1\d\d|2([01]\d|2[0-3]))
				\.
				(?:\d\d?|1\d\d|2([0-4]\d|5[0-5]))
				\.
				(?:\d\d?|1\d\d|2([0-4]\d|5[0-5]))
				\.
				(?:\d\d?|1\d\d|2([0-4]\d|5[0-5]))
			) | (
				[0-9a-f]{1,4}:(
					:([0-9a-f]{1,4}(:[0-9a-f]{1,4}){0,5})?
				|
					[0-9a-f]{1,4}:(
						:([0-9a-f]{1,4}(:[0-9a-f]{1,4}){0,4})?
					|
						[0-9a-f]{1,4}:(
							:([0-9a-f]{1,4}(:[0-9a-f]{1,4}){0,3})?
						|
							[0-9a-f]{1,4}:(
								:([0-9a-f]{1,4}(:[0-9a-f]{1,4}){0,2})?
							|
								[0-9a-f]{1,4}:(
									:([0-9a-f]{1,4}(:[0-9a-f]{1,4})?)?
								|
									[0-9a-f]{1,4}:(
										:([0-9a-f]{1,4})?
									|
										[0-9a-f]{1,4}:(
											:
										|
											[0-9a-f]{1,4}
										)
									)
								)
							)
						)
					)
				)
			|
				::([0-9a-f]{1,4}(:[0-9a-f]{1,4}){0,6})?
			)
		)(\/(0|[1-9]\d*))?$/ix;

	return $_;
}

sub validate_name {
	my $self = shift;
	local $_ = lc(shift);
	die "invalid name '$_'\n"
		unless /^\w+(?:-\w+)*$/;
	return $_;
}

sub validate_gecos {
	my $self = shift;
	local $_ = shift;
	s/\s+/ /g;
	s/^ | $//g;
	die "invalid name '$_'\n"
		if $_ eq '' || /:/;
	return $_;
}

sub validate_shell {
	my $self = shift;
	local $_ = shift;
	s/\s+/ /g;
	s/^ | $//g;
	die "invalid shell '$_'\n"
		if m{[ :]} || m{/$} || !m{^/};
	return $_;
}

sub validate_bool {
	my $self = shift;
	local $_ = shift;
	die "missing boolean value\n" unless defined;
	return 1 if /^(?:1|yes|true|on|enabled?)$/i;
	return 0 if /^(?:0|no|false|off|disabled?)$/i;
	die "unknown boolean value '$_'\n";
}

sub validate_date {
	my $self = shift;
	local $_ = shift;
	die "can't parse date '$_'\n" unless /^(\d\d\d\d)-(\d\d)-(\d\d)$/;
	local $ENV{TZ} = 'GMT';
	my $unix = mktime(0, 0, 0, $3, $2-1, $1-1900);
	my $days = int($unix / 86400);
	die "invalid date '$_'\n" unless $days > 0;
	return $days;
}

sub handle_set {
	my $self = shift;
	my $set = shift;
	my $sets = $self->{sets};
	die "set '$set' already defined\n"
		if exists $sets->{$set};
	my $ctx = {name => $set, users => {}, groups => {}};
	local $_;
	foreach(@_) {
		if(/^\w\S*$/) {
			my $name = $self->validate_name($_);
			die "unknown user '$_'\n" unless exists $self->{users}{$name};
			undef $ctx->{users}{$name};
		} elsif(/^\+(\S+)$/) {
			my $name = $self->validate_name($1);
			$self->{groups}{$name} ||= {};
			undef $ctx->{groups}{$name};
		} elsif(/^\%(\S+)$/) {
			my $l = $self->{sets}{$self->validate_name($1)}
				or die "unknown set '$_'\n";
			@{$ctx->{users}}{keys %{$l->{users}}} = ();
			@{$ctx->{groups}}{keys %{$l->{groups}}} = ();
		} else {
			die "unknown entity type '$_'\n";
		}
	}
	$sets->{$set} = $ctx;
	return $ctx;
}

# probeer te raden wat in deze regel de base64 key-blob is
sub guess_key {
	my $self = shift;
	my $line = shift;
	my @elements =
		grep { /^[a-zA-Z0-9\/+]{68,}={0,4}$/ }
		sort { length($b) <=> length($a) }
		split(' ', $line);
	return shift @elements;
}

sub handle_item {
	my $self = shift;
	my $key = shift;
	my $value = shift;

	local $_ = $key;
	if(/^\w\S*$/) {
		my $name = $self->validate_name($key);
		die "user '$name' already declared\n"
			if exists $self->{users}{$name};
		my ($uid, $gecos) = split(' ', $value, 2) if defined $value;
		my $dude = $self->{users}{$name} = {name => $name};
		$dude->{uid} = $self->validate_uid($uid)
			if defined $uid;
		$dude->{gecos} = $self->validate_gecos($gecos)
			if defined $gecos;

		foreach(@_) {
			my ($cmd, $arg) = /^(\w+)\s*:\s*(\S.*)$/
				or die "malformed declaration ($_)\n";
			$cmd = lc($cmd);
			if($cmd eq 'locked') {
				$dude->{locked} = $self->validate_bool($arg);
			} elsif($cmd eq 'uid' || $cmd eq 'anr') {
				$dude->{uid} = $self->validate_uid($arg);
			} elsif($cmd eq 'name') {
				$dude->{gecos} = $self->validate_gecos($arg);
			} elsif($cmd eq 'shell') {
				$dude->{shell} = $self->validate_shell($arg);
			} elsif($cmd eq 'expire') {
				$dude->{expire} = $self->validate_date($arg);
			} elsif($cmd eq 'ip') {
				push @{$dude->{ips}}, $self->validate_cidr($arg);
			} elsif($cmd eq 'authorized_key') {
				my $key = $self->guess_key($arg)
					or die "can't find base64 key blob in this line\n";
				my $keys = $dude->{authorized_keys} ||= {};
				die "authorized_keys can't be duplicate\n"
					if exists $keys->{$key};
				die "keys can't be both authorized and unauthorized\n"
					if exists $dude->{unauthorized_keys}{$key};
				$keys->{$key} = $arg;
			} elsif($cmd eq 'unauthorized_key') {
				my $key = $self->guess_key($arg)
					or die "can't find base64 key blob in this line\n";
				my $keys = $dude->{unauthorized_keys} ||= {};
				die "unauthorized_keys can't be duplicate\n"
					if exists $keys->{$key};
				die "keys can't be both authorized and unauthorized\n"
					if exists $dude->{authorized_keys}{$key};
				$keys->{$key} = $arg;
			} else {
				die "unknown attribute '$cmd'\n";
			}
		}
	} elsif(/^\+(\S+)$/) {
		my $name = $self->validate_name($1);
		my $group = $self->{groups}{$name};
		if($group) {
			die "group '$name' already declared\n"
				if exists $group->{declared};
		} else {
			$group = $self->{groups}{$name} = {name => $name};
		}

		undef $group->{declared};

		my $gid = $value;
		$group->{gid} = $self->validate_gid($gid)
			if defined $gid;

		foreach(@_) {
			my ($cmd, $arg) = /^(\w+)\s*:\s(\S.*)$/
				or die "malformed declaration\n";
			$cmd = lc($cmd);
			if($cmd eq 'gid') {
				$group->{gid} = $self->validate_gid($arg);
			} else {
				die "unknown attribute '$cmd'\n";
			}
		}
	} elsif(/^\%(\S+)$/) {
		my $set = $self->validate_name($1);
		unshift @_, $value if defined $value;
		$self->handle_set($set, map { split } @_);
	} else {
		die "unrecognized configuration item\n";
	}
}

sub handle_item_wrapper {
	my $self = shift;
	my ($key) = @_;
	eval { $self->handle_item(@_) };
	die "in stanza for $key: $@" if $@;
	return;
}

sub read_config_file {
	my ($self, $configfile) = @_;
	my $fh = new IO::File($configfile, '<')
		or die "open($configfile): $!\n";
	binmode($fh, ':utf8');

	my $key;
	my @values;

	my $line = 0;
	eval {
		my $fileline;
		my $stanzaline;
		local $_;
		while(defined($_ = $fh->getline)) {
			$fileline = $line = $fh->input_line_number;
			s/\s+$//;
			next if $_ eq '';
			unless(/^\s/) {
				if(defined $key) {
					$line = $stanzaline;
					$self->handle_item_wrapper($key, @values);
					$line = $fileline;
					undef $key;
				}
			}
			next if /^\s*#/;
			if(s/^\s+//) {
				die "continuation without prior declaration\n"
					unless defined $key;
				push @values, $_;
			} else {
				if(defined $key) {
					$line = $stanzaline;
					$self->handle_item_wrapper($key, @values);
					$line = $fileline;
					undef $key;
				}
				if(/^(\S+)\s*:(?:\s+(\S.*))?$/) {
					($key, @values) = ($1, $2);
					$stanzaline = $line;
				} else {
					die "can't find a valid key-value pair here\n";
				}
			}
		}
		if(defined $key) {
			$line = $stanzaline;
			$self->handle_item_wrapper($key, @values);
			$line = $fileline;
			undef $key;
		}
	};
	die "$configfile:$line: $@" if $@;

	$fh->eof or die "read($configfile): $!\n";
	$fh->close or die "close($configfile): $!\n";
	undef $fh;
}

sub new {
	my $class = shift;
	my $self = bless {users => {}, groups => {}, sets => {}}, ref $class || $class;

	my $configdir = shift;

	$self->{configdir} = $configdir;

	my $dir = new IO::Dir($configdir)
		or die "open($configdir): $!\n";
	my @files = sort(grep { /\.conf$/ } $dir->read);
	$dir->close
		or die "close($configdir): $!\n";
	undef $dir;

	foreach my $configfile (@files) {
		$self->read_config_file("$configdir/$configfile");
	}

	my %uids;
	while(my ($name, $dude) = each(%{$self->{users}})) {
		my $uid = $dude->{uid};
		next unless defined $uid;
		die "zero uid for $name\n" unless $uid;
		die "no gecos name specified for $name\n" unless $dude->{gecos};
		die "duplicate uid specified for $name\n" if exists $uids{$uid};
		undef $uids{$uid};
	}

	return $self;
}

package Facedin;

use Sys::Hostname;
use IO::File;
use POSIX qw(_exit setsid unlink);
use File::Glob qw(:glob);
use Encode;

use Data::Dumper;
$Data::Dumper::Indent = 1;

sub verbose {
	my $self = shift;
	return unless $self->{verbose};
	print $self->{dryrun} ? "(dryrun) @_" : "@_"
		or die $!;
}

sub write_file {
	my $file = shift;
	my $fh = new IO::File($file, '>')
		or die "open($file): $!\n";
	binmode($fh, ':utf8');
	$fh->print(@_) or die "write($file): $!\n";
	$fh->flush or die "write($file): $!\n";
	$fh->sync or die "write($file): $!\n";
	$fh->close or die "write($file): $!\n";
}

sub read_line {
	my $file = shift;
	my $fh = new IO::File($file, '<')
		or die "open($file): $!\n";
	binmode($fh, ':utf8');
	my $line = $fh->getline;
	unless(defined $line) {
		die "$file is empty\n" if $fh->eof;
		die "read($file): $!\n";
	}
	$fh->close or die "close($file): $!\n";
	$line =~ s/\s+/ /;
	$line =~ s/^ | $//g;
	die "$file is empty\n" if $line eq '';
	return $line;
}

sub print_uruk {
	my $self = shift;
	my $destdir = $self->{destdir};
	my $conf = $self->{conf};
	my $users = $conf->{users};

	my @users = ("# automatically generated by facedin; do not edit\n\n");
	foreach my $user (sort keys %$users) {
		my $name = $user;
		$name =~ tr/A-Za-z0-9/_/cs;
		my $dude = $conf->{users}{$user};
		my $ips = $dude->{ips} or next;
		my $locked = $dude->{locked} ? '# (locked) ' : '';
		push @users, $locked."my_user_$name='".join(' ', @$ips)."'\n" if @$ips;
	}
	write_file("$destdir/etc/uruk/.users.facedin-new", @users);
	$self->schedule("$destdir/etc/uruk/users", 0644);

	my @facedin = ("# automatically generated by facedin; do not edit\n\n");
	my $sets = $conf->{sets};
	foreach my $setname (sort keys %$sets) {
		my $set = $sets->{$setname};
		my @dudes = sort keys %{$set->{users}};
		next unless @dudes;
		my $name = $setname;
		$name =~ tr/A-Za-z0-9/_/cs;
		foreach(@dudes) { tr/A-Za-z0-9/_/cs }
		push @facedin, "my_facedin_$name=\"".join(' ', (map { "\$my_user_$_" } @dudes))."\"\n";
	}
	write_file("$destdir/etc/uruk/.facedin.facedin-new", @facedin);
	$self->schedule("$destdir/etc/uruk/facedin", 0644);

	my @dudes = sort keys %{$self->{users}};

	mkdir "$destdir/etc/uruk/extra.d"
		or $!{EEXIST} or die "mkdir($destdir/etc/uruk/extra.d): $!\n";
	if(@dudes) {
		write_file("$destdir/etc/uruk/extra.d/.facedin.facedin-new",
			". \$etcdir/users\nsources_eth0_default_tcp_admin=\"\$sources_eth0_default_tcp_admin",
			(map { " \$my_user_$_" } @dudes),
			"\"\n"
		);
	} else {
		write_file("$destdir/etc/uruk/extra.d/.facedin.facedin-new",
			"# This space intentionally not left blank\n");
	}

	$self->schedule("$destdir/etc/uruk/extra.d/facedin", 0644);
}

sub print_ssh {
	my $self = shift;
	my $conf = $self->{conf};
	my $users = $self->{users};
	my @allowusers = "# generated file\n\n";
	foreach my $user (sort keys %$users) {
		my %ips;
		my $dude = $conf->{users}{$user};
		next unless $dude->{ips};
		@ips{@{$dude->{ips}}} = ();
		next unless %ips;
		my $locked = $dude->{locked} ? '# (locked) ' : '';
		my $line = "${locked}AllowUsers";
		foreach my $ip (sort keys %ips) {
			$line .= " $user\@$ip";
		}
		push @allowusers, "$line\n";
	}
	my $destdir = $self->{destdir};
	write_file("$destdir/etc/ssh/sshd_config.d/.facedin.conf.facedin-new", @allowusers);
	$self->schedule("$destdir/etc/ssh/sshd_config.d/facedin.conf", 0644);
}


# Apache >= 2.4
#
#
# Creates apache2/facedin.d/my_{user,group}_<name> for each user and each group.

# Output:
#   /etc/apache2/facedin.d/my_user_cgielen
#     Require ip 137.56.12.34
#     Require ip 137.56.11.22
#     ...
#
#   /etc/apache2/facedin.d/my_group_root
#     Include facedin.d/my_user_cgielen
#     Include facedin.d/my_user_jhoeke
#     Include facedin.d/my_user_wsl
#     ...
#
# Use by including these files:
#   Include facedin.d/my_{user,group}_<name>
#
# Examples:
#   <Directory "/www/docs/private">
#       Include facedin.d/my_group_dba 
#       Include facedin.d/my_user_cgielen
#   </Directory>
# 
sub print_apache24 {
	my $self = shift;
	my $conf = $self->{conf};
	my $users = $self->{users};
	my $destdir = $self->{destdir};
	my $outputdir = "$destdir/etc/apache2/facedin.d";

	mkdir $outputdir or $!{EEXIST} or die "mkdir($outputdir): $!\n";

	# users
	foreach my $user (sort keys %$users) {
		my %ips;
		my $dude = $conf->{users}{$user};
		next unless $dude->{ips};
		@ips{@{$dude->{ips}}} = ();
		next unless %ips;
		my @requires = ("# automatically generated by facedin; do not edit\n\n");
		my $locked = $dude->{locked} ? '# (locked) ' : '';
		foreach my $ip (sort keys %ips) {
			push @requires, $locked."Require ip $ip\n";
		}

		my $name = $user;
		$name =~ tr/A-Za-z0-9/_/cs;
		write_file("$outputdir/.my_user_$name.facedin-new", @requires);
		$self->schedule("$outputdir/my_user_$name", 0644);
	}

	# groups
	my $sets = $conf->{sets};
	foreach my $setname (sort keys %$sets) {
		my $set = $sets->{$setname};
		my @dudes = sort keys %{$set->{users}};
		foreach(@dudes) { tr/A-Za-z0-9/_/cs }
		next unless @dudes;

		my @requires = ("# automatically generated by facedin; do not edit\n\n");
		foreach my $dude (@dudes) {
			push @requires, "Include facedin.d/my_user_$dude\n"
		}

		my $name = $setname;
		$name =~ tr/A-Za-z0-9/_/cs;
		write_file("$outputdir/.my_group_$name.facedin-new", @requires);
		$self->schedule("$outputdir/my_group_$name", 0644);
	}
}

use Data::Dumper;
$Data::Dumper::Indent = 1;

sub print_sudo {
	my $self = shift;
	my $conf = $self->{conf};
	my $users = $conf->{users};
	my $sets = $conf->{sets};
	my @sudoers = "# generated file\n\n";

	foreach my $name (sort keys %$sets) {
		my $set = $sets->{$name};
		my @users = sort grep { !$users->{$_}{locked} } keys %{$set->{users}};
		next unless @users;
		$name =~ tr/A-Za-z0-9/_/cs;
		$name = 'FACEDIN_' . uc($name);
		push @sudoers, "User_Alias $name = ", join(', ', @users), "\n";
	}

	my $destdir = $self->{destdir};
	write_file("$destdir/etc/sudoers.d/.10-facedin.facedin-new", @sudoers);
	$self->schedule("$destdir/etc/sudoers.d/10-facedin", 0440);
}

sub alloc_uid {
	my ($self, $uid) = @_;
	my $used = $self->{used_id};
	while(exists $used->{$uid}) {
		$uid++;
	}
	undef $used->{$uid};
	return $uid;
}

sub tweak_passwd {
	my $self = shift;

	my %changed;

	my $conf = $self->{conf};
	my $users = $self->{users};
	my $groups = $self->{groups};
	my $used_id = $self->{used_id};
	my $known_name = $self->{known_name};
	my $known_uid = $self->{known_uid};
	my $known_gid = $self->{known_gid};
	my $all_users = $conf->{users};
	my $all_groups = $conf->{groups};

	# create a list of all users not on this host:
	my %remove;
	@remove{keys %$all_users} = ();
	delete @remove{keys %$users};

	my $destdir = $self->{destdir};

	my %seen_passwd; # needs to be created if not in this hash
	my @passwd;
	my $fh = new IO::File("$destdir/etc/passwd", '<')
		or die "open($destdir/etc/passwd): $!\n";
	binmode($fh, ':utf8');
	eval {
		local $_;
		while(defined($_ = $fh->getline)) {
			chomp;
			my @fields = split(/:/, $_, -1);
			die "unexpected number of fields\n"
				if @fields != 7;
			my ($name, $locked, $uid, $gid, $gecos, $home, $shell) = @fields;

			die "can't parse numeric uid '$uid' for $name\n"
				unless $uid =~ /^(?:0|[1-9]\d{0,5})+$/;
			$uid = int($uid);
			die "can't parse numeric gid '$gid' for $name\n"
				unless $gid =~ /^(?:0|[1-9]\d{0,5})+$/;
			$gid = int($gid);

			die "password field '$locked' not recognized for $name\n"
				if length($locked) > 1;

			$name = $conf->validate_name($name);
			my $dude = $all_users->{$name};

			if(my $used = $known_uid->{$uid}) {
				die "numeric user ID $uid of $name conflicts with user $used\n"
					if $used ne $name;
			}

			$known_uid->{$uid} = $name;
			$known_name->{$name} = $uid;

			$gecos =~ s/,+$//;

			# indien user volstrekt onbekend, ongewijzigd printen
			unless($dude) {
				undef $used_id->{$uid};
				push @passwd, join(':', $name, $locked, $uid, $gid, $gecos, $home, $shell)."\n";
				next;
			}

			# anders, indien niet bekend voor deze host, overslaan
			unless(exists $users->{$name}) {
				# effectief dus een delete
				$self->verbose("deleting passwd entry for $name\n");
				$changed{passwd} = 1;
				next;
			}

			undef $seen_passwd{$name};
			undef $used_id->{$uid};

			if(my $duid = $dude->{uid}) {
				die "existing numeric uid '$uid' does not match uid '$duid' for $name\n"
					unless $uid == $duid;
			}
			die "existing numeric gid '$gid' does not match numeric uid '$uid' for $name\n"
				unless $gid == $uid;
			$gecos = $dude->{gecos};

			$locked = $dude->{locked} ? '!' : '*';

			die "home directory for $name has an unexpected value ($home)\n"
				unless $home eq "/home/$name";

			my $new = "$name:$locked:$uid:$gid:$gecos:/home/$name:$shell";
			push @passwd, "$new\n";

			if($new ne $_) {
				$self->verbose("changing passwd entry for $name\n");
				$changed{passwd} = 1;
			}
		}
	};
	die "$destdir/etc/passwd:".$fh->input_line_number.": $@" if $@;
	die "read($destdir/etc/passwd): $!\n" unless $fh->eof;
	$fh->close;
	undef $fh;

	my %seen_group; # needs to be created if not in this hash
	my @group;
	$fh = new IO::File("$destdir/etc/group", '<')
		or die "open($destdir/etc/group): $!\n";
	binmode($fh, ':utf8');
	eval {
		local $_;
		while(defined($_ = $fh->getline)) {
			chomp;
			my @fields = split(/:/, $_, -1);
			die "unexpected number of fields\n"
				if @fields != 4;
			my ($name, $locked, $gid, $members) = @fields;

			die "can't parse numeric gid '$gid' for $name\n"
				unless $gid =~ /^(?:0|[1-9]\d{0,5})+$/;
			$gid = int($gid);

			die "unexpected value in password field for group $name\n"
				if length($locked) > 1;

			if(my $used = $known_gid->{$gid}) {
				die "numeric group ID $gid of $name conflicts with group $used\n"
					if $used ne $name;
			}

			$known_gid->{$gid} = $name;
			$known_name->{$name} = $gid;

			$name = $conf->validate_name($name);
			my $dude = $all_users->{$name};
			my $group = $all_groups->{$name};

			my %members;
			@members{grep { $_ ne '' } split(/,+/, $members)} = ();
			if($group && exists $group->{declared}) {
				delete @members{keys %$all_users};
			} else {
				delete @members{keys %remove};
			}
			my %expired = %members;
			delete @expired{keys %$known_name};
			delete @members{keys %expired};

			if($dude || $group) {
				# indien leeg en niet bekend voor deze host, overslaan
				my $undeclared = $group && !exists $group->{declared};
				unless(%members || exists $users->{$name} || exists $groups->{$name} || $undeclared) {
					# effectief dus een delete
					$self->verbose("deleting group entry for $name\n");
					$changed{group} = 1;
					next;
				}

				undef $seen_group{$name};

				if($dude and my $duid = $dude->{uid}) {
					die "existing numeric gid '$gid' does not match uid '$duid' for user $name\n"
						unless $gid == $duid;
				}

				if($dude and my $duid = $known_name->{$name}) {
					die "existing numeric gid '$gid' does not match existing numeric uid '$duid' for group $name\n"
						unless $gid == $duid;
				}

				@members{$dude ? $name : keys %{$groups->{$name}}} = ();

				$locked = '!' if $locked !~ /^[x*!]{1,2}$/;
			}

			undef $used_id->{$gid};

			$members = join(',', sort keys %members);

			my $new = "$name:$locked:$gid:$members";
			push @group, "$new\n";

			if($new ne $_) {
				$self->verbose("changing group entry for $name\n");
				$changed{group} = 1;
			}
		}
	};
	die "$destdir/etc/group:".$fh->input_line_number.": $@" if $@;
	die "read($destdir/etc/group): $!\n" unless $fh->eof;
	$fh->close;
	undef $fh;

	my %seen_shadow; # needs to be created if not in this hash
	my @shadow;
	$fh = new IO::File("$destdir/etc/shadow", '<')
		or die "open($destdir/etc/shadow): $!\n";
	binmode($fh, ':utf8');
	eval {
		local $_;
		while(defined($_ = $fh->getline)) {
			chomp;
			my @fields = split(/:/, $_, -1);
			die "unexpected number of fields\n"
				if @fields != 9;
			my ($name, $pwd, undef, undef, undef, undef, undef, undef, $reserved) = @fields;

			die "password field empty for $name\n"
				if $pwd eq '';
			if($pwd =~ /^[x*!]{1,2}$/) {
				warn "root user has no password\n"
					if $name eq 'root';
			} else {
				warn "user $name has a password, this is likely NOT intended\n"
					if $name ne 'root';
			}

			$name = $conf->validate_name($name);
			my $dude = $all_users->{$name};

			# indien user volstrekt onbekend, ongewijzigd printen
			unless($dude) {
				push @shadow, $_."\n";
				next;
			}

			# anders, indien niet bekend voor deze host, overslaan
			unless(exists $users->{$name}) {
				# effectief dus een delete
				$changed{shadow} = 1;
				next;
			}

			undef $seen_shadow{$name};

			$pwd = $dude->{locked} ? '!' : '*';
			my $expire = defined $dude->{expire} ? $dude->{expire} : '';

			my $new = "$name:${pwd}::::::$expire:$reserved";
			push @shadow, "$new\n";

			if($new ne $_) {
				$self->verbose("changing shadow entry for $name\n");
				$changed{shadow} = 1;
			}
		}
	};
	die "$destdir/etc/shadow:".$fh->input_line_number.": $@" if $@;
	die "read($destdir/etc/shadow): $!\n" unless $fh->eof;
	$fh->close;
	undef $fh;

	my %seen_gshadow; # needs to be created if not in this hash
	my @gshadow;
	$fh = new IO::File("$destdir/etc/gshadow", '<')
		or die "open($destdir/etc/gshadow): $!\n";
	binmode($fh, ':utf8');
	eval {
		local $_;
		while(defined($_ = $fh->getline)) {
			chomp;
			my @fields = split(/:/, $_, -1);
			die "unexpected number of fields\n"
				if @fields != 4;
			my ($name, $pwd, $admins, $members) = @fields;

			warn "group(!) $name has a password, this is likely NOT intended\n"
				if $pwd !~ /^[x*!]{0,2}$/;

			$name = $conf->validate_name($name);
			my $dude = $all_users->{$name};
			my $group = $all_groups->{$name};

			my %members;
			@members{grep { $_ ne '' } split(/,+/, $members)} = ();
			if($group && exists $group->{declared}) {
				delete @members{keys %$all_users};
			} else {
				delete @members{keys %remove};
			}
			my %expired = %members;
			delete @expired{keys %$known_name};
			delete @members{keys %expired};

			if($dude || $group) {
				# indien leeg en niet bekend voor deze host, overslaan
				my $undeclared = $group && !exists $group->{declared};
				unless(%members || exists $users->{$name} || exists $groups->{$name} || $undeclared) {
					# effectief dus een delete
					$changed{gshadow} = 1;
					next;
				}

				undef $seen_gshadow{$name};

				@members{$dude ? $name : keys %{$groups->{$name}}} = ();

				die "admin field not empty for $name\n"
					if $admins ne '';

				$pwd = '!' if $pwd !~ /^[x*!]{1,2}$/;
			}

			$members = join(',', sort keys %members);

			my $new = "$name:${pwd}::$members";
			push @gshadow, "$new\n";

			if($new ne $_) {
				$self->verbose("changing gshadow entry for $name\n");
				$changed{gshadow} = 1;
			}
		}
	};
	die "$destdir/etc/gshadow:".$fh->input_line_number.": $@" if $@;
	die "read($destdir/etc/gshadow): $!\n" unless $fh->eof;
	$fh->close;
	undef $fh;

	# voor alle users van deze host, indien niet gezien, toevoegen.
	foreach my $name (keys %$users) {
		my $dude = $all_users->{$name};
		my ($uid, $gecos, $shell, $locked) = @{$dude}{qw(uid gecos shell locked)};
		$locked = $locked ? '!' : '*';
		$shell = $self->{default_shell} unless defined $shell;
		if(!$uid) {
			# als er al een group is met deze naam, pak daar de uid van
			# anders: alloceer en registreer er één
			$uid = $known_gid->{$name} || $self->alloc_uid(1000);
			$known_uid->{$name} = $uid;
			$known_gid->{$name} = $uid;
		}
		unless(exists $seen_passwd{$name}) {
			$self->verbose("add user $name ($uid)\n");
			push @passwd, "$name:$locked:$uid:$uid:$gecos:/home/$name:$shell\n";
			$changed{passwd} = 1;
		}
		unless(exists $seen_shadow{$name}) {
			push @shadow, "$name:${locked}:::::::\n";
			$changed{shadow} = 1;
		}
		unless(exists $seen_group{$name}) {
			push @group, "$name:!:$uid:$name\n";
			$changed{group} = 1;
		}
		unless(exists $seen_gshadow{$name}) {
			push @gshadow, "$name:!::$name\n";
			$changed{gshadow} = 1;
		}
	}

	# voor alle groups van deze host, indien niet gezien, toevoegen.
	foreach my $name (keys %$groups) {
		my $members = join(',', sort keys %{$groups->{$name}});
		unless(exists $seen_group{$name}) {
			my $group = $all_groups->{$name};
			die "group '$name' neither declared nor already present\n"
				unless exists $group->{declared};
			my $gid = $group->{gid};
			if(!$gid) {
				$gid = $known_gid->{$name} || $self->alloc_uid(100);
				$known_gid->{$name} = $gid;
			}
			$self->verbose("add group $name ($gid)\n");
			push @group, "$name:!:$gid:$members\n";
			$changed{group} = 1;
		}
		unless(exists $seen_gshadow{$name}) {
			push @gshadow, "$name:!::$members\n";
			$changed{gshadow} = 1;
		}
	}

	my @commit;

	if($changed{passwd}) {
		write_file("$destdir/etc/.passwd.facedin-new", @passwd);
		$self->schedule("$destdir/etc/passwd");
	} else {
		unlink("$destdir/etc/.passwd.facedin-new") or $!{ENOENT}
			or die "unlink(destdir/etc/.passwd.facedin-new): $!\n";
	}

	if($changed{shadow}) {
		write_file("$destdir/etc/.shadow.facedin-new", @shadow);
		$self->schedule("$destdir/etc/shadow");
	} else {
		unlink("$destdir/etc/.shadow.facedin-new") or $!{ENOENT}
			or die "unlink(destdir/etc/.shadow.facedin-new): $!\n";
	}

	if($changed{group}) {
		write_file("$destdir/etc/.group.facedin-new", @group);
		$self->schedule("$destdir/etc/group");
	} else {
		unlink("$destdir/etc/.group.facedin-new") or $!{ENOENT}
			or die "unlink(destdir/etc/.group.facedin-new): $!\n";
	}

	if($changed{gshadow}) {
		write_file("$destdir/etc/.gshadow.facedin-new", @gshadow);
		$self->schedule("$destdir/etc/gshadow");
	} else {
		unlink("$destdir/etc/.gshadow.facedin-new") or $!{ENOENT}
			or die "unlink(destdir/etc/.gshadow.facedin-new): $!\n";
	}
}

sub seteuid {
	my $uid = shift;
	local $!;
	$) = $uid;
	die "setegid($uid): $!\n" if $!;
	$> = $uid;
	die "seteuid($uid): $!\n" if $!;
}

sub run {
	my $prog = join(' ', @_);
	umask(022) or die "umask(022): $!\n";
	my $code = system {$_[0]} @_;
	umask(077) or die "umask(077): $!\n";
	if(POSIX::WIFEXITED($?)) {
		my $status = POSIX::WEXITSTATUS($?);
		die sprintf("%s exited with status %d\n", $prog, $status)
			if $status;
	} elsif(POSIX::WIFSIGNALED($?)) {
		my $sig = POSIX::WTERMSIG($?);
		die sprintf("%s killed with signal %d%s\n", $prog, $sig & 127, ($sig & 128) ? ' (core dumped)' : '')
	} elsif(POSIX::WIFSTOPPED($?)) {
		my $sig = POSIX::WSTOPSIG($?);
		warn sprintf("%s stopped with signal %d\n", $prog, $sig)
	}
}

sub tweak_homes {
	my $self = shift;
	my $conf = $self->{conf};
	my $users = $self->{users};
	my $known_name = $self->{known_name};
	foreach my $name (keys %$users) {
		my $dude = $conf->{users}{$name};
		my $uid = $known_name->{$name};
		my $destdir = $self->{destdir};
		my $home = "$destdir/home/$name";
		my $skel;

		unless(-e $home) {
			$self->verbose("create home directory $home\n");
			next if $self->{dryrun};
			mkdir $home or die "mkdir($home): $!\n";
			chmod 0755, $home or die "chmod($home): $!\n";
			chown $uid, $uid, $home or die "chown($home): $!\n";
			$skel = 1;
		}

		next if $self->{dryrun};

		# drop permissies om niet in symlink-vallen te trappen
		seteuid($uid);

		run('/bin/cp', '-rP', '--preserve=links,mode,timestamps', '--', '/etc/skel/.', "$home/")
			if $skel;

		unless(-e "$home/.ssh") {
			mkdir "$home/.ssh" or die "mkdir($home/.ssh): $!\n";
			chmod 0750, "$home/.ssh" or die "chmod($home/.ssh): $!\n";
		}

		my %authorized_keys;
		my @authorized_keys;
		my $unauthorized_keys = $dude->{unauthorized_keys} || {};
		my $new;

		if(my $fh = new IO::File("$home/.ssh/authorized_keys", '<')) {
			if(my $authorized_keys = $dude->{authorized_keys}) {
				%authorized_keys = %$authorized_keys;
				eval {
					local $_;
					while(defined($_ = $fh->getline)) {
						Encode::_utf8_on($_);
						Encode::_utf8_off($_) unless utf8::valid($_);
						my @words = split;
						push @authorized_keys, $_
							unless @$unauthorized_keys{@words};
						chomp;
						delete @authorized_keys{@words};
					}
				};
				die "$home/.ssh/authorized_keys:".$fh->input_line_number.": $@" if $@;
				die "read($home/.ssh/authorized_keys): $!\n" unless $fh->eof;
			}
			$fh->close;
			undef $fh;
		} else {
			$!{ENOENT} or die "open($home/.ssh/authorized_keys): $!\n";
			if(my $authorized_keys = $dude->{authorized_keys}) {
				@authorized_keys = "# see sshd(8) for syntax\n";
				%authorized_keys = %$authorized_keys;
			} else {
				%authorized_keys = (undef => "# see sshd(8) for syntax");
			}
			$new = 1;
		}

		if(%authorized_keys) {
			write_file("$home/.ssh/.authorized_keys.facedin-new", @authorized_keys,
				map { "$_\n" } values(%authorized_keys)
			);
			$self->schedule("$home/.ssh/authorized_keys", 0640);
		}

		# permissies weer terugpakken
		seteuid(0);
	}
}

sub ffs {
	my $s = shift;
	return 0 unless $s;
	my $lg = 0;
	until($s & 1<<$lg++) {}
	return $lg;
}

sub discard {
	my @gen;
	return grep { $gen[ffs($_)]++ } sort { $b <=> $a } @_;
}

sub max {
	return 0 unless @_;
	my $max = shift;
	foreach my $x (@_) {
		$max = $x if $x > $max;
	}
	return $max;
}

sub new {
	my $class = shift;
	my $self = bless {}, ref $class || $class;
	my $conf = $self->{conf} = shift;

	my %users;
	my %groups;
	my %used_id; # numeric IDs that are not available for dynamic allocation
	my %known_name; # index for declared names
	my %known_uid; # index for declared uids
	my %known_gid; # index for declared gids

	foreach my $set (values %{$conf->{sets}}) {
		foreach my $user (keys %{$set->{users}}) {
			@{$users{$user}}{keys %{$set->{groups}}} = ();
		}
		foreach my $group (keys %{$set->{groups}}) {
			@{$groups{$group}}{keys %{$set->{users}}} = ();
			die "a functional group '$group' is defined with the same name as a user\n"
				if exists $users{$group};
		}
	}

	# even checken of er geen ongewenste uid/gid combinaties voorkomen
	# en meteen een lijstje opbouwen van de uids/gids die moeten bestaan
	foreach my $name (keys %users) {
		my $dude = $conf->{users}{$name};
		my $uid = $dude->{uid};
		next unless defined $uid;
		$known_name{$name} = $uid;
		$known_uid{$uid} = $name;
		undef $used_id{$uid};
	}

	foreach my $name (keys %groups) {
		my $group = $conf->{groups}{$name};
		my $gid = $group->{gid};
		next unless defined $gid;
		if(my $unr = $known_name{$name}) {
			die "conflicting uid for user '$name' and group '$name': $unr != $gid\n"
				if $unr != $gid;
		}
		$known_name{$name} = $gid;
		$known_gid{$gid} = $name;
		undef $used_id{$gid};
	}

	# %known_* bevatten nu de users/groups waarvan de uids/gids vastliggen

	$self->{users} = \%users;
	$self->{groups} = \%groups;
	$self->{used_id} = \%used_id;
	$self->{known_name} = \%known_name;
	$self->{known_uid} = \%known_uid;
	$self->{known_gid} = \%known_gid;

	$self->{commit} = [];

	$self->{default_shell} = '/bin/bash';
	$self->{destdir} = '';
	$self->{verbose} = 1;
	$self->{dryrun} = 1;
	$self->{filesonly} = 1;
	$self->{backups} = 1;

	return $self;
}

sub lg2inc {
	my %st;
	@st{@_} = map { my @x = lstat($_) or die "$_: $!\n"; \@x } @_;

	my @files = sort { $st{$a}->[9] <=> $st{$b}->[9] } @_;

	my $count = 0;
	my %rename;

	foreach my $file (@files) {
		$file =~ /(\d+)$/ or die "$file: does not end in a number\n";
		my $lg = int($1);
		next unless $lg;
		my $num = 1 << $lg - 1;
		$count &= ~($num - 1);
		$count |= $num;
		my $rename = $file;
		$rename =~ s/\d+$/$count/;
		$rename{$file} = $rename;
	}

	foreach my $file (reverse @files) {
		next unless exists $rename{$file};
		rename $file, $rename{$file}
			or die "rename($file, $rename{$file}): $!\n";
	}
}

sub schedule {
	my ($self, $name, $mode) = @_;
	my $commits = $self->{commits} ||= {};
	die "internal error (duplicate commit)"
		if exists $commits->{$name};
	$commits->{$name} = $mode;
}

sub commit {
	my $self = shift;

	my $commits = $self->{commits}
		or return;

	my %stat;

	while(my ($name, $mode) = each(%$commits)) {
		my $dotfile = $name;
		$dotfile =~ s{/\.?([^./][^/]*)$}{/.$1};
		if(my @st = lstat($name)) {
			die "$name is not a file\n" unless -f _;
			die "$name is not writable\n" unless -w _;
			$stat{$name} = \@st;

			my ($mode, $uid, $gid) = @st[2, 4, 5];
			chown $uid, $gid, "$dotfile.facedin-new"
				or die "chown($dotfile.facedin-new, $uid, $gid): $!\n";
			chmod $mode, "$dotfile.facedin-new"
				or die "chmod($dotfile.facedin-new, $mode): $!\n";
		} else {
			die "$name: $!\n" unless $!{ENOENT};
			if(defined $mode) {
				chmod $mode, "$dotfile.facedin-new"
					or die "chmod($dotfile.facedin-new, $mode): $!\n";
			} else {
				die "required file '$name' doesn't exist\n";
			}
		}
		$self->verbose("commit $name\n");
	}

	return if $self->{dryrun};

	# als files als /etc/passwd verbokt raken, is dat superirritant
	# daarom op veilig spelen met wegschrijven: backups maken en slim linken/renamen
	if($self->{backups}) {
		while(my ($name, $mode) = each(%$commits)) {
			if(exists $stat{$name}) {	
				my $dotfile = $name;
				$dotfile =~ s{/\.?([^./][^/]*)$}{/.$1};
				my $qdotfile = $dotfile;
				$qdotfile =~ s/([][\\?*{}])/\\$1/g;

				# ouwe troep opruimen
				if(-e "$dotfile.facedin-seq") {
					# converteer nummers naar nieuwe stijl
					lg2inc(bsd_glob("$qdotfile.facedin-*[0-9]",
						GLOB_ERR|GLOB_LIMIT|GLOB_NOSORT|GLOB_QUOTE));
					POSIX::unlink "$dotfile.facedin-seq"
						or die "unlink($dotfile.facedin-seq): $!\n";
				}
				POSIX::unlink "$dotfile.facedin-seq.old" or $!{ENOENT}
					or die "unlink($dotfile.facedin-seq): $!\n";

				# welke nummertjes hebben we al?
				my @existing = map { /-(\d+)$/ ? int($1) : () }
					bsd_glob("$qdotfile.facedin-*[0-9]",
						GLOB_ERR|GLOB_LIMIT|GLOB_NOSORT|GLOB_QUOTE);
				die "glob($qdotfile.facedin-*): $!\n"
					if File::Glob::GLOB_ERROR;

				my $seq = @existing ? max(@existing) + 1 : 0;

				link $name, "$dotfile.facedin-$seq"
					or die "link($name, $dotfile.facedin-$seq): $!\n";

				foreach my $rm (discard(@existing)) {
					unlink "$dotfile.facedin-$rm"
						or die "unlink($dotfile.facedin-$rm): $!\n";
				}
			}
		}
	}

	while(my ($name, $mode) = each(%$commits)) {
		my $dotfile = $name;
		$dotfile =~ s{/\.?([^./][^/]*)$}{/.$1};
		rename "$dotfile.facedin-new", $name
			or die "rename($dotfile.facedin-new, $name): $!\n";
	}
}

package main;

use Getopt::Long qw(:config gnu_getopt);
use POSIX qw(O_WRONLY O_NOCTTY);

sub print_version {
	my $id = '$Id: facedin 45287 2016-06-22 08:38:03Z wsl $';
	my $url = '$URL: https://svn.uvt.nl/its-id/trunk/sources/facedin/bin/facedin $';
	print "$id\n$url\n" or die $!;
}

sub version {
	print_version();
	exit 0;
}

sub usage {
	my $fh = shift;
	print $fh "Usage: facedin [options]\n",
			" -V, --version        Show version information\n",
			" -h, --help           Show usage information\n",
			" -v, --verbose        Show what happens\n",
			" -c, --config <dir>   Use <dir> as the configuration directory\n",
			" -d, --destdir <dir>  Use <dir> as the filesystem root\n",
			" -n, --dry-run        Simulate only; don't commit changes\n",
			" -N, --files-only     Write files only, do not reload daemons\n",
			" -B, --no-backups     Do not backup files before overwriting\n"
		or die $!;
}

sub help {
	print_version();
	usage(*STDOUT);
	exit 0;
}

do {
	my ($destdir, $verbose, $dryrun, $filesonly, $nobackups, $configdir) = '';

	unless(GetOptions(
		'v|verbose' => \$verbose,
		'V|version' => \&version,
		'h|help' => \&help,
		'c|config=s' => \$configdir,
		'd|destdir=s' => \$destdir,
		'n|dry-run' => \$dryrun,
		'N|files-only' => \$filesonly,
		'B|no-backups' => \$nobackups,
	)) {
		usage(*STDERR);
		exit 1;
	}

	if(@ARGV) {
		usage(*STDERR);
		exit 1;
	}

	umask(077) or die "umask(077): $!\n";

	unless(defined $configdir) {
		$configdir = -d '/usr/local/etc/facedin'
			? '/usr/local/etc/facedin'
			: '/etc/facedin';
	}
	my $conf = new Facedin::Config($configdir);
	my $facedin = new Facedin($conf);

	unless($verbose) {
		# redirect stdout to /dev/null
		my $fd = POSIX::open("/dev/null", O_WRONLY | O_NOCTTY);
		die "open(/dev/null): $!\n" unless defined $fd;
		unless($fd == 1) {
			POSIX::dup2($fd, 1)
				or die "dup2($fd, 1): $!\n";
			POSIX::close($fd)
				or die "close($fd, 1): $!\n";
		}
	}

	$facedin->{verbose} = $verbose;
	$facedin->{destdir} = $destdir;
	$facedin->{dryrun} = $dryrun;
	$facedin->{filesonly} = $filesonly;
	$facedin->{backups} = !$nobackups;

	$facedin->print_ssh;
	$facedin->print_uruk;
	$facedin->print_apache24;
	$facedin->print_sudo;
	$facedin->tweak_passwd;
	$facedin->tweak_homes;
	$facedin->commit;

	unless($filesonly || $dryrun || $destdir) {
		Facedin::run(qw(sync));

		if(-e '/usr/sbin/nscd') {
			eval { Facedin::run(qw(/usr/sbin/nscd -i passwd -i shadow -i group -i gshadow)) };
			warn $@ if $@;
		}

		Facedin::run(qw(/usr/local/sbin/make-sudoers))
			if -e '/usr/local/sbin/make-sudoers';

		# apachectl lijkt op zowel RHEL als Debian te bestaan
		Facedin::run(qw(/usr/sbin/apachectl graceful))
			if -e '/usr/sbin/apachectl';

		Facedin::run(qw(make -sC /etc/ssh));

		Facedin::run(qw(service uruk force-reload));
	}
};

__END__

=head1 NAME

facedin - manage user accounts

=head1 DESCRIPTION

Accounts are managed by editing OpenSSH configuration files
(F</etc/ssh/sshd_config.d/facedin.conf>), Uruk iptables rules files
(F</etc/uruk/extra.d/facedin>), F</etc/passwd> and other Unix user account
information files, by creating users' homedirectories. Furthermore, ssh
authorized_keys files in users' home directories are updated.  Since
F<~/.ssh/authorized_keys> is typically managed by the user, B<facedin>
only adds entries to this file, and never removes anything from it.

=head2 Command line options

=head3 -v, --verbose

Print all actions that are performed.

=head3 -V, --version

Print the software version and exit.

=head3 -h, --help

Print a short option summary.

=head3 -c, --config I<path>

Use I<path> as the configuration directory instead of the default.

=head3 -n, --dry-run

Go through all the motions but do not actually modify the system
configuration. The files C</etc/.passwd.facedin-new>,
C</etc/.shadow.facedin-new>, C</etc/.group.facedin-new>,
C</etc/.gshadow.facedin-new>,
C</etc/ssh/sshd_config.d/.facedin.conf.facedin-new>,
C</etc/uruk/.users.facedin-new> and
C</etc/uruk/extra.d/.facedin.facedin-new> are created.

Use with -v to see what would happen.

=head3 -d, --destdir I<dir>

Use I<dir> as the filesystem root. Useful for testing and debugging.

=head2 Configuration file

The configuration directories /usr/local/etc/facedin and /etc/facedin are
searched for files ending in .conf. Any files found are read in sorted
order.

The configuration file for facedin follows a pure declarative style.
Order only matters in the sense that declarations for entities must
precede their use.

Three types of entities are recognized: users, groups and sets.

=head2 Users

Users are declared with simply their unix username as the identifier.

Syntax:

	<uid>: [<anr> [<Full Name>]]
		locked: <yes|no>
		anr: 123456
		name: Full Name
		shell: /bin/zsh
		expire: 2012-02-29
		ip: 192.0.2.3
		authorized_key: ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIItlkLP19WSuPO80K0/BKP/1gpSsFMBrryejAUHtCyoG
		unauthorized_key: ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDez8Ic0/2SDRPJZK119Oq7vj8OSfpkCCwjQXv...

The anr (numeric user ID) and name may be specified either on the same
line as the uid or explicitly in the attribute list (but not both).
The ip and authorized_key attributes are multi-valued.

Typically, users are defined for human useraccounts, I<not> for system
users.

Simply declaring a user will not cause it to be created on the system.
You need to add it to one or more sets (see below) for that to happen.
In fact, if a declared user account is present on the system and not
referenced by any set it will be removed!

Authorized keys are only added to the file if they are not already there
(even if just in commented form). Unknown authorized keys will not be
removed: the authorized_keys files is considered the domain of the user.

To explicitly remove keys, use the unauthorized_key statement. Lines with
these keys will be deleted from the authorized_keys file by facedin.

=head2 Groups

Unix groups are declared using a + sign and their unix name.

Syntax:

	+<group name>: [<numeric group ID>]
		gid: <numeric group ID>

The gid (numeric group ID) may be specified either on the same line as
the group name or explicitly in the attribute list (but not both).

The gid should be in the system gid range (1..999).

Simply declaring a group will not cause it to be created on the system.
You need to add it to one or more sets (see below) for that to happen.
In fact, if a declared group is present on the system and not referenced
by any set it will be removed!

You can use a group without explicitly declaring it, but in such a case
the group is required to be already present on all hosts that use it.
Such an undeclared group will not be created (nor will it be removed)
automatically.

Membership of non-facedin system users of a group on a host is not touched
by facedin. Only facedin-managed users are added to (or removed from)
groups.

=head2 Sets

Sets define (possibly empty) sets of users and groups.

Defining a set has two effects:

=over

=item 1

The referenced users and groups are actualized on the system, in the
combination(s) specified;

=item 2

The set (and the users/groups in it) can be referenced by name by other
sets, by sudo and by uruk.

=back

A user or group can belong to multiple sets and you can even create sets by
combining other sets.

If a set contains users, then facedin will conclude that those users need
to exist. Likewise, if a group is mentioned in a set it will be created.

If a user account that is defined in the configuration exists but is
not defined in any set, it will be removed from that host. The same goes
for groups.

Sets are denoted by a % in front of their hostname.

Syntax:

	%dbas: bob jane joe
	%webmasters: jack

	%web: %webmasters +www-data
