From beeb4a6a6279b20d611bbb311f09d139a42d228f Mon Sep 17 00:00:00 2001 From: iaretechnician Date: Wed, 10 Jun 2026 20:27:30 -0400 Subject: [PATCH 1/2] log fix --- OGP64/OGP/ogp_agent.pl | 65 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/OGP64/OGP/ogp_agent.pl b/OGP64/OGP/ogp_agent.pl index 03e49e6..93d8208 100644 --- a/OGP64/OGP/ogp_agent.pl +++ b/OGP64/OGP/ogp_agent.pl @@ -1439,6 +1439,55 @@ sub log_path_has_wildcards return defined($path) && $path =~ /[*?\[]/ ? 1 : 0; } +sub wildcard_pattern_to_regex +{ + my ($pattern) = @_; + return undef unless defined($pattern); + + my $regex = ''; + my @chars = split(//, $pattern); + my $in_class = 0; + for (my $i = 0; $i <= $#chars; $i++) + { + my $c = $chars[$i]; + if (!$in_class) + { + if ($c eq '*') + { + $regex .= '[^/]*'; + } + elsif ($c eq '?') + { + $regex .= '[^/]'; + } + elsif ($c eq '[') + { + $in_class = 1; + $regex .= '['; + } + else + { + $regex .= quotemeta($c); + } + } + else + { + if ($c eq ']') + { + $in_class = 0; + $regex .= ']'; + } + else + { + $regex .= $c; + } + } + } + + return undef if $in_class; + return qr/^$regex\z/i; +} + sub resolve_latest_log_match { my ($home_path, $pattern) = @_; @@ -1452,8 +1501,11 @@ sub resolve_latest_log_match my $home_abs = abs_path($home_path); return undef unless defined($home_abs); - - my @matches = bsd_glob("$home_path/$pattern", GLOB_NOSORT); + my $regex = wildcard_pattern_to_regex($pattern); + return undef unless defined($regex); + my $glob_pattern = $pattern; + $glob_pattern =~ s/\[([^\]]+)\]/\*/g; + my @matches = bsd_glob("$home_path/$glob_pattern", GLOB_NOSORT); my @candidates = (); foreach my $candidate (@matches) { @@ -1461,6 +1513,12 @@ sub resolve_latest_log_match my $candidate_abs = abs_path($candidate); next unless defined($candidate_abs); next unless $candidate_abs eq $home_abs || index($candidate_abs, "$home_abs/") == 0; + my $candidate_rel = $candidate_abs; + $candidate_rel =~ s{\\}{/}g; + my $home_norm = $home_abs; + $home_norm =~ s{\\}{/}g; + $candidate_rel =~ s/^\Q$home_norm\E\/?//; + next unless $candidate_rel =~ $regex; my $mtime = (stat($candidate_abs))[9] || 0; push(@candidates, [ $candidate_abs, $mtime ]); } @@ -1509,8 +1567,9 @@ sub get_log if (!defined($resolved_log_file)) { logger "No log files matched wildcard pattern '$log_file' in '$home_path'."; - return -8; + return "-8;" . encode("No log files matched pattern: $log_file"); } + logger "Wildcard log '$log_file' resolved to '$resolved_log_file'."; $log_file = Path::Class::File->new($resolved_log_file); } else From 2d16aeb91abe8e7248529a2c69387f2a8c6266dd Mon Sep 17 00:00:00 2001 From: Frank Harris Date: Tue, 16 Jun 2026 12:25:46 -0500 Subject: [PATCH 2/2] logfin expand --- OGP64/OGP/Cfg/Config.pm.default | 3 +- OGP64/OGP/agent_conf.sh | 2 + OGP64/OGP/documentation/agent-guide.md | 3 +- OGP64/OGP/ogp_agent.pl | 492 ++++++++++++++++++++++--- docs/AGENT_ACTIVITY_EVENTS.md | 52 +++ docs/PANEL_INTEGRATION.md | 14 + 6 files changed, 512 insertions(+), 54 deletions(-) create mode 100644 docs/AGENT_ACTIVITY_EVENTS.md diff --git a/OGP64/OGP/Cfg/Config.pm.default b/OGP64/OGP/Cfg/Config.pm.default index 214c660..510b979 100644 --- a/OGP64/OGP/Cfg/Config.pm.default +++ b/OGP64/OGP/Cfg/Config.pm.default @@ -8,6 +8,7 @@ sudo_password => '', web_admin_api_key => '', web_api_url => '', + agent_event_url => '', + remote_server_id => '', steam_dl_limit => '0', ); - diff --git a/OGP64/OGP/agent_conf.sh b/OGP64/OGP/agent_conf.sh index 956a26c..95cea74 100644 --- a/OGP64/OGP/agent_conf.sh +++ b/OGP64/OGP/agent_conf.sh @@ -184,6 +184,8 @@ done sudo_password => '${sudo_password}', web_admin_api_key => '{your_admin_ogp_web_api_key_here}', web_api_url => '{your_url_to_ogp_api.php}', + agent_event_url => '{your_url_to_agent_event_receiver.php}', + remote_server_id => '{panel_remote_server_id}', steam_dl_limit => '0', );" > $cfgfile diff --git a/OGP64/OGP/documentation/agent-guide.md b/OGP64/OGP/documentation/agent-guide.md index 4ed1613..1115ae5 100644 --- a/OGP64/OGP/documentation/agent-guide.md +++ b/OGP64/OGP/documentation/agent-guide.md @@ -28,7 +28,7 @@ The Windows agent bundles Cygwin, Perl, GNU Screen, and helper scripts so the Ga cd /OGP bash agent_conf.sh -p "gameserverPassword" ``` -4. **Edit configuration** – `/OGP/Cfg/Config.pm` mirrors the Linux agent. Set `listen_ip`, `listen_port`, `key`, `web_api_url`, and (optionally) the stats database credentials. +4. **Edit configuration** – `/OGP/Cfg/Config.pm` mirrors the Linux agent. Set `listen_ip`, `listen_port`, `key`, `web_api_url`, `agent_event_url`, `remote_server_id`, and (optionally) the stats database credentials. 5. **Start the service** – The installer already created a scheduled task (“OGP agent start on boot”). Run it immediately from Task Scheduler or execute `schtasks /Run /tn "OGP agent start on boot"`. ## Updating the agent @@ -62,6 +62,7 @@ The updater downloads to a temporary file, rejects empty files, HTML error pages - Customer servers run inside GNU Screen sessions—attach via `C:\\OGP\\bin\\screen -r ogp_agent` - Server readiness uses the `server_status` RPC and validates only the game/query/RCON ports supplied by the Panel. - Port validation settings live in `/OGP/Cfg/Preferences.pm`: `PortValidationEnabled`, `StartupValidationTimeoutSeconds`, and `PortCheckIntervalSeconds`. +- Panel activity-log lifecycle event delivery is documented in `docs/AGENT_ACTIVITY_EVENTS.md`. - Port validation smoke test: `bash /OGP/tests/port_validation_smoke.sh 2302/udp 2303/udp`. - Firewall: open TCP 12679 (or your configured port) and any game-specific ports before provisioning. - Authentication errors almost always mean the `key` in `Cfg/Config.pm` does not match the value stored in the panel → Administration → Servers. diff --git a/OGP64/OGP/ogp_agent.pl b/OGP64/OGP/ogp_agent.pl index 93d8208..7a8ae87 100644 --- a/OGP64/OGP/ogp_agent.pl +++ b/OGP64/OGP/ogp_agent.pl @@ -45,6 +45,9 @@ use File::Path qw(mkpath); use Archive::Extract; # Used to handle archived files. use File::Find; use Schedule::Cron; # Used for scheduling tasks +use JSON::PP; +use Digest::SHA qw(hmac_sha256_hex); +use Sys::Hostname qw(hostname); # Compression tools use IO::Compress::Bzip2 qw(bzip2 $Bzip2Error); # Used to compress files to bz2. @@ -89,6 +92,10 @@ use constant GAME_STARTUP_DIR => Path::Class::Dir->new(AGENT_RUN_DIR, 'startups'); use constant SERVER_RUNTIME_DIR => Path::Class::Dir->new(AGENT_RUN_DIR, 'runtime_status'); +use constant AGENT_EVENTS_DIR => + Path::Class::Dir->new(AGENT_RUN_DIR, 'events'); +use constant AGENT_EVENT_QUEUE_FILE => + Path::Class::File->new(AGENT_EVENTS_DIR, 'pending-events.jsonl'); use constant SCREENRC_FILE => Path::Class::File->new(AGENT_RUN_DIR, 'ogp_screenrc'); use constant SCREENRC_FILE_BK => @@ -155,6 +162,252 @@ sub logger close(LOGFILE) or die("Failed to close log file."); } +sub agent_event_panel_url +{ + return $Cfg::Config{agent_event_url} if(defined($Cfg::Config{agent_event_url}) && $Cfg::Config{agent_event_url} ne ""); + if(defined($Cfg::Config{web_api_url}) && $Cfg::Config{web_api_url} ne "") + { + my $url = $Cfg::Config{web_api_url}; + $url =~ s/ogp_api\.php(?:\?.*)?$/agent_event_receiver.php/; + return $url if($url =~ /agent_event_receiver\.php$/); + $url =~ s/\/+$//; + return "$url/agent_event_receiver.php"; + } + return ""; +} + +sub agent_event_remote_server_id +{ + return $Cfg::Config{remote_server_id} if(defined($Cfg::Config{remote_server_id}) && $Cfg::Config{remote_server_id} =~ /^\d+$/); + return $Cfg::Config{agent_remote_server_id} if(defined($Cfg::Config{agent_remote_server_id}) && $Cfg::Config{agent_remote_server_id} =~ /^\d+$/); + return ""; +} + +sub new_correlation_id +{ + return time() . "-" . $$ . "-" . int(rand(1000000000)); +} + +sub new_agent_event_uuid +{ + return "agent-" . new_correlation_id(); +} + +sub build_agent_event +{ + my ($event_type, $severity, $fields) = @_; + $fields = {} unless(ref($fields) eq 'HASH'); + my %event = %$fields; + $event{event_uuid} ||= new_agent_event_uuid(); + $event{timestamp_utc} ||= gmtime() . " UTC"; + $event{event_type} = $event_type; + $event{severity} = $severity; + $event{source} = "agent"; + $event{agent_os} ||= "Windows"; + $event{agent_hostname} ||= hostname(); + $event{remote_server_id} ||= agent_event_remote_server_id(); + $event{correlation_id} ||= new_correlation_id(); + foreach my $secret_key (qw(password control_password steam_password encryption_key database_password envVars environment_variables)) + { + delete $event{$secret_key}; + } + return \%event; +} + +sub agent_event_json +{ + my ($event) = @_; + return JSON::PP->new->ascii->canonical->encode($event); +} + +sub send_agent_event +{ + my ($event) = @_; + my $url = agent_event_panel_url(); + my $remote_server_id = agent_event_remote_server_id(); + return 0 if($url eq "" || $remote_server_id eq "" || AGENT_KEY eq ""); + my $body = agent_event_json($event); + my $timestamp = time(); + my $signature = "sha256=" . hmac_sha256_hex($timestamp . "." . $body, AGENT_KEY); + my $ua = LWP::UserAgent->new(timeout => 5, ssl_opts => { verify_hostname => 0, SSL_verify_mode => 0x00 }); + my $response = $ua->post($url, + 'Content-Type' => 'application/json', + 'X-GSP-Agent-Id' => $remote_server_id, + 'X-GSP-Agent-Timestamp' => $timestamp, + 'X-GSP-Agent-Signature' => $signature, + Content => $body + ); + return ($response && $response->is_success) ? 1 : 0; +} + +sub append_agent_event_queue +{ + my ($event) = @_; + mkpath(AGENT_EVENTS_DIR) unless(-d AGENT_EVENTS_DIR); + if(open(EVENTQ, '>>', AGENT_EVENT_QUEUE_FILE)) + { + flock(EVENTQ, LOCK_EX); + print EVENTQ agent_event_json($event) . "\n"; + flock(EVENTQ, LOCK_UN); + close(EVENTQ); + } + if(-e AGENT_EVENT_QUEUE_FILE && -s AGENT_EVENT_QUEUE_FILE > 1048576) + { + rename(AGENT_EVENT_QUEUE_FILE, AGENT_EVENT_QUEUE_FILE . "." . time() . ".old"); + } +} + +sub flush_agent_event_queue +{ + return 0 unless(-e AGENT_EVENT_QUEUE_FILE); + open(EVENTQ, '<', AGENT_EVENT_QUEUE_FILE) or return 0; + my @lines = ; + close(EVENTQ); + my @remaining; + my $delivered = 0; + foreach my $line (@lines) + { + chomp($line); + next if($line eq ""); + my $event = eval { JSON::PP->new->decode($line) }; + if($@ || ref($event) ne 'HASH') + { + next; + } + if(send_agent_event($event)) + { + $delivered++; + } + else + { + push(@remaining, $line); + } + } + if(open(EVENTQ, '>', AGENT_EVENT_QUEUE_FILE)) + { + flock(EVENTQ, LOCK_EX); + print EVENTQ join("\n", @remaining) . (@remaining ? "\n" : ""); + flock(EVENTQ, LOCK_UN); + close(EVENTQ); + } + return $delivered; +} + +sub queue_agent_event +{ + my ($event_type, $severity, $fields) = @_; + my $event = build_agent_event($event_type, $severity, $fields); + if(send_agent_event($event)) + { + flush_agent_event_queue(); + return 1; + } + append_agent_event_queue($event); + return 0; +} + +sub agent_event_state_path +{ + my ($home_id) = @_; + $home_id =~ s/[^0-9]//g; + return Path::Class::File->new(AGENT_EVENTS_DIR, "state-$home_id.kv"); +} + +sub read_agent_event_state +{ + my ($home_id) = @_; + my %state; + my $file = agent_event_state_path($home_id); + return \%state unless(-e $file); + open(STATE, '<', $file) or return \%state; + while(my $line = ) + { + chomp($line); + next unless($line =~ /^([^=]+)=(.*)$/); + $state{$1} = $2; + } + close(STATE); + return \%state; +} + +sub write_agent_event_state +{ + my ($home_id, $state) = @_; + mkpath(AGENT_EVENTS_DIR) unless(-d AGENT_EVENTS_DIR); + my $file = agent_event_state_path($home_id); + open(STATE, '>', $file) or return 0; + foreach my $key (sort keys %$state) + { + my $value = defined($state->{$key}) ? $state->{$key} : ""; + $value =~ s/[\r\n]//g; + print STATE "$key=$value\n"; + } + close(STATE); + return 1; +} + +sub record_server_state_transition +{ + my ($status_info) = @_; + return unless(ref($status_info) eq 'HASH'); + my $home_id = $status_info->{home_id}; + return unless(defined($home_id) && $home_id =~ /^\d+$/); + my $previous = read_agent_event_state($home_id); + my $current_status = $status_info->{status} || "UNKNOWN"; + my $previous_status = $previous->{status} || ""; + my $now = time(); + my ($hint_time, $hint) = read_status_hint($home_id); + my ($event_type, $severity); + if($hint eq "AUTORESTARTING" && !($previous->{autorestart_pending} || 0)) + { + ($event_type, $severity) = ("automatic_restart_started", "warning"); + $previous->{autorestart_pending} = 1; + } + elsif(($previous->{autorestart_pending} || 0) && $current_status eq "ONLINE") + { + ($event_type, $severity) = ("automatic_restart_succeeded", "info"); + delete $previous->{autorestart_pending}; + } + elsif(($previous->{autorestart_pending} || 0) && $current_status eq "UNRESPONSIVE") + { + ($event_type, $severity) = ("automatic_restart_failed", "error"); + delete $previous->{autorestart_pending}; + } + elsif($previous_status eq "ONLINE" && $current_status eq "OFFLINE") + { + ($event_type, $severity) = ($hint eq "STOPPING" || $hint eq "STOPPED") ? ("server_stop_confirmed", "info") : ("server_crash_detected", "warning"); + } + elsif($previous_status ne "ONLINE" && $current_status eq "ONLINE") + { + ($event_type, $severity) = ("server_start_confirmed", "info"); + } + elsif($current_status eq "UNRESPONSIVE" && $previous_status ne "UNRESPONSIVE") + { + ($event_type, $severity) = ("server_unresponsive", "warning"); + } + elsif($current_status eq "WARNING" && $previous_status ne "WARNING") + { + $event_type = $status_info->{process_running} ? "server_process_found_without_port" : "server_port_found_without_managed_process"; + $severity = "warning"; + } + elsif(($previous_status eq "WARNING" || $previous_status eq "UNRESPONSIVE") && $current_status eq "ONLINE") + { + ($event_type, $severity) = ("server_port_restored", "notice"); + } + if($event_type) + { + my $last_sent = $previous->{"sent_$event_type"} || 0; + if($previous_status ne $current_status || ($now - $last_sent) > 900) + { + queue_agent_event($event_type, $severity, $status_info); + $previous->{"sent_$event_type"} = $now; + } + } + $previous->{status} = $current_status; + $previous->{updated_at} = $now; + write_agent_event_state($home_id, $previous); +} + # If for some reason the screenrc file doesn't exist, restore it from the backup copy # I've seen this happen a few times if (! -e SCREENRC_FILE) @@ -526,11 +779,12 @@ $batch_server_command .= "set STARTTIME=%TIME: =0%" . "\r\n" . "set ENDTIME=%TIME: =0%\r\n" . "set \"end=!ENDTIME:%time:~8,1%=%%100)*100+1!\" & set \"start=!STARTTIME:%time:~8,1%=%%100)*100+1!\"\r\n" . "set /A \"elap=((((10!end:%time:~2,1%=%%100)*60+1!%%100)-((((10!start:%time:~2,1%=%%100)*60+1!%%100)\"\r\n" - . "set /A \"cc=elap%%100+100,elap/=100,ss=elap%%60+100,elap/=60,mm=elap%%60+100,hh=elap/60+100\"\r\n" - . "set hour=%hh:~1%\r\n" - . "set minute=%mm:~1%\r\n" - . "set second=%ss:~1%\r\n" + . "set /A \"cc=elap%%100+100,elap/=100,ss=elap%%60+100,elap/=60,mm=elap%%60+100,hh=elap/60+100\"\r\n" + . "set hour=%hh:~1%\r\n" + . "set minute=%mm:~1%\r\n" + . "set second=%ss:~1%\r\n" . "if exist \"$status_hint_file\" findstr /I /R /C:\"^[0-9][0-9]*,STOPPING$\" /C:\"^[0-9][0-9]*,STOPPED$\" \"$status_hint_file\" >nul && exit\r\n" + . "if not \"$status_hint_file\" == \"\" for /f %%t in ('powershell.exe -NoProfile -Command \"[int][double]::Parse((Get-Date -UFormat %%s))\"') do echo %%t,AUTORESTARTING>\"$status_hint_file\"\r\n" . "IF \"%hour%\" == \"00\" IF \"%minute%\" == \"00\" IF %second% lss 60 exit\r\n" . "timeout /t 30 /nobreak >nul\r\n" . "goto TOP\r\n"; @@ -1101,7 +1355,8 @@ sub server_status_without_decrypt $status_state = "Stopped"; } - return { + my $status_info = { + home_id => $home_id, status => $status, StatusState => $status_state, status_state => $status_state, @@ -1133,9 +1388,12 @@ sub server_status_without_decrypt port => $server_port, query_port => $query_port, rcon_port => $rcon_port, + game_port => $server_port, last_error => $last_error, query_info => "" }; + record_server_state_transition($status_info); + return $status_info; } # Universal startup function @@ -1337,6 +1595,19 @@ sub universal_start_without_decrypt ip => $server_ip, port => $server_port }); + queue_agent_event("server_process_started", "info", { + home_id => $home_id, + game_home_path => $home_path, + ip => $server_ip, + game_port => $server_port, + screen_session_name => $screen_id, + tracked_pid => $windows_pid, + detected_process_pid => $windows_pid, + actual_process_state => "starting", + actual_session_state => $screen_pid ne "" ? "present" : "unknown", + actual_port_state => "pending_validation", + message => "Server process start command completed; status polling will confirm port readiness." + }); if(defined $preStart && $preStart ne ""){ # Get it in the format that the startup file can use @@ -1732,6 +2003,16 @@ sub verify_runtime_stop_complete_without_decrypt if(!$session_running && !$pid_running && !$port_listening) { write_status_hint($home_id, "STOPPED"); + queue_agent_event("server_stop_confirmed", "info", { + home_id => $home_id, + ip => $server_ip, + game_port => $server_port, + tracked_pid => $pid_meta->{windows_pid} || $pid_meta->{game_pid} || "", + actual_process_state => "stopped", + actual_session_state => "stopped", + actual_port_state => "closed", + message => "Server stop confirmed after process, session, and port validation." + }); return 0; } sleep 2; @@ -1750,9 +2031,29 @@ sub verify_runtime_stop_complete_without_decrypt if($session_running || $pid_running || $port_listening) { logger "Server $server_ip:$server_port is still running or listening after stop escalation."; + queue_agent_event("server_unresponsive", "error", { + home_id => $home_id, + ip => $server_ip, + game_port => $server_port, + tracked_pid => $pid_meta->{windows_pid} || $pid_meta->{game_pid} || "", + actual_process_state => $pid_running ? "running" : "stopped", + actual_session_state => $session_running ? "running" : "stopped", + actual_port_state => $port_listening ? "listening" : "closed", + message => "Server remained active after stop escalation." + }); return 1; } write_status_hint($home_id, "STOPPED"); + queue_agent_event("server_stop_confirmed", "info", { + home_id => $home_id, + ip => $server_ip, + game_port => $server_port, + tracked_pid => $pid_meta->{windows_pid} || $pid_meta->{game_pid} || "", + actual_process_state => "stopped", + actual_session_state => "stopped", + actual_port_state => "closed", + message => "Server stop confirmed after escalation." + }); return 0; } @@ -3033,7 +3334,21 @@ sub restart_server_without_decrypt { my ($home_id, $server_ip, $server_port, $control_protocol, $control_password, $control_type, $home_path, $server_exe, $run_dir, - $cmd, $cpu, $nice, $preStart, $envVars, $game_key, $console_log) = @_; + $cmd, $cpu, $nice, $preStart, $envVars, $game_key, $console_log, $restart_reason) = @_; + $restart_reason = "panel_restart" unless(defined($restart_reason) && $restart_reason ne ""); + my $correlation_id = new_correlation_id(); + my $restart_started_event = $restart_reason eq "scheduled_restart" ? "scheduled_restart_started" : "automatic_restart_started"; + my $restart_success_event = $restart_reason eq "scheduled_restart" ? "scheduled_restart_succeeded" : "automatic_restart_succeeded"; + my $restart_failed_event = $restart_reason eq "scheduled_restart" ? "scheduled_restart_failed" : "automatic_restart_failed"; + my $report_restart_events = ($restart_reason eq "scheduled_restart" || $restart_reason eq "automatic_restart") ? 1 : 0; + queue_agent_event($restart_started_event, "info", { + home_id => $home_id, + ip => $server_ip, + game_port => $server_port, + restart_reason => $restart_reason, + correlation_id => $correlation_id, + message => "Restart operation started." + }) if($report_restart_events); if (stop_server_without_decrypt($home_id, $server_ip, $server_port, $control_protocol, @@ -3044,20 +3359,54 @@ sub restart_server_without_decrypt if (verify_runtime_stop_complete_without_decrypt($home_id, $server_ip, $server_port) != 0) { logger "Restart cancelled: previous instance is still active after stop wait window."; + queue_agent_event($restart_failed_event, "error", { + home_id => $home_id, + ip => $server_ip, + game_port => $server_port, + restart_reason => $restart_reason, + correlation_id => $correlation_id, + message => "Restart cancelled because previous instance was still active." + }) if($report_restart_events); return -2; } if (universal_start_without_decrypt($home_id, $home_path, $server_exe, $run_dir, $cmd, $server_port, $server_ip, $cpu, $nice, $preStart, $envVars, $game_key, $console_log) == 1) { + queue_agent_event($restart_success_event, "info", { + home_id => $home_id, + game_home_path => $home_path, + ip => $server_ip, + game_port => $server_port, + restart_reason => $restart_reason, + correlation_id => $correlation_id, + message => "Restart command completed; status polling will confirm process and port readiness." + }) if($report_restart_events); return 1; } else { + queue_agent_event($restart_failed_event, "error", { + home_id => $home_id, + game_home_path => $home_path, + ip => $server_ip, + game_port => $server_port, + restart_reason => $restart_reason, + correlation_id => $correlation_id, + message => "Restart failed because the server start path returned an error." + }) if($report_restart_events); return -1; } } else { + queue_agent_event($restart_failed_event, "error", { + home_id => $home_id, + ip => $server_ip, + game_port => $server_port, + restart_reason => $restart_reason, + correlation_id => $correlation_id, + message => "Restart failed because the server could not be stopped." + }) if($report_restart_events); return -2; } } @@ -4222,7 +4571,7 @@ sub scheduler_server_action elsif($action eq "%ACTION=restart") { my ($home_id, $ip, $port) = ($server_args[0], $server_args[1], $server_args[2]); - my $ret = restart_server_without_decrypt(@server_args); + my $ret = restart_server_without_decrypt(@server_args, "scheduled_restart"); if($ret == 1) { scheduler_log_events("Restarted server home ID $home_id on address $ip:$port"); @@ -4855,7 +5204,7 @@ sub steam_workshop_without_decrypt $regex, $mods_backreference_index, $variable, $place_after, $mod_string, $string_separator, $config_file_path, - $post_install, $mod_names_list); + $post_install, $mod_names_list, $home_id); my $bash_scripts_path = MANUAL_TMP_DIR . "/home_id_" . $home_id; @@ -4880,19 +5229,25 @@ sub generate_post_install_scripts $regex, $mods_backreference_index, $variable, $place_after, $mod_string, $string_separator, $config_file_path, - $post_install, $mod_names_list) = @_; + $post_install, $mod_names_list, $home_id) = @_; my $post_install_scripts = ""; my $mods_info_path = Path::Class::Dir->new(AGENT_RUN_DIR, 'WorkshopModsInfo'); $post_install_scripts .= "mods_full_path=\"$mods_full_path\"\n". "workshop_id=\"$workshop_id\"\n". + "home_id=\"$home_id\"\n". "regex=\"$regex\"\n". "mods_backreference_index=\"$mods_backreference_index\"\n". "variable=\"$variable\"\n". "place_after=\"$place_after\"\n". "string_separator=\"$string_separator\"\n". "config_file_path=\"$config_file_path\"\n". - "mods_info_path=\"$mods_info_path/\"\n"; + "mods_info_path=\"$mods_info_path/\"\n". + "mods_info_home_path=\"$mods_info_path/home_$home_id/\"\n". + "workshop_status_path=\"$mods_full_path/WorkshopInstallStatus.json\"\n". + "write_workshop_status(){ status=\"\$1\"; message=\"\$2\"; now=\"\$(date '+%Y-%m-%d %H:%M:%S')\"; message_json=\$(printf '%s' \"\$message\"|sed 's/\\\\/\\\\\\\\/g;s/\"/\\\\\"/g'); printf '{\"operation\":\"steam_workshop\",\"home_id\":\"%s\",\"workshop_app_id\":\"%s\",\"status\":\"%s\",\"message\":\"%s\",\"updated_at\":\"%s\"}\\n' \"\$home_id\" \"\$workshop_id\" \"\$status\" \"\$message_json\" \"\$now\" > \"\$workshop_status_path\"; }\n". + "write_workshop_status running \"Workshop install running\"\n". + "trap 'rc=\$?; if [ \$rc -ne 0 ]; then write_workshop_status failed \"Workshop install failed\"; echo GSP_WORKSHOP_INSTALL_FAILED; fi' EXIT\n"; my @workshop_mods = split /,/, $mods_list; my @mod_names = split /,/, $mod_names_list; @@ -4902,6 +5257,10 @@ sub generate_post_install_scripts my $steamcmd_download_path = '/steamapps/workshop/content/'.$workshop_id.'/'.$workshop_mod_id.'/'; my $workshop_mod_path = $mods_full_path.$steamcmd_download_path; my $this_mod_string = $mod_string; + if(!defined $this_mod_string || $this_mod_string eq '') + { + $this_mod_string = '%workshop_mod_id%'; + } $this_mod_string =~ s/\%workshop_mod_id\%/$workshop_mod_id/g; $post_install_scripts .= "mod_string[$index]=\"$this_mod_string\"\n". @@ -4911,11 +5270,7 @@ sub generate_post_install_scripts $index++; } - $post_install_scripts .= 'if [ ! -e $config_file_path ];then'."\n". - ' if [ ! -d "$(dirname $config_file_path)" ];then mkdir -p "$(dirname $config_file_path)";fi'."\n". - ' echo -e "${place_after}\n${variable}" > $config_file_path'."\n". - 'fi'."\n". - 'i=0'."\n". + $post_install_scripts .= 'i=0'."\n". 'for mod_id in "${workshop_mod_id[@]}"'."\n". 'do'."\n". ' first_file="$(ls "${workshop_mod_path[$i]}"| sort -n | head -1)"'."\n"; @@ -4930,46 +5285,66 @@ sub generate_post_install_scripts } } - $post_install_scripts .= ' file_content=$(cat $config_file_path)'."\n". - ' if [[ $file_content =~ $regex ]]; then'."\n". - ' full_match="${BASH_REMATCH[0]}"'."\n". - ' mods_match="${BASH_REMATCH[$mods_backreference_index]}"'."\n". - ' found=1'."\n". - ' else'."\n". - ' found=0'."\n". - ' fi'."\n". - ' first_file_string="\%first_file%"'."\n". - + $post_install_scripts .= ' first_file_string="\%first_file%"'."\n". ' if [ -z "${mod_string[$i]##*$first_file_string*}" ];then'."\n". ' mod_string[$i]="${mod_string[$i]/$first_file_string/$first_file}"'."\n". - ' fi'."\n". - ' if [ $found == 1 ] && [ "X$full_match" != "X" ];then'."\n". - ' if [ "X$mods_match" == "X" ];then'."\n". - ' new_mods=$(echo -e "${full_match}${mod_string[$i]}")'."\n". - ' echo -e "${file_content/$full_match/$new_mods}">"$config_file_path"'."\n". - ' else'."\n". - ' if [ ! -z "${mods_match##*${mod_string[$i]}*}" ];then'."\n". - ' new_mods=$(echo -e "${full_match}${string_separator}${mod_string[$i]}")'."\n". - ' echo -e "${file_content/$full_match/$new_mods}">"$config_file_path"'."\n". - ' fi'."\n". - ' fi'."\n". - ' else'."\n". - ' if [ "X$place_after" == "X" ];then'."\n". - ' echo -e "${file_content}${variable}${mod_string[$i]}">"$config_file_path"'."\n". - ' else'."\n". - ' if [ -z "${file_content##*${place_after}*}" ];then'."\n". - ' new_var="${variable}${mod_string[$i]}"'."\n". - ' place_after_esc=$(echo -e "$place_after"|sed -e \'s/[]\\/$*.^[]/\\\\&/g\')'."\n". - ' echo -e "$file_content"|sed \'/\'$place_after_esc\'/a \'$new_var>"$config_file_path"'."\n". - ' else'."\n". - ' echo -e "${file_content}${place_after}\n${variable}${mod_string[$i]}">"$config_file_path"'."\n". - ' fi'."\n". - ' fi'."\n". - ' fi'."\n". + ' fi'."\n"; + + if(defined $config_file_path && $config_file_path ne '') + { + $post_install_scripts .= ' if [ ! -e "$config_file_path" ];then'."\n". + ' if [ ! -d "$(dirname "$config_file_path")" ];then mkdir -p "$(dirname "$config_file_path")";fi'."\n". + ' echo -e "${place_after}\n${variable}" > "$config_file_path"'."\n". + ' fi'."\n". + ' file_content=$(cat "$config_file_path")'."\n". + ' if [[ $file_content =~ $regex ]]; then'."\n". + ' full_match="${BASH_REMATCH[0]}"'."\n". + ' mods_match="${BASH_REMATCH[$mods_backreference_index]}"'."\n". + ' found=1'."\n". + ' else'."\n". + ' found=0'."\n". + ' fi'."\n". + ' if [ $found == 1 ] && [ "X$full_match" != "X" ];then'."\n". + ' if [ "X$mods_match" == "X" ];then'."\n". + ' new_mods=$(echo -e "${full_match}${mod_string[$i]}")'."\n". + ' echo -e "${file_content/$full_match/$new_mods}">"$config_file_path"'."\n". + ' else'."\n". + ' if [ ! -z "${mods_match##*${mod_string[$i]}*}" ];then'."\n". + ' new_mods=$(echo -e "${full_match}${string_separator}${mod_string[$i]}")'."\n". + ' echo -e "${file_content/$full_match/$new_mods}">"$config_file_path"'."\n". + ' fi'."\n". + ' fi'."\n". + ' else'."\n". + ' if [ "X$place_after" == "X" ];then'."\n". + ' echo -e "${file_content}${variable}${mod_string[$i]}">"$config_file_path"'."\n". + ' else'."\n". + ' if [ -z "${file_content##*${place_after}*}" ];then'."\n". + ' new_var="${variable}${mod_string[$i]}"'."\n". + ' place_after_esc=$(echo -e "$place_after"|sed -e \'s/[]\\/$*.^[]/\\\\&/g\')'."\n". + ' echo -e "$file_content"|sed \'/\'$place_after_esc\'/a \'$new_var>"$config_file_path"'."\n". + ' else'."\n". + ' echo -e "${file_content}${place_after}\n${variable}${mod_string[$i]}">"$config_file_path"'."\n". + ' fi'."\n". + ' fi'."\n". + ' fi'."\n"; + } + + $post_install_scripts .= ' if [ ! -d "${mods_info_path}" ];then mkdir -p "${mods_info_path}";fi'."\n". - ' echo "${mod_name[$i]}" > "${mods_info_path}${mod_string[$i]}.ogpmod"'."\n". + ' if [ ! -d "${mods_info_home_path}" ];then mkdir -p "${mods_info_home_path}";fi'."\n". + ' installed_path=""'."\n". + ' if [ -e "$mods_full_path/${mod_string[$i]}" ];then installed_path="$mods_full_path/${mod_string[$i]}";fi'."\n". + ' if [ -z "$installed_path" ] && [ -e "$mods_full_path/@$mod_id" ];then installed_path="$mods_full_path/@$mod_id";fi'."\n". + ' if [ -z "$installed_path" ] && [ -e "$mods_full_path/$mod_id" ];then installed_path="$mods_full_path/$mod_id";fi'."\n". + ' installed_folder="$(basename "$installed_path")"'."\n". + " install_time=\"\$(date '+%Y-%m-%d %H:%M:%S')\"\n". + ' printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" "${mod_string[$i]}" "$mod_id" "${mod_name[$i]}" "$installed_folder" "$installed_path" "$workshop_id" "installed" "$install_time" "$install_time" > "${mods_info_home_path}${mod_id}.ogpmod"'."\n". + ' printf "%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n" "${mod_string[$i]}" "$mod_id" "${mod_name[$i]}" "$installed_folder" "$installed_path" "$workshop_id" "installed" "$install_time" "$install_time" > "${mods_info_path}${mod_string[$i]}.ogpmod"'."\n". ' i=$(expr $i + 1)'."\n". - 'done'."\n"; + 'done'."\n". + 'write_workshop_status completed "Workshop install completed"'."\n". + 'trap - EXIT'."\n". + 'echo GSP_WORKSHOP_INSTALL_COMPLETE'."\n"; return "$post_install_scripts"; } @@ -4977,7 +5352,13 @@ sub get_workshop_mods_info() { return "Bad Encryption Key" unless(decrypt_param(pop(@_)) eq "Encryption checking OK"); + my ($home_id) = decrypt_params(@_); my $mods_info_dir_path = Path::Class::Dir->new(AGENT_RUN_DIR, 'WorkshopModsInfo'); + if(defined $home_id && $home_id =~ /^\d+$/) + { + my $home_mods_info_dir_path = Path::Class::Dir->new($mods_info_dir_path, "home_$home_id"); + $mods_info_dir_path = $home_mods_info_dir_path if(-d $home_mods_info_dir_path); + } if(-d $mods_info_dir_path) { @@ -4995,7 +5376,14 @@ sub get_workshop_mods_info() if($row ne "") { my ($string_name, $ext) = split(/\.ogp/, $mod_info_file); - push @mods_info, "$string_name:$row"; + if(index($row, "\t") >= 0) + { + push @mods_info, $row; + } + else + { + push @mods_info, "$string_name:$row"; + } } close($fh); } diff --git a/docs/AGENT_ACTIVITY_EVENTS.md b/docs/AGENT_ACTIVITY_EVENTS.md new file mode 100644 index 0000000..870bd90 --- /dev/null +++ b/docs/AGENT_ACTIVITY_EVENTS.md @@ -0,0 +1,52 @@ +# Windows Agent Activity Events + +The Windows/Cygwin agent can report meaningful server lifecycle events to the GSP Panel activity log. It does not send routine XML-RPC chatter, file reads, status polls with no state change, or every local agent log line. + +## Configuration + +Set these values in `OGP64/OGP/Cfg/Config.pm`: + +```perl +agent_event_url => 'https://panel.example.com/agent_event_receiver.php', +remote_server_id => '1', +``` + +If `agent_event_url` is empty, the agent attempts to derive it from `web_api_url`. The `key` value must match the Panel remote server encryption key because it is used to sign event payloads. + +## Authentication + +Events are JSON POST requests signed with HMAC-SHA256: + +- `X-GSP-Agent-Id`: Panel `remote_server_id` +- `X-GSP-Agent-Timestamp`: Unix timestamp +- `X-GSP-Agent-Signature`: `sha256=` plus HMAC of `timestamp.body` + +The Panel validates the signature, remote server identity, event type, severity, and `home_id` ownership before writing the activity log. + +## Offline Queue + +If the Panel is unavailable, the Windows agent appends events to: + +```text +events/pending-events.jsonl +``` + +The queue is retried after later successful event deliveries and rotates when it exceeds 1 MB. Server start, stop, and restart operations do not wait on Panel event delivery. + +## Lifecycle Detection + +The agent uses existing runtime evidence: + +- screen/session status +- tracked Cygwin/Windows PID metadata +- Windows process existence +- required game/query/RCON port validation +- short-lived status hints such as `STARTING`, `STOPPING`, and `STOPPED` + +It does not create or read `SERVER_STOPPED` marker files. + +## Reported Events + +The Windows agent reports confirmed start and stop outcomes, unresponsive process states, missing/restored port transitions, unexpected `ONLINE -> OFFLINE` transitions, and scheduled restart sequences when the scheduler invokes restart actions. + +External kills, including a companion bot terminating a server process, are recorded after status polling observes the validated state change. diff --git a/docs/PANEL_INTEGRATION.md b/docs/PANEL_INTEGRATION.md index a7d9f9a..0be480f 100644 --- a/docs/PANEL_INTEGRATION.md +++ b/docs/PANEL_INTEGRATION.md @@ -15,6 +15,20 @@ The Panel is authoritative. The Windows agent executes the work the Panel reques The Windows agent should mirror the Linux agent behaviorally as much as possible so the Panel can treat both platforms as one product family. +## Legacy Workshop RPC + +The dedicated Panel `steam_workshop` module still uses the legacy `steam_workshop` XML-RPC call. + +Current compatibility rule: + +- if the Panel sends a blank `config_file_path`, the Windows agent runs post-install logic only +- the generated script must skip all `cat` / regex / config-write logic in that case +- `WorkshopModsInfo` is still written so the Panel can list/uninstall installed items even when no game config file is edited +- new installs write per-home records under `WorkshopModsInfo/home_/` +- each record stores mod string, Workshop item ID, title, installed folder/path, Workshop App ID, install status, and timestamps +- the generated job writes `/WorkshopInstallStatus.json` as `running`, `completed`, or `failed` +- the generated job prints `GSP_WORKSHOP_INSTALL_COMPLETE` or `GSP_WORKSHOP_INSTALL_FAILED` so the Panel can avoid stale `Update in progress` displays if the generic update screen lingers + ## Status validation The Panel remains the source of truth for assigned ports. The Windows agent validates only the ports supplied by the Panel for a specific server.