#! /usr/bin/perl -W # # ts-shell - Simple terminal server shell to access systems over IUCV # # This script can be used as login shell for users to restrict # IUCV-based terminal access to other systems. # # Copyright IBM Corp. 2008, 2017 # # s390-tools is free software; you can redistribute it and/or modify # it under the terms of the MIT license. See LICENSE for details. # use strict; use warnings; use File::Basename; use Getopt::Long qw(:config no_ignore_case); use Scalar::Util qw(tainted); use Term::ReadLine; use POSIX; $ENV{'PERL5LIB'} = ""; $ENV{'PERL5OPT'} = ""; $ENV{'PERLLIB'} = ""; $ENV{'PERL_USE_UNSAFE_INC'} = ""; delete @ENV{qw(IFS CDPATH ENV BASH_ENV)}; $ENV{'PERL_RL'} = " o=0"; # use best avail. readline $ENV{'PATH'} = "/bin:/sbin:/usr/bin:/usr/sbin"; $ENV{'LESSSECURE'} = 1; # let less run in "secure" mode my $ts_shell = fileparse($0, qr/\.[^.]+/); $SIG{__WARN__} = sub { print STDERR "$ts_shell: $_[0]"; }; $SIG{__DIE__} = sub { print STDERR "$ts_shell: $_[0]"; exit 255; }; # Terminal server configuration settings my %config = ( # general options 'conffile' => "@sysconf_path@/ts-shell.conf", 'authfile' => "@sysconf_path@/ts-authorization.conf", 'sysfile' => "@sysconf_path@/ts-systems.conf", 'auditfile' => "@sysconf_path@/ts-logsys.conf", 'auditdir' => "@var_path@/log/ts-shell", 'iucvconn' => "@iucvconn_path@/iucvconn", 'prompt' => getpwuid($>) . '@'."$ts_shell> ", 'pager' => "/usr/bin/less", # runtime options 'rl' => undef, # terminal readline (rl) 'user' => getpwuid($>), # user name 'groups' => [], # user groups (list ref) 'service' => "lnxhvc0", # default service 'auth_func' => sub { return -1; }, # no authorization 'regex' => [], # re to match systems (list ref) 'systems' => [], # system list (list reference) 'services' => [qw/lnxhvc0/], # services list (completion) 'termsys' => {}, # global ts sys list (hash ref) 'auditsys' => {}, # audit ts sys list (hash ref) ); sub main(); sub help(); sub usage(); sub version(); sub intro(); sub auth_global($$); sub auth_regex($$); sub auth_list($$); sub get_auditlog_file($$$); sub cmd_connect($\%); sub cmd_service($\%); sub cmd_list(\%); sub rl_cmd_completion($$$); sub list_regex_match($); sub updateConfiguration(\%$); sub checkConfiguration(\%); sub loadAuthorization(\%); sub readFile($$); sub log_debug($); sub log_error($); sub log_info($); sub pager($); # main() - Terminal server program # # The program loads the system authorizations for the effective user id, # and then initializes the term readline environment and starts the shell. # sub main() { unless (GetOptions("v|version" => sub { version(); exit 0; }, "h|help" => sub { usage(); exit 0; })) { log_error "Enter '$ts_shell --help' for more information"; exit 1; } unless (-t STDIN) { log_error "The $ts_shell requires a terminal to run on"; exit 1; } unless ($config{user}) { log_error "Resolving the name of user ID $> failed"; exit 2; } # update ts-shell configuration unless (readFile($config{conffile}, sub { updateConfiguration(%config, $_); })) { log_error "Reading the ts-shell configuration file " . "$config{conffile} failed: $!"; exit 3; } checkConfiguration(%config); # load list of systems the ts-shell is allowed to use; and # hash the systems names in uppercase unless (readFile($config{sysfile}, sub { $config{termsys}->{uc $_} = 1; })) { log_error "Reading $config{sysfile} failed: $!"; exit 4; } # load list of systems for for that auditing should be enabled # hash the system names in uppercase unless (readFile($config{auditfile}, sub { $config{auditsys}->{uc $_} = 1; })) { log_error "Reading $config{auditsys} failed: $!"; exit 5; } # retrieve user group membership and load authorizations my %uniq_groups = (); # $) returns egid twice, so we hash group names to avoid dups foreach(map {$_ = getgrgid($_)} split / /, $)) { $uniq_groups{$_} = 1; } push @{$config{groups}}, keys %uniq_groups; # finally, load user specific authorization data from file unless (loadAuthorization(%config)) { log_error "Reading $config{authfile} failed: $!"; exit 5; } # set up terminal readline $config{rl} = new Term::ReadLine 'Terminal Server Shell'; $config{rl}->Attribs->{'completion_function'} = \&rl_cmd_completion; select ($config{rl}->OUT || \*STDOUT); # setup signal handler to ignore SIGINT my $sigint = POSIX::SigAction->new(sub { return 1; }, POSIX::SigSet->new(), &POSIX::SA_NODEFER); POSIX::sigaction(&POSIX::SIGINT, $sigint); log_debug "User: $config{user} / Groups: $) [@{$config{groups}}]"; log_debug("Using Term::ReadLine backend: " . $config{rl}->ReadLine); intro(); SHELL: while (defined($_ = $config{rl}->readline($config{prompt}))) { chomp; s/^\s+|\s+$//; next if /^$/; SWITCH: { /^(?:q|quit|exit)/ and last SHELL; /^help/ and help(), last SWITCH; /^version/ and version(), last SWITCH; /^(?:list|ls)/ and cmd_list(%config), last SWITCH; /^terminal\s*(\w*)/ and cmd_service($1, %config), last SWITCH; if (/^connect\s*(\w*$|\w+\s+\w+)/) { cmd_connect($1, %config); last SWITCH; } log_error "$_ is not a known command"; } $config{rl}->addhistory($_) if (/\S/ && !$config{rl}->Features->{autohistory}); } exit 0; } # updateConfiguration() - Load ts-shell configuration from ts-shell.conf # # %cfg: Hash reference to terminal server configuration # $line: Configuration line (key = value pair) # # The routine updates the ts-shell configuration based on configuration lines # from the ts-shell.conf file. # sub updateConfiguration(\%$) { my ($cfg, $line) = @_; my ($option, $value) = split /\s*=\s*/, $line; $cfg->{sysfile} = $value if $option =~ /^ts-systems$/; $cfg->{authfile} = $value if $option =~ /^ts-authorization$/; $cfg->{auditdir} = $value if $option =~ /^transcript-directory$/; $cfg->{auditfile} = $value if $option =~ /^transcript-systems$/; $cfg->{pager} = $value if $option =~ /^pager$/; $cfg->{iucvconn} = $value if $option =~ /^iucvconn$/; } # checkConfiguration() - Validate and untaint configuration values # # Called once after readFile() has finished populating %cfg from # ts-shell.conf. Validates each security-sensitive path and untaints # it via a regex capture so Perl taint mode permits later filesystem use. # # %cfg: Hash reference to terminal server configuration # sub checkConfiguration(\%) { my $cfg = shift; # Validate pager to untaint for later use unless ($cfg->{pager} =~ m#^(/[\w./-]+)$#) { log_error "Invalid pager configuration"; exit 6; } $cfg->{pager} = $1; # Validate iucvconn path to untaint for later use unless ($cfg->{iucvconn} =~ m#^(/[\w./-]+)$#) { log_error "Invalid iucvconn configuration"; exit 7; } $cfg->{iucvconn} = $1; # Validate transcript-directory to untaint for later use unless ($cfg->{auditdir} =~ m#^(/[\w./-]+)$#) { log_error "Invalid transcript-directory configuration"; exit 8; } $cfg->{auditdir} = $1; # Untaint user name (tainted by getpwuid) for use in filesystem paths ($cfg->{user}) = ($cfg->{user} =~ /^([\w.-]+)$/); } # loadAuthorization() - Load system authorizations from file # # $cfg: Hash reference to terminal server configuration # # The routine parses the authorization data for the effective user # and stores the information in the configuration hash. Further, it # sets the auth_func reference to either auth_list() or auth_regex() # to abstract authorization checks. # sub loadAuthorization(\%) { my $cfg = shift(); return 0 unless open(AUTH, "<", $cfg->{authfile}); AUTH_ENT: while () { chomp; next if /^#/; # ignore comments next if /^\s*$/; # skip empty lines s/^\s+|\s+$//g; # trim my $authorized = 0; my ($key, $val) = split /\s*=\s*/; # read authorization configuration for user and its groups if ($key =~ /^\Q$cfg->{user}\E$/) { $authorized = 1; log_debug "Found user: $key"; } elsif ($key =~ /^@(\S+)$/) { my $group = $1; $authorized = 1 if grep {/^\Q${group}\E$/} @{$cfg->{groups}}; log_debug "Found group: $key" if $authorized; } # skip line if there was no auth data for current user next unless $authorized; # build authorization policy and set auth_func if ($val =~ /^list:\s*(.+)$/) { if (@{$cfg->{regex}}) { log_error "Authorization by list is ignored because " ."$cfg->{user} uses regular expressions " ."($cfg->{authfile}:$.)"; next AUTH_ENT; } $cfg->{auth_func} = \&auth_list; push @{$cfg->{systems}}, split /\s*[;,]\s*/, $1; } elsif ($val =~ /^file:(.+)$/) { if (@{$cfg->{regex}}) { log_error "Authorization by list is ignored because " . "$cfg->{user} uses regular expressions " . "($cfg->{authfile}:$.)"; next AUTH_ENT; } $cfg->{auth_func} = \&auth_list; unless(readFile($1, sub { push @{$cfg->{systems}}, $_; })) { log_error "Reading $1 failed: $! " . "($cfg->{authfile}:$.)"; } } elsif ($val =~ /^regex:(.+)$/) { if (@{$cfg->{systems}}) { log_error "Authorization by regular expression " . "is ignored because $cfg->{user} " . "uses lists ($cfg->{authfile}:$.)"; next AUTH_ENT; } # check regex syntax and complain if it is not correct my $re = eval "qr/$1/io"; if ($@) { log_error "An authorization entry is not a valid" . " regular expression " . "($cfg->{authfile}:$.):\n$@"; next AUTH_ENT; } $cfg->{auth_func} = \&auth_regex; push @{$cfg->{regex}}, $re; } else { log_error "An authorization entry is not valid " . "($cfg->{authfile}:$.)"; } log_debug "'$key' => '$val'"; } close(AUTH); return 1; } # readFile() - Helper routine to read data from file # # The routine reads the file content, line by line, skips # comments and empty lines. It calls a routine for each line # containing data. The routine can access the data either # using the first parameter or using the context variable $_. # # Error handling is up to the caller; the routine exits with # zero if the file could not be opened. open() should set $!. # # $file: File name # $sub: Reference to sub routine # sub readFile($$) { my ($file, $sub) = @_; return 0 unless open(CONF, "<", "$file"); while () { chomp; next if /^#/; # ignore comments next if /^\s*$/; # skip empty lines s/^\s+|\s+$//g; # trim &{$sub}($_); # execute sub in current context } close(CONF); return 1; } # auth_global() - Authorization check using global system list # # $guest: Scalar reference to the guest name string # $cfg: Hash reference to terminal server configuration # sub auth_global($$) { my ($guest, $cfg) = @_; return -1 unless $cfg->{termsys}; # unstricted return 1 if exists $cfg->{termsys}->{'[*ALL*]'}; # if restricted, check if system has been hashed (in uppercase) return 1 if exists $cfg->{termsys}->{uc $$guest}; # guest has not been defined, so deny authorization request return -2; } # auth_regex() - Authorization check using a regex # # $guest: Scalar reference to the guest name string # $cfg: Hash reference to terminal server configuration # sub auth_regex($$) { my ($guest, $cfg) = @_; return -1 unless @{$cfg->{regex}}; foreach my $re (@{$cfg->{regex}}) { return 1 if $$guest =~ /$re/; # $re has been compiled as //io } return 0; } # auth_list() - Authorization check using a list of systems # # $guest: Scalar reference to the guest name string # $cfg: Hash reference to terminal server configuration # sub auth_list($$) { my ($guest, $cfg) = @_; return -1 unless @{$cfg->{systems}}; return 1 if grep(/^\s*\Q$$guest\E\s*$/i, @{$cfg->{systems}}); return 0; } # get_auditlog_file() - Set file path for session transcript # # Return false if an error has occurred; otherwise true. # # $filepath: Scalar reference to file path variable # $guest: Name of the virtual guest machine # $cfg: Hash reference to terminal server configuration sub get_auditlog_file($$$) { my ($filepath, $guest, $cfg) = @_; unless (exists $cfg->{auditsys}->{'[*ALL*]'} || exists $cfg->{auditsys}->{uc $guest}) { $$filepath = ""; return 1; # no transcript, return success } # check and create log directory for user unless (-d "$cfg->{auditdir}/$cfg->{user}") { unless (mkdir "$cfg->{auditdir}/$cfg->{user}") { log_error "Creating the session transcript directory " . "$cfg->{auditdir}/$cfg->{user} failed: $!"; $$filepath = ""; return 0; # return error } } # validate and sanitize guest directory $guest = uc $guest; unless ($guest =~ /^([A-Z0-9@#\$_-]{1,8})$/) { log_error "Creating session file path failed for guest: $guest"; return 0; # return error } # construct the file path $$filepath = "$cfg->{auditdir}/$cfg->{user}/" . $guest . "_"; $$filepath .= strftime '%F-%H%M%S', localtime; log_debug "Session transcript file: $$filepath"; return 1; # return success } # cmd_connect() - Perform auth check; and on success, connect to system # # $guest: Name of the virtual guest machine # $cfg: Hash reference to terminal server configuration # sub cmd_connect($\%) { my ($params, $cfg) = @_; my $service = $cfg->{service}; unless (length($params)) { log_error "The z/VM guest name is missing"; return; } my ($guest, $srv) = split /\s+/, $params; $service = $srv if $srv && $srv =~ /^\w{1,8}$/; # check authorization: # The auth_func contract is to return a code, that must be one of: # -2: ts-shell is not permitted to connect to the guest # -1: The user does not have any authorization policy # 0: User does not have authorization to connect to guest # 1: User is permitted to connect to guest (success) # Other return codes are not allowed! # # call auth_func to check authorization my $rc = &{$cfg->{auth_func}}(\$guest, $cfg); # if the user is authorized, finally check if the system is # (globally) restricted to be used by the terminal server $rc = auth_global(\$guest, $cfg) if $rc > 0; if ($rc == -2) { log_error "$ts_shell is not configured to connect to $guest"; return; } if ($rc == -1) { log_error "You are not authorized to connect to " . "any z/VM virtual machines"; return; } if ($rc == 0) { log_error "You are not authorized to connect to $guest"; return; } # construct iucvconn command line my @iucvconn = ("$cfg->{iucvconn}"); # check if the terminal session to $guest requires session logging my $session_path = ""; # return if get_auditlog_file() failed return unless get_auditlog_file(\$session_path, $guest, $cfg); push @iucvconn, "-s", $session_path if $session_path; # add $guest name and terminal identifier (service) push @iucvconn, $guest, $service; log_info "Connecting to $guest (terminal identifier: $service)..."; if (-x $cfg->{iucvconn}) { system @iucvconn; log_info "Connection ended"; } else { log_error "Running $cfg->{iucvconn} failed: $!"; } } # cmd_service() - Show / set service/terminal name to identify remote terminal # # $new: New terminal name; can be empty # %cfg: Hash reference to terminal server configuration # sub cmd_service($\%) { my ($new, $cfg) = @_; unless ($new) { print "$cfg->{service}\n"; return; } if ($new =~ /^\w{1,8}$/) { $cfg->{service} = $new; # push new service for cmd completion unless (grep /$new/, @{$cfg->{services}}) { push @{$cfg->{services}}, $new; } } else { log_error("Terminal identifier $new is not valid"); log_error("Terminal identifiers consist of up to " . "eight alphanumerical characters"); } } # cmd_list() - List system authorization for effective user # # $cfg: Hash reference to terminal server configuration # sub cmd_list(\%) { my $cfg = shift(); if (@{$cfg->{regex}}) { pager sub { print "Regular expressions for your authorization:\n"; print "$_\n" foreach (@{$cfg->{regex}}); my $matches = list_regex_match($cfg); if (@$matches) { print "\nYou are authorized to connect to ". "these z/VM guest virtual machines:\n"; print "$_\n" foreach (@$matches); } }; } elsif (@{$cfg->{systems}}) { pager sub { foreach (@{$cfg->{systems}}) { printf "$_\n" if auth_global(\$_, $cfg) > 0; } }; } else { log_error "You are not authorized to connect to " . "any z/VM virtual machines"; } } sub log_debug($) { print STDERR "[DEBUG] " . shift() . "\n" if $ENV{'TS_SHELL_DEBUG'}; } sub log_error($) { print STDERR "$ts_shell: " . shift() . "\n"; } sub log_info($) { print "$ts_shell: " . shift() . "\n"; } sub pager($) { my $eval = shift(); unless (open(PAGER, '|-', "$config{'pager'}")) { eval &$eval; return; } my $old_out = select PAGER; eval &$eval; select $old_out; close (PAGER); } sub intro() { print < Connect to specified z/VM guest virtual machine. terminal [] Display or set the terminal identifier. q | quit | exit Exit the current shell session. help Display help information. version Display version information. EoHelp } sub usage() { printf <{termsys}->{'[*ALL*]'}; # get a list of systems if ts shell runs unrestricted foreach my $re (@{$cfg->{regex}}) { foreach (grep (/$re/, keys %{$cfg->{termsys}})) { $uniq_sys{$_} = 1; } } push @result, (keys %uniq_sys); return \@result; } # rl_cmd_completion() - Term::ReadLine completion for ts-shell commands # # $text: Text to complete # $line: Line buffer # $start: offset # sub rl_cmd_completion($$$) { my ($text, $line, $start) = @_; my @cmds = qw/help version list connect terminal quit exit/; # complete commands return grep (/^$text/, @cmds) unless $start; # complete terminal names for service and optionally for connect command if ($line =~ /^(?:terminal|connect\s+\w+)\s+(?:\w+)?$/) { return grep (/^$text/, @{$config{services}}); } # complete systems for connect command if ($line =~ /^connect\s+(\w+)?$/) { my $systems = []; # list ref for completion # complete for simple lists if (@{$config{systems}}) { foreach my $sys (@{$config{systems}}) { if (auth_global(\$sys, \%config) > 0) { push @$systems, $sys; } } # complete if ts shell uses regex's } elsif (@{$config{regex}}) { $systems = list_regex_match(\%config); } return grep {/^$text/} @$systems; } return (); } # start the terminal server shell &main(); __DATA__ __END__ # vim: set ai noet ts=8 sw=8 tw=80: