#!/usr/bin/perl use strict; use warnings; use Term::ReadLine; use File::Basename; use Getopt::Long; use HTTP::Status qw(:constants :is status_message); use Text::ParseWords; use PVE::JSONSchema; use PVE::SafeSyslog; use PVE::INotify; use PVE::CLIHandler; use PMG::RESTEnvironment; use PMG::Ticket; use PMG::Cluster; use PMG::API2; use JSON; my $basedir = '/api2/json'; my $cdir = ''; sub print_usage { my $msg = shift; print STDERR "ERROR: $msg\n" if $msg; print STDERR "USAGE: pmgsh [verifyapi]\n"; print STDERR " pmgsh CMD [OPTIONS]\n"; } my $disable_proxy = 0; my $opt_nooutput = 0; my $cmd = shift; my $optmatch; do { $optmatch = 0; if ($cmd) { if ($cmd eq '--noproxy') { $cmd = shift; $disable_proxy = 1; $optmatch = 1; } elsif ($cmd eq '--nooutput') { # we use this when starting task in CLI (suppress printing upid) $cmd = shift; $opt_nooutput = 1; $optmatch = 1; } } } while ($optmatch); if ($cmd) { if ($cmd eq 'verifyapi') { PVE::RESTHandler::validate_method_schemas(); exit 0; } elsif ($cmd =~ /^(?:ls|get|create|set|delete|help)$/) { PMG::RESTEnvironment->setup_default_cli_env(); # only set up once actually required initlog($ENV{PVE_LOG_ID} || 'pmgsh'); pmg_command([ $cmd, @ARGV], $opt_nooutput); exit(0); } else { print_usage ("unknown command '$cmd'"); exit (-1); } } if (scalar (@ARGV) != 0) { print_usage(); exit(-1); } # only set up once actually required allows calling verifyapi in restricted clean sbuild env PMG::RESTEnvironment->setup_default_cli_env(); initlog($ENV{PVE_LOG_ID} || 'pmgsh'); print "entering PMG shell - type 'help' for help\n"; my $term = new Term::ReadLine('pmgsh'); my $attribs = $term->Attribs; sub complete_path { my($text) = @_; my ($dir, undef, $rest) = $text =~ m|^(.*/)?(([^/]*))?$|; my $path = abs_path($cdir, $dir); my @res = (); my $di = dir_info($path); if (my $children = $di->{children}) { foreach my $c (@$children) { if ($c =~ /^\Q$rest/) { my $new = $dir ? "$dir$c" : $c; push @res, $new; } } } if (scalar(@res) == 0) { return undef; } elsif (scalar(@res) == 1) { return ($res[0], $res[0], "$res[0]/"); } # lcd : lowest common denominator my $lcd = ''; my $tmp = $res[0]; for (my $i = 1; $i <= length($tmp); $i++) { my $found = 1; foreach my $p (@res) { if (substr($tmp, 0, $i) ne substr($p, 0, $i)) { $found = 0; last; } } if ($found) { $lcd = substr($tmp, 0, $i); } else { last; } } return ($lcd, @res); }; # just to avoid an endless loop (called by attempted_completion_function) $attribs->{completion_entry_function} = sub { my($text, $state) = @_; return undef; }; $attribs->{attempted_completion_function} = sub { my ($text, $line, $start) = @_; my $prefix = substr($line, 0, $start); if ($prefix =~ /^\s*$/) { # first word (command completion) $attribs->{completion_word} = [qw(help ls cd get set create delete quit)]; return $term->completion_matches($text, $attribs->{list_completion_function}); } if ($prefix =~ /^\s*\S+\s+$/) { # second word (path completion) return complete_path($text); } return (); }; sub abs_path { my ($current, $path) = @_; my $ret = $current; return $current if !defined($path); $ret = '' if $path =~ m|^\/|; foreach my $d (split (/\/+/ , $path)) { if ($d eq '.') { next; } elsif ($d eq '..') { $ret = dirname($ret); $ret = '' if $ret eq '.'; } else { $ret = "$ret/$d"; } } $ret =~ s|\/+|\/|g; $ret =~ s|^\/||; $ret =~ s|\/$||; return $ret; } my $param_mapping = sub { my ($name) = @_; return [ PVE::CLIHandler::get_standard_mapping('pve-password') ]; }; sub reverse_map_cmd { my $method = shift; my $mmap = { GET => 'get', PUT => 'set', POST => 'create', DELETE => 'delete', }; my $cmd = $mmap->{$method}; die "got strange value for method ('$method') - internal error" if !$cmd; return $cmd; } sub map_cmd { my $cmd = shift; my $mmap = { create => 'POST', set => 'PUT', get => 'GET', ls => 'GET', delete => 'DELETE', }; my $method = $mmap->{$cmd}; die "unable to map method" if !$method; return $method; } sub check_proxyto { my ($info, $uri_param) = @_; if ($info->{proxyto}) { my $pn = $info->{proxyto}; my $node; if ($pn eq 'master') { $node = PMG::Cluster::get_master_node(); } else { $node = $uri_param->{$pn}; die "proxy parameter '$pn' does not exists" if !$node; } if ($node ne 'localhost' && ($node ne PVE::INotify::nodename())) { die "proxy loop detected - aborting\n" if $disable_proxy; my $remip = PMG::Cluster::remote_node_ip($node); return ($node, $remip); } } return undef; } sub proxy_handler { my ($node, $remip, $dir, $cmd, $args) = @_; my $remcmd = ['ssh', '-o', 'BatchMode=yes', "root\@$remip", 'pmgsh', '--noproxy', $cmd, $dir, @$args]; system(@$remcmd) == 0 || die "proxy handler failed\n"; } sub call_method { my ($dir, $cmd, $args, $nooutput) = @_; my $method = map_cmd($cmd); my $uri_param = {}; my ($handler, $info) = PMG::API2->find_handler($method, $dir, $uri_param); if (!$handler || !$info) { die "no '$cmd' handler for '$dir'\n"; } my ($node, $remip) = check_proxyto($info, $uri_param); return proxy_handler($node, $remip, $dir, $cmd, $args) if $node; my $data = $handler->cli_handler("$cmd $dir", $info->{name}, $args, [], $uri_param, $param_mapping); return if $nooutput; warn "200 OK\n"; # always print OK status if successful if ($info && $info->{returns} && $info->{returns}->{type}) { my $rtype = $info->{returns}->{type}; return if $rtype eq 'null'; if ($rtype eq 'string') { print $data if $data; return; } } print to_json($data, {utf8 => 1, allow_nonref => 1, canonical => 1, pretty => 1 }); return; } sub find_resource_methods { my ($path, $ihash) = @_; for my $method (qw(GET POST PUT DELETE)) { my $uri_param = {}; my $path_match; my ($handler, $info) = PMG::API2->find_handler($method, $path, $uri_param, \$path_match); if ($handler && $info && !$ihash->{$info}) { $ihash->{$info} = { path => $path_match, handler => $handler, info => $info, uri_param => $uri_param, }; } } } sub print_help { my ($path, $opts) = @_; my $ihash = {}; find_resource_methods($path, $ihash); if (!scalar(keys(%$ihash))) { die "no such resource\n"; } my $di = dir_info($path); if (my $children = $di->{children}) { foreach my $c (@$children) { my $cp = abs_path($path, $c); find_resource_methods($cp, $ihash); } } foreach my $mi (sort { $a->{path} cmp $b->{path} } values %$ihash) { my $method = $mi->{info}->{method}; # we skip index methods for now. next if ($method eq 'GET') && PVE::JSONSchema::method_get_child_link($mi->{info}); my $path = $mi->{path}; $path =~ s|/+$||; # remove trailing slash my $cmd = reverse_map_cmd($method); print $mi->{handler}->usage_str($mi->{info}->{name}, "$cmd $path", [], $mi->{uri_param}, $opts->{verbose} ? 'full' : 'short'); print "\n\n" if $opts->{verbose}; } }; sub resource_cap { my ($path) = @_; my $res = ''; my ($handler, $info) = PMG::API2->find_handler('GET', $path); if (!($handler && $info)) { $res .= '--'; } else { if (PVE::JSONSchema::method_get_child_link($info)) { $res .= 'Dr'; } else { $res .= '-r'; } } ($handler, $info) = PMG::API2->find_handler('PUT', $path); if (!($handler && $info)) { $res .= '-'; } else { $res .= 'w'; } ($handler, $info) = PMG::API2->find_handler('POST', $path); if (!($handler && $info)) { $res .= '-'; } else { $res .= 'c'; } ($handler, $info) = PMG::API2->find_handler('DELETE', $path); if (!($handler && $info)) { $res .= '-'; } else { $res .= 'd'; } return $res; } sub extract_children { my ($lnk, $data) = @_; my $res = []; return $res if !($lnk && $data); my $href = $lnk->{href}; if ($href =~ m/^\{(\S+)\}$/) { my $prop = $1; foreach my $elem (sort {$a->{$prop} cmp $b->{$prop}} @$data) { next if !ref($elem); my $value = $elem->{$prop}; push @$res, $value; } } return $res; } sub dir_info { my ($path) = @_; my $res = { path => $path }; my $uri_param = {}; my ($handler, $info, $pm) = PMG::API2->find_handler('GET', $path, $uri_param); if ($handler && $info) { eval { my $data = $handler->handle($info, $uri_param); my $lnk = PVE::JSONSchema::method_get_child_link($info); $res->{children} = extract_children($lnk, $data); }; # ignore errors ? } return $res; } sub list_dir { my ($dir, $args) = @_; my $uri_param = {}; my ($handler, $info) = PMG::API2->find_handler('GET', $dir, $uri_param); if (!$handler || !$info) { die "no such resource\n"; } if (!PVE::JSONSchema::method_get_child_link($info)) { die "resource does not define child links\n"; } my ($node, $remip) = check_proxyto($info, $uri_param); return proxy_handler($node, $remip, $dir, 'ls', $args) if $node; my $data = $handler->cli_handler("ls $dir", $info->{name}, $args, [], $uri_param, $param_mapping); my $lnk = PVE::JSONSchema::method_get_child_link($info); my $children = extract_children($lnk, $data); foreach my $c (@$children) { my $cap = resource_cap(abs_path($dir, $c)); print "$cap $c\n"; } } sub pmg_command { my ($args, $nooutput) = @_; my $rpcenv = PMG::RESTEnvironment->get(); $rpcenv->init_request(); my $ticket = PMG::Ticket::assemble_ticket('root@pam'); $rpcenv->set_ticket($ticket); $rpcenv->set_user('root@pam'); $rpcenv->set_role('root'); my $cmd = shift @$args; if ($cmd eq 'cd') { my $path = shift @$args; die "usage: cd [dir]\n" if scalar(@$args); if (!defined($path)) { $cdir = ''; return; } else { my $new_dir = abs_path($cdir, $path); my ($handler, $info) = PMG::API2->find_handler('GET', $new_dir); die "no such resource\n" if !$handler; $cdir = $new_dir; } } elsif ($cmd eq 'help') { my $help_usage_error = sub { die "usage: help [path] [--verbose]\n"; }; my $opts = {}; &$help_usage_error() if !Getopt::Long::GetOptionsFromArray($args, $opts, 'verbose'); my $path; if (scalar(@$args) && $args->[0] !~ m/^\-/) { $path = shift @$args; } &$help_usage_error() if scalar(@$args); print "help [path] [--verbose]\n"; print "cd [path]\n"; print "ls [path]\n\n"; print_help(abs_path($cdir, $path), $opts); } elsif ($cmd eq 'ls') { my $path; if (scalar(@$args) && $args->[0] !~ m/^\-/) { $path = shift @$args; } list_dir(abs_path($cdir, $path), $args); } elsif ($cmd =~ m/^get|delete|set$/) { my $path; if (scalar(@$args) && $args->[0] !~ m/^\-/) { $path = shift @$args; } call_method(abs_path($cdir, $path), $cmd, $args); } elsif ($cmd eq 'create') { my $path; if (scalar(@$args) && $args->[0] !~ m/^\-/) { $path = shift @$args; } call_method(abs_path($cdir, $path), $cmd, $args, $nooutput); } else { die "unknown command '$cmd'\n"; } } my $input; while (defined ($input = $term->readline("pmg:/$cdir> "))) { chomp $input; next if $input =~ m/^\s*$/; if ($input =~ m/^\s*q(uit)?\s*$/) { exit (0); } # add input to history if it gets not # automatically added if (!$term->Features->{autohistory}) { $term->addhistory($input); } eval { my $args = [ shellwords($input) ]; pmg_command($args); }; warn $@ if $@; } __END__ =head1 NAME pmgsh - shell interface to the Promox Mail Gateway API =head1 SYNOPSIS pmgsh [get|set|create|delete|help] [REST API path] [--verbose] =head1 DESCRIPTION pmgsh provides a shell-like interface to the Proxmox Mail Gateway API, in two different modes: =over =item interactive when called without parameters, pmgsh starts an interactive client, where you can navigate in the different parts of the API =item command line when started with parameters pmgsh will send a command to the corresponding REST url, and will return the JSON formatted output =back =head1 EXAMPLES get the list of nodes in my cluster pmgsh get /nodes