The Perl Toolchain Summit needs more sponsors. If your company depends on Perl, please support this very important event.
package App::Netdisco::AnyEvent::Nbtstat;

use strict;
use warnings;

use Socket qw(AF_INET SOCK_DGRAM inet_aton sockaddr_in);
use List::Util ();
use Carp       ();

use AnyEvent (); BEGIN { AnyEvent::common_sense }
use AnyEvent::Util ();

sub new {
    my ( $class, %args ) = @_;

    my $interval = $args{interval};
    # This default should generate ~ 50 requests per second
    $interval = 0.2 unless defined $interval;

    my $timeout = $args{timeout};

    # Timeout should be 250ms according to RFC1002, but we're going to double
    $timeout = 0.5 unless defined $timeout;

    my $self = bless { interval => $interval, timeout => $timeout, %args },
        $class;

    Scalar::Util::weaken( my $wself = $self );

    socket my $fh4, AF_INET, Socket::SOCK_DGRAM(), 0
        or Carp::croak "Unable to create socket : $!";

    AnyEvent::Util::fh_nonblocking $fh4, 1;
    $self->{fh4} = $fh4;
    $self->{rw4} = AE::io $fh4, 0, sub {
        if ( my $peer = recv $fh4, my $resp, 2048, 0 ) {
            $wself->_on_read( $resp, $peer );
        }
    };

    # Nbtstat tasks
    $self->{_tasks} = {};

    return $self;
}

sub interval { @_ > 1 ? $_[0]->{interval} = $_[1] : $_[0]->{interval} }

sub timeout { @_ > 1 ? $_[0]->{timeout} = $_[1] : $_[0]->{timeout} }

sub nbtstat {
    my ( $self, $host, $cb ) = @_;

    my $ip   = inet_aton($host);
    my $port = 137;

    my $request = {
        host        => $host,
        results     => {},
        cb          => $cb,
        destination => scalar sockaddr_in( $port, $ip ),
    };

    $self->{_tasks}{ $request->{destination} } = $request;

    my $delay = $self->interval * scalar keys %{ $self->{_tasks} || {} };

    # There's probably a better way to throttle the sends
    # but this will work for now since we currently don't support retries
    my $w; $w = AE::timer $delay, 0, sub {
        undef $w;
        $self->_send_request($request);
    };

    return $self;
}

sub _on_read {
    my ( $self, $resp, $peer ) = @_;

    ($resp) = $resp =~ /^(.*)$/s
        if AnyEvent::TAINT && $self->{untaint};

    # Find our task
    my $request = $self->{_tasks}{$peer};

    return unless $request;

    $self->_store_result( $request, 'OK', $resp );

    return;
}

sub _store_result {
    my ( $self, $request, $status, $resp ) = @_;

    my $results = $request->{results};

    my @rr          = ();
    my $mac_address = "";

    if ( $status eq 'OK' && length($resp) > 56 ) {
        my $num_names = unpack( "C", substr( $resp, 56 ) );
        my $name_data = substr( $resp, 57 );

        for ( my $i = 0; $i < $num_names; $i++ ) {
            my $rr_data = substr( $name_data, 18 * $i, 18 );
            push @rr, _decode_rr($rr_data);
        }

        $mac_address = join "-",
            map { sprintf "%02X", $_ }
            unpack( "C*", substr( $name_data, 18 * $num_names, 6 ) );
        $results = {
            'status'      => 'OK',
            'names'       => \@rr,
            'mac_address' => $mac_address
        };
    }
    elsif ( $status eq 'OK' ) {
        $results = { 'status' => 'SHORT' };
    }
    else {
        $results = { 'status' => $status };
    }

    # Clear request specific data
    delete $request->{timer};

    # Cleanup
    delete $self->{_tasks}{ $request->{destination} };

    # Done
    $request->{cb}->($results);

    undef $request;

    return;
}

sub _send_request {
    my ( $self, $request ) = @_;

    my $msg = "";
    # We use process id as identifier field, since don't have a need to
    # unique responses beyond host / port queried 
    $msg .= pack( "n*", $$, 0, 1, 0, 0, 0 );
    $msg .= _encode_name( "*", "\x00", 0 );
    $msg .= pack( "n*", 0x21, 0x0001 );

    $request->{start} = time;

    $request->{timer} = AE::timer $self->timeout, 0, sub {
        $self->_store_result( $request, 'TIMEOUT' );
    };

    my $fh = $self->{fh4};

    send $fh, $msg, 0, $request->{destination}
        or $self->_store_result( $request, 'ERROR' );

    return;
}

sub _encode_name {
    my $name   = uc(shift);
    my $pad    = shift || "\x20";
    my $suffix = shift || 0x00;

    $name .= $pad x ( 16 - length($name) );
    substr( $name, 15, 1, chr( $suffix & 0xFF ) );

    my $encoded_name = "";
    for my $c ( unpack( "C16", $name ) ) {
        $encoded_name .= chr( ord('A') + ( ( $c & 0xF0 ) >> 4 ) );
        $encoded_name .= chr( ord('A') + ( $c & 0xF ) );
    }

    # Note that the _encode_name function doesn't add any scope,
    # nor does it calculate the length (32), it just prefixes it
    return "\x20" . $encoded_name . "\x00";
}

sub _decode_rr {
    my $rr_data = shift;

    my @nodetypes = qw/B-node P-node M-node H-node/;
    my ( $name, $suffix, $flags ) = unpack( "a15Cn", $rr_data );
    $name =~ tr/\x00-\x19/\./;    # replace ctrl chars with "."
    $name =~ s/\s+//g;

    my $rr = {};
    $rr->{'name'}   = $name;
    $rr->{'suffix'} = $suffix;
    $rr->{'G'}      = ( $flags & 2**15 ) ? "GROUP" : "UNIQUE";
    $rr->{'ONT'}    = $nodetypes[ ( $flags >> 13 ) & 3 ];
    $rr->{'DRG'}    = ( $flags & 2**12 ) ? "Deregistering" : "Registered";
    $rr->{'CNF'}    = ( $flags & 2**11 ) ? "Conflict" : "";
    $rr->{'ACT'}    = ( $flags & 2**10 ) ? "Active" : "Inactive";
    $rr->{'PRM'}    = ( $flags & 2**9 ) ? "Permanent" : "";

    return $rr;
}

1;
__END__

=head1 NAME

App::Netdisco::AnyEvent::Nbtstat - Request NetBIOS node status with AnyEvent

=head1 SYNOPSIS

    use App::Netdisco::AnyEvent::Nbtstat;;

    my $request = App::Netdisco::AnyEvent::Nbtstat->new();

    my $cv = AE::cv;

    $request->nbtstat(
        '127.0.0.1',
        sub {
            my $result = shift;
            print "MAC: ", $result->{'mac_address'} || '', " ";
            print "Status: ", $result->{'status'}, "\n";
            printf '%3s %-18s %4s %-18s', '', 'Name', '', 'Type'
                if ( $result->{'status'} eq 'OK' );
            print "\n";
            for my $rr ( @{ $result->{'names'} } ) {
                printf '%3s %-18s <%02s> %-18s', '', $rr->{'name'},
                    $rr->{'suffix'},
                    $rr->{'G'};
                print "\n";
            }
            $cv->send;
        }
    );

    $cv->recv;

=head1 DESCRIPTION

L<App::Netdisco::AnyEvent::Nbtstat> is an asynchronous AnyEvent NetBIOS node
status requester.

=head1 ATTRIBUTES

L<App::Netdisco::AnyEvent::Nbtstat> implements the following attributes.

=head2 C<interval>

    my $interval = $request->interval;
    $request->interval(1);

Interval between requests, defaults to 0.02 seconds.

=head2 C<timeout>

    my $timeout = $request->timeout;
    $request->timeout(2);

Maximum request response time, defaults to 0.5 seconds.

=head1 METHODS

L<App::Netdisco::AnyEvent::Nbtstat> implements the following methods.

=head2 C<nbtstat>

    $request->nbtstat($ip, sub {
        my $result = shift;
    });

Perform a NetBIOS node status request of $ip.

=head1 SEE ALSO

L<AnyEvent>

=cut