#!/usr/bin/perl -w
#
# Copyright (c) 2008 Landeshauptstadt München
# Copyright (c) 2008-2010 GONICUS GmbH
#
# Authors: Jan-Marek Glogowski
#          Cajus Pollmeier
#
# 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 2 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/>
#


=head1 NAME

ldap2fai - read FAI config from LDAP and create config space.

=head1 SYNOPSIS

ldap2fai [-hnvW] [-c config] [-D bind_dn ] [-w bind password] [-d dump_dir] [-H hostname] <MAC>

=head1 OPTIONS

B<-h>, B<--help>
    print out this help message

B<-v>, B<--verbose>
    be verbose (multiple v's will increase verbosity) 

B<-n>, B<--foreground>
    dry run (includes verbose)

B<-c>
    LDAP config file (default: /etc/ldap/ldap.conf)

B<-d>
    output dir (default: /var/lib/fai/config)

B<-D>
    bind dn

B<-W>
    prompt for password
    
B<-w>
    read password from command line
		
B<-H>
    check hostname

=head1 DESCRIPTION

ldap2fai is a script to create read the fai config from LDAP and create fai config space.

=head1 BUGS 

Please report any bugs, or post any suggestions, to the GOsa mailing list <gosa-devel@oss.gonicus.de> or to <https://oss.gonicus.de/labs/gosa>


=head1 LICENCE AND COPYRIGHT

This code is part of GOsa (L<http://www.gosa-project.org>)

COPYRIGHT
        Copyright (c) 2008 Landeshauptstadt München
        Copyright (c) 2008-2010 GONICUS GmbH

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.

=cut

use strict;
use warnings;

use Net::LDAP;
use Net::LDAP::Util qw(:escape);
use Getopt::Long;
use File::Path;

use GOsa::Common qw(:ldap :misc);
use GOsa::FAI qw(:flags);

my $bind_dn;
my $bind_pwd;
my $prompt_pwd;
my $ldapuris;
my $ldap_conf = "/etc/ldap/ldap.conf";
my $dump_dir = "/var/lib/fai/config";
my $verbose = 0;
my $print_classes = 0;
my( $hostname, $host_base, $host_dn, $host_tag );
my $fai_mirror;
my $dry_run = 0;
my $release_var = 'FAIclientRelease';
my $check_hostname;
my $kernel;

Getopt::Long::Configure ("bundling");

GetOptions( 'v|verbose' => \$verbose,
            'h|help' => \&usage,
            'c|config=s' => \$ldap_conf,
            'd|dump-dir=s' => \$dump_dir,
            'D|bind-dn=s' => \$bind_dn,
            'n|dry-run' => \$dry_run,
            'W|prompt-pwd' => \$prompt_pwd,
            'w|password=s' => \$bind_pwd,
            'H|hostname=s' => \$check_hostname )
  or usage( 'Wrong parameters' );

# If we use dry-run, be verbose
$verbose = 1 if( $dry_run );

# Get MAC from cmdline
my $mac = shift @ARGV;
$mac eq '' && usage( "MAC address not specified." );
usage( "No valid MAC address specified." )
  if( ! ($mac =~ m/^([0-9a-f]{2}:){5}[0-9a-f]{2}/i) );

# Is dump_dir a directory
if( ! $dry_run ) {
  -d "$dump_dir" 
    || usage("'$dump_dir' is not a directory.\n");
}

# initialize ldap
my $init_results =
  gosa_ldap_init( $ldap_conf, 0, $bind_dn, $prompt_pwd, $bind_pwd );
do_exit( 3, $init_results ) if( 'HASH' ne ref( $init_results ) );

my $ldap = $init_results->{ 'HANDLE' };
my $base = $init_results->{ 'BASE' };

# Get FAI object
my $faiobj = GOsa::FAI->new( 'LDAP' => $ldap,
                             'base' => $base,
                             'dumpdir' => $dump_dir );

# Set FAI flags
$faiobj->flags( FAI_FLAG_VERBOSE ) if( $verbose );
$faiobj->flags( FAI_FLAG_VERBOSE | FAI_FLAG_DRY_RUN ) if( $dry_run );

my $class_str = get_classes( $mac );
print( "  + FAIclass string:    $class_str\n" ) if( $verbose );

my ($res_classlist, $release) = $faiobj->resolve_classlist( $class_str );
if( 'ARRAY' eq ref( $res_classlist ) ) {
  if( $verbose ) {
    print( "  + Release:            $release\n" );
    print( "  + Resolved classlist: " . join( ' ', @$res_classlist ) . "\n" );
  }
}
else { do_exit( 8, $res_classlist ); }

if( ! $dry_run ) {
  create_dir( "$dump_dir/class" );
  open (FAICLASS,">$dump_dir/class/${hostname}")
    || do_exit( 4, "Can't create $dump_dir/class/${hostname}. $!\n" );
  print( FAICLASS join( ' ', @$res_classlist ) );
  close( FAICLASS );
}

$res_classlist = $faiobj->expand_fai_classlist( $res_classlist, $hostname );
if( 'ARRAY' eq ref( $res_classlist ) ) {
  print( "  + FAI classlist:      " . join( ' ', @$res_classlist ) . "\n" )
    if( $verbose );
}

print( "Extending FAI classtree with real objects...\n" );
$faiobj->extend_class_cache( $release );

print( "Dumping config space to '$dump_dir'...\n" );
my( $sections, $error ) = $faiobj->dump_release( $release, $res_classlist, $hostname );
print $error . "\n" if( defined $error );

if( defined ${hostname} ) {
  if( open( HOSTVARS, ">> ${dump_dir}/class/${hostname}.var" ) ) {
    print( HOSTVARS "FAIclientRelease='$release'\n" );
    print( HOSTVARS "MAXPACKAGES=20000\n" );
    print( HOSTVARS "printk=0\n" );
    print( HOSTVARS "STOP_ON_ERROR=700\n" );
    close( HOSTVARS );
  }
}

generate_sources_list( $sections );
generate_kernel_packagelist( $kernel );

$ldap->unbind();   # take down session
$ldap->disconnect();

exit 0;

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
sub usage
{
  (@_) && print STDERR "\n@_\n\n";

  print STDERR << "EOF";
 usage: $0 [-hnvW] [-c config] [-D bind_dn ] [-w bind password] [-d dump_dir] [-H hostname] <MAC>

  -h         : this (help) message
  -n         : dry run (includes verbose)
  -v         : be verbose
  -c         : LDAP config file (default: ${ldap_conf})
  -d         : dump dir (default: ${dump_dir})
  -D         : bind dn 
  -W         : prompt for password
  -w         : read password from command line
  -H <name>  : check hostname

EOF
  exit -1;
}

# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
sub do_exit {
  my ($code,$msg) = @_;

  my @exit_msg = (
    0, # Ok
    0, # Usage
    0, # LDAP error
    0, # No entries found
    0, # Create file
    0, # Mkdir (5)
    0, # LDAP lookup
    0, # FAI object
    "No releases found in classlist. Releases are classes starting with ':'.",
    "Multiple releases found! Fix your classes or profiles.\n",
    0, # Hostname mismatch (10)
    0, # Release object not found
    0, # Multiple profiles
  );

  if( ! defined $msg ) {
    if( exists $exit_msg[ $code ] ) {
      $msg = $exit_msg[ $code ];
    }
  }
  else {
    if( ! exists $exit_msg[ $code ] ) {
      $msg .= "\nMissing exit ID - assign one!";
    }
    elsif( $exit_msg[ $code ] ) {
      $msg .= "\n" . $exit_msg[ $code ];
    }
  }

  print( "$msg\n" ) if( defined $msg );

  $ldap->unbind() if( defined $ldap );

  exit( -1 * $code );
}


sub create_dir
{
  if( ! -d "$_[0]" ) {
    return if( $dry_run );
    eval { 
      mkpath "$_[0]";
    };
    do_exit( 5, "Can't create dir $_[0]: $!\n" ) if( $@ );
  }
}


sub get_classes {

  # return list of FAI classes defined for host
  my $mac = shift;
  my (@classes,$mesg,$entry);
  my $host_info;
  my $real_hostname;

  print( "Lookup host for MAC '$mac'...\n" ) if( $verbose );

  my $filter = "(&(objectClass=goHard)(macAddress=$mac))";
  $mesg = $ldap->search(
    base => "$base",
    filter => $filter,
    attrs => [ 'FAIclass', 'cn', 'FAIdebianMirror', 'gosaUnitTag', 'gotoBootKernel' ]);
  $mesg->code && do_exit( 2, sprintf( "LDAP error: %s (%i)", $mesg->error, __LINE__ ) );

  # normally, only one value should be returned
  if( 1 != $mesg->count ) {
    if( 0 == $mesg->count ) {
      do_exit( 3, "LDAP search for client failed!\n"
        . "No entries have been returned.\n"
        . "  - Base:   $base\n" 
        . "  - Filter: $filter\n" );
    }
    else {
      do_exit( 3, "LDAP search for client failed!\n"
        . $mesg->count . " entries have been returned.\n"
        . "  - Base:   $base\n"
        . "  - Filter: $filter\n" );
    }
  }

  # get the entry, host DN and hostname
  $entry = ($mesg->entries)[0];
  $host_dn = $entry->dn;
  $hostname = $entry->get_value( 'cn' );
  $host_tag = $entry->get_value( 'gosaUnitTag' );
  $kernel = $entry->get_value( 'gotoBootKernel' );
  $real_hostname = $hostname;

  $faiobj->tag( $host_tag ) if( defined $host_tag );

  # set $host_base
  my @rdn = gosa_ldap_split_dn( $host_dn );
  shift( @rdn ); # hostname
  shift( @rdn ); # servers / workstations / terminals
  shift( @rdn ); # systems
  $host_base = join( ',', @rdn );

  # strip domain from LDAP hostname for FAI class
  $hostname =~ s/\..*//;

  $host_info  = "  + Host DN:            $host_dn\n"
              . "  + Base:               $host_base\n"
              . "  + Hostname:           $hostname";
  $host_info .= ' (' . $real_hostname . ')'
    if ( $hostname ne $real_hostname );
  $host_info .= "\n";

  # Check for hostname mismatch
  if( defined $check_hostname ) {
    if( $real_hostname !~ m/^${check_hostname}$/i ) {
      # Try stripped domain (non-FQDN) hostname
      do_exit( 10, "Hostname mismatch: net='$check_hostname', "
          . "LDAP='$real_hostname', non-FQDN='$hostname'" )
        if( $hostname !~ m/^${check_hostname}$/i );
    }
  }

  # check, if we have a FAIclass value, otherwise check groups
  my $fai_class_str = $entry->get_value( 'FAIclass' );
  if( (! defined $fai_class_str) || ('' eq $fai_class_str) ) {
    print( "No FAI information stored in host object - looking for host groups...\n" ) if( $verbose );

    $filter = '(&(member=' . escape_filter_value(${host_dn}) . ')(objectClass=gosaGroupOfNames)(gosaGroupObjects=[W])(objectClass=FAIobject))';
    $mesg = $ldap->search(
      base => "$base",
      filter => $faiobj->prepare_filter( $filter ),
      attrs => [ 'FAIclass', 'cn', 'FAIdebianMirror', 'gotoBootKernel' ]);
    $mesg->code && do_exit( 2, sprintf( "LDAP error: %s (%i)", $mesg->error, __LINE__ ) );

    if( 1 != $mesg->count ) {
      if( 0 == $mesg->count ) {
        do_exit( 3, "LDAP search for object groups with FAIobject containing the client failed!\n"
        . "No entries have been returned.\n"
        . "  - Base:   $base\n"
        . "  - Filter: $filter\n" );
      }
      else {
        do_exit( 3, "LDAP search for object groups with FAIobject containing the client failed!\n"
          . $mesg->count . " entries have been returned.\n"
          . "  - Base:   $base\n"
          . "  - Filter: $filter\n" );
      }
    }

    $entry = ($mesg->entries())[0];
    print( "Found FAI information in object group '" . $entry->get_value( 'cn' )  . "'\n"
          . '  + Object group:       ' . $entry->dn() . "\n" )
      if( $verbose );

    if (not defined $kernel){
      $kernel = $entry->get_value( 'gotoBootKernel' );
    }
  }

  if (not defined $kernel){
      do_exit( 3, "There is no kernel defined for this client: check the gotoBootKernel attribute!\n" );
  }

  $fai_mirror = $entry->get_value( 'FAIdebianMirror' );
 
  print( $host_info ) if $verbose;

  return $entry->get_value( 'FAIclass' );
}


sub generate_sources_list {
  my( $sections ) = @_;
  my( $mesg, $entry, $line, @deblines, @modsections, @rdns, %saw, $debline );

  # Create unique list
  undef %saw;
  @saw{@$sections} = ();
  @$sections = sort keys %saw;

  if ($verbose) {
    print "Generate template '/etc/apt/sources.list' for class 'LAST'\n"
        . " - searching server(s) for\n"
        . "   + release:  ${release}\n"
        . "   + sections: @$sections\n";
  }

  create_dir( "${dump_dir}/files/etc/apt/sources.list" );
  if( ! $dry_run ) {
    open (SOURCES,">${dump_dir}/files/etc/apt/sources.list/LAST")
      || do_exit( 4, "Can't create ${dump_dir}/files/etc/apt/sources.list/LAST. $!\n" );
  }

  if( "auto" ne "$fai_mirror" ) {
    if( ! $dry_run ) {
      print SOURCES "deb $fai_mirror $release @$sections\n";
      close (SOURCES);
    }
    print( " = Using default: $fai_mirror\n" ) if( $verbose );
    return 0;
  }

  my %release_sections = ();
  my @sec = @$sections;
  my ($search_base,@entries);
  $release_sections{ "$release" } = \@sec;

  my $fin = 0;
  
  while( 1 ) {
    # Prepare search base
    if( ! defined $search_base )
      { $search_base = $host_base; }
    else {
      my @rdn = gosa_ldap_split_dn( $search_base );
      shift( @rdn );
      $search_base = join( ',', @rdn );
    }

    print( " - using search start base: $search_base\n" ) if $verbose;

    # Look for repository servers
    ($mesg,$search_base) = gosa_ldap_rsearch( $ldap, $host_base, '',
      $faiobj->prepare_filter( '(objectClass=FAIrepositoryServer)' ),
      'one', 'ou=servers,ou=systems', [ 'FAIrepository', 'cn' ] );

    goto BAILOUT_CHECK_SERVER if( ! defined $mesg );
    $mesg->code && do_exit
      ( 2, sprintf( "LDAP error: %s (%i)", $mesg->error, __LINE__ ) );
    if( 0 == scalar $mesg->entries ) {
      next;
    }

    # Check all found servers
    print( " - found matches in base: $search_base\n" )
       if( $verbose && $mesg->count() );

    $fin = 1;
    foreach $entry ($mesg->entries) {
      print "   - inspecting repository server: " 
        . $entry->get_value('cn') . "\n" if $verbose;

      foreach my $repoline ($entry->get_value('FAIrepository')){
        my (@items) = split( '\|', ${repoline} );
        my (@modsections) = split( ',', $items[3] );

        # Check repository release
        
        if( exists $release_sections{ $items[ 2 ] } ) {

          # Check sections
          # Idea: try to remove local section from global section list.
          # If not remove, removed from local list
          # and add to 
          my $index = 0;
          foreach my $section (@modsections) {
            if( 0 == gosa_array_find_and_remove
              ( $release_sections{ $items[ 2 ] }, $section ) ) 
            {
              splice( @modsections, $index, 1 );
              if( 0 == scalar $release_sections{ $items[ 2 ] } ) {
                delete $release_sections{ $items[ 2 ] };
                last;
              }
            }
            $index++;
          }

          # Add deb-line for server, if we have local sections
          if( scalar @modsections > 0 ) {
            $debline = "deb $items[ 0 ] $items[ 2 ] " . join(' ',@modsections) . "\n";
            print "   + add: $debline" if $verbose;
            print SOURCES "$debline" if( ! $dry_run );
          }

          last if( 0 == scalar keys ( %release_sections ) );
        }
      }

      # Check, if there we still have some sections in any release
      while ( my ($key, $value) = each(%release_sections) ) {
        if (0 != scalar @$value) {
          $fin = 0;
          last;
        }
      }
      last if (1 == $fin);
    }
    last if(1 == $fin);
  }

BAILOUT_CHECK_SERVER:
  if( 0 == $fin ) {
    if( $verbose ) {
      print "Missing sections for release:\n";
      while ( my ($key, $value) = each(%release_sections) ) {
        print " + $key: @$value\n"
      }
    }
    exit -2;
  }

  close (SOURCES) if( ! $dry_run );
}


sub generate_kernel_packagelist {
  my $kernel= shift;
  my( $mesg, $entry, $line, @deblines, @modsections, @rdns, %saw, $debline );

  # Some fallback for "default" values
  if( $kernel eq "default" ) {
    $kernel= "linux-image-2.6-486";
  }

  if ($verbose) {
    print "Generate kernel package script for class 'LAST'\n"
        . " - kernel: ${kernel}\n"
  }

  create_dir( "${dump_dir}/scripts/LAST" );
  if( ! $dry_run ) {
    open (SOURCES,">${dump_dir}/scripts/LAST/99-install-kernel")
      || do_exit( 4, "Can't create ${dump_dir}/scripts/LAST/99-install-kernel. $!\n" );

    print SOURCES "#!/bin/sh\n";
    print SOURCES "# - automatically created by ldap2fai -\n";
    print SOURCES "\$ROOTCMD aptitude -o \"Aptitude::CmdLine::Ignore-Trust-Violations=yes\" -y install $kernel\n";
    close (SOURCES);
    chmod 0700, "${dump_dir}/scripts/LAST/99-install-kernel";
  }
}

# vim:ts=2:sw=2:expandtab:shiftwidth=2:syntax:paste
