#!/usr/bin/perl -w
#
# DO NOT EDIT.
#
# This file was tangled from a small institute's README.org.

use strict;
use IO::File;

sub note_missing_file_p ($);
sub note_missing_directory_p ($);

{
  my $missing = 0;
  if (note_missing_file_p "ansible.cfg") { $missing += 1; }
  if (note_missing_file_p "hosts") { $missing += 1; }
  if (note_missing_directory_p "Secret") { $missing += 1; }
  if (note_missing_file_p "Secret/become.yml") { $missing += 1; }
  if (note_missing_directory_p "playbooks") { $missing += 1; }
  if (note_missing_file_p "playbooks/site.yml") { $missing += 1; }
  if (note_missing_directory_p "roles") { $missing += 1; }
  if (note_missing_directory_p "public") { $missing += 1; }
  if (note_missing_directory_p "private") { $missing += 1; }

  for my $filename (glob "private/*") {
    my $perm = (stat $filename)[2];
    if ($perm & 077) {
      print "$filename: not private\n";
    }
  }
  die "$missing missing files\n" if $missing != 0;
}

sub note_missing_file_p ($) {
  my ($filename) = @_;
  if (! -f $filename) {
    print "$filename: missing\n";
    return 1;
  } else {
    return 0;
  }
}

sub note_missing_directory_p ($) {
  my ($dirname) = @_;
  if (! -d $dirname) {
    print "$dirname: missing\n";
    return 1;
  } else {
    return 0;
  }
}

sub mysystem (@) {
  my $line = join (" ", @_);
  print "$line\n";
  my $status = system $line;
  die "status: $status\nCould not run $line: $!\n" if $status != 0;
}

mysystem "ansible-playbook playbooks/check-inst-vars.yml >/dev/null";

our ($domain_name, $domain_priv,
     $front_addr, $front_wg_pubkey,
     $public_wg_net_cidr, $public_wg_port,
     $private_net_cidr, $wild_net_cidr,
     $gate_wild_addr, $gate_wg_pubkey,
     $campus_wg_net_cidr, $campus_wg_port,
     $core_addr, $core_wg_pubkey);
do "./private/vars.pl";

if (defined $ARGV[0] && $ARGV[0] eq "CA") {
  die "usage: $0 CA" if @ARGV != 1;
  die "Secret/CA/easyrsa: not an executable\n"
    if ! -x "Secret/CA/easyrsa";
  die "Secret/CA/pki/: already exists\n" if -e "Secret/CA/pki";
  die "gpg: command not found" if system "which -s gpg";
  die "ssh-keygen: command not found" if system "which -s ssh-keygen";

  umask 077;
  mysystem "cd Secret/CA; ./easyrsa init-pki";
  mysystem "cd Secret/CA; ./easyrsa build-ca nopass";
  # Common Name: small.example.org

  my $dom = $domain_name;
  my $pvt = $domain_priv;

  mysystem ("cd Secret/CA;",
	    "./easyrsa build-server-full $dom nopass");
  mysystem ("cd Secret/CA;",
	    "./easyrsa build-server-full core.$pvt nopass");

  mysystem "mkdir --mode=700 Secret/root.gnupg";
  mysystem ("gpg --homedir Secret/root.gnupg",
	    "--batch --quick-generate-key --passphrase ''",
	    "root\@core.$pvt");
  mysystem ("gpg --homedir Secret/root.gnupg",
	    "--export --armor --output Secret/root-pub.pem",
	    "root\@core.$pvt");
  chmod 0440, "root-pub.pem";
  mysystem ("gpg --homedir Secret/root.gnupg",
	    "--export-secret-key --armor",
	    "--output Secret/root-sec.pem",
	    "root\@core.$pvt");
  chmod 0400, "root-sec.pem";

  mysystem "mkdir Secret/ssh_admin";
  chmod 0700, "Secret/ssh_admin";
  mysystem ("ssh-keygen -q -t ed25519",
	    "-C A\\ Small\\ Institute\\ Administrator",
	    "-N '' -f Secret/ssh_admin/id_ed25519");

  mysystem "mkdir Secret/ssh_monkey";
  chmod 0700, "Secret/ssh_monkey";
  mysystem "echo 'HashKnownHosts  no' >Secret/ssh_monkey/config";
  mysystem ("ssh-keygen -q -t ed25519 -C monkey\@core.$pvt",
	    "-N '' -f Secret/ssh_monkey/id_ed25519");

  mysystem "mkdir --mode=700 Secret/opendkim";
  if (system "which opendkim-genkey") {
    warn "opendkim-genkey: command not found, skipped"
  } else {
    mysystem ("opendkim-genkey -D Secret/opendkim",
	      "-b 2048 -d $dom -s default -S");
  }
  exit;
}

if (defined $ARGV[0] && $ARGV[0] eq "config") {
  die "Secret/CA/easyrsa: not executable\n"
    if ! -x "Secret/CA/easyrsa";
  shift;
  my $cmd = "ansible-playbook -e \@Secret/become.yml";
  if (defined $ARGV[0] && $ARGV[0] eq "-n") {
    shift;
    $cmd .= " --check --diff"
  }
  if (@ARGV == 0) {
    ;
  } elsif (defined $ARGV[0]) {
    my $hosts = lc $ARGV[0];
    die "$hosts: contains illegal characters"
      if $hosts !~ /^!?[a-z][-a-z0-9,!]+$/;
    $cmd .= " -l $hosts";
  } else {
    die "usage: $0 config [-n] [HOSTS]\n";
  }
  $cmd .= " playbooks/site.yml";
  mysystem $cmd;
  exit;
}

use YAML::XS qw(LoadFile DumpFile);

sub read_members_yaml () {
  my $path;
  $path = "private/members.yml";
  if (-e $path) { return LoadFile ($path); }
  $path = "private/members-empty.yml";
  if (-e $path) { return LoadFile ($path); }
  die "private/members.yml: not found\n";
}

sub write_members_yaml ($) {
  my ($yaml) = @_;
  my $old_umask = umask 077;
  my $path = "private/members.yml";
  print "$path: "; STDOUT->flush;
  eval { #DumpFile ("$path.tmp", $yaml);
	 dump_members_yaml ("$path.tmp", $yaml);
	 rename ("$path.tmp", $path)
	   or die "Could not rename $path.tmp: $!\n"; };
  my $err = $@;
  umask $old_umask;
  if ($err) {
    print "ERROR\n";
  } else {
    print "updated\n";
  }
  die $err if $err;
}

sub dump_members_yaml ($$) {
  my ($pathname, $yaml) = @_;
  my $O = new IO::File;
  open ($O, ">$pathname") or die "Could not open $pathname: $!\n";
  print $O "---\n";
  if (keys %{$yaml->{"members"}}) {
    print $O "members:\n";
    for my $user (sort keys %{$yaml->{"members"}}) {
      print_member ($O, $user, $yaml->{"members"}->{$user});
    }
    print $O "usernames:\n";
    for my $user (sort keys %{$yaml->{"members"}}) {
      print $O "- $user\n";
    }
  } else {
    print $O "members: {}\n";
    print $O "usernames: []\n";
  }
  if (@{$yaml->{"clients"}}) {
    print $O "clients:\n";
    for my $name (@{$yaml->{"clients"}}) {
      print $O "- $name\n";
    }
  } else {
    print $O "clients: []\n";
  }
  close $O or die "Could not close $pathname: $!\n";
}

sub print_member ($$$) {
  my ($out, $username, $member) = @_;
  print $out "  ", $username, ":\n";
  print $out "    status: ", $member->{"status"}, "\n";
  if (@{$member->{"clients"} || []}) {
    print $out "    clients:\n";
    for my $name (@{$member->{"clients"} || []}) {
      print $out "    - ", $name, "\n";
    }
  } else {
    print $out "    clients: []\n";
  }
  print $out "    password_front: ", $member->{"password_front"}, "\n";
  print $out "    password_core: ", $member->{"password_core"}, "\n";
  if (defined $member->{"password_fetchmail"}) {
    print $out "    password_fetchmail: !vault |\n";
    for my $line (split /\n/, $member->{"password_fetchmail"}) {
      print $out "      $line\n";
    }
  }
  my @standard_keys = ( "status", "clients",
			"password_front", "password_core",
			"password_fetchmail" );
  my @other_keys = (sort
		    grep { my $k = $_;
			   ! grep { $_ eq $k } @standard_keys }
		    keys %$member);
  for my $key (@other_keys) {
    print $out "    $key: ", $member->{$key}, "\n";
  }
}

sub valid_username (@);
sub shell_escape ($);
sub strip_vault ($);

if (defined $ARGV[0] && $ARGV[0] eq "new") {
  my $user = valid_username (@ARGV);
  my $yaml = read_members_yaml ();
  my $members = $yaml->{"members"};
  die "$user: already exists\n" if defined $members->{$user};

  my $pass = `apg -n 1 -x 12 -m 12`; chomp $pass;
  print "Initial password: $pass\n";
  my $epass = shell_escape $pass;
  my $front = `mkpasswd -m sha-512 "$epass"`; chomp $front;
  my $core = `mkpasswd -m sha-512 "$epass"`; chomp $core;
  my $vault = strip_vault `ansible-vault encrypt_string "$epass"`;
  mysystem ("ansible-playbook -e \@Secret/become.yml",
	    "playbooks/nextcloud-new.yml",
	    "-e user=$user", "-e pass=\"$epass\"",
	    ">/dev/null");
  $members->{$user} = { "status" => "current",
			"password_front" => $front,
			"password_core" => $core,
			"password_fetchmail" => $vault };
  write_members_yaml $yaml;
  mysystem ("ansible-playbook -e \@Secret/become.yml",
	    "-t accounts -l core,front playbooks/site.yml",
	    ">/dev/null");
  exit;
}

sub valid_username (@) {
  my $sub = $_[0];
  die "usage: $0 $sub USER\n"
    if @_ != 2;
  my $username = lc $_[1];
  die "$username: does not begin with an alphabetic character\n"
    if $username !~ /^[a-z]/;
  die "$username: contains non-alphanumeric character(s)\n"
    if $username !~ /^[a-z0-9]+$/;
  return $username;
}

sub shell_escape ($) {
  my ($string) = @_;
  my $result = "$string";
  $result =~ s/([\$`"\\ ])/\\$1/g;
  return ($result);
}

sub strip_vault ($) {
  my ($string) = @_;
  die "Unexpected result from ansible-vault: $string\n"
    if $string !~ /^ *!vault [|]/;
  my @lines = split /^ */m, $string;
  return (join "", @lines[1..$#lines]);
}

use MIME::Base64;
sub write_wireguard ($);

if (defined $ARGV[0] && $ARGV[0] eq "pass") {
  my $I = new IO::File;
  open $I, "gpg --homedir Secret/root.gnupg --quiet --decrypt |"
    or die "Error running gpg: $!\n";
  my $msg_yaml = LoadFile ($I);
  close $I or die "Error closing pipe from gpg: $!\n";

  my $user = $msg_yaml->{"username"};
  die "Could not find a username in the decrypted input.\n"
    if ! defined $user;
  my $pass64 = $msg_yaml->{"password"};
  die "Could not find a password in the decrypted input.\n"
    if ! defined $pass64;

  my $mem_yaml = read_members_yaml ();
  my $members = $mem_yaml->{"members"};
  my $member = $members->{$user};
  die "$user: does not exist\n" if ! defined $member;
  die "$user: no longer current\n" if $member->{"status"} ne "current";

  my $pass = decode_base64 $pass64;
  my $epass = shell_escape $pass;
  my $front = `mkpasswd -m sha-512 "$epass"`; chomp $front;
  my $core = `mkpasswd -m sha-512 "$epass"`; chomp $core;
  my $vault = strip_vault `ansible-vault encrypt_string "$epass"`;
  $member->{"password_front"} = $front;
  $member->{"password_core"} = $core;
  $member->{"password_fetchmail"} = $vault;

  mysystem ("ansible-playbook -e \@Secret/become.yml",
	    "playbooks/nextcloud-pass.yml",
	    "-e user=$user", "-e \"pass=$epass\"",
	    ">/dev/null");
  write_members_yaml $mem_yaml;
  mysystem ("ansible-playbook -e \@Secret/become.yml",
	    "-t accounts playbooks/site.yml",
	    ">/dev/null");
  my $O = new IO::File;
  open ($O, "| sendmail $user\@$domain_priv")
    or die "Could not pipe to sendmail: $!\n";
  print $O "From: <root>
To: <$user>
Subject: Password change.

Your new password has been distributed to the servers.

As always: please email root with any questions or concerns.\n";
  close $O or die "pipe to sendmail failed: $!\n";
  exit;
}

if (defined $ARGV[0] && $ARGV[0] eq "old") {
  my $user = valid_username (@ARGV);
  my $yaml = read_members_yaml ();
  my $members = $yaml->{"members"};
  my $member = $members->{$user};
  die "$user: does not exist\n" if ! defined $member;

  mysystem ("ansible-playbook -e \@Secret/become.yml",
	    "playbooks/nextcloud-old.yml -e user=$user",
	    ">/dev/null");
  $member->{"status"} = "former";
  umask 077;
  write_members_yaml $yaml;
  write_wireguard $yaml;
  mysystem ("ansible-playbook -e \@Secret/become.yml",
	    "-t accounts playbooks/site.yml",
	    ">/dev/null");
  exit;
}

sub write_wg_server ($$$$$);
sub write_wg_client ($$$$$$);
sub hostnum_to_ipaddr ($$);
sub hostnum_to_ipaddr_cidr ($$);

if (defined $ARGV[0] && $ARGV[0] eq "client") {
  my $type = $ARGV[1]||"";
  my $name = $ARGV[2]||"";
  my $user = $ARGV[3]||"";
  my $pubkey = $ARGV[4]||"";
  if ($type eq "android" || $type eq "debian") {
    die "usage: $0 client $type NAME USER PUBKEY\n" if @ARGV != 5;
    die "$name: invalid host name\n" if $name !~ /^[a-z][-a-z0-9]+$/;
  } elsif ($type eq "campus") {
    die "usage: $0 client campus NAME PUBKEY\n" if @ARGV != 4;
    die "$name: invalid host name\n" if $name !~ /^[a-z][-a-z0-9]+$/;
    $pubkey = $user;
    $user = "";
  } else {
    die "usage: $0 client [debian|android|campus]\n";
  }
  my $yaml = read_members_yaml;
  my $members = $yaml->{"members"};
  my $member = $members->{$user};
  die "$user: does not exist\n"
    if !defined $member && $type ne "campus";
  die "$user: no longer current\n"
    if defined $member && $member->{"status"} ne "current";

  my @campus_peers # [ name, hostnum, type, pubkey, user|"" ]
     = map { [ (split / /), "" ] } @{$yaml->{"clients"}};

  my @member_peers = ();
  for my $u (sort keys %$members) {
    push @member_peers,
	 map { [ (split / /), $u ] } @{$members->{$u}->{"clients"}};
  }

  my @all_peers = sort { $a->[1] <=> $b->[1] }
		       (@campus_peers, @member_peers);

  for my $p (@all_peers) {
    my ($n, $h, $t, $k, $u) = @$p;
    die "$n: name already in use by $u\n"
        if $name eq $n && $u ne "";
    die "$n: name already in use on campus\n"
        if $name eq $n && $u eq "";
  }

  my $hostnum = (@all_peers
		 ? 1 + $all_peers[$#all_peers][1]
		 : 3);

  push @{$type eq "campus"
	 ? $yaml->{"clients"}
	 : $member->{"clients"}},
       "$name $hostnum $type $pubkey";

  umask 077;
  write_members_yaml $yaml;
  write_wireguard $yaml;

  umask 033;
  write_wg_client ("public.conf",
		   hostnum_to_ipaddr ($hostnum, $public_wg_net_cidr),
		   $type,
		   $front_wg_pubkey,
		   "$front_addr:$public_wg_port",
		   hostnum_to_ipaddr (1, $public_wg_net_cidr))
    if $type ne "campus";
  write_wg_client ("campus.conf",
		   hostnum_to_ipaddr ($hostnum, $campus_wg_net_cidr),
		   $type,
		   $gate_wg_pubkey,
		   "$gate_wild_addr:$campus_wg_port",
		   hostnum_to_ipaddr (1, $campus_wg_net_cidr));

  mysystem ("ansible-playbook -e \@Secret/become.yml",
	    "-l gate,front",
	    "-t accounts playbooks/site.yml",
	    ">/dev/null");
  exit;
}

sub write_wireguard ($) {
  my ($yaml) = @_;

  my @campus_peers # [ name, hostnum, type, pubkey, user|"" ]
     = map { [ (split / /), "" ] } @{$yaml->{"clients"}};

  my $members = $yaml->{"members"};
  my @member_peers = ();
  for my $u (sort keys %$members) {
    next if $members->{$u}->{"status"} ne "current";
    push @member_peers,
	 map { [ (split / /), $u ] } @{$members->{$u}->{"clients"}};
  }

  my @all_peers = sort { $a->[1] <=> $b->[1] }
		       (@campus_peers, @member_peers);

  my $core_wg_addr = hostnum_to_ipaddr (2, $public_wg_net_cidr);
  my $extra_front_config = "
PostUp = resolvectl dns %i $core_addr
PostUp = resolvectl domain %i $domain_priv

# Core
[Peer]
PublicKey = $core_wg_pubkey
AllowedIPs = $core_wg_addr
AllowedIPs = $private_net_cidr
AllowedIPs = $wild_net_cidr
AllowedIPs = $campus_wg_net_cidr\n";

  write_wg_server ("private/front-wg0.conf", \@member_peers,
		   hostnum_to_ipaddr_cidr (1, $public_wg_net_cidr),
		   $public_wg_port, $extra_front_config);
  write_wg_server ("private/gate-wg0.conf", \@all_peers,
		   hostnum_to_ipaddr_cidr (1, $campus_wg_net_cidr),
		   $campus_wg_port, "\n");
}

sub write_wg_server ($$$$$) {
  my ($file, $peers, $addr_cidr, $port, $extra) = @_;
  my $O = new IO::File;
  open ($O, ">$file.tmp") or die "Could not open $file.tmp: $!\n";
  print $O "[Interface]
Address = $addr_cidr
ListenPort = $port
PostUp = wg set %i private-key /etc/wireguard/private-key$extra";
  for my $p (@$peers) {
    my ($n, $h, $t, $k, $u) = @$p;
    next if $k =~ /^-/;
    my $ip = hostnum_to_ipaddr ($h, $addr_cidr);
    print $O "
# $n
[Peer]
PublicKey = $k
AllowedIPs = $ip\n";
  }
  close $O or die "Could not close $file.tmp: $!\n";
  rename ("$file.tmp", $file)
    or die "Could not rename $file.tmp: $!\n";
}

sub write_wg_client ($$$$$$) {
  my ($file, $addr, $type, $pubkey, $endpt, $server_addr) = @_;

  my $O = new IO::File;
  open ($O, ">$file.tmp") or die "Could not open $file.tmp: $!\n";

  my $DNS = ($type eq "android"
	     ? "
DNS = $core_addr
Domain = $domain_priv"
	     : "
PostUp = wg set %i private-key /etc/wireguard/private-key
PostUp = resolvectl dns %i $core_addr
PostUp = resolvectl domain %i $domain_priv");

  my $WILD = ($file eq "public.conf"
	      ? "
AllowedIPs = $wild_net_cidr"
	      : "");

  print $O "[Interface]
Address = $addr$DNS

[Peer]
PublicKey = $pubkey
EndPoint = $endpt
AllowedIPs = $server_addr
AllowedIPs = $private_net_cidr$WILD
AllowedIPs = $public_wg_net_cidr
AllowedIPs = $campus_wg_net_cidr\n";
  close $O or die "Could not close $file.tmp: $!\n";
  rename ("$file.tmp", $file)
    or die "Could not rename $file.tmp: $!\n";
}

sub hostnum_to_ipaddr ($$)
{
  my ($hostnum, $net_cidr) = @_;

  # Assume 24bit subnet, 8bit hostnum.
  # Find a Perl library for more generality?
  die "$hostnum: hostnum too large\n" if $hostnum > 255;
  my ($prefix) = $net_cidr =~ m"^(\d+\.\d+\.\d+)\.\d+/24$";
  die if !$prefix;
  return "$prefix.$hostnum";
}

sub hostnum_to_ipaddr_cidr ($$)
{
  my ($hostnum, $net_cidr) = @_;

  # Assume 24bit subnet, 8bit hostnum.
  # Find a Perl library for more generality?
  die "$hostnum: hostnum too large\n" if $hostnum > 255;
  my ($prefix) = $net_cidr =~ m"^(\d+\.\d+\.\d+)\.\d+/24$";
  die if !$prefix;
  return "$prefix.$hostnum/24";
}

die "usage: $0 [CA|config|new|pass|old|client] ...\n";
