Create database(LiveAPI) inside Perl Module Standardized Hook

jleckie

Member
Feb 4, 2016
7
0
1
Midwest
cPanel Access Level
Root Administrator
Hi everybody,

I'm trying to learn a bit of Perl in order to automate some tasks that are done after new account creation. I've created a standardized hook for Whostmgr::Accounts::Create, and I'm able to grab the username of the created user... Now I'd like to create a Mysql database with this name.

I'm trying to follow the example for the LiveAPI Perl Module here: Guide to UAPI - Software Development Kit - cPanel Documentation

My problem is that after being instantiated, the LiveAPI object/class is undefined.

"Can't call method "uapi" on an undefined value at ..."

I have looked into it being a scope issue, and I don't think that it is. I don't seem to know enough about Perl or WHM/cPanel yet to figure out why this isn't working. My module also complains about $cpanel being a global variable(and I have read into this, I'm not sure if I should disable "use strict;" or prepend "my" to the $cpanel variable, or something else...) It sort of looks like the documentation is incorrect and $cpanel should instead be whatever variable I used to instantiate the "Cpanel::LiveAPI->new();" object, but that doesn't work either.

Here is my module, I'd greatly appreciate any and all input on what might be my issue:

Code:
#!/usr/bin/perl

use lib "/usr/local/cpanel";

# Package this module.
package MyDeploymentModule;
 
# Return errors if Perl experiences problems.
use strict;
use warnings;

# Use cPanel's error logging module.
use Cpanel::Logger;

# Use cPanel's LiveAPI module.
use Cpanel::LiveAPI ();
   
# Properly decode JSON.
use JSON;

# Instantiate the cPanel logging object
my $logger = Cpanel::Logger->new();

# Instantiate cPanel API.
my $cpliveapi = Cpanel::LiveAPI->new();
 
# Embed hook attributes alongside the action code.
sub describe {
    my $my_add = {
        'category' => 'Whostmgr',
        'event'    => 'Accounts::Create',
        'stage'    => 'post',
        'hook'     => 'MyDeploymentModule::add',
        'exectype' => 'module',
    };
    return [ $my_add ];
}
sub add {
    # Get the data that the system passes to the hook.
    # Within a subroutine, the array @_ contains the parameters passed to that subroutine.
    # context: string - the hookable event and its category
    #   ex: Passwd::ChangePasswd
    # data: hash reference - the event's information
    #   ex: key=value [or] undefined
    my ( $context, $data ) = @_;   

    $logger->info("CPANEL USER ACCOUNT CREATED $data->{user}");

    # Create database
    my $create_db = $cpliveapi->uapi(
        'Mysql', 'create_database',
        {
            'name' => '$data->{user}',
        }
    );

    $cpanel->end();
}
1;
Thanks again for any help!
 
Last edited by a moderator:

cPanelMichael

Administrator
Staff member
Apr 11, 2011
47,880
2,258
463
Hello :)

One of the first steps you can take when troubleshooting this type of issue is to utilize the API shell option in cPanel to see if it works outside of your custom script:

API Shell for cPanel - Documentation - cPanel Documentation

Could you let us know if it works as expected when utilizing the API shell in cPanel?

Thank you.
 

jleckie

Member
Feb 4, 2016
7
0
1
Midwest
cPanel Access Level
Root Administrator
Hi Michael,

I took a pretty long break from this project, but I wanted to post what I ended up with as my solution for anybody who searches this forum in the future. I never did get the LiveAPI module to work, instead I pulled in three Perl modules to make requests to WHM's JSON API:

The purpose of this module is to "launch" a WordPress website(move it from a shared staging area to a sandboxed cPanel account.) The process at a high level:
  1. Trigger account creation via API through a custom web admin panel
  2. Move files from the staging area to a sandboxed cPanel account
  3. Dump the SQL from staging and execute a find and replace to update URL info
  4. Create a new database for the WordPress site
  5. Create a new database user for the WordPress database
  6. Assign all permissions for this new database to the new database user
  7. Import the dumped and modified SQL into the new database and get rid of the leftover .SQL file
  8. Update the wp-config.php file to reflect the new database and database user credentials
This was my first time working with WHM/cPanel and also my first time writing Perl code, so this module could probably be improved(suggestions are welcome if anybody stumbles upon this.)

The code(in the form of a Perl module, which resides in /usr/local/cpanel and is implemented as a Standardized Hook):

Code:
#!/usr/bin/perl

# Package this module.
package MyCustomLaunchCode;
   
# Return errors if Perl experiences problems.
use strict;
use warnings;

# Use libwww-perl set of Perl modules for WWW API
use LWP::UserAgent;
use LWP::Protocol::https;
use HTTP::Request::Common;

# Properly decode JSON.
use JSON;
use JSON::XS;

# Use cPanel's error logging module. Writes to /usr/local/cpanel/logs/error_log by default.
use Cpanel::Logger;

# Instantiate the cPanel logging object
my $logger = Cpanel::Logger->new();

# Access hash from WHM
my $hash = "yourhashgoeshere";

# Define authorization header to be later attached to API requests
my $auth = "WHM root:" . $hash;

# The "describe" subroutine is optional, but it allows Standardized Hooks to be registered without manually specifying all of this information on the command line.
# For example, this module is registered like so:
# /usr/local/cpanel/bin/manage_hooks add module MyCustomLaunchCode
# For future reference, it can be removed like so:
# /usr/local/cpanel/bin/manage_hooks delete module MyCustomLaunchCode
sub describe {
    my $deploy = {
        blocking => 1,
        category => 'Whostmgr',
        event    => 'Accounts::Create',
        stage    => 'post',
        hook     => 'MyCustomLaunchCode::launch',
        exectype => 'module',
    };
 
    return [ $deploy ];
}

# Migrate directory from staging to new users home directory
sub launch {
    # Within a subroutine, the array @_ contains the parameters passed to that subroutine.
    # When called from a Standardized Hook, the arguments passed in are as follows:
    # context: the hookable event and its category
    #   ex: {
    #          "stage" : "post",
    #          "point" : "main",
    #          "category" : "Whostmgr",
    #          "event" : "Accounts::Create"
    #       }
    # data: the events information(this is where any variables added via package extensions are stored)
    #   ex: {
    #          "useregns" : 0,
    #          "dkim" : null,
    #          "locale" : "en",
    #          "maxftp" : "0",
    #          "max_defer_fail_percentage" : "unlimited",
    #          "maxaddon" : 0,
    #          "user" : "yournewuser",
    #          "plan" : "Standard",
    #          "maxpop" : "0",
    #          "uid" : "",
    #          "homeroot" : "/home",
    #          "is_restore" : 1,
    #          "hasshell" : "n",
    #          "useip" : "n",
    #          "maxlst" : "0",
    #          "gid" : "",
    #          "no_cache_update" : 0,
    #          "quota" : 0,
    #          "bwlimit" : 0,
    #          "skip_mysql_dbowner_check" : 0,
    #          "hascgi" : "n",
    #          "domain" : "yournewclientdomain.com",
    #          "contactemail" : "[email protected]",
    #          "mxcheck" : null,
    #          "featurelist" : "default",
    #          "spf" : null,
    #          "cpmod" : "paper_lantern",
    #          "owner" : "root",
    #          "pass" : "HIDDEN",
    #          "maxpark" : 0,
    #          "max_email_per_hour" : "unlimited",
    #          "digestauth" : "n",
    #          "forcedns" : 1,
    #          "homedir" : "/home/yournewuser",
    #          "force" : 0,
    #          "maxsql" : "0",
    #          "maxsub" : "0",
    #          "custom_var_1" : "value passed via URL parameter in API request",
    #          "custom_var_2" : "another value passed via URL parameter in API request",
    #          "staging_dir" : "/home/staging/public_html/sample.client/site"
    #          "staging_url" : "host.yourwebsite.com/staging/sample.client/site"
    #       }
    my ( $context, $data ) = @_;     

    # =========================================
    # Log the event broadcasted by WHM
    # =========================================
    _more_logging( $context, $data );
    $logger->info("New cPanel user account created: $data->{user}. Deploying account with the \"$data->{plan}\" package.");

    # =========================================
    # Check if this is a typical WordPress launch
    # =========================================
    if($data->{plan} eq 'WordPress') {
        # =========================================
        # Create user agent for API requests
        # =========================================
        my $ua = LWP::UserAgent->new(
            ssl_opts => { verify_hostname => 0, SSL_verify_mode => 'SSL_VERIFY_NONE', 'SSL_use_cert' => 0 },
        );
        $ua->agent('Mozilla/5.0');

        # =========================================
        # Pull variables out of the "account creation" event
        # =========================================
        my $cpanel_username = $data->{user};
        my $staging_dir = $data->{staging_dir};
        my $home_dir = $data->{homedir};
        my $user = $data->{user};
        my $staging_url = $data->{staging_url};
        my $domain = $data->{domain};
        # NOTE: WHM requires that the first 8 characters of account names be unique, and enforces Mysql DB Prefixing by default.
        # NOTE: MySQL has a 64 character limit on database names.
        # NOTE: In WHM MySQL, "_" expends two characters.
        # =========================================
        # Strip first 8 chars from username and prefix the Mysql database name.
        # =========================================
        my $db_prefix = substr( "$data->{user}", 0, 8 );
        my $db_name = $db_prefix . '_wordpress';
        # =========================================
        # A fresh database user is created so that WordPress doesn't break if the user changes their cPanel password(because that will update their default MySQL user password as well.)
        # =========================================
        # NOTE: MySQL has a 16 character limit on database usernames.
        my $db_username = $data->{user} . '_wp';
        # =========================================
        # Generate a password for the new database user via a helper subroutine
        # =========================================
        my $db_password = random_pwd(16);


        # - Migrate files from staging directory
        # - Dump SQL
        # - Find and replace(does not deserialize data)
        migrate($staging_dir, $home_dir, $user, $db_prefix, $staging_url, $domain);

        # Create new database
        create_db($ua, $cpanel_username, $db_name);

        # Create new MySQL user for cPanel user(so if they change their cPanel password, their hardcoded database creds aren't invalidated)
        create_db_user($ua, $cpanel_username, $db_username, $db_password);

        # Assign permissions for new database to new MySQL user
        assign_db_permissions($ua, $cpanel_username, $db_username, $db_name);

        # Import sql into database
        # Find and replace on wp-config.php(to update database information)
        # Cleanup leftover .sql file
        import_sql($db_username, $db_password, $db_name, $db_prefix, $home_dir);

    }
}

sub migrate {
    # Since no array is provided to "shift", it will shift the @_ array within the lexical scope of this subroutine.
    my $staging_dir = shift;
    my $home_dir = shift;
    my $user = shift;
    my $db_prefix = shift;
    my $staging_url = shift;
    my $domain = shift;
    # =========================================
    # Copy files from staging directories and dump .sql in users root directory
    # =========================================
    `cp -r $staging_dir $home_dir/public_html`;
    # =========================================
    # Fix permissions/file ownership of created files since these shell commands are executed as root
    # =========================================
    `find $home_dir/public_html -type d -exec chmod 755 {} \\;`;
    `find $home_dir/public_html -type f -exec chmod 644 {} \\;`;
    `chown -R $user:$user $home_dir/public_html`;
    # Get all tables with prefix
    my $tables = `mysql -u staging_wp -pMYSQLPASSWORD -B -s -e 'SHOW TABLES LIKE "$db_prefix%"' staging_wordpress`;
    # Replace newlines with spaces in order to properly pass table names in as a parameter to 'mysqldump'
    $tables =~ s{\n}{ }g;
    # Dump SQL
    `mysqldump -u staging_wp -pMYSQLPASSWORD staging_wordpress $tables > $home_dir/$db_prefix.sql`;

    # Replace URLs in SQL
    `sed -i 's#$staging_url#$domain#g' $home_dir/$db_prefix.sql`;
}

sub create_db {
    # Since no array is provided to "shift", it will shift the @_ array within the lexical scope of this subroutine.
    my $ua = shift;
    my $cpanel_username = shift;
    my $db_name = shift;
    # =========================================
    # Create empty database for new cPanel user
    # =========================================
    # Create Mysql database creation request(JSON API)
    my $db_request = HTTP::Request->new(GET => "https://127.0.0.1:2087/json-api/cpanel?cpanel_jsonapi_user=$cpanel_username&cpanel_jsonapi_module=Mysql&cpanel_jsonapi_func=create_database&cpanel_jsonapi_apiversion=3&name=$db_name");
    # Set header for API auth
    $db_request->header( Authorization => $auth );
    # Send database creation request
    my $db_response = $ua->request($db_request);
    # Unwrap response
    my $decoded_db_response = JSON::XS::decode_json($db_response->content);
    my $db_response_status = $decoded_db_response->{result}->{status};
    # Check response status
    if($db_response_status == 1) {
        $logger->info("Database $db_name created for cPanel user $cpanel_username");
    } else {
        $logger->info("Failed to create database: $db_name for user: $cpanel_username");
    }
    $logger->info("Create database API result: " . $db_response->content);
}

sub create_db_user {
    # Since no array is provided to "shift", it will shift the @_ array within the lexical scope of this subroutine.
    my $ua = shift;
    my $cpanel_username = shift;
    my $db_username = shift;
    my $db_password = shift;

    # Create Mysql user creation request(JSON API)
    my $user_request = HTTP::Request->new(GET => "https://127.0.0.1:2087/json-api/cpanel?cpanel_jsonapi_user=$cpanel_username&cpanel_jsonapi_module=Mysql&cpanel_jsonapi_func=create_user&cpanel_jsonapi_apiversion=3&name=$db_username&password=$db_password");
    # Set header for API auth
    $user_request->header( Authorization => $auth );
    # Send database user creation request
    my $user_response = $ua->request($user_request);
    # Unwrap response
    my $decoded_user_response = JSON::XS::decode_json($user_response->content);
    my $user_response_status = $decoded_user_response->{result}->{status};
    # Check response status
    if($user_response_status == 1) {
        $logger->info("Database user $db_username created for cPanel user $cpanel_username with password ${db_password}");
    } else {
        $logger->info("Failed to create database user $db_username for cPanel user $cpanel_username");
    }
    $logger->info("Create database user API result: " . $user_response->content );
}

sub assign_db_permissions {
    # Since no array is provided to "shift", it will shift the @_ array within the lexical scope of this subroutine.
    my $ua = shift;
    my $cpanel_username = shift;
    my $db_username = shift;
    my $db_name = shift;

    # Create Mysql user permissions modification request(JSON API)
    my $permissions_request = HTTP::Request->new(GET => "https://127.0.0.1:2087/json-api/cpanel?cpanel_jsonapi_user=$cpanel_username&cpanel_jsonapi_module=Mysql&cpanel_jsonapi_func=set_privileges_on_database&cpanel_jsonapi_apiversion=3&user=$db_username&database=$db_name&privileges=ALL+PRIVILEGES");
    # Set header for API auth
    $permissions_request->header( Authorization => $auth );
    # Send database user permissions modification request
    my $permissions_response = $ua->request($permissions_request);
    # Unwrap response
    my $decoded_permissions_response = JSON::XS::decode_json($permissions_response->content);
    my $permissions_response_status = $decoded_permissions_response->{result}->{status};
    # Check response status
    if($permissions_response_status == 1) {
        $logger->info("All database permissions for database $db_name assigned for user $db_username");
    } else {
        $logger->info("Failed to assign database permissions for database $db_name for user $db_username");
    }
    $logger->info("Assign database permissions API result: " . $permissions_response->content );
}

sub import_sql {
    # Since no array is provided to "shift", it will shift the @_ array within the lexical scope of this subroutine.
    my $db_username = shift;
    my $db_password = shift;
    my $db_name = shift;
    my $db_prefix = shift;
    my $home_dir = shift;
   
    # Import SQL into new database
    `mysql -u $db_username -p$db_password $db_name < $home_dir/$db_prefix.sql`;
    # Change wp-config file to use new MySQL database
    `sed -i 's#staging_wordpress#$db_name#g' $home_dir/public_html/site/wp-config.php`;
    # Change wp-config file to use new MySQL user
    `sed -i 's#staging_wp#$db_username#g' $home_dir/public_html/site/wp-config.php`;
    # Change wp-config file to use new MySQL user
    `sed -i 's#MYSQLPASSWORD#$db_password#g' $home_dir/public_html/site/wp-config.php`;
    $logger->info("wp-config.php credentials updated, removing .sql file.");
    `rm -f $home_dir/$db_prefix.sql`;
}

sub random_pwd {
    # Since no array is provided to "shift", it will shift the @_ array within the lexical scope of this subroutine.
    my $length = shift;
    my @chars = (0 .. 9, 'a' .. 'z', 'A' .. 'Z');
    return join '', @chars[ map rand @chars, 0 .. $length ];
}

# Log extra info for debugging.
sub _more_logging {
    my ( $context, $data ) = @_;
    my $pretty_json = JSON->new->pretty;
    $logger->info( $pretty_json->encode($context) );
    $logger->info( $pretty_json->encode($data) );
}
 
1;
It works! A side note for anybody trying to replicate this functionality in their own setup, the "WordPress" package is supplemented by a package extension in "/var/cpanel/packages/extensions" that defines three new variables:
  1. staging_dir
  2. staging_url
  3. db_prefix
These are critical to the functionality of the module, since it allows us to pick which site will get migrated from staging in our custom admin panel.