Bivio::Util::LinuxConfig
# Copyright (c) 2002-2013 bivio Software, Inc. All Rights Reserved.
# $Id$
package Bivio::Util::LinuxConfig;
use strict;
use Bivio::Base 'Bivio.ShellUtil';
b_use('IO.Trace');
my($_D) = b_use('Bivio.Die');
our($_TRACE);
b_use('IO.Config')->register(my $_CFG = {
root_prefix => '',
networks => {},
});
sub USAGE {
return <<'EOF';
usage: b-linux-config [options] command [args...]
commands:
add_aliases alias:value ... -- add entries to aliases
add_crontab_line user entry... -- add entries to crontab
add_group group[:gid] -- add a group
add_sendmail_class_line filename line ... -- add values trusted-users, relay-domains, etc
add_user user[:uid] [group[:gid] [shell]] -- create a user
add_users_to_group group user... -- add users to group
append_lines file owner group perms line ... -- appends lines to a file if they don't already exist
delete_aliases user entry... -- delete entries from crontab
delete_file file -- deletes file
disable_service service... -- calls chkconfig and stops services
enable_service service ... -- enables service
rename_rpmnew all | file.rpmnew... -- renames rpmnew to orig and rpmsaves orig
replace_file file owner group perms content -- replaces file with content
serial_console [speed] -- configure grub and securetty for serial port console
sh_param file param value ... -- updates an sh-style config file
split_file file -- splits a file into an array, ignoring # comments
sshd_param param value ... -- add or delete a parameter from sshd config
EOF
}
sub add_aliases {
# Adds aliases: 'foo: bar'. Ensures a \t is between : and destination.
return _add_aliases('/etc/aliases', ':', @_);
}
sub add_bashrc_d {
my($self) = @_;
# Updates /etc/bashrc to search /etc/bashrc.d.
return _mkdir($self, '/etc/bashrc.d', 0755)
. _edit($self, '/etc/bashrc', ['$', <<'EOF', qr{/etc/bashrc.d/}]);
# Load local bashrcs
for i in /etc/bashrc.d/*.sh ; do
if [ -r $i ]; then
. $i
fi
done
unset i
EOF
}
sub add_crontab_line {
my($self, $user, @entry) = @_;
# Add I<entry>s to this I<user>'s crontab.
return $self->append_lines(
-d '/var/spool/cron/tabs' ? "/var/spool/cron/tabs/$user"
: "/var/spool/cron/$user",
'root',
$user,
0600,
@entry,
);
}
sub add_group {
my($self, $group) = @_;
# If you want a specific gid, append it with a colon, e.g.
#
# add_group support:498
#
# Returns string if it created the group. Does nothing if group exists.
$self->usage_error('must supply a group') unless $group;
my($gname, $gid) = split(/:/, $group);
my($real) = (getgrnam($gname))[2];
if (defined($real)) {
Bivio::IO::Alert->warn("$gname: expected gid ($gid) but got ($real)")
if defined($gid) && $real != $gid;
return '';
}
return _exec($self, 'groupadd '
. (defined($gid) ? "-g '$gid' " : '')
. "'$gname'")
}
sub add_sendmail_class_line {
my($self, $file, @value) = @_;
# Adds I<value>s to class file (e.g. trusted-users),
# creating if it doesn't exist.
return $self->append_lines("/etc/mail/$file", 'root', 'mail', 0640,
@value);
}
sub add_user {
my($self, $user, $group, $shell) = @_;
# Adds I<user> with optional I<group> and I<shell>. Set I<group> is '', if you
# want to set I<shell>. User isn't added if it exists.
#
# If you want a specific uid or gid, append it with a colon, e.g.
#
# add_user support:498 support:498
$self->usage_error('must at least supply a user') unless $user;
my($res) = '';
$group = $user unless $group;
$res .= $self->add_group($group);
$group =~ s/:.*//;
my($uname, $uid) = split(/:/, $user);
my($real) = (getpwnam($uname))[2];
if (defined($real)) {
Bivio::IO::Alert->warn("$uname: expected uid ($uid) but got ($real)")
if defined($uid) && $uid != $real;
return '';
}
return $res . _exec($self, 'useradd -m '
. (defined($uid) ? "-u '$uid' " : '')
. ($group ? "-g '$group' " : '')
. ($shell ? "-s '$shell' " : '')
. "'$uname'");
}
sub add_users_to_group {
my($self, $group, @user) = @_;
# Adds users to /etc/group.
my($res) = _edit($self, '/etc/group', map {
my($user) = $_;
[
qr{^($group:.*:)(.*)}m,
sub {$1 . (length($2) ? "$2,$user" : "$user")},
qr{^$group:.*[:,]$user(,|$)}m,
];
} @user);
$res .= _exec($self, 'grpconv')
if -f '/etc/gshadow' && $res && $> == 0;
return $res;
}
sub append_lines {
my($self, $file, $owner, $group, $perms, @lines) = @_;
# Adds lines to file, creating if necessary.
$perms = oct($perms) if $perms =~ /^0/;
return _add_file($self, $file, $owner, $group, $perms)
. _edit($self, $file, _gen_append_cmds(@lines));
}
sub delete_aliases {
my($self) = shift;
return _delete_lines($self, '/etc/aliases', [map(qr{^$_\:[^\n]+$}m, @_)]);
}
sub delete_file {
my($self, $file) = @_;
# Deletes I<file> if it exists. Otherwise, does nothing. If it can't delete,
# dies.
$file = _prefix_file($file);
return ''
unless -e $file;
return ($self->unsafe_get('noexecute')
? 'Would have '
: (unlink($file) || b_die("unlink($file): $!"))
) . "Deleted: $file\n";
}
sub disable_service {
my($self, @service) = @_;
# Disables services.
my($res);
foreach my $s (@service) {
# Ignore uninstalled services
my($chk) = $self->piped_exec("chkconfig --list $s 2>/dev/null", '', 1);
# Look for a line like: $s 0 or $s on...
next unless $$chk =~ /^\Q$s\E\s+\w/ && $$chk =~ /^\Q$s\E\s.*\bon\b/;
# xinetd services don't respond to --del
$res .= -x "/etc/rc.d/init.d/$s"
? _exec($self, "chkconfig --del $s")
. _exec($self, "/etc/rc.d/init.d/$s stop", 1)
: _exec($self, "chkconfig $s off");
}
return $res;
}
sub enable_service {
my($self, @service) = @_;
# Enables I<service>s and starts them running at 2345 run levels.
my($res);
foreach my $s (@service) {
# Should blow up if service doesn't exist
next if ${$self->piped_exec("chkconfig --list $s 2>/dev/null", '', 1)}
=~ /^$s\s.*\bon\b/;
$res .= _exec($self, "chkconfig --level 2345 $s on");
$res .= _exec($self, "/etc/rc.d/init.d/$s start")
if -x "/etc/rc.d/init.d/$s";
}
return $res;
}
sub generate {
my($self) = @_;
foreach my $op (@{$_D->eval_or_die(${$self->read_input})}) {
my($method) = shift(@$op);
$self->$method(@$op);
}
return;
}
sub handle_config {
my(undef, $cfg) = @_;
$_CFG = $cfg;
return;
}
sub postgres_base {
my($self) = @_;
return _replace_param(
$self, '/var/lib/pgsql/data/postgresql.conf',
['#*\s*(timezone\s*=\s*)', 'UTC'],
) . _replace_param($self, '/var/lib/pgsql/data/pg_hba.conf',
['(local.*)ident(?:\s+sameuser)?', 'trust'],
['(host.*127.0.*)ident(?:\s+sameuser)?', 'password'],
['(host.*1/128.*)ident(?:\s+sameuser)?', 'password'],
) . _optional(
$self, '/etc/rc.d/init.d/postgresql',
\&_replace_param,
['(#\s*chkconfig:\s*)', '345 84 16'],
);
}
# sub postgresql_param {
# my($self) = @_;
# return _replace_param(
# $self, '/var/lib/pgsql/data/postgresql.conf',
# ['#*\s*(timezone\s*=\s*)', 'UTC'],
# return;
# }
sub rename_rpmnew {
my($self, @rpmnew_file) = @_;
# Renames rpmnew files to actual file.
#
# Usage is typically:
#
# b-linux-config rename_rpmnew all
#
# Returns list of actions. "all" is the following:
#
# find /etc /var /usr -name \*.rpmnew
#
# You can also say:
#
# b-linux-config rename_rpmnew /etc
@rpmnew_file = ('/etc', '/var', '/usr')
if "@rpmnew_file" eq 'all';
chomp(@rpmnew_file = `find @rpmnew_file -name '*.rpmnew'`)
unless grep(/\.rpmnew$/, @rpmnew_file);
my($res) = '';
foreach my $n (map {_prefix_file($_)} @rpmnew_file) {
my($f) = $n;
$f =~ s/.rpmnew$//;
next unless -f $n;
unless ($self->unsafe_get('noexecute')) {
my($s) = "$f.rpmsave";
unlink($s);
$self->piped_exec("cp -pRf $f $s");
$self->piped_exec("cp -pRf $n $f");
unlink($n);
}
else {
$res .= 'Would have ';
}
$res .= "Updated: $f\n";
}
return $res;
}
sub replace_file {
my($self) = shift;
# Add content to file; deleting old one if it exists.
return $self->delete_file($_[0]) . _add_file($self, @_);
}
sub serial_console {
my($self, $speed) = @_;
$speed ||= '57600';
return _edit($self, '/etc/securetty', ['$', "ttyS0\n", "ttyS0\n"])
. _edit($self, '/boot/grub/menu.lst',
['(?<!\#)splashimage', '#splashimage'],
['(?<!\#)hiddenmenu', '#hiddenmenu'],
["(?=\n\tinitrd)", " console=ttyS0,$speed",
'console=ttyS0,'
],
['console=ttyS0,\d+', "console=ttyS0,$speed",
"console=ttyS0,$speed"],
["\ntimeout=\\d+\n", "\ntimeout=5\n"],
["(?<=\ntimeout=5\n)", "serial --unit=0 --speed=$speed\n",
"serial --unit=0 --speed=",
],
['serial --unit=0 --speed=\d+', "serial --unit=0 --speed=$speed"],
["(?<=serial --unit=0 --speed=$speed\n)",
"terminal --timeout=1 serial\n",
],
['? rhgb ', ' '],
['? quiet ', ' '],
);
}
sub sh_param {
my($self, $file, @args) = @_;
return _edit($self, $file, map {
my($param, $value) = @$_;
["(?<=\n)\\s*#?\\s*$param\[^\n]+", "$param='$value'"],
} @{$self->group_args(2, \@args)});
}
sub split_file {
my(undef, $file) = @_;
return [grep(
length($_) && $_ !~ /^\s*#/,
split(/\n+/, ${Bivio::IO::File->read($file)}),
)];
}
sub sshd_param {
my($self, @args) = @_;
# Set I<param> to I<value> in sshd_config. Knows how to replace only
# those parameters which already exist in the file.
return _edit($self, '/etc/ssh/sshd_config', map {
my($param, $value) = @$_;
["(?<=\n)\\s*#?\\s*$param\[^\n]+", "$param $value"],
} @{$self->group_args(2, \@args)});
}
sub _add_aliases {
my($file, $sep, $self) = splice(@_, 0, 3);
return $self->append_lines(
$file, qw(root root 0640),
map({
(my $x = $_) =~ s/;/,/g;
join("$sep\t", split(/:\s*/, $x, 2));
} @_),
);
}
sub _add_file {
my($self, $file, $owner, $group, $perms, $content) = @_;
# Creates the file if it doesn't exist. Always creates if $content.
$file = _prefix_file($file);
return '' if -e $file && !defined($content);
return "Would have created: $file\n" if $self->unsafe_get('noexecute');
Bivio::IO::File->write($file, defined($content) ? $content : '');
Bivio::IO::File->chown_by_name($owner, $group, $file)
if $> == 0;
Bivio::IO::File->chmod($perms, $file);
return "Created: $file\n";
}
sub _assert_dns_configured_for {
my($self, $domain) = @_;
my($ip) = _dig($domain);
my($cfg) = _network_config_for($ip);
Bivio::DieCode->CONFIG_ERROR->throw_die(
"config missing DNS for subnet containing '$ip ($domain)'")
unless exists($cfg->{dns}) && ref($cfg->{dns}) eq 'ARRAY';
return $cfg->{dns};
}
sub _assert_interface_and_domain {
my($self, $interface_and_domain) = @_;
b_die('must specify interface and domain -- remember to use quotes')
unless defined($interface_and_domain)
&& $interface_and_domain =~ / /;
my($device, $domain) = split(" ", $interface_and_domain, 2);
b_die('failed to parse interface and domain. Did you use quotes? (e.g. "eth0 some.example.com")')
unless defined($device) && defined($domain) && $domain !~ / /;
return $device, $domain;
}
sub _assert_netmask_and_gateway_for {
my($self, $domain) = @_;
my($ip) = _dig($domain);
my($cfg) = _network_config_for($ip);
Bivio::DieCode->CONFIG_ERROR->throw_die(
"subnet containing '$ip ($domain)' missing netmask")
unless $cfg->{mask};
_trace($ip, ' ', $cfg) if $_TRACE;
return (_bits2netmask($self, $cfg->{mask}),
$cfg->{gateway} && _dig($cfg->{gateway}));
}
sub _assert_network_configured_for {
my($self, $domain) = @_;
my($ip) = _dig($domain);
my($cfg) = _network_config_for($ip);
Bivio::DieCode->CONFIG_ERROR->throw_die(
"no subnet configured containing address '$ip ($domain)'")
unless defined($cfg);
return $cfg;
}
sub _base_domain {
my($domain) = @_;
($domain) = $domain =~ /(\w+\.\w+)$/;
return $domain;
}
sub _bits2netmask {
my($self, $bits) = @_;
b_die("$bits is not between 8 and 30")
unless defined($bits) && $bits >= 8 && $bits <= 30;
return join(
'.',
unpack(
'C4',
pack('N', $bits == 32 ? 0 : 0xffffffff << (32 - $bits)),
),
);
}
sub _delete_lines {
my($self, $file, $lines) = @_;
# Removes lines to file.
#TODO: Should it delete the file???
return _edit($self, $file,
[sub {
my($data) = @_;
my($got);
foreach my $l (@$lines) {
my($x) = ref($l) ? $l : qr{^\Q$l\E(\n|$)}m;
$$data =~ s/$x//mg and $got++;
}
return $got;
}]);
}
sub _device {
my($num) = @_;
return 'eth0' . ($num ? ":$num" : '');
}
sub _dig {
my($hostname) = @_;
b_die('missing hostname')
unless defined($hostname);
# TODO: this is a HACK. caching in the config is bad form, but this is run
# from the command line and won't be hanging around in memory for very
# long. As an added bonus, it also serves to spoof dns from the unit test.
my($cache) = $_CFG->{_dig_cache} ||= {};
unless (exists($cache->{$hostname})) {
my($ip) = $hostname =~ /^\d+\.\d+\.\d+\.\d+$/ ? $hostname
: `dig +short $hostname`;
_trace('dig ', $hostname, ': ', $ip)
if $_TRACE;
Bivio::DieCode->NOT_FOUND->throw_die(
"failed to resolve ip address for '$hostname': $!")
unless defined($ip);
chomp($ip);
$cache->{$hostname} = $ip;
}
return $cache->{$hostname};
}
sub _edit {
my($self, $file, @op) = @_;
# Inserts a value into a file.
$file = _prefix_file($file);
my($data) = Bivio::IO::File->read($file);
my($orig_data) = $$data;
my($got);
foreach my $op (@op) {
my($where, $value, $search) = @$op;
if (ref($where) eq 'CODE') {
$got++ if $where->($data);
next;
}
$search = $value =~ /\n/ ? qr{\Q$value}s : qr{^\s*\Q$value}m
unless defined($search);
#TODO: Replace when perl bug is fixed.
my($x) = "$search";
next if $$data =~ /$x/;
if ($where eq '$') {
# Special case for append_lines
b_die("$value: bad value")
if ref($value);
$$data .= $value;
}
else {
my($optional);
unless (ref($where)) {
$optional = $where =~ s/^\?//s;
$where = qr{$where}s;
}
b_die($file, ": didn't find /$where/\n")
unless $$data =~ s/$where/ref($value) ? $value->() : $value/eg
|| $optional;
}
$got++;
}
return ''
unless $got && $$data ne $orig_data;
return "Would have updated: $file\n"
if $self->unsafe_get('noexecute');
# Delete the backup file. This has side effects for add_crontab_line
# which needs to modify /var/spool/cron for cron to "wakeup" and reread
# all crontabs.
unlink("$file.rpmsave");
system("cp -pR $file $file.rpmsave");
Bivio::IO::File->write($file, $data);
return "Updated: $file\n";
}
sub _exec {
my($self, $cmd, $in, $ignore_exit_code) = @_;
# Execute obeying noexecute.
$in ||= '';
$cmd .= ' 2>&1';
return "Would have executed: $cmd\n"
if $self->unsafe_get('noexecute');
return "Executed: $cmd\n" . ${$self->piped_exec($cmd, \$in, $ignore_exit_code)};
}
sub _file_hosts {
my($self, $hostname, @others) = @_;
_trace(join(' ', $hostname, @others))
if $_TRACE;
my($result) = _prepend_auto_generated_header(<<"EOF")
# Do not remove the following line, or various programs
# that require network functionality will fail.
127.0.0.1 localhost.localdomain localhost
EOF
. join('', map(sprintf("%s\t%s\n", _dig($_), $_),
$hostname, @others
));
return 'etc/hosts', \$result;
}
sub _file_ifcfg {
my($self, $device, $domain, $gateways_seen) = @_;
my($ip) = _dig($domain);
my($netmask) = _bits2netmask($self, _mask_for($ip));
my($gateway) = _network_config_for($ip)->{gateway} || '';
$gateway = _dig($gateway)
if $gateway;
my($gw_line) = '';
if ($gateway && $gateway ne $ip && !exists($gateways_seen->{$gateway})) {
$gateways_seen->{$gateway} = 1;
$gw_line = 'GATEWAY=' . $gateway;
}
return 'etc/sysconfig/network-scripts/ifcfg-' . $device,
\(_prepend_auto_generated_header(<<"EOF"));
DEVICE=$device
ONBOOT=yes
BOOTPROTO=none
IPADDR=$ip
NETMASK=$netmask
$gw_line
EOF
}
sub _file_network {
my($self, $hostname) = @_;
return 'etc/sysconfig/network', \(_prepend_auto_generated_header(<<"EOF"));
NETWORKING=yes
NETWORKING_IPV6=yes
HOSTNAME=$hostname
EOF
}
sub _file_resolv_conf {
my($self, $domain) = @_;
my($base_domain) = _base_domain($domain);
my($ns1, $ns2) =
map(_dig($_), @{_assert_dns_configured_for($self, $domain)});
return 'etc/resolv.conf', \(_prepend_auto_generated_header(<<"EOF"));
search $base_domain
domain $base_domain
nameserver $ns1
nameserver $ns2
EOF
}
sub _file_static_routes {
my($self) = shift();
my($buf) = '';
my($seen_network) = {};
foreach my $x (@_) {
my($device, $domain) = _assert_interface_and_domain($self, $x);
_trace($device, ' ', $domain)
if $_TRACE;
my($ip) = _dig($domain);
my($routes) = _static_routes_for($ip);
next unless defined($routes);
_trace($routes)
if $_TRACE;
foreach my $network (keys %$routes) {
my($mask) = _mask_for($network);
unless (exists($seen_network->{$network.'/'.$mask})) {
$buf .= sprintf("%s net %s netmask %s gw %s\n",
$device, $network,
_bits2netmask($self, $mask),
_dig($routes->{$network}));
}
$seen_network->{$network.'/'.$mask}++;
}
}
return $buf eq '' ? () : ('etc/sysconfig/static-routes',
\(_prepend_auto_generated_header($buf)));
}
sub _gateway_for {
return _dig(_network_config_for(shift)->{gateway});
}
sub _gen_append_cmds {
return map(['$', "$_\n", qr{^\Q$_\E$}m], @_);
}
sub _get_networks_config {
return $_CFG->{networks};
}
sub _mask_for {
return _network_config_for(shift)->{mask};
}
sub _maybe_write {
my($filename, $data) = @_;
return unless defined($filename) && defined($data);
return _write($filename, $data);
}
sub _mkdir {
my($self, $dir, $perms) = @_;
# Creates dir if it doesn't exist
$dir = _prefix_file($dir);
return '' if -d $dir;
return "Would have created: $dir\n" if $self->unsafe_get('noexecute');
return "Created " . Bivio::IO::File->mkdir_p($dir, $perms) . "\n";
}
sub _network_config_for {
return $_CFG->{networks}->{shift()};
}
sub _network_for {
return _network_config_for(shift)->{network};
}
sub _optional {
my($self, $file, $op, @args) = @_;
return -f _prefix_file($file) ? $op->($self, $file, @args) : '';
}
sub _prefix_file {
my($file) = @_;
# Adds root_prefix to $file.
return $_CFG->{root_prefix} ? "$_CFG->{root_prefix}$file" : $file;
}
sub _prepend_auto_generated_header {
my($data) = @_;
return <<'EOF' . $data;
################################################################
# Automatically Generated File; LOCAL CHANGES WILL BE LOST!
# By: Bivio::Util::LinuxConfig
################################################################
EOF
}
sub _replace_param {
my($self, $file, @op) = @_;
return _edit(
$self,
$file,
[Bivio::Die->eval_or_die(q(sub {
my($data) = @_;
my($got) = 0;
) . join("\n", map(
"\$got += \$\$data =~ s{^$_->[0].*}{\${1}$_->[1]}m;",
@op,
)) . q(
return $got;
}))],
);
}
sub _static_routes_for {
return _network_config_for(shift)->{static_routes};
}
sub _write {
my($filename, $data) = @_;
#TODO: figure out the permissions and use _add_file() instead
$filename = _prefix_file($filename);
_trace($filename)
if $_TRACE;
Bivio::IO::File->mkdir_parent_only($filename);
Bivio::IO::File->write($filename, $data);
return;
}
1;