#!/usr/bin/perl
#
# File: cz-node-ldap
# Description: Create an LDAP service entry
# Author: Bill MacAllister <whm@dropbox.com>
# Copyright 2026 CZ Software

use AppConfig qw(:argcount :expand);
use Carp;
use Getopt::Long;
use CZ::LDAPtools;
use Net::LDAPapi;
use Pod::Usage;
use strict;

my $CONF;
my $DEBUG_TIME = time();
my $LDAP_MASTER;

my $opt_conf = '/etc/cz-node-ldap.conf';
my $opt_debug;
my $opt_example;
my $opt_help;
my $opt_manual;

my @OBJECTCLASSES
  = ('domain', 'domainRelatedObject', 'czHost', 'krb5Principal');
my %ATTRIBUTES = (
    'comment'    => 'czComments',
    'config'     => 'czConfigID',
    'configlast' => 'czConfigLastRun',
    'history'    => 'czHistory',
    'ip'         => 'ipHostNumber',
    'oskernel'   => 'czOSKernelInstalled',
    'oslast'     => 'czOSLastUpdate',
    'osversion'  => 'czOSVersion',
    'osrunning'  => 'czOSKernelRunning',
    'desc'       => 'description',
    'role'       => 'czRole',
    'status'     => 'czNetStatus',
);

##############################################################################
# Subroutines
##############################################################################

# ------------------------------------------------
# output debugging information

sub dbg {
    my ($tmp)   = @_;
    my $now     = time();
    my $elapsed = $now - $DEBUG_TIME;
    print {*STDOUT} "$now ($elapsed) $tmp \n"
      or croak("debugging print to STDOUT failed: $!");
    $DEBUG_TIME = $now;
    return;
}

#-------------------------------------------------------------------------
# Return host and dn given a fqdn

sub get_host_dn {
    my ($fqdn) = @_;

    my @fqdnParts = split(/[.]/, $fqdn);
    my $host      = shift(@fqdnParts);
    my $dn        = "dc=${host}";
    for my $p (@fqdnParts) {
        $dn .= ",dc=$p";
    }
    $dn .= ',' . $CONF->ldap_net_base();

    return $host, $dn;
}

#-------------------------------------------------------------------------
# standard output

sub msg {
    my ($msg) = @_;
    print {*STDOUT} "$msg\n" or croak("print to STDOUT failed: $!");
    return;
}

# ------------------------------------------------------------------------
# Read configuration properties

sub read_conf {
    my ($filename) = @_;

    if (!$filename) {
        $filename = $opt_conf;
    }

    my $conf = AppConfig->new({});
    $conf->define(
        'default_domain',
        {
            DEFAULT  => 'ca-zephyr.org',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $conf->define(
        'krb_realm',
        {
            DEFAULT  => 'CA-ZEPHYR.ORG',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $conf->define(
        'ldap_bindtype',
        {
            DEFAULT  => 'gssapi',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $conf->define(
        'ldap_base',
        {
            DEFAULT  => 'dc=ca-zephyr,dc=org',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $conf->define(
        'ldap_net_base',
        {
            DEFAULT  => 'ou=net,dc=ca-zephyr,dc=org',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $conf->define(
        'ldap_host',
        {
            DEFAULT  => 'localhost',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $conf->define(
        'ldap_port',
        {
            DEFAULT  => '389',
            ARGCOUNT => ARGCOUNT_ONE
        }
    );
    $conf->define('ldap_password', { ARGCOUNT => ARGCOUNT_ONE });
    $conf->define('ldap_user',     { ARGCOUNT => ARGCOUNT_ONE });

    if (-e $filename) {
        if ($opt_debug) {
            dbg("Reading $filename");
        }
        $conf->file($filename) or die "ERROR: problem reading $filename";
    } else {
        if ($opt_debug) {
            dbg("Configuration file not found ($filename)");
        }
    }

    return $conf;
}

#------------------------------------------------------------------------
# Print example configuration file

sub print_example_config {

    msg('# /etc/cz-node-ldap.conf');
    msg('default_domain   = ldap.com');
    msg('krb_realm        = KRB.REALM');
    msg('ldap_base        = dc=ldap,dc=com');
    msg('ldap_bindtype    = gssapi');
    msg('ldap_net_base    = ou=net,dc=ldap,dc=com');
    msg('ldap_host        = localhost');
    msg('ldap_port        = 389');
    msg('ldap_master_host = master.ldap.com');
    msg('ldap_password    = somepassword');
    msg('ldap_user        = cn=manager,dc=ldap,dc=com');

    return;
}

# ----------------------------------------------------------------------
# Connect to the directory

sub dir_connect {

    if ($CONF->ldap_bindtype eq 'simple') {
        $LDAP_MASTER = lt_ldap_connect(
            {
                host     => $CONF->ldap_host,
                port     => $CONF->ldap_port,
                bindtype => $CONF->ldap_bindtype,
                user_dn  => $CONF->ldap_user,
                user_pw  => $CONF->ldap_password,
                debug    => $opt_debug
            }
        );
    } else {
        $LDAP_MASTER = lt_ldap_connect(
            {
                host     => $CONF->ldap_host,
                port     => $CONF->ldap_port,
                bindtype => $CONF->ldap_bindtype,
                debug    => $opt_debug
            }
        );
    }
    return;
}

# --------------------------------------------------------------------
# Return the LDAP attributes of a host

sub get_host_attr {
    my ($fqdn) = @_;

    my ($host, $dn) = get_host_dn($fqdn);
    my $base   = $dn;
    my $filter = "(objectClass=*)";
    if ($opt_debug) {
        dbg("base:$base filter:$filter");
    }
    my $msg = $LDAP_MASTER->search_s(
        -basedn    => $base,
        -scope     => LDAP_SCOPE_BASE,
        -filter    => $filter,
        -attrs     => [],
        -attrsonly => 0,
    );

    my %entry = ();
    if ($msg != LDAP_SUCCESS) {
        return %entry;
    }

    my %entries = %{ $LDAP_MASTER->get_all_entries };
    if (scalar(%entries) < 1) {
        if ($opt_debug) {
            dbg("Search returned zero entries");
        }
    } elsif (scalar(%entries) > 1) {
        msg("ERROR $fqdn is abigious");
    } else {
        if (exists($entries{$dn})) {
            %entry = %{ $entries{$dn} };
            if ($opt_debug) {
                for my $a (sort keys %entry) {
                    for my $v (@{ $entry{$a} }) {
                        dbg("$a: $v");
                    }
                }
            }
        } else {
            if ($opt_debug) {
                dbg("Search entries zero entries");
            }
        }
    }
    return %entry;
}

# --------------------------------------------------------------------
# Show host's entry details

sub show_host {
    my ($fqdn) = @_;

    my %attrs = get_host_attr($fqdn);

    msg("FQDN: $fqdn");
    for my $a (sort keys %attrs) {
        for my $v (@{ $attrs{$a} }) {
            msg("$a: $v");
        }
    }
    return;
}

# --------------------------------------------------------------------
# Find service entries that match an id fragment

sub find_host {
    my ($frag) = @_;
    $frag =~ s/[.].*//xms;

    my $base   = $CONF->ldap_net_base();
    my $filter = '(&';
    $filter .= "(dc=*$frag*)";
    $filter .= '(|';
    $filter .= '(objectclass=czHost)';
    $filter .= '(objectclass=dNSDomain2)';
    $filter .= ')';
    $filter .= ')';

    if ($opt_debug) {
        dbg("find_host - base:$base filter:$filter");
    }
    my $msg = $LDAP_MASTER->search_s(
        -basedn    => $base,
        -scope     => LDAP_SCOPE_SUBTREE,
        -filter    => $filter,
        -attrs     => [],
        -attrsonly => 0,
    );
    if ($LDAP_MASTER->errno != 0) {
        msg('errno: ' . $LDAP_MASTER->errno)
          . ' errstring:'
          . $LDAP_MASTER->errstring;
        $LDAP_MASTER->perror(
            "ERROR: problem searching using base:$base filter:$filter\n");
    }

    my @lines   = ();
    my %entries = %{ $LDAP_MASTER->get_all_entries };
    if (scalar(%entries) > 0) {
        for my $dn (keys %entries) {
            push @lines, $dn;
        }
        for my $l (sort @lines) {
            msg($l);
        }
    } else {
        msg("No matching entries found");
    }
    return;
}

# --------------------------------------------------------------------
# Get facts about the local host

sub get_node_info {

    my $puppet_conf = '/etc/cz-puppet.conf';
    $puppet_conf =~ s/\s//xmsg;

    my %details = ();

    my $os = `uname -r`;
    $os =~ s/\s//xmsg;
    $details{ $ATTRIBUTES{'osrunning'} } = [$os];

    my @kernels      = `dpkg -l | grep linux-image`;
    my @kernels_trim = ();
    for my $k (@kernels) {
        $k =~ s/\s+/ /xmsg;
        $k =~ s/^\s+//xms;
        $k =~ s/^(\S+\s\S+).*/$1/xms;
        push(@kernels_trim, $k);
    }
    $details{ $ATTRIBUTES{'oskernel'} } = \@kernels_trim;

    my $manifest = '';
    my $open_err = 0;
    if (-e $puppet_conf) {
        open(my $fd, '<', $puppet_conf) or $open_err = $!;
        if ($open_err) {
            $manifest = "ERROR: problem opening $open_err";
        } else {
            while (<$fd>) {
                chomp;
                my $in_line = $_;
                if ($in_line =~ /MANIFEST\_ID\s* =\s* (\S+)/xms) {
                    $manifest = $1;
                    last;
                }
            }
            if (!$manifest) {
                $manifest = "ERROR: manifest id not found in $puppet_conf";
            }
            close($fd);
        }
    } else {
        $manifest = "ERROR: $puppet_conf not found\n";
    }
    $details{ $ATTRIBUTES{'config'} } = [$manifest];
    $details{ $ATTRIBUTES{'role'} }   = [$manifest];

    my %repos         = ();
    my @repo_list     = ();
    my $deb_repo_file = '/etc/apt/sources.list';
    if (-e $deb_repo_file) {
        my $repo_err = 0;
        open(my $fd, '<', $deb_repo_file)
          or $repo_err = 1;
        if ($repo_err) {
            push(@repo_list, "ERROR: problem opening $deb_repo_file");
        } else {
            while (<$fd>) {
                chomp;
                my $inline = $_;
                if ($inline =~ /^deb \s+ (\S+) \s+ (\S+)/xms) {
                    my $repo     = $1;
                    my $repoName = $2;
                    $repos{$repoName} += 1;
                }
            }
            close($fd);
            if (scalar(keys %repos)) {
                for my $repo (sort keys %repos) {
                    push(@repo_list, "$repo ($repos{$repo})");
                }
            } else {
                push(@repo_list,
                    "ERROR: no repositories found in $deb_repo_file");
            }
        }
    } else {
        push(@repo_list, "ERROR: $deb_repo_file not found");
    }
    $details{ $ATTRIBUTES{'osversion'} } = \@repo_list;

    if ($opt_debug) {
        for my $a (sort keys %details) {
            for my $v (@{ $details{$a} }) {
                dbg("host details: $a = $v");
            }
        }
    }
    return %details;
}

# --------------------------------------------------------------------
# Add a host` entry

sub add_host {
    my ($fqdn) = @_;

    # Values that every entry needs
    my ($host, $dn) = get_host_dn($fqdn);
    my $princ = "host/${fqdn}@" . $CONF->krb_realm;
    if ($opt_debug) {
        dbg("fqdn:$fqdn");
        dbg("dn:$dn");
    }

    my %host_entry = ();

    $host_entry{'dc'}                = [$host];
    $host_entry{'associatedDomain'}  = [$fqdn];
    $host_entry{'objectclass'}       = \@OBJECTCLASSES;
    $host_entry{'krb5PrincipalName'} = [$princ];
    # Set defaults from what we can learn ourselves
    my %info = get_node_info();
    for my $a (sort keys %info) {
        $host_entry{$a} = \@{ $info{$a} };
    }

    # Create the LDAP entry
    if ($LDAP_MASTER->add_s($dn, \%host_entry) != LDAP_SUCCESS) {
        my $msg = 'ERROR: ' . $LDAP_MASTER->errstring;
        $msg .= ' (' . $LDAP_MASTER->errno . ')';
        $msg .= "\nINFO: problem dn ($dn)";
        msg($msg);
    } else {
        msg("ADDED: $dn");
    }

    return;
}

# --------------------------------------------------------------------
# Delete a host entry

sub del_host {
    my ($fqdn) = @_;

    my ($host, $dn) = get_host_dn($fqdn);

    if ($LDAP_MASTER->delete_s($dn) != LDAP_SUCCESS) {
        my $msg = 'ERROR: ' . $LDAP_MASTER->errstring;
        $msg .= ' (' . $LDAP_MASTER->errno . ')';
        msg($msg);
    } else {
        msg("DELETED: $dn");
    }

    return;
}

# --------------------------------------------------------------------
# Update host entry

sub update_host {
    my ($fqdn) = @_;

    my ($host, $dn) = get_host_dn($fqdn);
    my $princ = "host/${fqdn}@" . $CONF->krb_realm;

    if ($opt_debug) {
        dbg('----------------------------------------------');
        dbg("Host update for fqdn:$fqdn");
        dbg("dn:$dn");
        dbg("princ:$princ");
    }

    my %add_list    = ();
    my %del_list    = ();
    my %update_list = ();
    my $update_msg  = '';

    # Get defaults
    my %info = get_node_info();

    # Get the attributes from the existing entry
    my %host_entry = get_host_attr($fqdn);
    my $a          = 'associatedDomain';
    if (!exists($host_entry{$a})) {
        $add_list{$a} = { 'a', [$fqdn] };
        $update_msg .= "Adding $a: $fqdn\n";
    }
    $a = 'krb5PrincipalName';
    if (!exists($host_entry{$a})) {
        $add_list{$a} = { 'a', [$princ] };
        $update_msg .= "Adding $a: $princ\n";
    }

    # Make sure the objectclass list is up to date
    $a = 'objectClass';
    my @oc_update = ();
    for my $this_oc (@OBJECTCLASSES) {
        my $oc_found = 0;
        for my $oc (@{ $host_entry{$a} }) {
            if ($this_oc eq $oc) {
                $oc_found = 1;
            }
        }
        if (!$oc_found) {
            push(@oc_update, $this_oc);
            msg("Adding $a: $this_oc");
        }
    }
    if (scalar(@oc_update) > 0) {
        $add_list{$a} = { 'a', \@oc_update };
    }

    # Look for thinks to delete
    for my $a (sort keys %info) {
        my @d_list  = ();
        my $v_found = 0;
        if (exists($host_entry{$a})) {
            for my $host_v (@{ $host_entry{$a} }) {
                $v_found = 0;
                for my $v (@{ $info{$a} }) {
                    if ($v eq $host_v) {
                        $v_found = 1;
                        last;
                    }
                }
                if (!$v_found) {
                    msg("Deleting $a: $host_v");
                    push(@d_list, $host_v);
                }
            }
        }
        if (scalar(@d_list) > 0) {
            $del_list{$a} = { 'd', \@d_list };
        }
    }

    # Look for thinks to add
    for my $a (sort keys %info) {
        my $a_found = 0;
        my @a_list  = ();
        for my $this_v (@{ $info{$a} }) {
            if (exists($host_entry{$a})) {
                for my $v (@{ $host_entry{$a} }) {
                    if ($v eq $this_v) {
                        $a_found = 1;
                        last;
                    }
                }
            }
            if (!$a_found) {
                push(@a_list, $this_v);
                msg("Adding $a: $this_v");
            }
        }
        if (scalar(@a_list) > 0) {
            $add_list{$a} = { 'a', \@a_list };
        }
    }

    # Update what needs to be updated
    if (scalar(%del_list) > 0) {
        if ($LDAP_MASTER->modify_s($dn, \%del_list) != LDAP_SUCCESS) {
            my $msg = 'ERROR: ' . $LDAP_MASTER->errstring;
            $msg .= ' (' . $LDAP_MASTER->errno . ')';
            $msg .= "\nINFO: problem dn ($dn)";
            msg($msg);
        }
    }
    if (scalar(%add_list) > 0) {
        if ($LDAP_MASTER->modify_s($dn, \%add_list) != LDAP_SUCCESS) {
            my $msg = 'ERROR: ' . $LDAP_MASTER->errstring;
            $msg .= ' (' . $LDAP_MASTER->errno . ')';
            $msg .= "\nINFO: problem dn ($dn)";
            msg($msg);
        }
    }
    return;
}

##############################################################################
# Main routine
##############################################################################

# -- get options
GetOptions(
    'conf=s'  => \$opt_conf,
    'debug'   => \$opt_debug,
    'example' => \$opt_example,
    'help'    => \$opt_help,
    'manual'  => \$opt_manual
);

# -- Flush output immediately
local $| = 1;

# Display an example configuration file
if ($opt_example) {
    print_example_config();
    exit;
}

if (defined($ARGV[0])) {
    if ($ARGV[0] eq 'help') {
        $opt_help = 1;
    }
    if ($ARGV[0] eq 'manual') {
        $opt_manual = 1;
    }
} else {
    $opt_help = 1;
}

# Display help if requested
if ($opt_help) {
    pod2usage(-verbose => 0);
}
if ($opt_manual) {
    pod2usage(-verbose => 2);
}

# Read the configuration
$CONF = read_conf($opt_conf);

# Command line arguments
my $action = shift(@ARGV);
my $frag   = shift(@ARGV);

my $fqdn = `hostname -f`;
$fqdn =~ s/\s//xmsg;
if ($opt_debug) {
    dbg("INFO: using FQDN of $fqdn\n");
}

# Read the configuration file
if ($action =~ /^(add|del|up|show|find)/xms) {
    if ($fqdn !~ /[.]/xms) {
        $fqdn .= '.' . $CONF->default_domain;
    }
}

dir_connect();

if ($action =~ /^(add|del|up)/xms && $frag) {
    msg("ERROR: too many arguments");
    exit 1;
}

my $entry_found;
if ($action =~ /^(add|del|up|show)/xms) {
    my %entry = get_host_attr($fqdn);
    if (scalar(keys %entry)) {
        $entry_found = 1;
    } else {
        $entry_found = 0;
    }
}

if ($action eq 'add') {
    if ($entry_found) {
        msg("ERROR: entry $fqdn already exists");
        exit 1;
    }
    add_host($fqdn);
} elsif ($action =~ /^del/xms) {
    if (!$entry_found) {
        msg("ERROR: nothing found to delete for $fqdn");
        exit 1;
    }
    del_host($fqdn);
} elsif ($action =~ /^up/xms) {
    if (!$entry_found) {
        add_host($fqdn);
    } else {
        update_host($fqdn);
    }
} elsif ($action eq 'show') {
    if (!$entry_found) {
        msg("ERROR: entry $fqdn not found\n");
        exit 1;
    }
    show_host($fqdn);
} elsif ($action eq 'find') {
    find_host($fqdn);
} else {
    msg("ERROR: unknown action ($action)");
    exit 1;
}

$LDAP_MASTER->unbind;

exit;

__END__

=head1 NAME

cz-node-ldap - maintain LDAP service entries

=head1 SYNOPSIS

cz-node-ldap update|delete|show|find|help [<host>|<fqdn>|<fragment>]
[--help] [--manual] [--debug]

=head1 DESCRIPTION

This script is for the maintenance of host entries in the directory.
The script accumulates data from the running system and either adds
or updates an LDAP entry with the data.

=head1 ACTIONS

=over 4

=item update [<host|fqdn>]

Update a host entry in the directory.  If and entry does not
exist then one is created.  Command line options are ignored
and the data written to the directory is derived from the
running system.

=item delete <host|fqdn>

Delete a host entry from the directory.

=item show <host|fqdn>

Dump attributes and values for host entry.

=item find <fragment>

Display distinguished names of all hosts that match the filter
(&(dc=*host*)(objectclass=czHost)).

=back

=head1 OPTIONS

=over 4

=item --conf=file.conf

The configuration file.  The default is /etc/ldaptools.conf.

=item --example

Print an example configuration file to STDOUT.

=item --help

Display short help text.

=item --manual

Display the complete documentation.

=item --debug

Display debugging messages.

=back

=head1 AUTHOR

Bill MacAllister <bill@ca-zephyr.org>

=head1 COPYRIGHT

Copyright (C) 2026, CZ Software

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

    http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

=cut
