logfin expand

This commit is contained in:
Frank Harris 2026-06-16 12:25:46 -05:00
parent beeb4a6a62
commit 2d16aeb91a
6 changed files with 512 additions and 54 deletions

View file

@ -8,6 +8,7 @@
sudo_password => '',
web_admin_api_key => '',
web_api_url => '',
agent_event_url => '',
remote_server_id => '',
steam_dl_limit => '0',
);

View file

@ -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

View file

@ -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.

View file

@ -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 = <EVENTQ>;
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 = <STATE>)
{
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);
}

View file

@ -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.

View file

@ -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_<home_id>/`
- each record stores mod string, Workshop item ID, title, installed folder/path, Workshop App ID, install status, and timestamps
- the generated job writes `<game_home>/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.