#!/usr/bin/perl -T

use strict;
use warnings;
use Net::LDAP;
use Net::LDAP::LDIF;
use Date::Parse;
use esmith::ConfigDB;
use esmith::AccountsDB;
use esmith::util;
use Getopt::Long qw(:config bundling);

$ENV{'PATH'} = '/bin:/usr/bin:/sbin:/usr/sbin';
$ENV{'LANG'} = 'C';
$ENV{'TZ'} = '';

sub dnsort {
    my %type = ( add => 1, modrdn => 2, moddn => 2, modify => 3, delete => 4);
    my %attr = ( dc => 1, ou => 2, cn => 3, uid => 4);

    my ($oa) = ($a->get_value('newrdn') || $a->dn) =~ /^([^=]+)=/;
    my ($ob) = ($b->get_value('newrdn') || $b->dn) =~ /^([^=]+)=/;
    my ($ua, $ub) = map { my $tu = $_->get_value('uidnumber'); defined $tu && $tu ne '' ? $tu : -1 } ($a, $b);
    my ($ga, $gb) = map { my $tg = $_->get_value('gidnumber'); defined $tg && $tg ne '' ? $tg : -1 } ($a, $b);

    ($attr{$oa} || 9) <=> ($attr{$ob} || 9) || ($type{$a->changetype} || 9) <=> ($type{$b->changetype} || 9) || 
    $ua <=> $ub || $ga <=> $gb || ($a->get_value('newrdn') || $a->dn) cmp ($b->get_value('newrdn') || $b->dn);
}

my $c = esmith::ConfigDB->open_ro;
my $a = esmith::AccountsDB->open_ro;

my $auth = $c->get('ldap')->prop('Authentication') || 'disabled';
my $schema = '/etc/openldap/schema/samba.schema';

my $domain = $c->get('DomainName')->value;
my $basedn = esmith::util::ldapBase($domain);

my $userou = 'ou=Users';
my $groupou = 'ou=Groups';
my $compou = 'ou=Computers';

my ($dc) = split /\./, $domain;
my $company = $c->get_prop('ldap', 'defaultCompany') || $domain;

my %opt;
GetOptions ( \%opt, "diff|d", "update|u", "input|i=s", "output|o=s" );
$opt{input} = '/usr/sbin/slapcat -c 2> /dev/null|' unless $opt{input} && ($opt{input} eq '-' || -f "$opt{input}" || -c "$opt{input}");
$opt{diff} = 1 if $opt{update};
if ( $opt{output} && $opt{output} =~ m{^([-\w/.]+)$}) {
    $opt{output} = $1;
} else {
    $opt{output} = '-';
}

my ($data, $dn);

# Top object (base)
$data->{$basedn} = {
    objectclass => [qw/organization dcObject top/],
    dc          => $dc,
    o           => $company,
};

# Top containers for users/groups/computers
foreach (qw/Users Groups Computers/) {
    $data->{"ou=$_,$basedn"} = {
        objectclass => [qw/organizationalUnit top/],
        ou          => $_,
    };
}

# Common accounts needed for SME to work properly
$data->{"cn=nobody,$groupou,$basedn"}->{objectclass} = [ qw/posixGroup/ ];
$data->{"uid=www,$userou,$basedn"}->{objectclass} = [ qw/account/ ];
$data->{"cn=www,$groupou,$basedn"} = { objectclass => [ qw/posixGroup/ ], memberuid   => [ qw/admin/ ] };
$data->{"cn=rsshusers,$groupou,$basedn"}->{objectclass} = [ qw/posixGroup/ ];
$data->{"cn=shared,$groupou,$basedn"} = {
    objectclass => [ qw/posixGroup mailboxRelatedObject/ ],
    mail        => "everyone\@$domain",
    memberuid   => [ qw/www/ ]
};

# Read in accounts database information
foreach my $acct ($a->get('admin'), $a->users, $a->groups, $a->ibays, $a->get_all_by_prop(type => 'machine')) {
    my $key = $acct->key;
    my $type = $acct->prop('type');

    next if $key eq 'Primary';

    $dn = "uid=$key,".($type eq 'machine' ? $compou : $userou).",$basedn";
    if ($type =~ /^(?:user|group|machine|ibay)$/ || $key eq 'admin') {
        if ($type eq 'user' || $key eq 'admin') {
            # Allow removal of obsolete person objectclass and samba attributes
            push @{$data->{$dn}->{_delete}->{objectclass}}, 'person';
            

            push @{$data->{$dn}->{objectclass}}, 'inetOrgPerson';
            $data->{$dn}->{mail} = "$key\@$domain";
            @{$data->{$dn}}{qw/givenname sn telephonenumber o ou l street/} =
                map { $acct->prop($_) || [] } qw/FirstName LastName Phone Company Dept City Street/;
            $data->{$dn}->{cn} = $acct->prop('FirstName').' '.$acct->prop('LastName');
        }
        else {
            push @{$data->{$dn}->{objectclass}}, 'account';
        }

        # users/ibays need to be a member of shared
        push @{$data->{"cn=shared,$groupou,$basedn"}->{memberuid}}, $key if $type =~ /^(user|ibay)$/ || $key eq 'admin';

        # users need to be a member of rsshusers if their shell is /usr/bin/rssh
        push @{$data->{"cn=rsshusers,$groupou,$basedn"}->{memberuid}}, $key if ($type =~ /^(user)$/ || $key eq 'admin') && (($acct->prop('Shell') || '/usr/bin/rssh') eq '/usr/bin/rssh');

        if ($auth ne 'enabled') {
            # Allow removal of shadow properties
            push @{$data->{$dn}->{_delete}->{objectclass}}, 'shadowAccount';
            $data->{$dn}->{_delete}->{lc($_)} = 1 foreach qw/userPassword shadowLastChange shadowMin shadowMax
                                                             shadowWarning shadowInactive shadowExpire shadowFlag/;

            if ( -f "$schema" ) {
                # If we will be adding samba properties then allow removal
                push @{$data->{$dn}->{_delete}->{objectclass}}, 'sambaSamAccount';
                $data->{$dn}->{_delete}->{lc($_)} = 1 foreach qw/displayName sambaAcctFlags sambaLMPassword sambaNTPassword 
                                                                 sambaNTPassword sambaPrimaryGroupSID sambaPwdLastSet sambaSID/;
            }
        }
    }

    $dn = "cn=$key,$groupou,$basedn";
    push @{$data->{$dn}->{objectclass}}, 'posixGroup';
    if ($type eq 'group') {
        # Allways replace memberuid with new set
        $data->{$dn}->{_delete}->{memberuid} = 1;

        push @{$data->{$dn}->{objectclass}}, 'mailboxRelatedObject';

        $data->{$dn}->{mail} = "$key\@$domain";
        $data->{$dn}->{description} = $acct->prop('Description') || [];
        push @{$data->{$dn}->{memberuid}}, split /,/, ($acct->prop('Members') || '');

        # www needs to be a memeber of every group
        push @{$data->{$dn}->{memberuid}}, 'www';

        if ($auth ne 'enabled' && -f "$schema" ) {
            # If we will be adding samba properties then allow removal
            push @{$data->{$dn}->{_delete}->{objectclass}}, 'sambaGroupMapping';
            $data->{$dn}->{_delete}->{lc($_)} = 1 foreach qw/displayName sambaGroupType sambaSID/;
        }
    }
    elsif ($type eq 'ibay') {
        $dn = "cn=".$acct->prop('Group').",$groupou,$basedn";
        push @{$data->{$dn}->{memberuid}}, $acct->key;
    }
}

if ($auth ne 'enabled') {
    # Read in information from unix (passwd) system 
    open PASSWD, '/etc/passwd';
    while (<PASSWD>) {
        chomp;
        my @passwd = split /:/, $_;
        next unless scalar @passwd == 7;

        $dn = "uid=$passwd[0],".($passwd[0] =~ /\$$/ ? $compou : $userou).",$basedn";
        next unless exists $data->{$dn};

        push @{$data->{$dn}->{objectclass}}, 'posixAccount';
        @{$data->{$dn}}{qw/cn uid uidnumber gidnumber homedirectory loginshell/} =
            map { $passwd[$_] ? $passwd[$_] : [] } (4,0,2,3,5,6);
    }
    close (PASSWD);

    # Shadow file defaults (pulled from cpu.conf)
    my %shadow_def = ( 1 => [], 2 => 11192, 3 => -1, 4 => 99999, 5 => 7, 6 => -1, 7 => -1, 8 => 134538308 );

    # Read in information from unix (shadow) system 
    open SHADOW, '/etc/shadow';
    while (<SHADOW>) {
        chomp;
        my @shadow = split /:/, $_;
        next unless scalar @shadow >= 6;
        $shadow[1] = '!*' if $shadow[1] eq '!!';
        $shadow[1] = "{CRYPT}$shadow[1]" unless $shadow[1] =~ /^\{/;

        $dn = "uid=$shadow[0],".($shadow[0] =~ /\$$/ ? $compou : $userou).",$basedn";
        next unless exists $data->{$dn};

        push @{$data->{$dn}->{objectclass}}, 'shadowAccount';
        @{$data->{$dn}}{ map { lc($_) } qw/userPassword shadowLastChange shadowMin shadowMax shadowWarning shadowInactive 
                                           shadowExpire shadowFlag/} = map { $shadow[$_] ? $shadow[$_] : $shadow_def{$_} } (1..8);
    }
    close (SHADOW);

    # Read in information from unix (group) system 
    open GROUP, '/etc/group';
    while (<GROUP>) {
        chomp;
        my @group = split /:/, $_;
        next unless scalar @group >= 3;
        $group[3] = [ split /,/, ($group[3] || '') ];

        $dn = "cn=$group[0],$groupou,$basedn";
        next unless exists $data->{$dn};

        push @{$data->{$dn}->{objectclass}}, 'posixGroup';
        @{$data->{$dn}}{qw/cn gidnumber/} = map { $group[$_] ? $group[$_] : [] } (0,2);
        push @{$data->{$dn}->{memberuid}}, @{$group[3]};
    }
    close (GROUP);

    my %smbprop = (
        'User SID'            => 'sambasid',
        'Account Flags'       => 'sambaacctflags',
        'Primary Group SID'   => 'sambaprimarygroupsid',
        'Full Name'           => 'displayname',
        'Password last set'   => 'sambapwdlastset',
    );

    # Read in information from unix (smbpasswd) system 
    if ( -f "$schema" && -x '/usr/bin/pdbedit' ) {
        $dn = undef;
        open SMBDETAIL, '/usr/bin/pdbedit -vL 2> /dev/null|';
        while (<SMBDETAIL>) {
            chomp;

            $dn = ("uid=$1,".($1 =~ /\$$/ ? $compou : $userou).",$basedn") if m/^Unix username:\s+(\S.*)$/;
            next unless $dn && exists $data->{$dn};

            # Map the samba account properties that we care about
            $data->{$dn}->{$smbprop{$1}} = ($2 ? str2time($2) : (defined $3 ? $3 : []))
                if m/^(.+):\s+(?:(\S.*\d{4} \d{2}:\d{2}:\d{2}.*)|(.*))$/ && exists $smbprop{$1};
        }
        close (SMBDETAIL);

        open SMBPASSWD, '/usr/bin/pdbedit -wL 2> /dev/null|';
        while (<SMBPASSWD>) {
            chomp;
            my @smbpasswd = split /:/, $_;
            next unless scalar @smbpasswd >= 6;

            $dn = "uid=$smbpasswd[0],".($smbpasswd[0] =~ /\$$/ ? $compou : $userou).",$basedn";
            next unless exists $data->{$dn} && exists $data->{$dn}->{uidnumber} && $data->{$dn}->{uidnumber} eq $smbpasswd[1];

            push @{$data->{$dn}->{objectclass}}, 'sambaSamAccount';
            @{$data->{$dn}}{qw/sambalmpassword sambantpassword/} = map { $smbpasswd[$_] ? $smbpasswd[$_] : [] } (2,3);
        }
        close (SMBPASSWD);
    }

    if ( -f "$schema" && -x '/usr/bin/net' ) {
        open GROUPMAP, '/usr/bin/net groupmap list 2> /dev/null|';
        while (<GROUPMAP>) {
            chomp;

            if (m/^(.+) \((.+)\) -> (.+)$/) {
                # Skip local machine accounts
                next if $2 =~ /S-1-5-32-\d+/;

                $dn = "cn=$3,$groupou,$basedn";
                next unless exists $data->{$dn};

                push @{$data->{$dn}->{objectclass}}, 'sambaGroupMapping';
                @{$data->{$dn}}{qw/displayname sambasid sambagrouptype/} = ($1, $2, 2);
            }
        }
        close (GROUPMAP);
    }
}

my @ldif;

# Loop through ldap data and update as necessary
my $reader = Net::LDAP::LDIF->new( $opt{input}, 'r', onerror => 'undef' );
while( not $reader->eof()) {
    my $entry = $reader->read_entry() || next;
    $dn = $entry->dn;

    # Ensure the basedn is correct
    $dn = "$1$basedn" if $dn =~ /^((?:(?!dc=)[^,]+,)*)dc=/;

    # Ensure correct ou is part of user/groups/computers
    if ($dn =~ /^(uid=([^,\$]+)(\$)?),((?:(?!dc=)[^,]+,)*)dc=/) {
        if ( defined $3 && $3 eq '$') {
            $dn = "$1,$compou,$basedn";
        }
        elsif (grep /posixGroup/, @{$entry->get_value('objectclass', asref => 1) || []}) {
            $dn = "cn=$2,$groupou,$basedn";
            
            # Cleanup attributes that the modrdn will perform
            $entry->add(cn => $2);
            $entry->delete(uid => [$2]);
        }
        else {
            $dn = "$1,$userou,$basedn";
        }
    }
    elsif ($dn =~ /^(cn=[^,]+),((?:(?!dc=)[^,]+,)*)dc=/) {
        $dn = "$1,$groupou,$basedn" unless $2 =~ /^ou=auto\./;
    }

    # Don't process records twice
    next if $data->{$dn}->{_done};

    # Rename existing entry into place if we can
    if ($dn ne $entry->dn) {
        my $rdn = Net::LDAP::Entry->new;
        $rdn->dn($entry->dn);
        $rdn->changetype('modrdn');
        my ($newdn, $newbase) = split /,/, $dn, 2;
        $rdn->add(newrdn => $newdn, deleteoldrdn => 1, newsuperior => $newbase);
        push @ldif, $rdn;

        # Now we can change the entry to new dn
        $entry->dn($dn);
    }

    # Change type to modify so that we can keep track of changes we make
    $entry->changetype('modify');

    # Hack to make upgrades work (add calEntry if calFGUrl attributes exists)
    if ($entry->exists('calFBURL') && -f "/etc/openldap/schema/rfc2739.schema") {
        push @{$data->{$dn}->{objectclass}}, 'calEntry';
    }

    my %attributes = ();
    @attributes{ keys %{$data->{$dn}}, exists $data->{$dn}->{_delete} ? map { lc($_) } keys %{$data->{$dn}->{_delete}} : () } = ();

    foreach my $attr (sort keys %attributes) {
        # Skip the pseudo attributes
        next if $attr =~ /^_/;

        my @l = @{$entry->get_value($attr, asref => 1) || []};
        my @u = exists $data->{$dn}->{$attr} ? (ref $data->{$dn}->{$attr} ? @{$data->{$dn}->{$attr}} : ($data->{$dn}->{$attr})) : ();

        # Figure out differences between attributes
        my (@lonly, @uonly, @donly, %lseen, %useen, %dseen) = () x 6;

        # Unique lists of what is in ldap and what needs to be in ldap
        @lseen{@l} = ();
        @useen{@u} = ();

        # Create list of attributes that aren't in the other
        @uonly = grep { ! exists $lseen{$_} } keys %useen;
        @lonly = grep { ! exists $useen{$_} } keys %lseen;

        # Determine which of the ldap only attributes we need to remove
        if ((keys %useen == 1 && keys %lseen == 1) || (keys %useen == 0 && exists $data->{$dn}->{$attr})) {
            # Replacing a single entry or erasing entire entry
            @donly = @lonly;
        }
        elsif ($data->{$dn}->{_delete} && $data->{$dn}->{_delete}->{$attr}) {
            if (my $ref = ref($data->{$dn}->{_delete}->{$attr})) {
                # Map hash keys or array elemts to valid values to delete
                @dseen{$ref eq 'HASH' ? keys %{$data->{$dn}->{_delete}->{$attr}} : @{$data->{$dn}->{_delete}->{$attr}}} = ();
                @donly = grep { exists $dseen{$_} } @lonly;
            }
            else {
                # Permission to remove all values
                @donly = @lonly;
            }
        }

        if (@donly && @donly == keys %lseen) {
            # If we are removing all ldap attributes do a remove or full delete
            if (@uonly) {
                $entry->replace($attr => [ @uonly ]);
            }
            else {
                $entry->delete($attr => []);
            }
        }
        else {
            $entry->delete($attr => [ @donly ]) if @donly;
            $entry->add($attr => [ @uonly ]) if @uonly;
        }
    }

    $data->{$dn}->{_done} = 1;
    push @ldif, $entry;
}
$reader->done();

# Add missing records that didn't exist in ldap yet
foreach $dn (grep { ! exists $data->{$_}->{_done} } sort keys %$data) {
    my $entry = Net::LDAP::Entry->new;
    $entry->dn($dn);

    foreach my $attr (sort keys %{$data->{$dn}}) {
        # Skip the pseudo attributes
        next if $attr =~ /^_/;

        my %seen = ();
        @seen{ref $data->{$dn}->{$attr} ? @{$data->{$dn}->{$attr}} : ($data->{$dn}->{$attr})} = ();
        $entry->add($attr => [ sort keys %seen ]) if keys %seen != 0;
    }

    push @ldif, $entry;
}

#------------------------------------------------------------
# Update LDAP database entry.
#------------------------------------------------------------
my $ldap;
if ($opt{update}) {
    $ldap = Net::LDAP->new('localhost') or die "$@";
    $ldap->bind( dn => "cn=root,$basedn", password => esmith::util::LdapPassword() );
}

my $writer = Net::LDAP::LDIF->new( $opt{output}, 'w', onerror => 'undef', wrap => 0, sort => 1, change => $opt{diff} );
foreach my $entry (sort dnsort @ldif) {
    if ($opt{update} && ($entry->changetype ne 'modify' || @{$entry->{changes}}) ) {
        my $result = $entry->update($ldap);
        warn "Failure to ",$entry->changetype," ",$entry->dn,": ",$result->error,"\n" if $result->code;
    }

    if ($writer->{change} || $entry->changetype !~ /modr?dn/) {
        $writer->write_entry($entry);
    }
}
