##########################################################################
# Copyright (c) 2010-2022 Alexander Bluhm <alexander.bluhm@gmx.net>
#
# Permission to use, copy, modify, and distribute this software for any
# purpose with or without fee is hereby granted, provided that the above
# copyright notice and this permission notice appear in all copies.
#
# THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
# WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
# MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
# ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
# WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
# ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
# OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
##########################################################################

use strict;
use warnings;

=pod

=head1 NAME

OSPF::LSDB::ospfd - parse OpenBSD B<ospfd> link state database

=head1 SYNOPSIS

use OSPF::LSDB::ospfd;

my $ospfd = OSPF::LSDB::ospfd-E<gt>new();

my $ospfd = OSPF::LSDB::ospfd-E<gt>new(ssh => "user@host");

$ospfd-E<gt>parse(%files);

=head1 DESCRIPTION

The OSPF::LSDB::ospfd module parses the output of OpenBSD B<ospfctl>
and fills the L<OSPF::LSDB> base object.
The output of
C<show summary>,
C<show database router>,
C<show database network>,
C<show database summary>,
C<show database asbr>,
C<show database external>
is needed.
It can be given as separate files or obtained dynamically.
In the latter case B<doas> is invoked if permissions are not
sufficient to run B<ospfctl>.
If the object has been created with the C<ssh> argument, the specified
user and host are used to login and run B<ospfctl> there.

There is only one public method:

=cut

package OSPF::LSDB::ospfd;
use base 'OSPF::LSDB';
use File::Slurp;
use Regexp::Common;
use fields qw(
    ospfd ospfctl ospfsock showdb
    selfid
    router network summary boundary external
);

sub new {
    my OSPF::LSDB::ospfd $self = OSPF::LSDB::new(@_);
    $self->{ospfd} = "ospfd";
    $self->{ospfctl} = "ospfctl";
    # XXX hard coded routing domain 0
    $self->{ospfsock} = "/var/run/ospfd.sock.0";
    $self->{showdb} = {
	router   => "router",
	network  => "network",
	summary  => "summary",
	boundary => "asbr",
	external => "external",
    };
    return $self;
}

# shortcut
my $IP = qr/$RE{net}{IPv4}{-keep}/;

sub ospfctl_show {
    my OSPF::LSDB::ospfd $self = shift;
    my @cmd = ($self->{ospfctl}, "show", @_);
    if ($self->{ssh}) {
	unshift @cmd, "ssh", $self->{ssh};
    } elsif (-S $self->{ospfsock}) {
	# no doas if user is root or in wheel group
	# srw-rw----  1 root  wheel  0 Jun 13 10:10 /var/run/ospfd.sock
	unshift @cmd, "doas" unless -w $self->{ospfsock};
    } else {
	die "Control socket '$self->{ospfsock}' for $self->{ospfd} missing.\n";
    }
    open(my $fh, '-|', @cmd)
	or die "Open pipe from '@cmd' failed: $!";
    my @lines = $fh->getlines();
    close($fh) or die $! ?
	"Close pipe from '@cmd' failed: $!" :
	"Command '@cmd' failed: $?\n";
    return wantarray ? @lines : join("", @lines);
}

sub read_files {
    my OSPF::LSDB::ospfd $self = shift;
    my %files = @_;
    my $file = $files{selfid};
    my @lines = $file ? read_file($file) : $self->ospfctl_show("summary");
    $self->{selfid} = \@lines;
    foreach (sort keys %{$self->{showdb}}) {
	my $file = $files{$_};
	my @lines = $file ? read_file($file) :
	    $self->ospfctl_show("database", $self->{showdb}{$_});
	$self->{$_} = \@lines;
    }
}

sub parse_self {
    my OSPF::LSDB::ospfd $self = shift;
    my($routerid, @areas);
    foreach (@{$self->{selfid}}) {
	if (/^Router ID: $IP$/) {
	    $routerid = $1;
	} elsif (/^Area ID: $IP$/) {
	    push @areas, $1;
	}
    }
    $self->{ospf}{self} = { routerid => $routerid, areas => \@areas };
}

sub parse_router {
    my OSPF::LSDB::ospfd $self = shift;
    my($area, $router, $link) = ("", "", "");
    my(@routers, $lnum, $type, $r, $l);
    foreach (@{$self->{router}}) {
	if (/^ +Router Link States \(Area $IP\)$/) {
	    die "$_ Link $link of router $router in area $area not finished.\n"
	      if $l;
	    die "$_ Too few links at router $router in area $area.\n" if $lnum;
	    die "$_ Router $router in area $area not finished.\n" if $r;
	    $area = $1;
	    next;
	} elsif (/^$/) {
	    if ($l) {
		die "Link $link of router $router in area $area without type.\n"
		  if ! $type;
		push @{$r->{$type.'s'}}, $l;
		undef $type;
		undef $l;
	    }
	    if (! $lnum) {
		undef $r;
	    }
	    next;
	} elsif (! $r && /^\w/) {
	    die "$_ No area for router defined.\n" if ! $area;
	    $router = "";
	    $r = { area => $area };
	    push @routers, $r;
	} elsif (! $l && /^ {4}\w/) {
	    die "$_ Too many links at router $router in area $area.\n"
	      if ! $lnum;
	    $lnum--;
	    $link = "";
	    $l = {};
	}
	if (/^LS age: $RE{num}{int}{-keep}$/) {
	    $r->{age} = $1;
	} elsif (/^LS Type: ([\w ()]+)$/) {
	    die "$_ Type of router-LSA is $1 and not Router in area $area.\n"
	      if $1 ne "Router";
	} elsif (/^Link State ID: $IP$/) {
	    $r->{router} = $1;
	    $router = $1;
	} elsif (/^Advertising Router: $IP$/) {
	    $r->{routerid} = $1;
	} elsif (/^LS Seq Number: (0x$RE{num}{hex})$/) {
	    $r->{sequence} = $1;
	} elsif (/^Flags: ([-*|\w]*)$/) {
	    my $flags = $1;
	    foreach (qw(V E B)) {
		$r->{bits}{$_} = $flags =~ /\b$_\b/ ? 1 : 0;
	    }
	} elsif (/^Number of Links: $RE{num}{int}{-keep}$/) {
	    $lnum = $1;
	} elsif (/^    Link connected to: ([\w -]+)$/) {
	    if ($1 eq "Point-to-Point") {
		$type = "pointtopoint";
	    } elsif ($1 eq "Transit Network") {
		$type = "transit";
	    } elsif ($1 eq "Stub Network") {
		$type = "stub";
	    } elsif ($1 eq "Virtual Link") {
		$type = "virtual";
	    } else {
		die "$_ Unknown link type $1 at router $router ".
		  "in area $area.\n";
	    }
	} elsif (/^    Link ID \(Neighbors Router ID\): $IP$/) {
	    $l->{routerid} = $1;
	    $link = $1;
	} elsif (/^    Link ID \(Designated Router address\): $IP$/) {
	    $l->{address} = $1;
	    $link = $1;
	} elsif (/^    Link ID \(Network ID\): $IP$/) {
	    $l->{network} = $1;
	    $link = $1;
	} elsif (/^    Link Data \(Router Interface address\): $IP$/) {
	    $l->{interface} = $1;
	} elsif (/^    Link Data \(Network Mask\): $IP$/) {
	    $l->{netmask} = $1;
	} elsif (/^    Metric: $RE{num}{int}{-keep}$/) {
	    $l->{metric} = $1;
	} elsif (/^ {4}\w/) {
	    die "$_ Unknown line at link $link of router $router ".
	      "in area $area.\n";
	} elsif (! /^(Options|Checksum|Length):/) {
	    die "$_ Unknown line at router $router in area $area.\n";
	}
    }
    die "Link $link of router $router in area $area not finished.\n" if $l;
    die "Too few links at router $router in area $area.\n" if $lnum;
    die "Router $router in area $area not finished.\n" if $r;
    $self->{ospf}{database}{routers} = \@routers;
}

sub parse_network {
    my OSPF::LSDB::ospfd $self = shift;
    my($area, $network) = ("", "");
    my(@networks, $attachments, $rnum, $n);
    foreach (@{$self->{network}}) {
	if (/^ +Net Link States \(Area $IP\)$/) {
	    die "$_ Attached routers of network $network in area $area ".
	      "not finished.\n" if $attachments;
	    die "$_ Network $network in area $area not finished.\n" if $n;
	    $area = $1;
	    next;
	} elsif (/^$/) {
	    die "$_ Too few attached routers at network $network ".
	      "in area $area.\n" if $rnum;
	    undef $attachments;
	    undef $n;
	    next;
	} elsif (! $n) {
	    die "$_ No area for network defined.\n" if ! $area;
	    $network = "";
	    $n = { area => $area };
	    push @networks, $n;
	}
	if (/^LS age: $RE{num}{int}{-keep}$/) {
	    $n->{age} = $1;
	} elsif (/^LS Type: ([\w ()]+)$/) {
	    die "$_ Type of network-LSA is $1 and not Network in area $area.\n"
	      if $1 ne "Network";
	} elsif (/^Link State ID: $IP \(address of Designated Router\)$/) {
	    $n->{address} = $1;
	    $network = $1;
	} elsif (/^Advertising Router: $IP$/) {
	    $n->{routerid} = $1;
	} elsif (/^LS Seq Number: (0x$RE{num}{hex})$/) {
	    $n->{sequence} = $1;
	} elsif (/^Network Mask: $IP$/) {
	    $n->{netmask} = $1;
	} elsif (/^Number of Routers: $RE{num}{int}{-keep}$/) {
	    $rnum = $1;
	} elsif (/^    Attached Router: $IP$/) {
	    if (! $attachments) {
		$attachments = [];
		$n->{attachments} = $attachments;
	    }
	    $rnum-- if defined $rnum ;
	    push @$attachments, { routerid => $1 };
	} elsif (! /^(Options|Checksum|Length):/) {
	    die "$_ Unknown line at network $network in area $area.\n";
	}
    }
    die "Attached routers of network $network in area $area not finished.\n"
      if $attachments;
    die "Network $network in area $area not finished.\n" if $n;
    $self->{ospf}{database}{networks} = \@networks;
}

sub parse_summary {
    my OSPF::LSDB::ospfd $self = shift;
    my($area, $summary) = ("", "");
    my(@summarys, $s);
    foreach (@{$self->{summary}}) {
	if (/^ +Summary Net Link States \(Area $IP\)$/) {
	    die "$_ Summary $summary in area $area not finished.\n" if $s;
	    $area = $1;
	    next;
	} elsif (/^$/) {
	    undef $s;
	    next;
	} elsif (! $s) {
	    die "$_ No area for summary defined.\n" if ! $area;
	    $summary = "";
	    $s = { area => $area };
	    push @summarys, $s;
	}
	if (/^LS age: $RE{num}{int}{-keep}$/) {
	    $s->{age} = $1;
	} elsif (/^LS Type: ([\w ()]+)$/) {
	    die "$_ Type of summary-LSA is $1 and not Summary (Network) ".
	      "in area $area.\n" if $1 ne "Summary (Network)";
	} elsif (/^Link State ID: $IP \(Network ID\)$/) {
	    $s->{address} = $1;
	    $summary = $1;
	} elsif (/^Advertising Router: $IP$/) {
	    $s->{routerid} = $1;
	} elsif (/^LS Seq Number: (0x$RE{num}{hex})$/) {
	    $s->{sequence} = $1;
	} elsif (/^Network Mask: $IP$/) {
	    $s->{netmask} = $1;
	} elsif (/^Metric: $RE{num}{int}{-keep}$/) {
	    $s->{metric} = $1;
	} elsif (! /^(Options|Checksum|Length):/) {
	    die "$_ Unknown line at summary $summary in area $area.\n";
	}
    }
    die "Summary $summary in area $area not finished.\n" if $s;
    $self->{ospf}{database}{summarys} = \@summarys;
}

sub parse_boundary {
    my OSPF::LSDB::ospfd $self = shift;
    my($area, $boundary) = ("", "");
    my(@boundarys, $b);
    foreach (@{$self->{boundary}}) {
	if (/^ +Summary Router Link States \(Area $IP\)$/) {
	    die "$_ Boundary $boundary in area $area not finished.\n" if $b;
	    $area = $1;
	    next;
	} elsif (/^$/) {
	    undef $b;
	    next;
	} elsif (! $b) {
	    die "$_ No area for boundary defined.\n" if ! $area;
	    $boundary = "";
	    $b = { area => $area };
	    push @boundarys, $b;
	}
	if (/^LS age: $RE{num}{int}{-keep}$/) {
	    $b->{age} = $1;
	} elsif (/^LS Type: ([\w ()]+)$/) {
	    die "$_ Type of boundary-LSA is $1 and not Summary (Router) ".
	      "in area $area.\n" if $1 ne "Summary (Router)";
	} elsif (/^Link State ID: $IP \(ASBR Router ID\)$/) {
	    $b->{asbrouter} = $1;
	    $boundary = $1;
	} elsif (/^Advertising Router: $IP$/) {
	    $b->{routerid} = $1;
	} elsif (/^LS Seq Number: (0x$RE{num}{hex})$/) {
	    $b->{sequence} = $1;
	} elsif (/^Metric: $RE{num}{int}{-keep}$/) {
	    $b->{metric} = $1;
	} elsif (! /^(Options|Checksum|Length|Network Mask):/) {
	    die "$_ Unknown line at boundary $boundary in area $area.\n";
	}
    }
    die "Boundary $boundary in area $area not finished.\n" if $b;
    $self->{ospf}{database}{boundarys} = \@boundarys;
}

sub parse_external {
    my OSPF::LSDB::ospfd $self = shift;
    my $external = "";
    my(@externals, $e);
    foreach (@{$self->{external}}) {
	if (/^ +Type-5 AS External Link States$/) {
	    die "$_ External $external not finished.\n" if $e;
	    die "$_ Too many external sections.\n", if @externals;
	    next;
	} elsif (/^$/) {
	    undef $e;
	    next;
	} elsif (! $e) {
	    $external = "";
	    $e = {};
	    push @externals, $e;
	}
	if (/^LS age: $RE{num}{int}{-keep}$/) {
	    $e->{age} = $1;
	} elsif (/^LS Type: ([\w ()]+)$/) {
	    die "$_ Type of external-LSA is $1 and not AS External.\n"
	      if $1 ne "AS External";
	} elsif (/^Link State ID: $IP \(External Network Number\)$/) {
	    $e->{address} = $1;
	    $external = $1;
	} elsif (/^Advertising Router: $IP$/) {
	    $e->{routerid} = $1;
	} elsif (/^LS Seq Number: (0x$RE{num}{hex})$/) {
	    $e->{sequence} = $1;
	} elsif (/^Network Mask: $IP$/) {
	    $e->{netmask} = $1;
	} elsif (/^    Metric type: ([1-2])$/) {
	    $e->{type} = $1;
	} elsif (/^    Metric: $RE{num}{int}{-keep}$/) {
	    $e->{metric} = $1;
	} elsif (/^    Forwarding Address: $IP$/) {
	    $e->{forward} = $1;
	} elsif (! /^(Options|Checksum|Length|    External Route Tag):/) {
	    die "$_ Unknown line at external $external.\n";
	}
    }
    die "External $external not finished.\n" if $e;
    $self->{ospf}{database}{externals} = \@externals;
}

sub parse_lsdb {
    my OSPF::LSDB::ospfd $self = shift;
    $self->parse_router();
    $self->parse_network();
    $self->parse_summary();
    $self->parse_boundary();
    $self->parse_external();
}

=pod

=over 4

=item $self-E<gt>parse(%files)

This function takes a hash with file names as value containing the
B<ospfctl> output data.
The hash keys are named C<selfid>, C<router>, C<network>, C<summary>,
C<boundary>, C<external>.
If a hash entry is missing, B<ospfctl> is run instead to obtain the
information dynamically.

The complete OSPF link state database is stored in the B<ospf> field
of the base class.

=back

=cut

sub parse {
    my OSPF::LSDB::ospfd $self = shift;
    my %files = @_;
    $self->read_files(%files);
    $self->parse_self();
    $self->parse_lsdb();
    $self->{ospf}{ipv6} = 0;
}

=pod

This module has been tested with OpenBSD 4.8 and 5.1.
If it works with other versions is unknown.

=head1 ERRORS

The methods die if any error occurs.

=head1 SEE ALSO

L<OSPF::LSDB>,
L<OSPF::LSDB::ospf6d>

L<ospfd2yaml>

=head1 AUTHORS

Alexander Bluhm

=cut

1;
