# Functions for editing the minecraft config # XXX plugin manager BEGIN { push(@INC, ".."); }; use strict; use warnings; no warnings 'redefine'; no warnings 'uninitialized'; use WebminCore; use Time::Local; use POSIX; &init_config(); our ($module_root_directory, %text, %gconfig, $root_directory, %config, $module_name, $remote_user, $base_remote_user, $gpgpath, $module_config_directory, @lang_order_list, @root_directories, $module_config_file); our $history_file = "$module_config_directory/history.txt"; our $download_page_url = "https://www.minecraft.net/en-us/download/server"; our $playtime_dir = "$module_config_directory/playtime"; our $uuid_cache_file = "$module_config_directory/uuids"; &foreign_require("webmin"); # get_minecraft_jar() # Returns the path to the JAR file sub get_minecraft_jar { if ($config{'minecraft_jar'} && $config{'minecraft_jar'} =~ /^\//) { return $config{'minecraft_jar'}; } elsif ($config{'minecraft_jar'}) { return $config{'minecraft_dir'}."/".$config{'minecraft_jar'}; } else { return $config{'minecraft_dir'}."/"."minecraft_server.jar"; } } # check_minecraft_server() # Returns an error message if the Minecraft server is not installed sub check_minecraft_server { -d $config{'minecraft_dir'} || return &text('check_edir', $config{'minecraft_dir'}); my $jar = &get_minecraft_jar(); -r $jar || return &text('check_ejar', $jar); &has_command($config{'java_cmd'}) || return &text('check_ejava', $config{'java_cmd'}); return undef; } # is_minecraft_port_in_use() # If any server is using the default Minecraft port or looks like it is running # minecraft_server.jar, return the PID. sub is_minecraft_port_in_use { &foreign_require("proc"); my $port = $config{'port'} || 25565; my ($pid) = &proc::find_socket_processes("tcp:".$port); return $pid if ($pid); my @procs = &proc::list_processes(); my $jar = &get_minecraft_jar(); foreach my $p (@procs) { if ($p->{'args'} =~ /^java.*\Q$jar\E/) { return $p->{'pid'}; } } return undef; } # is_minecraft_server_running() # If the minecraft server is running, return the PID sub is_minecraft_server_running { &foreign_require("proc"); my @procs = &proc::list_processes(); my $jar = &get_minecraft_jar(); my $shortjar = $jar; $shortjar =~ s/^.*\///; foreach my $p (@procs) { if ($p->{'args'} =~ /^\S*\Q$config{'java_cmd'}\E.*\Q$jar\E/) { return $p->{'pid'}; } } return undef; } # is_any_minecraft_server_running() # If the server is runnign for ANY version of Minecraft, return the PID sub is_any_minecraft_server_running { &foreign_require("proc"); my @procs = &proc::list_processes(); my $dir = $config{'minecraft_dir'}; my $jar = &get_minecraft_jar(); # Prefer the default version foreach my $p (@procs) { if ($p->{'args'} =~ /^\S*\Q$config{'java_cmd'}\E.*\Q$jar\E/) { my $ver = $jar =~ /([0-9\.]+)\.jar$/ ? $1 : undef; return wantarray ? ($p->{'pid'}, $ver, $jar) : $p->{'pid'}; } } # Look for other versions foreach my $p (@procs) { if ($p->{'args'} =~ /^\S*\Q$config{'java_cmd'}\E.*(\Q$dir\E\S+\.jar)/) { my $jar = $1; my $ver = $jar =~ /([0-9\.]+)\.jar$/ ? $1 : undef; return wantarray ? ($p->{'pid'}, $ver, $jar) : $p->{'pid'}; } } return wantarray ? ( ) : undef; } sub get_minecraft_config_file { return $config{'minecraft_dir'}."/server.properties"; } # get_minecraft_config() # Parses the config into an array ref of hash refs sub get_minecraft_config { my @rv; my $fh = "CONFIG"; my $lnum = 0; &open_readfile($fh, &get_minecraft_config_file()) || return [ ]; while(<$fh>) { s/\r|\n//g; s/#.*$//; if (/^([^=]+)=(.*)/) { push(@rv, { 'name' => $1, 'value' => $2, 'line' => $lnum }); } $lnum++; } close($fh); return \@rv; } # find(name, &config) # Returns all objects with some name in the config sub find { my ($name, $conf) = @_; my @rv = grep { lc($_->{'name'}) eq lc($name) } @$conf; return wantarray ? @rv : $rv[0]; } # find_value(name, &config) # Returns the values of all objects with some name in the config sub find_value { my ($name, $conf) = @_; my @rv = map { $_->{'value'} } &find($name, $conf); return wantarray ? @rv : $rv[0]; } # save_directive(name, value, &config) # Update one directive in the config sub save_directive { my ($name, $value, $conf) = @_; my $old = &find($name, $conf); my $lref = &read_file_lines(&get_minecraft_config_file()); if ($old && defined($value)) { # Update existing line $lref->[$old->{'line'}] = $name."=".$value; $old->{'value'} = $value; } elsif ($old && !defined($value)) { # Delete existing line splice(@$lref, $old->{'line'}, 1); my $idx = &indexof($old, @$conf); splice(@$conf, $idx, 1) if ($idx >= 0); foreach my $c (@$conf) { if ($c->{'line'} > $old->{'line'}) { $c->{'line'}--; } } } elsif (!$old && defined($value)) { # Add new line my $n = { 'name' => $name, 'value' => $value, 'line' => scalar(@$lref) }; push(@$lref, $name."=".$value); push(@$conf, $n); } } # get_start_command([suffix]) # Returns a command to start the server sub get_start_command { my ($suffix) = @_; my $jar = &get_minecraft_jar(); my $ififo = &get_input_fifo(); my $rv = "(test -e ".$ififo." || mkfifo ".$ififo.") ; ". "cd ".$config{'minecraft_dir'}." && ". "(tail -f ".$ififo." | ". $config{'java_envs'}." ". &has_command($config{'java_cmd'})." ". $config{'java_args'}." ". " -jar ".$jar." nogui ". $config{'jar_args'}." ". ">> server.out 2>&1 )"; $rv .= " ".$suffix if ($suffix); if ($config{'unix_user'} ne 'root') { $rv = &command_as_user($config{'unix_user'}, 0, $rv); } return $rv; } sub get_input_fifo { return $config{'minecraft_dir'}."/input.fifo"; } # start_minecraft_server() # Launch the minecraft server in the background sub start_minecraft_server { # Mark EULA as accepted my $eula = $config{'minecraft_dir'}."/eula.txt"; my $lref = &read_file_lines($eula); my $changed = 0; foreach my $l (@$lref) { if ($l =~ /eula=false/) { $l =~ s/false/true/; $changed++; } } if ($changed) { &flush_file_lines($eula); } else { &unflush_file_lines($eula); } my $cmd = &get_start_command(); my $pidfile = &get_pid_file(); &unlink_file($pidfile); &system_logged("$cmd &"); sleep(1); my $pid = &is_minecraft_server_running(); if (!$pid) { my $out = &backquote_command( "tail -2 ".$config{'minecraft_dir'}."/server.out"); return $out || "Unknown error - no output produced"; } my $fh = "PID"; &open_tempfile($fh, ">$pidfile"); &print_tempfile($fh, $pid."\n"); &close_tempfile($fh); &set_ownership_permissions($config{'unix_user'}, undef, undef, $pidfile); return undef; } # stop_minecraft_server([other-version]) # Kill the server, if running sub stop_minecraft_server { my ($any) = @_; my $func = $any ? \&is_any_minecraft_server_running : \&is_minecraft_server_running; my $pid = &$func(); $pid || return "Not running!"; # Try graceful shutdown &send_server_command("/save-all"); &send_server_command("/stop"); for(my $i=0; $i<10; $i++) { last if (!&$func()); sleep(1); } # Clean kill if (&$func()) { kill('TERM', $pid); for(my $i=0; $i<10; $i++) { last if (!&$func()); sleep(1); } } # Fatal kill if (&$func()) { kill('KILL', $pid); } # Clean up FIFO tailer my $fpid = int(&backquote_command("fuser ".&get_input_fifo()." 2>/dev/null")); if ($fpid) { kill('TERM', $fpid); } return undef; } # send_server_command(command, [no-log]) # Just sends a command to the server sub send_server_command { my ($cmd, $nolog) = @_; my $ififo = &get_input_fifo(); my $fh = "FIFO"; &open_tempfile($fh, ">$ififo", 1, 1, 1); &print_tempfile($fh, $cmd."\n"); &close_tempfile($fh); if (!$nolog) { &additional_log('minecraft', 'server', $cmd); } } # get_minecraft_log_file() sub get_minecraft_log_file { my $newfile = $config{'minecraft_dir'}."/logs/latest.log"; if (-r $newfile) { return $newfile; } else { return $config{'minecraft_dir'}."/server.log"; } } # execute_minecraft_command(command, [no-log], [wait-time]) # Run a command, and return output from the server log sub execute_minecraft_command { my ($cmd, $nolog, $wait) = @_; $cmd =~ s/^\///; # Leading / is now obsolete $wait ||= 100; my $logfile = &get_minecraft_log_file(); my $fh = "LOG"; &open_readfile($fh, $logfile); seek($fh, 0, 2); my $pos = tell($fh); &send_server_command($cmd, $nolog); for(my $i=0; $i<$wait; $i++) { select(undef, undef, undef, 0.1); my @st = stat($logfile); last if ($st[7] > $pos); } my $out; while(<$fh>) { $out .= $_; } close($fh); return wantarray ? split(/\r?\n/, $out) : $out; } # get_command_history() # Returns the history of commands run sub get_command_history { my $lref = &read_file_lines($history_file); return @$lref; } # save_command_history(&commands) sub save_command_history { my ($cmds) = @_; my $lref = &read_file_lines($history_file); @$lref = @$cmds; &flush_file_lines($history_file); } # list_connected_players() # Returns a list of players currently online sub list_connected_players { my @out = &execute_minecraft_command("/list", 1); my @rv; foreach my $l (@out) { if ($l !~ /players\s+online:/ && $l =~ /INFO\]:?\s+(\S.*)$/) { push(@rv, split(/,\s+/, $1)); } elsif ($l =~ /max\s+\d+\s+players\s+online:\s+(\S.*)/ || $l =~ /max\s+of\s+\d+\s+players\s+online:\s+(\S.*)/) { push(@rv, split(/,\s+/, $1)); } } return @rv; } # get_login_logout_times(player) # Returns the last login IP, time, X, Y, Z, logout time (if any) and list of # recent events. sub get_login_logout_times { my ($name) = @_; my ($ip, $intime, $xx, $yy, $zz, $outtime); my $logfile = &get_minecraft_log_file(); my @events; my @files = ( $logfile ); if ($logfile =~ /^(.*)\/latest.log$/) { # New server version keeps old rotated log files in gzip format my $dir = $1; my @extras; opendir(DIR, $dir); foreach my $f (readdir(DIR)) { if ($f =~ /^(\d+\-\d+\-\d+-\d+)\.log\.gz$/) { push(@extras, $f); } } closedir(DIR); @extras = sort { $a cmp $b } @extras; unshift(@files, map { "$dir/$_" } @extras); # To avoid reading too much, limit to newest 100k of logs my @small; my $total = 0; foreach my $f (reverse(@files)) { push(@small, $f); my @st = stat($f); $total += $st[7]; last if ($total > 100000); } @files = reverse(@small); } foreach my $f (@files) { my $fh = "TAIL"; if ($f =~ /\/latest.log$/) { # Latest log, read all of it &open_readfile($fh, $f); } elsif ($f =~ /\.gz$/) { # Read whole compressed log &open_execute_command($fh, "gunzip -c $f", 1, 1); } else { # Old single log file, read only the last 10k lines &open_execute_command($fh, "tail -10000 $f", 1, 1); } my @tm = localtime(time()); while(<$fh>) { my ($y, $mo, $d, $h, $m, $s, $msg); if (/^(\d+)\-(\d+)\-(\d+)\s+(\d+):(\d+):(\d+)\s+\[\S+\]\s+(.*)/) { # Old log format ($y, $mo, $d, $h, $m, $s, $msg) = ($1, $2, $3, $4, $5, $6, $7); } elsif (/^\[(\d+):(\d+):(\d+)\]\s+\[[^\[]+\]:\s*(.*)/) { # New log format ($h, $m, $s, $msg) = ($1, $2, $3, $4); if ($f =~ /\/(\d+)\-(\d+)\-(\d+)/) { # Get date from old rotated log ($y, $mo, $d) = ($1, $2, $3); } else { # Assume latest.log, which is for today ($y, $mo, $d) = ($tm[5]+1900, $tm[4]+1, $tm[3]); } } else { next; } if ($msg =~ /^\Q$name\E\[.*\/([0-9\.]+):(\d+)\]\s+logged\s+in.*\((\-?[0-9\.]+),\s+(\-?[0-9\.]+),\s+(\-?[0-9\.]+)\)/) { # Login message $ip = $1; ($xx, $yy, $zz) = ($3, $4, $5); $intime = &parse_log_time($y, $m, $d, $h, $mo, $s); } elsif ($msg =~ /^\Q$name\E\s+(\[.*\]\s+)?lost/ || $msg =~ /^Disconnecting\s+\Q$name\E/) { # Logout message $outtime = &parse_log_time($y, $m, $d, $h, $mo, $s); } elsif ($msg =~ /^(\S+\s+)?\Q$name\E(\s|\[)/) { # Some player event push(@events, { 'time' => &parse_log_time($y, $m, $d, $h, $mo, $s), 'msg' => $msg }); } } close($fh); } return ( $ip, $intime, $xx, $yy, $zz, $outtime, \@events ); } sub parse_log_time { my ($y, $m, $d, $h, $mo, $s) = @_; return timelocal($s, $m, $h, $d, $mo-1, $y-1900); } # item_chooser_button(fieldname) sub item_chooser_button { my ($field) = @_; return "\n"; } # list_minecraft_items() # Returns a list of hash refs with id and name keys # CSV generated with : # items-page-to-csv.pl > items.csv sub list_minecraft_items { my $fh = "ITEMS"; &open_readfile($fh, "$module_root_directory/items.csv"); my @rv; while(<$fh>) { s/\r|\n//g; my ($id, $name, $desc) = split(/,/, $_); push(@rv, { 'id' => $id, 'name' => $name, 'desc' => $desc }); } close($fh); return @rv; } # list_banned_players() # Returns a list of players who are banned sub list_banned_players { my @out = &execute_minecraft_command("/banlist", 1); my @rv; foreach my $l (@out) { if ($l !~ /banned\s+players:/ && $l =~ /INFO\]:?\s+(\S.*)$/) { push(@rv, grep { $_ ne "and" } split(/[, ]+/, $1)); } } return @rv; } # list_whitelisted_players() # Returns a list of players who are whitelisted sub list_whitelisted_players { my @out = &execute_minecraft_command("/whitelist list", 1); my @rv; foreach my $l (@out) { if ($l !~ /whitelisted\s+players:/ && $l =~ /INFO\]:?\s+(\S.*)$/) { push(@rv, grep { $_ ne "and" } split(/[, ]+/, $1)); } } return @rv; } sub get_whitelist_file { return $config{'minecraft_dir'}.'/white-list.txt'; } # list_whitelist_users() # Returns a list of usernames on the whitelist sub list_whitelist_users { my $lref = &read_file_lines(&get_whitelist_file(), 1); return @$lref; } # save_whitelist_users(&users) # Update the usernames on the whitelist sub save_whitelist_users { my ($users) = @_; my $lref = &read_file_lines(&get_whitelist_file()); @$lref = @$users; &flush_file_lines(&get_whitelist_file()); &set_ownership_permissions($config{'unix_user'}, undef, undef, &get_whitelist_file()); } sub get_op_file { return $config{'minecraft_dir'}.'/ops.txt'; } # list_op_users() # Returns a list of usernames on the operator list sub list_op_users { my $lref = &read_file_lines(&get_op_file(), 1); return @$lref; } # save_op_users(&users) # Update the usernames on the operator list sub save_op_users { my ($users) = @_; my $lref = &read_file_lines(&get_op_file()); @$lref = @$users; &flush_file_lines(&get_op_file()); &set_ownership_permissions($config{'unix_user'}, undef, undef, &get_op_file()); } # list_worlds() # Returns a list of possible world directories sub list_worlds { my @rv; foreach my $dat (glob("$config{'minecraft_dir'}/*/level.dat")) { $dat =~ /^(.*\/([^\/]+))\/level.dat$/ || next; my $path = $1; my $name = $2; my @players; if (-d "$path/players") { # Old format @players = map { s/^.*\///; s/\.dat$//; $_ } glob("\Q$path\E/players/*"); } if (-d "$path/playerdata" && !@players) { # New format (UUID based) @players = map { s/^.*\///; s/\.dat$//; $_ } glob("\Q$path\E/playerdata/*"); @players = map { my $u = $_; &uuid_to_username($u) || $u } @players; } push(@rv, { 'path' => $path, 'name' => $name, 'size' => &disk_usage_kb($path)*1024, 'lock' => (-r "$path/session.lock"), 'players' => \@players }); } return @rv; } # uuid_to_username(uuid) # Returns the username with some UUID, by searching logs if needed sub uuid_to_username { my ($uuid) = @_; my %cache; &read_file_cached($uuid_cache_file, \%cache); return $cache{$uuid} if (exists($cache{$uuid})); my $found = 0; foreach my $file (&get_minecraft_log_file(), sort { $b cmp $a } glob("$config{'minecraft_dir'}/logs/*.log.gz")) { if ($file =~ /\.gz$/) { open(LOG, "gunzip -c ".quotemeta($file)." |"); } else { open(LOG, $file); } while() { if (/UUID\s+of\s+player\s+(\S+)\s+is\s+(\S+)/) { my ($lp, $lu) = ($1, $2); if ($lu =~ /^([0-9a-f]{8})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{4})([0-9a-f]{12})$/) { # Convert to new UUID format $lu = "$1-$2-$3-$4-$5"; } $cache{$lp} = $lu; $cache{$lu} = $lp; if ($cache{$uuid}) { $found = 1; last; } } } close(LOG); if ($found) { &write_file($uuid_cache_file, \%cache); last; } } # If we got this far, it wasn't found if (!exists($cache{$uuid})) { $cache{$uuid} = ""; &write_file($uuid_cache_file, \%cache); } return $cache{$uuid}; } # list_banned_ips() # Returns an array of banned addresses sub list_banned_ips { my @out = &execute_minecraft_command("/banlist ips", 1); my @rv; foreach my $l (@out) { if ($l !~ /banned\s+IP\s+addresses:/ && $l =~ /INFO\]:?\s+(\S.*)$/) { push(@rv, grep { $_ ne "and" } split(/[, ]+/, $1)); } } return @rv; } # md5_checksum(file) # Returns a checksum for a file sub md5_checksum { my ($file) = @_; &has_command("md5sum") || &error("md5sum command not installed!"); return undef if (!-r $file); my $out = &backquote_command("md5sum ".quotemeta($file)); return $out =~ /^([a-f0-9]+)\s/ ? $1 : undef; } # get_pid_file() # Returns the file in which the server PID is stored sub get_pid_file { return $config{'minecraft_dir'}."/server.pid"; } # update_init_script_args(&args) # Updates all Java command-line args in the init script sub update_init_script_args { my ($args) = @_; my $mode; &foreign_require("init"); if (defined(&init::get_action_mode)) { $mode = &init::get_action_mode($config{'init_name'}); } $mode ||= $init::init_mode; # Find the init script file my $file; if ($mode eq "init") { $file = &init::action_filename($config{'init_name'}); } elsif ($mode eq "upstart") { $file = "/etc/init/$config{'init_name'}.conf"; } elsif ($mode eq "systemd") { my $unit = $config{'init_name'}; $unit .= ".service" if ($unit !~ /\.service$/); $file = &init::get_systemd_root($config{'init_name'})."/".$unit; } elsif ($mode eq "local") { $file = "$init::module_config_directory/$config{'init_name'}.sh"; } elsif ($mode eq "osx") { my $ucfirst = ucfirst($config{'init_name'}); $file = "$init::config{'darwin_setup'}/$ucfirst/$init::config{'plist'}"; } elsif ($mode eq "rc") { my @dirs = split(/\s+/, $init::config{'rc_dir'}); $file = $dirs[$#dirs]."/".$config{'init_name'}.".sh"; } else { return 0; } return 0 if (!-r $file); # Not enabled? # Find and edit the Java command &lock_file($file); my $lref = &read_file_lines($file); my $found = 0; foreach my $l (@$lref) { if ($l =~ /^(.*su.*-c\s+)(.*)/) { # May be wrapped in an su command my $su = $1; my $cmd = &unquotemeta($2); if ($cmd =~ /^(.*\Q$config{'java_cmd'}\E)\s+(.*)(-jar.*)/) { $cmd = $1." ".$args." ".$3; $l = $su.quotemeta($cmd); $found = 1; } } elsif ($l =~ /^(.*\Q$config{'java_cmd'}\E)\s+(.*)(-jar.*)/) { $l = $1." ".$args." ".$3; $found = 1; } } &flush_file_lines($file); &unlock_file($file); return $found; } sub unquotemeta { my ($str) = @_; eval("\$str = \"$str\""); return $str; } # get_server_jar_url() # Returns the URL for downloading the server JAR file, and optionally the # latest version number sub get_server_jar_url { my ($host, $port, $page, $ssl) = &parse_http_url($download_page_url); return undef if (!$host); my ($out, $err); &http_download($host, $port, $page, \$out, \$err, undef, $ssl, undef, undef, 5, 0, 1); return undef if ($err); $out =~ /"((http|https):[^"]+server\.jar)"/ || return undef; my $url = $1; my $ver; if ($out =~ /minecraft_server\.([0-9\.]+)\.jar/) { $ver = $1; } return wantarray ? ($url, $ver) : $url; } # check_server_download_size() # Returns the size in bytes of the minecraft server that is available # for download sub check_server_download_size { my ($host, $port, $page, $ssl) = &parse_http_url(&get_server_jar_url()); # Make HTTP connection my @headers; push(@headers, [ "Host", $host ]); push(@headers, [ "User-agent", "Webmin" ]); push(@headers, [ "Accept-language", "en" ]); alarm(5); my $h = &make_http_connection($host, $port, $ssl, "HEAD", $page, \@headers); alarm(0); return undef if (!ref($h)); # Read headers my $line; ($line = &read_http_connection($h)) =~ tr/\r\n//d; if ($line !~ /^HTTP\/1\..\s+(200)(\s+|$)/) { return undef; } my %header; while(1) { $line = &read_http_connection($h); $line =~ tr/\r\n//d; $line =~ /^(\S+):\s+(.*)$/ || last; $header{lc($1)} = $2; } &close_http_connection($h); return $header{'content-length'}; } # get_backup_job() # Returns the webmincron job to backup worlds sub get_backup_job { &foreign_require("webmincron"); my @jobs = &webmincron::list_webmin_crons(); my ($job) = grep { $_->{'module'} eq $module_name && $_->{'func'} eq "backup_worlds" } @jobs; return $job; } # backup_worlds() # This function is called by webmincron to perform a backup sub backup_worlds { my ($out, $failed) = &execute_backup_worlds(); &send_backup_email(join("\n", @$out)."\n", $failed); } # execute_backup_worlds() # Run the configured backup, and return output and the failed flag sub execute_backup_worlds { # Get worlds to include my @allworlds = &list_worlds(); my @worlds; if ($config{'backup_worlds'}) { my %names = map { $_, 1 } split(/\s+/, $config{'backup_worlds'}); @worlds = grep { $names{$_->{'name'}} } @allworlds; } else { @worlds = @allworlds; } if (!@worlds) { return (["No worlds were found to backup!"], 1); } # Get destination dir, with strftime my @tm = localtime(time()); &clear_time_locale(); my $dir = strftime($config{'backup_dir'}, @tm); &reset_time_locale(); # Create destination dir if (!-d $dir) { if (!&make_dir($dir, 0755)) { return (["Failed to create destination directory $dir : $!"],1); } if ($config{'unix_user'} ne 'root') { &set_ownership_permissions($config{'unix_user'}, undef, undef, $dir); } } # Find active world my $conf = &get_minecraft_config(); my $def = &find_value("level-name", $conf); # Backup each world my @out; my $failed = 0; foreach my $w (@worlds) { my $file = "$dir/$w->{'name'}.zip"; push(@out, "Backing up $w->{'name'} to $file .."); if ($w->{'name'} eq $def && &is_minecraft_server_running()) { # World is live, flush state to disk &execute_minecraft_command("save-off"); &execute_minecraft_command("save-all"); } my $out = &backquote_command( "cd ".quotemeta($config{'minecraft_dir'})." && ". "zip -r ".quotemeta($file)." ".quotemeta($w->{'name'})); my $ex = $?; &set_ownership_permissions(undef, undef, 0755, $file); if ($w->{'name'} eq $def && &is_minecraft_server_running()) { # Re-enable world writes &execute_minecraft_command("save-on"); } my @st = stat($file); if (@st && $config{'unix_user'} ne 'root') { &set_ownership_permissions($config{'unix_user'}, undef, undef, $file); } if ($ex) { push(@out, " .. ZIP of $w->{'name'} failed : $out"); $failed++; } elsif (!@st) { push(@out, " .. ZIP of $w->{'name'} produced no output : $out"); $failed++; } else { push(@out, " .. done (".&nice_size($st[7]).")"); } push(@out, ""); } return (\@out, $failed); } # send_backup_email(msg, error) # Sends a backup report email, if configured sub send_backup_email { my ($msg, $err) = @_; return 0 if (!$config{'backup_email'}); return 0 if ($config{'backup_email_err'} && !$err); &foreign_require("mailboxes"); &mailboxes::send_text_mail( &mailboxes::get_from_address(), $config{'backup_email'}, undef, "Minecraft backup ".($err ? "FAILED" : "succeeded"), $msg); } # level_to_orbs(level) # Converts a desired level to a number of orbs. From : # http://www.minecraftwiki.net/wiki/Experience sub level_to_orbs { my ($lvl) = @_; if ($lvl < 17) { return $lvl * 17; } my @xpmap = split(/\s+/, "17 292 18 315 19 341 20 370 21 402 22 437 23 475 24 516 ". "25 560 26 607 27 657 28 710 29 766 30 825 31 887 32 956 ". "33 1032 34 1115 35 1205 36 1302 37 1406 38 1517 39 1635 ". "40 1760 41 3147 42 3297 43 3451 44 3608 45 3769 46 3933 ". "47 4101 48 4272 49 4447 50 4625"); for(my $i=0; $i<@xpmap; $i+=2) { if ($xpmap[$i] == $lvl) { return $xpmap[$i+1]; } } return undef; } # get_current_day_usage() # Returns a hash ref from usernames to total usage over the last day, and # usage that counts towards any limits sub get_current_day_usage { my $logfile = &get_minecraft_log_file(); # Seek back till we find a day line from a previous day my @st = stat($logfile); return { } if (!@st); my $pos = $st[7]; open(LOGFILE, $logfile); my @tm = localtime(time()); my $wantday = sprintf("%4.4d-%2.2d-%2.2d", $tm[5]+1900, $tm[4]+1, $tm[3]); my $lasttime; while(1) { $pos -= 4096; $pos = 0 if ($pos < 0); seek(LOGFILE, $pos, 0); last if ($pos == 0); my $dummy = ; # Skip partial line my $line = ; if ($line =~ /^((\d+)\-(\d+)\-(\d+))/) { # Format with the date in it if ($1 ne $wantday) { # Found a line for another day last; } } elsif ($line =~ /^\[((\d+):(\d+):(\d+))\]/) { # Format with the time only if ($lasttime && ($1 cmp $lasttime) > 0) { # Time has gone forwards, meaning its a new day last; } $lasttime = $1; } } # Read forwards, looking for logins and logouts for today my (%rv, %limit_rv); my (%lastlogin, %limit_lastlogin); while(my $line = ) { my ($day, $secs); if ($line =~ /^((\d+)\-(\d+)\-(\d+))\s+(\d+):(\d+):(\d+)/) { # Old log format, which contains the day and time $day = $1; $day eq $wantday || next; $secs = $5*60*60 + $6*60 + $7; } elsif ($line =~ /^\[(\d+):(\d+):(\d+)\]/) { # New log format, assume that it is for the current day $day = $wantday; $secs = $1*60*60 + $2*60 + $3; } if ($line =~ /\s(\S+)\[.*\/([0-9\.]+):(\d+)\]\s+logged\s+in\s/) { # Login by a user my ($u, $ip) = ($1, $2); $lastlogin{$u} = $secs; if (&limit_user($ip, $u, $day)) { $limit_lastlogin{$u} = $secs; } } elsif ($line =~ /\s(\S+)(\s*\[[^\]]+\])?\s+lost\s+connection/) { # Logout .. count time if (defined($lastlogin{$1})) { # Add time from last login $rv{$1} += $secs - $lastlogin{$1}; delete($lastlogin{$1}); } if (defined($limit_lastlogin{$1})) { # Also for login that counts towards limits $limit_rv{$1} += $secs - $limit_lastlogin{$1}; delete($limit_lastlogin{$1}); } } } close(LOGFILE); # Add any active sessions my $now = $tm[2]*60*60 + $tm[1]*60 + $tm[0]; foreach my $u (keys %lastlogin) { $rv{$u} += $now - $lastlogin{$u}; } foreach my $u (keys %limit_lastlogin) { $limit_rv{$u} += $now - $limit_lastlogin{$u}; } return (\%rv, \%limit_rv); } # get_past_days_usage(user) # Returns a list of array refs, each with day, playtime and enforced playtime sub get_past_day_usage { my ($u) = @_; my $ufile = "$playtime_dir/$u"; my %days; &read_file($ufile, \%days); my @rv; foreach my $k (sort { $a cmp $b } (keys %days)) { next if ($k !~ /^total_(\d+\-\d+\-\d+)$/); my $day = $1; push(@rv, [ $day, $days{"total_".$day}, $days{"limit_".$day} ]); } return @rv; } # list_playtime_users() # Returns a list of all users for which we have playtime stats sub list_playtime_users { opendir(DIR, $playtime_dir) || return (); my @users = grep { !/^\./ } readdir(DIR); closedir(DIR); return @users; } # nice_seconds(secs) # Converts a number of seconds into HH:MM format sub nice_seconds { my ($time) = @_; my $days = int($time / (24*60*60)); my $hours = int($time / (60*60)) % 24; my $mins = sprintf("%d", int($time / 60) % 60); my $secs = sprintf("%d", int($time) % 60); if ($days) { return "$days days, $hours hours, $mins mins"; } elsif ($hours) { return "$hours hours, $mins mins"; } else { return "$mins mins"; } } # limit_user(ip, user, date) # Returns 1 if some usage should be counted for limiting purposes sub limit_user { my ($ip, $user, $date) = @_; my @users = split(/\s+/, $config{'playtime_users'}); if (@users && &indexoflc($user, @users) < 0) { return 0; } my @ips = split(/\s+/, $config{'playtime_ips'}); if (@ips && !&webmin::ip_match($ip, @ips)) { return 0; } my @days = split(/\s+/, $config{'playtime_days'}); if (@days > 0 && @days < 7) { my ($y, $m, $d) = split(/\-/, $date); my @tm = localtime(timelocal(0, 0, 0, $d, $m-1, $y-1900)); if (@tm && &indexof($tm[6], @days) < 0) { return 0; } } return 1; } # check_playtime_limits() # Function called by webmincron to update and enforce playtime usage sub check_playtime_limits { # Get usage for today, and update today's files my ($usage, $limit_usage) = &get_current_day_usage(); if (!-d $playtime_dir) { &make_dir($playtime_dir, 0700); } my $today = strftime("%Y-%m-%d", localtime(time())); my (@bans, @unbans); foreach my $u (keys %$usage) { my $ufile = "$playtime_dir/$u"; my %days; &read_file($ufile, \%days); $days{"total_".$today} = $usage->{$u}; $days{"limit_".$today} = $limit_usage->{$u}; if ($config{'playtime_max'} && $limit_usage->{$u} > $config{'playtime_max'}*60) { # Flag as banned if (!$days{"banned_".$today}) { $days{"banned_".$today} = 1; push(@bans, $u); } } else { # Not banned if ($days{"banned_".$today}) { push(@unbans, $u); } } &write_file($ufile, \%days); } # Band and un-ban players my @banned = &list_banned_players(); foreach my $u (@bans) { &execute_minecraft_command( "/ban $u Exceeded $config{'playtime_max'} minutes of play time"); } foreach my $u (@unbans) { &execute_minecraft_command("/pardon $u"); } } # get_playtime_job() # Returns the webmincron job to enforce play time limits sub get_playtime_job { &foreign_require("webmincron"); my @jobs = &webmincron::list_webmin_crons(); my ($job) = grep { $_->{'module'} eq $module_name && $_->{'func'} eq "check_playtime_limits" } @jobs; return $job; } # get_player_stats(name, [world]) # Returns all stats available for a player, in the format of the JSON file sub get_player_stats { my ($name, $world) = @_; if (!$world) { my $conf = &get_minecraft_config(); $world = &find_value("level-name", $conf); $world ||= "world"; } my $uuid = &uuid_to_username($name); my $wdir = "$config{'minecraft_dir'}/$world"; my $file = "$wdir/stats/$name.json"; if (!-r $file) { $file = "$wdir/stats/$uuid.json"; } if (!-r $file) { return $text{'conn_nostats'}; } eval "use JSON::PP"; return &text('conn_noperl', "JSON::PP") if ($@); my $coder = JSON::PP->new->pretty; my $perl; eval { $perl = $coder->decode(&read_file_contents($file)); }; return &text('conn_ejson', $@) if ($@); return $perl; } # minecraft_server_type() # Returns 'default' or 'bukkit' sub minecraft_server_type { my $jar = &get_minecraft_jar(); return $jar =~ /bukkit-[0-9]/ ? 'bukkit' : 'default'; } # list_installed_versions() # Returns a list of hash refs, one per available server version sub list_installed_versions { # Find all the jars my @files; my $dir = $config{'minecraft_dir'}; my $cur = &get_minecraft_jar(); opendir(DIR, $dir); foreach my $f (readdir(DIR)) { push(@files, $dir."/".$f) if ($f =~ /\.jar$/); } closedir(DIR); push(@files, $cur) if (&indexof($cur, @files) < 0); # Figure out what they are my @rv; foreach my $f (sort { $a cmp $b } @files) { my $ver = { 'path' => $f }; $ver->{'file'} = $f =~ /^\Q$dir\E\/(.*)/ ? $1 : $f; $ver->{'ver'} = $f =~ /([0-9][0-9\.]+)\.jar$/ ? $1 : "Unknown"; $ver->{'desc'} = $ver->{'ver'}; push(@rv, $ver); } return @rv; } # save_minecraft_jar(file) # Update the server jar file sub save_minecraft_jar { my ($file) = @_; my $dir = $config{'minecraft_dir'}; &lock_file($module_config_file); if ($file =~ /^\Q$dir\E\/(.*)$/) { $config{'minecraft_jar'} = $1; } else { $config{'minecraft_jar'} = $file; } &save_module_config(\%config); &unlock_file($module_config_file); } 1;