1#!/usr/bin/env perl
2
3use strict;
4use warnings;
5
6our $home;
7
8BEGIN {
9  use FindBin;
10  FindBin::again();
11
12  $home = ($ENV{NETDISCO_HOME} || $ENV{HOME});
13
14  # try to find a localenv if one isn't already in place.
15  if (!exists $ENV{PERL_LOCAL_LIB_ROOT}) {
16      use File::Spec;
17      my $localenv = File::Spec->catfile($FindBin::RealBin, 'localenv');
18      exec($localenv, $0, @ARGV) if -f $localenv;
19      $localenv = File::Spec->catfile($home, 'perl5', 'bin', 'localenv');
20      exec($localenv, $0, @ARGV) if -f $localenv;
21
22      die "Sorry, can't find libs required for App::Netdisco.\n"
23        if !exists $ENV{PERLBREW_PERL};
24  }
25}
26
27BEGIN {
28  use Path::Class;
29
30  # stuff useful locations into @INC and $PATH
31  unshift @INC,
32    dir($FindBin::RealBin)->parent->subdir('lib')->stringify,
33    dir($FindBin::RealBin, 'lib')->stringify;
34
35  use Config;
36  $ENV{PATH} = $FindBin::RealBin . $Config{path_sep} . $ENV{PATH};
37}
38
39use App::Netdisco;
40use Dancer ':script';
41use Dancer::Plugin::DBIC 'schema';
42use Dancer::Plugin::Passphrase;
43use App::Netdisco::Util::Statistics ();
44
45info "App::Netdisco $App::Netdisco::VERSION loaded.";
46
47use 5.010_000;
48use Term::UI;
49use Term::ReadLine;
50use Term::ANSIColor;
51
52use Archive::Extract;
53$Archive::Extract::PREFER_BIN = 1;
54use File::Slurper 'read_lines';
55use HTTP::Tiny;
56use Digest::MD5;
57use Try::Tiny;
58use File::Path ();
59use File::Copy ();
60use Encode;
61
62=head1 NAME
63
64netdisco-deploy - Database, OUI and MIB deployment for Netdisco
65
66=head1 USAGE
67
68This script deploys the Netdisco database schema, OUI data, and MIBs. Each of
69these is an optional service which the user is asked to confirm.
70
71Pre-existing requirements are that there be a database table created and a
72user with rights to create tables in that database. Both the table and user
73name must match those configured in your environment YAML file (default
74F<~/environments/deployment.yml>).
75
76This script will download the latest MAC address vendor prefix data from the
77Internet, and update the OUI table in the database. Hence Internet access is
78required to run the script.
79
80Similarly the latest Netdisco MIB bundle is also downloaded and placed into
81the user's home directory (or C<$ENV{NETDISCO_HOME}>).
82
83If you upgrade Netdisco make sure you run this script again to make sure
84your config remains compatible.
85
86Before each upgrade also review the
87L<Release notes|https://github.com/netdisco/netdisco/wiki/Release-Notes> since
88additional steps might be required!
89
90=cut
91
92print color 'bold cyan';
93say 'This is the Netdisco 2 deployment script.';
94say '';
95say 'Before we continue, the following prerequisites must be in place:';
96say ' * Database added to PostgreSQL for Netdisco';
97say ' * User added to PostgreSQL with rights to the Netdisco Database';
98say ' * "~/environments/deployment.yml" file configured with Database dsn/user/pass';
99say ' * A full backup of any existing Netdisco database data';
100say ' * Internet access (for OUIs and MIBs)';
101say '';
102say 'If you are upgrading Netdisco 2 read the release notes:';
103say 'https://github.com/netdisco/netdisco/wiki/Release-Notes';
104say 'There you will find required and incompatible changes';
105say 'which are not covered by this script.';
106say '';
107say 'You will be asked to confirm all changes to your system.';
108say '';
109print color 'reset';
110
111my $term = Term::ReadLine->new('netdisco');
112my $bool = $term->ask_yn(
113  prompt => 'So, is all of the above in place?', default => 'n',
114);
115
116exit(0) unless $bool;
117
118say '';
119$bool = $term->ask_yn(
120  prompt => 'Would you like to deploy the database schema?', default => 'n',
121);
122deploy_db() if $bool;
123
124say '';
125$bool = $term->ask_yn(
126  prompt => 'Download and update vendor MAC prefixes (OUI data)?', default => 'n',
127);
128deploy_oui() if $bool;
129
130say '';
131my $default_mibhome = dir($home, 'netdisco-mibs');
132if (setting('mibhome') and setting('mibhome') ne $default_mibhome) {
133    my $mibhome = $term->get_reply(
134      print_me => "MIB home options:",
135      prompt   => "Download and update MIB files to...?",
136      choices  => [setting('mibhome'), $default_mibhome, 'Skip this.'],
137      default  => 'Skip this.',
138    );
139    deploy_mibs($mibhome) if $mibhome and $mibhome ne 'Skip this.';
140}
141else {
142    $bool = $term->ask_yn(
143      prompt => "Download and update MIB files?", default => 'n',
144    );
145    deploy_mibs($default_mibhome) if $bool;
146}
147
148sub deploy_db {
149  system 'netdisco-db-deploy';
150  print color 'bold blue';
151  say 'DB schema update complete.';
152  print color 'reset';
153
154  print color 'bold blue';
155  print 'Updating statistics... ';
156  App::Netdisco::Util::Statistics::update_stats();
157  say 'done.';
158  print color 'reset';
159
160  if (not setting('safe_password_store')) {
161      say '';
162      print color 'bold red';
163      say '*** WARNING: Weak password hashes are being stored in the database! ***';
164      say '*** WARNING: Please add "safe_password_store: true" to your ~/environments/deployment.yml file. ***';
165      print color 'reset';
166  }
167
168  sub _make_password {
169    my $pass = (shift || passphrase->generate_random);
170    if (setting('safe_password_store')) {
171        return passphrase($pass)->generate;
172    }
173    else {
174        return Digest::MD5::md5_hex($pass),
175    }
176  }
177
178  # set up initial admin user
179  my $users = schema('netdisco')->resultset('User');
180  if ($users->search({-bool => 'admin'})->count == 0) {
181      say '';
182      print color 'bold green';
183      say 'We need to create a user for initial login. This user will be a full Administrator.';
184      say 'Afterwards, you can go to Admin -> User Management to manage users.';
185      print color 'reset';
186      say '';
187
188      my ($name, $pass) = get_userpass($term);
189      $users->create({
190        username => $name,
191        password => _make_password($pass),
192        admin => 'true',
193        port_control => 'true',
194      });
195
196      print color 'bold blue';
197      say 'New user created.';
198      print color 'reset';
199  }
200
201  # set initial dancer web session cookie key
202  schema('netdisco')->resultset('Session')->find_or_create(
203    {id => 'dancer_session_cookie_key', a_session => \'md5(random()::text)'},
204    {key => 'primary'},
205  );
206}
207
208sub get_userpass {
209  my $upterm = shift;
210  my $name = $upterm->get_reply(prompt => 'Username: ');
211  my $pass = $upterm->get_reply(prompt => 'Password: ');
212
213  unless ($name and $pass) {
214    say 'username and password cannot be empty, please try again.';
215    ($name, $pass) = get_userpass($upterm);
216  }
217
218  return ($name, $pass);
219}
220
221sub deploy_oui {
222  my $schema = schema('netdisco');
223  $schema->storage->disconnect;
224  my @lines = ();
225  my %data = ();
226
227  if (@ARGV) {
228      @lines = File::Slurper::read_lines($ARGV[0]);
229  }
230  else {
231      my $url = 'https://raw.githubusercontent.com/netdisco/upstream-sources/master/ieee/oui.txt';
232      my $resp = HTTP::Tiny->new->get($url);
233      @lines = split /\n/, $resp->{content};
234  }
235
236  if (scalar @lines > 50) {
237      foreach my $line (@lines) {
238          if ($line =~ m/^\s*(.{2}-.{2}-.{2})\s+\(hex\)\s+(.*)\s*$/i) {
239              my ($oui, $company) = ($1, $2);
240              $oui =~ s/-/:/g;
241              $company =~ s/[\r\n]//g;
242              my $abbrev = shorten($company);
243              $data{lc($oui)}{'company'} = $company;
244              $data{lc($oui)}{'abbrev'}  = $abbrev;
245          }
246      }
247
248      if ((scalar keys %data) > 15_000) {
249          $schema->txn_do(sub{
250            $schema->resultset('Oui')->delete;
251            $schema->resultset('Oui')->populate([
252                          map {
253                              {   oui     => $_,
254                                  company => Encode::decode('UTF-8', $data{$_}{'company'}),
255                                  abbrev  => Encode::decode('UTF-8', $data{$_}{'abbrev'}),
256                              }
257                              } keys %data
258            ]);
259          });
260      }
261
262      print color 'bold blue';
263      say 'OUI update complete.';
264  }
265  else {
266      print color 'bold red';
267      say 'OUI update failed!';
268  }
269
270  print color 'reset';
271}
272
273# This subroutine is based on Wireshark's make-manuf
274# http://anonsvn.wireshark.org/wireshark/trunk/tools/make-manuf
275sub shorten {
276    my $manuf = shift;
277
278    $manuf = decode("utf8", $manuf, Encode::FB_CROAK) unless @ARGV;
279    $manuf = " " . $manuf . " ";
280
281    # Remove any punctuation
282    $manuf =~ tr/',.()/    /;
283
284    # & isn't needed when Standalone
285    $manuf =~ s/ \& / /g;
286
287    # remove junk whitespace
288    $manuf =~ s/\s+/ /g;
289
290    # Remove any "the", "inc", "plc" ...
291    $manuf
292        =~ s/\s(?:the|inc|incorporated|plc|systems|corp|corporation|s\/a|a\/s|ab|ag|kg|gmbh|co|company|limited|ltd|holding|spa)(?= )//gi;
293
294    # Convert to consistent case
295    $manuf =~ s/(\w+)/\u\L$1/g;
296
297    # Deviating from make-manuf for HP
298    $manuf =~ s/Hewlett[-]?Packard/Hp/;
299
300    # Truncate all names to first two words max 20 chars
301    if (length($manuf) > 21) {
302        my @twowords = grep {defined} (split ' ', $manuf)[0 .. 1];
303        $manuf = join ' ', @twowords;
304    }
305
306    # Remove all spaces
307    $manuf =~ s/\s+//g;
308
309    return encode( "utf8", $manuf );
310}
311
312sub deploy_mibs {
313  my $mibhome = dir(shift); # /path/to/netdisco-mibs
314  my $fail = 0;
315
316  my $latest = 'https://github.com/netdisco/netdisco-mibs/releases/latest';
317  my $resp = HTTP::Tiny->new->get($latest);
318
319  if ($resp->{url} =~ m/([0-9.]+)$/) {
320    my $ver = $1;
321    my $url = "https://github.com/netdisco/netdisco-mibs/releases/download/${ver}/netdisco-mibs.tar.gz";
322    my $file = file($home, 'netdisco-mibs.tar.gz');
323    $resp = HTTP::Tiny->new->mirror($url, $file);
324
325    if ($resp->{success}) {
326      my $ae = Archive::Extract->new(archive => $file, type => 'tgz');
327      $ae->extract(to => $mibhome->parent->stringify);
328
329      my $from = file($mibhome->parent->stringify, "netdisco-mibs-$ver");
330      my $to = file($mibhome->parent->stringify, 'netdisco-mibs');
331
332      if (-d $from) {
333        File::Path::remove_tree($to, { verbose => 0 });
334        File::Copy::move($from, $to);
335      }
336      unlink $file;
337    }
338    else { ++$fail }
339  }
340  else { ++$fail }
341
342  if ($fail) {
343    print color 'bold red';
344    say 'MIB download failed!';
345  }
346  else {
347    print color 'bold blue';
348    say 'MIBs update complete.';
349  }
350
351  print color 'reset';
352}
353
354exit 0;
355