# cpanel.pl: Automating cPanel actions using cPanel API 2 and UAPI # # This is a minimal script for using the cPanel APIs, which let you # automate tedious, repetitive actions that would give you carpal tunnel # if you did them by clicking on the cPanel web UI. # # Examples of what you can do: # - add domains, specifying their htdocs directory # - add new email addresses/forwarders/settings # - create mysql databases # ... lots more # # This script supports two of the cPanel APIs: # - cPanel API 2 ("deprecated," JSON-based, same functions as cPanel UI) # - UAPI (current, JSON-based, supposedly combines cPanel and WHM API) # # Here is everything you can do with the cPanel API 2: # # https://documentation.cpanel.net/display/DD/Guide+to+cPanel+API+2 # # Here is everything you can do with UAPI: # # https://documentation.cpanel.net/display/DD/Guide+to+UAPI # # Notice how cPanel API 2 is supposed to be "deprecated" but you # still need to use it for certain things not yet supported in UAPI, # like AddonDomain::createaddondomain (the only way to create a domain). # # You can run this script from your hosting server or from another # computer out on the internet. # # To use this script, # 1. put your login details under $login below # 2. modify the script below by calling request() in whatever way you want # # This script does NOT depend on either the cPanel::PublicAPI or # cPanel::LiveAPI Perl modules, both of which are hard to find and # build and do not build at all on Windows Perl. This script only # needs the few simple Perl modules shown below. # # This script first logs in as your specified $login cPanel user using URL: # https://hostname:2083/login # so it can perform any action that that cPanel user can perform. # # cPanel API 2 URLs we then fetch look like: # https://hostname:2083/cpsessXXXXX/json-api/cpanel?cpanel_jsonapi_module=... # # UAPI URLs we then fetch look like: # https://hostname:2083/cpsessXXXXX/execute/Email/add_mx?params... # # NOTE: it is possible that your ISP may put these APIs on different ports. # check with your ISP dox. # # This script currently does not support 2-factor authentication. # # This script does not support these older or admin/reseller APIs: # - WHM API 0 (obsolete, XML-based, for account admin/reseller functions) # - WHM API 1 (obsolete?, XML-based, for account admin/reseller functions) # - cPanel API 1 (obsolete, JSON-based, same functions as cPanel UI) # - Manage2 API (no clue) # which are described at: # https://documentation.cpanel.net/display/DD/Developer+Documentation+Home # # PUT YOUR LOGIN DETAILS HERE: my $login = { # cPanel/linux username for hosting account 'user' => 'USERNAME', # cPanel/linux password for hosting account 'pass' => 'PASSWORD', # hostname on your hosting server to access API # (run 'hostname' on your ssh, or look in your # "New Account Information" email from your ISP) # 'host' => 'foo123.myisp.com', # show commands as we execute them # 'debug' => 1, # don't actually execute commands # - still attempt to log in, though (logging in has no side effects) # - note this will make all request() calls return undef # 'dry_run' => 0, }; use utf8; use strict; use English qw( -no_match_vars ); use Carp qw(cluck confess); use Data::Dumper; use HTTP::Tiny (); use HTTP::CookieJar (); use Encode; use JSON (); sub url_escape { my $s = shift; $s = Encode::encode('utf8', $s); $s =~ s/([^a-zA-Z0-9_.-])/uc sprintf("%%%02x",ord($1))/eg; $s = Encode::decode('utf8', $s); return $s; } sub make_query_string { my $h = shift; return join('&', map { my $k = $ARG; url_escape($k) . '=' . url_escape($h->{$k}); } sort keys %$h); } # call request() to send each requests # sub request { # ===== ARGUMENTS # pass the $login hash above # - we will store some stuff in there too # my $login = shift; # choose your API here # # 'cpanel2': make a cPanel API 2 request # https://documentation.cpanel.net/display/DD/Guide+to+cPanel+API+2 # # 'uapi' : make a UAPI request # https://documentation.cpanel.net/display/DD/Guide+to+UAPI # my $api = shift; # module from API documentation, e.g. 'Email' # my $module = shift; # function from API documentation, e.g. 'add_mx' # my $function = shift; # optional parameters from API documentation as a hash, e.g. # { # 'domain' => 'foobar.com', # 'exchanger' => 'mail.foobar.com', # ... # } # my $params = shift; # possibly undef # optional extra HTTP headers as a hash or a newline-separated string # - you probably won't need this # my $headers = shift // {}; # ===== RETURN VALUE # - function confesses/dies on ANY HTTP error or false API 'status' # - wrap request() in eval if you want to detect errors # - undef if $login->{'dry_run'} set # - a hash of the returned JSON on API call success # constants my $max_attempts = 3; my $timeout = 300; my $port = 2083; # cpanel (not WHM) https:// port # we can share one ua amongst several ports # - but anyway all APIs use the same port my $ua = $login->{'ua'}; if (!$ua) { $ua = $login->{'ua'} = HTTP::Tiny->new(agent => "$PROGRAM_NAME ", verify_SSL => 1, keep_alive => 0, timeout => $timeout); } # we need one cookie_jar and security_token per port # - but currently all APIs use the same $port my $cookie_jar = $login->{'cookie_jar'}; my $security_token = $login->{'security_token'}; if (!$security_token) { $cookie_jar = $login->{'cookie_jar'} = HTTP::CookieJar->new(); $ua->cookie_jar($cookie_jar); # log in to the UAPI and put the required security token in # my $url = "https://$login->{'host'}:$port/login"; my $resp = $ua->post_form ($url, { 'user' => $login->{'user'}, 'pass' => $login->{'pass'}, }); $security_token = $login->{'security_token'} = (split(/\//, $resp->{'headers'}->{'location'}))[1]; if (!$security_token) { confess "Failed to establish session and parse security token:\n" . "$url\n" . "$resp->{'status'} $resp->{'reason'}"; } } $ua->cookie_jar($cookie_jar); my $orig_alarm = 0; my $response_content; my $url; if ('uapi' eq $api) { # UAPI # # https://documentation.cpanel.net/display/DD/Guide+to+UAPI # $url = "https://$login->{'host'}:$port" . "/$security_token" . "/execute/$module/$function"; } elsif ('cpanel2' eq $api) { # cPanel API 2 # # https://documentation.cpanel.net/display/DD/Guide+to+cPanel+API+2 # $url = "https://$login->{'host'}:$port" . "/$security_token" . "/json-api/cpanel"; # we shove module and function and other stuff in $params $params = { %{ $params // {} }, 'cpanel_jsonapi_user' => $login->{'user'}, 'cpanel_jsonapi_apiversion' => 2, 'cpanel_jsonapi_module' => $module, 'cpanel_jsonapi_func' => $function, }; } else { confess "bad \$api value passed to request()"; } my $request_content = make_query_string($params) if (ref($params)); if (!ref $headers) { # change string headers into hash headers my @lines = split /\r\n/, $headers; $headers = {}; foreach my $line (@lines) { last unless length $line; my ( $key, $value ) = split /:\s*/, $line, 2; next unless length $key; $headers->{$key} ||= []; push @{ $headers->{$key} }, $value; } } my $options = { 'headers' => $headers, }; $options->{'content'} = $request_content if defined $request_content; if ($login->{'debug'}) { print "[dry_run] " if ($login->{'dry_run'}); print "$url\n"; print "$request_content\n"; } if (!$login->{'dry_run'}) { my $attempts = 0; my $finished_request = 0; my $hassigpipe; local $SIG{'ALRM'} = sub { confess "Connection Timed Out after $timeout seconds\n" . "$url\n$request_content\n"; }; local $SIG{'PIPE'} = sub { $hassigpipe = 1; }; $orig_alarm = alarm($timeout); while ( ++$attempts < $max_attempts ) { $hassigpipe = 0; my $response = $ua->request( 'POST', $url, $options ); if ( $response->{'status'} == 599 ) { confess "Could not connect:" . "$url\n$request_content\n" . "$response->{'content'}"; } if ($hassigpipe) { next; } # http spec says to reconnect if (!$response->{'success'}) { confess "Server Error:\n" . "$url\n$request_content\n" . "$response->{'status'} $response->{'reason'}"; } $response_content = $response->{'content'}; $finished_request = 1; last; } if (!$finished_request) { confess "The request could not be completed " . "after $max_attempts attempts:" . "$url\n$request_content\n"; } }; alarm($orig_alarm); # Reset with parent's alarm value if ($response_content) { $response_content = JSON::decode_json($response_content); if ($login->{'debug'}) { print "response:\n" . Dumper($response_content) . "\n"; } my $orig_response_content = $response_content; my $worked; if ('uapi' eq $api) { $worked = $response_content->{'status'} && 0 == length($response_content->{'error'}); $response_content = $response_content->{'data'}; } elsif ('cpanel2' eq $api) { $response_content = $response_content->{'cpanelresult'}; $worked = $response_content && 0 == length($response_content->{'error'}); $response_content = $response_content->{'data'}; } if (!$worked) { confess "Bad status from server:\n" . "$url\n$request_content\n" . Dumper($orig_response_content) . "\n"; } } return $response_content; # undef on error } my $ret; if (1) { # print info about add-on domains: example of cPanel API 2 call # $ret = request($login, 'cpanel2', 'AddonDomain', 'listaddondomains'); print Dumper($ret) . "\n"; # print info about domains: example of UAPI call # $ret = request($login, 'uapi', 'DomainInfo', 'list_domains'); print Dumper($ret) . "\n"; } # example to create domains # - note the ONLY way to create domain by API is AddonDomain::addaddondomain # - which has (harmless) side effects of # - adding a subdomain to the "Main Domain" in the account # - which you will probably never use # - making logs appear under site.maindomanain.com instead of site.com # my @sites = ( { 'domain' => 'site1.com', 'dir' => 'site1', }, { 'domain' => 'site2.com', 'dir' => 'site2', }, { 'domain' => 'site3.com', 'dir' => 'site3', }, { 'domain' => 'site4.com', 'dir' => 'site4', }, # 'site5.com', nope, EIG killed them { 'domain' => 'site6.com', 'dir' => 'site6', }, ); if (1) { foreach my $site (@sites) { $ret = request($login, 'cpanel2', 'AddonDomain', 'addaddondomain', { # htdocs directory 'dir' => $site->{'dir'} . "/www", # full domain name 'newdomain' => $site->{'domain'}, # - addaddondomain also creates a subdomain of # the "Main Domain" in the account. # - probably you wll not use this subdomain # - this is the name to use for subdomain # 'subdomain' => $site->{'dir'}, }); print Dumper($ret) . "\n"; } } # example of creating catch-all email addresses # my @catchalls = ( { 'domain' => 'site1.com', 'email' => 'mymail@othercompany.com', }, ); if (1) { foreach my $catchall (@catchalls) { $ret = request($login, 'uapi', 'Email', 'set_default_address', { 'domain' => $catchall->{'domain'}, 'fwdopt' => 'fwd', 'fwdemail' => $catchall->{'email'}, }); print Dumper($ret) . "\n"; } # list all of them $ret = request($login, 'uapi', 'Email', 'list_default_address', { 'user' => $login->{'user'}, }); print Dumper($ret) . "\n"; } # example of creating email forwarders (for single addresses, not catch-all) # my @forwarders = ( { 'from' => 'goober@site1.com', 'to' => [ 'mymail@othercompany.com' ], }, ); if (1) { foreach my $forwarder (@forwarders) { confess unless ($forwarder->{'from'} =~ /^[^@]*@([^@]*)$/); $forwarder->{'domain'} = $1; $ret = request($login, 'uapi', 'Email', 'add_forwarder', { 'domain' => $forwarder->{'domain'}, 'email' => $forwarder->{'from'}, 'fwdopt' => 'fwd', 'fwdemail' => join(',', @{$forwarder->{'to'}}), }); print Dumper($ret) . "\n"; } # list all of them $ret = request($login, 'uapi', 'Email', 'list_forwarders', { }); print Dumper($ret) . "\n"; } # example of creating MySQL databases and database users # my @databases = ( { 'db_name' => 'mysite_db', 'user_name' => 'mysite_user', 'user_pass' => 'MYSQL_PASSWORD', 'dir' => 'mydir', # used below for backup/restore scripts }, ); if (1) { foreach my $database (@databases) { $ret = request($login, 'uapi', 'Mysql', 'create_database', { 'name' => $database->{'db_name'}, }); print Dumper($ret) . "\n"; $ret = request($login, 'uapi', 'Mysql', 'create_user', { 'name' => $database->{'user_name'}, 'password' => $database->{'user_pass'}, }); print Dumper($ret) . "\n"; $ret = request($login, 'uapi', 'Mysql', 'set_privileges_on_database', { 'database' => $database->{'db_name'}, 'user' => $database->{'user_name'}, 'privileges' => 'ALL PRIVILEGES', }); print Dumper($ret) . "\n"; } } # generate shell commands to back up database (good for backup cron job) # if (1) { my $text = ''; $text .= <<"THEEND" #!/usr/bin/zsh date=`date -u +'%Y-%m-%d.%H-%M-%S'` && \\ THEEND ; foreach my $database (@databases) { $text .= <<"THEEND" \\ cd ~/$database->{'dir'} && \\ fn=$database->{'db_name'}.\$date.sql && \\ echo doing \$fn && \\ mysqldump \\ --user=$database->{'user_name'} \\ --password=$database->{'user_pass'} \\ $database->{'db_name'} \\ > \$fn \\ && \\ bzip2 -v \$fn \\ && \\ THEEND ; } $text .= <<"THEEND" echo done THEEND ; print "backup script:\n\n" . $text; } # generate shell commands to restore database from above backup files # if (1) { print <<"THEEND" bunzip2 -v *.sql.bz2 THEEND ; foreach my $database (@databases) { print <<"THEEND" echo doing $database->{'db_name'} && \\ mysql \\ --user=$database->{'user_name'} \\ --password=$database->{'user_pass'} \\ $database->{'name'} \\ < $database->{'name'}.sql \\ && \\ THEEND ; } print <<"THEEND" echo done THEEND ; }