Skip to content

Commit 749c30e

Browse files
committed
Fixes IfW API to properly read client streams complete, even when not fully send
1 parent 167c1a1 commit 749c30e

10 files changed

+182
-14
lines changed

doc/100-General/10-Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Released closed milestones can be found on [GitHub](https://github.com/Icinga/ic
1717

1818
### Bugfixes
1919

20+
* [#672](https://github.com/Icinga/icinga-powershell-framework/pull/issues) Fixes Icinga for Windows REST-Api to fully read client data, even when they client is sending the packets on a very slow basis, preventing the API trying to process an incomplete request
2021
* [#707](https://github.com/Icinga/icinga-powershell-framework/pull/707) Fixes size of the `Icinga for Windows` eventlog by setting it to `20MiB`, allowing to store more events before they are overwritten
2122
* [#710](https://github.com/Icinga/icinga-powershell-framework/pull/710) Fixes various console errors while running Icinga for Windows outside of an administrative shell
2223
* [#713](https://github.com/Icinga/icinga-powershell-framework/pull/713) Fixes Icinga for Windows REST-Api which fails during certificate auth handling while running as `NT Authority\NetworkService`

lib/daemon/Set-IcingaForWindowsThreadAlive.psm1

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@ function Set-IcingaForWindowsThreadAlive()
66
$ThreadPool = $null,
77
[hashtable]$ThreadArgs = @{ },
88
[switch]$Active = $FALSE,
9-
[hashtable]$TerminateAction = @{ }
9+
[hashtable]$TerminateAction = @{ },
10+
[int]$Timeout = 300
1011
);
1112

1213
if ([string]::IsNullOrEmpty($ThreadName)) {
@@ -27,6 +28,7 @@ function Set-IcingaForWindowsThreadAlive()
2728
'ThreadPool' = $ThreadPool;
2829
'Active' = [bool]$Active;
2930
'TerminateAction' = $TerminateAction;
31+
'Timeout' = $Timeout;
3032
}
3133
);
3234

@@ -36,4 +38,5 @@ function Set-IcingaForWindowsThreadAlive()
3638
$Global:Icinga.Public.ThreadAliveHousekeeping[$ThreadName].LastSeen = [DateTime]::Now;
3739
$Global:Icinga.Public.ThreadAliveHousekeeping[$ThreadName].Active = [bool]$Active;
3840
$Global:Icinga.Public.ThreadAliveHousekeeping[$ThreadName].TerminateAction = $TerminateAction;
41+
$Global:Icinga.Public.ThreadAliveHousekeeping[$ThreadName].Timeout = $Timeout;
3942
}

lib/daemon/Suspend-IcingaForWindowsFrozenThreads.psm1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ function Suspend-IcingaForWindowsFrozenThreads()
1212
}
1313

1414
# Check if the thread is active and not doing something for 5 minutes
15-
if (([DateTime]::Now - $ThreadConfig.LastSeen).TotalSeconds -lt 300) {
15+
if (([DateTime]::Now - $ThreadConfig.LastSeen).TotalSeconds -lt $ThreadConfig.Timeout) {
1616
continue;
1717
}
1818

lib/daemons/RestAPI/daemon/New-IcingaForWindowsRESTApi.psm1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,8 @@ function New-IcingaForWindowsRESTApi()
125125
try {
126126
$NextRESTApiThreadId = (Get-IcingaNextRESTApiThreadId);
127127

128+
Write-IcingaDebugMessage -Message 'Scheduling Icinga for Windows API request' -Objects 'REST-Thread Id', $NextRESTApiThreadId;
129+
128130
if ($Global:Icinga.Public.Daemons.RESTApi.ApiRequests.ContainsKey($NextRESTApiThreadId) -eq $FALSE) {
129131
Close-IcingaTCPConnection -Connection $Connection;
130132
$Connection = $null;

lib/daemons/RestAPI/threads/Get-IcingaNextRESTApiThreadId.psm1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ function Get-IcingaNextRESTApiThreadId()
33
# Improve our thread management by distributing new REST requests to a non-active thread
44
[array]$ConfiguredThreads = $Global:Icinga.Public.ThreadAliveHousekeeping.Keys;
55

6+
Write-IcingaDebugMessage -Message 'Distributing Icinga for Windows REST-Api calls to one of those threads' -Objects 'REST-Thread Ids', ($ConfiguredThreads | Out-String);
7+
68
foreach ($thread in $ConfiguredThreads) {
79
if ($thread.ToLower() -NotLike 'Start-IcingaForWindowsRESTThread::New-IcingaForWindowsRESTThread::CheckThread::*') {
810
continue;

lib/daemons/RestAPI/threads/New-IcingaForWindowsRESTThread.psm1

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,23 @@ function New-IcingaForWindowsRESTThread()
1616
continue;
1717
}
1818

19+
Write-IcingaDebugMessage -Message 'Icinga for Windows REST-Api thread is processing a request' -Objects 'REST-Thread Id', $ThreadId;
20+
1921
# block sleeping until content available
2022
$Connection = $Global:Icinga.Public.Daemons.RESTApi.ApiRequests.$ThreadId.Take();
2123

24+
# Set our thread being active before we start reading the TCP stream, as we otherwise might find ourself
25+
# in a process were we block our API entirely, because all reqests are scheduled to one single thread
26+
Set-IcingaForWindowsThreadAlive -ThreadName $Global:Icinga.Protected.ThreadName -Active -Timeout 6 -TerminateAction @{ 'Command' = 'Close-IcingaTCPConnection'; 'Arguments' = @{ 'Connection' = $Connection } };
27+
2228
# Read the received message from the stream by using our smart functions
2329
[string]$RestMessage = Read-IcingaTCPStream -Client $Connection.Client -Stream $Connection.Stream;
2430
# Now properly translate the entire rest message to a parsable hashtable
2531
$RESTRequest = Read-IcingaRESTMessage -RestMessage $RestMessage -Connection $Connection;
2632

33+
# Once we read all of our messages, reset the thread alive with the default timeout
34+
Set-IcingaForWindowsThreadAlive -ThreadName $Global:Icinga.Protected.ThreadName -Active -TerminateAction @{ 'Command' = 'Close-IcingaTCPConnection'; 'Arguments' = @{ 'Connection' = $Connection } };
35+
2736
if ($null -ne $RESTRequest) {
2837

2938
# Check if we require to authenticate the user
@@ -62,9 +71,6 @@ function New-IcingaForWindowsRESTThread()
6271
}
6372
}
6473

65-
# Set our thread being active
66-
Set-IcingaForWindowsThreadAlive -ThreadName $Global:Icinga.Protected.ThreadName -Active -TerminateAction @{ 'Command' = 'Close-IcingaTCPConnection'; 'Arguments' = @{ 'Connection' = $Connection } };
67-
6874
# We should remove clients from the blacklist who are sending valid requests
6975
Remove-IcingaRESTClientBlacklist -Client $Connection.Client -ClientList $Global:Icinga.Public.Daemons.RESTApi.ClientBlacklist;
7076
switch (Get-IcingaRESTPathElement -Request $RESTRequest -Index 0) {

lib/daemons/ServiceCheckDaemon/daemon/Add-IcingaServiceCheckDaemon.psm1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function Add-IcingaServiceCheckDaemon()
1717
$RegisteredServices = Get-IcingaRegisteredServiceChecks;
1818

1919
# Debugging message
20-
Write-IcingaDebugMessage 'Found these service checks to load within service check daemon: {0}' -Objects ($RegisteredServices.Keys | Out-String);
20+
Write-IcingaDebugMessage 'Found these service checks to load within service check daemon' -Objects ($RegisteredServices.Keys | Out-String);
2121

2222
# Loop all found background services and create a new thread for each check
2323
foreach ($service in $RegisteredServices.Keys) {

lib/webserver/Icinga_HTTPResponse_Enums.psm1

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
403 = 'Forbidden';
1111
404 = 'Not Found'
1212
500 = 'Internal Server Error';
13+
504 = 'Gateway Timeout';
1314
};
1415

1516
[hashtable]$HTTPResponseType = @{
@@ -19,6 +20,7 @@
1920
'Forbidden' = 403;
2021
'Not Found' = 404;
2122
'Internal Server Error' = 500;
23+
'Gateway Timeout' = 504;
2224
};
2325

2426
<#

lib/webserver/Read-IcingaRESTMessage.psm1

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,69 @@ function Read-IcingaRESTMessage()
7171
# Body
7272
$RestMessage -match '(\{(.*\n)*}|\{.*\})' | Out-Null;
7373

74-
if ($null -ne $Matches) {
74+
# Store your messag body inside a stringbuilder object
75+
$MessageBody = New-Object 'System.Text.StringBuilder';
76+
77+
# Our message is not complete
78+
if ($null -eq $Matches) {
79+
# Try to figure out if we received parts of your body from the already read message
80+
$RestMsgArray = $RestMessage.Split("`r`n");
81+
[bool]$EmptyLine = $FALSE;
82+
[bool]$BeginBody = $FALSE;
83+
84+
foreach ($entry in $RestMsgArray) {
85+
if ($BeginBody) {
86+
$MessageBody.Append($entry) | Out-Null;
87+
continue;
88+
}
89+
90+
if ([string]::IsNullOrEmpty($entry)) {
91+
Write-IcingaDebugMessage `
92+
-Message 'Found end of header, lets check for body elements';
93+
94+
# In case we found two empty lines in a row, we found our body
95+
if ($EmptyLine) {
96+
$BeginBody = $TRUE;
97+
continue;
98+
}
99+
100+
# A first empty line means we found a possible header end
101+
$EmptyLine = $TRUE;
102+
} else {
103+
#Reset the empty line in case the next line contains content
104+
$EmptyLine = $FALSE;
105+
}
106+
}
107+
108+
Write-IcingaDebugMessage `
109+
-Message 'We partially received the body of the message, but it might be incomplete. Lets check. Read body length' `
110+
-Objects $MessageBody.Length, $MessageBody.ToString();
111+
} else {
112+
# Our message is already complete
75113
$Request.Add('Body', $Matches[1]);
76114
}
77115

78116
# We received a content length, but couldn't load the body. Some clients will send the body as separate message
79117
# Lets try to read the body content
80118
if ($null -ne $Connection) {
81119
if ($Request.ContainsKey('ContentLength') -And $Request.ContentLength -gt 0 -And ($Request.ContainsKey('Body') -eq $FALSE -Or [string]::IsNullOrEmpty($Request.Body))) {
82-
$Request.Body = Read-IcingaTCPStream -Client $Connection.Client -Stream $Connection.Stream -ReadLength $Request.ContentLength;
83-
Write-IcingaDebugMessage -Message 'Body Content' -Objects $Request;
120+
121+
Write-IcingaDebugMessage `
122+
-Message 'The message body was not send or incomplete. Received body size and content' `
123+
-Objects $MessageBody.Length, $MessageBody.ToString();
124+
125+
# In case we received party of the message earlier, read the remaining bytes from the message
126+
# and append it to our body
127+
$MessageBody.Append(
128+
(Read-IcingaTCPStream -Client $Connection.Client -Stream $Connection.Stream -ReadLength ($Request.ContentLength - $MessageBody.Length))
129+
) | Out-Null;
130+
131+
# Add our fully read message to our body object
132+
$Request.Body = $MessageBody.ToString();
133+
134+
# Some debug output
135+
Write-IcingaDebugMessage -Message 'Full request dump' -Objects $Request;
136+
Write-IcingaDebugMessage -Message 'Body Content' -Objects $Request.Body;
84137
}
85138
}
86139

lib/webserver/Read-IcingaTCPStream.psm1

Lines changed: 104 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,19 +6,118 @@ function Read-IcingaTCPStream()
66
[int]$ReadLength = 0
77
);
88

9+
[bool]$UnknownMessageSize = $FALSE;
10+
[int]$TimeoutInSeconds = 5;
11+
912
if ($ReadLength -eq 0) {
10-
$ReadLength = $Client.ReceiveBufferSize;
13+
$ReadLength = $Client.ReceiveBufferSize;
14+
$UnknownMessageSize = $TRUE;
1115
}
1216

1317
if ($null -eq $Stream) {
1418
return $null;
1519
}
1620

21+
# Ensure that our stream will timeout after a while to not block
22+
# the API permanently
23+
$Stream.ReadTimeout = $TimeoutInSeconds * 1000;
1724
# Get the maxium size of our buffer
18-
[byte[]]$bytes = New-Object byte[] $ReadLength;
19-
# Read the content of our SSL stream
20-
$MessageSize = $Stream.Read($bytes, 0, $ReadLength);
21-
# Resize our array to the correct size
25+
[int]$MessageSize = 0;
26+
[byte[]]$bytes = New-Object byte[] $ReadLength;
27+
[int]$DebugReadLength = $ReadLength;
28+
[int]$EmptyStreamCount = 0;
29+
# Ensure we calculate our own timeouts for the API, in case some malicious actions take place
30+
# and we only receive one byte each second for a large message. We should terminate the request
31+
# in this case
32+
$ReadTimeout = [DateTime]::Now;
33+
34+
while ($ReadLength -ne 0) {
35+
# Create a buffer element to store our message size into
36+
[byte[]]$buffer = New-Object byte[] $ReadLength;
37+
# Read the content of our SSL stream
38+
$ReadBytes = $Stream.Read($buffer, 0, $ReadLength);
39+
# Now lets copy all read bytes from our buffer to our bytes variable
40+
# As we might read multiple times from our stream, we have to read
41+
# the entire buffer from index 0 and copy our content to our bytes
42+
# variable, while our starting index is always at the last message
43+
# sizes end (Message size equals ReadBytes from the previous attempt)
44+
# and we need to copy as much bytes to our new array, as we read
45+
[array]::Copy($buffer, 0, $bytes, $MessageSize, $ReadBytes);
46+
47+
$ReadLength -= $ReadBytes;
48+
$MessageSize += $ReadBytes;
49+
50+
# In case the client terminates the session, we might receive a bunch of invalid
51+
# information. Just to make sure there is no other error happening,
52+
# we should wait a little to check if the client is still present and
53+
# trying to send data
54+
if ($ReadBytes -eq 0) {
55+
$EmptyStreamCount += 1;
56+
Start-Sleep -Milliseconds 100;
57+
}
58+
59+
if ($EmptyStreamCount -gt 20) {
60+
# This would mean we waited 2 seconds to receive actual data, the client decide not to do anything
61+
# We should terminate this session
62+
63+
Send-IcingaTCPClientMessage -Message (
64+
New-IcingaTCPClientRESTMessage `
65+
-HTTPResponse ($IcingaHTTPEnums.HTTPResponseType.'Internal Server Error') `
66+
-ContentBody @{ 'message' = 'The Icinga for Windows API received no data while reading the TCP stream. The session was terminated to protect the API.' }
67+
) -Stream $Stream;
68+
69+
Close-IcingaTCPConnection -Connection @{ 'Client' = $Client; 'Stream' = $Stream; };
70+
71+
Write-IcingaDebugMessage `
72+
-Message 'Icinga for Windows API received multiple empty results and attempts from the client. Terminating session.' `
73+
-Objects $DebugReadLength, $ReadBytes, $ReadLength, $MessageSize;
74+
75+
# Return an empty JSON
76+
return '{ }';
77+
}
78+
79+
if ((([DateTime]::Now) - $ReadTimeout).TotalSeconds -gt $TimeoutInSeconds -And $ReadLength -ne 0) {
80+
# This would mean we waited to long to receive the entire message
81+
# We should terminate this session, in case we have't just completed the read
82+
# of our message
83+
84+
Send-IcingaTCPClientMessage -Message (
85+
New-IcingaTCPClientRESTMessage `
86+
-HTTPResponse ($IcingaHTTPEnums.HTTPResponseType.'Gateway Timeout') `
87+
-ContentBody @{ 'message' = 'The Icinga for Windows API waited too long to receive the entire message from the client. The session was terminated to protect the API.' }
88+
) -Stream $Stream;
89+
90+
Close-IcingaTCPConnection -Connection @{ 'Client' = $Client; 'Stream' = $Stream; };
91+
92+
Write-IcingaDebugMessage `
93+
-Message 'Icinga for Windows API waited too long to receive the entire message from the client. Terminating session.' `
94+
-Objects $DebugReadLength, $ReadBytes, $ReadLength, $MessageSize;
95+
96+
# Return an empty JSON
97+
return '{ }';
98+
}
99+
100+
# In case our client did not set a defined message size, we just take the default from the Client
101+
# In most cases, the client will default to 65536 as network buffer for this and we should just
102+
# pretend the message was send properly. In most cases, the important request will go through
103+
# as this will only happen to the first message. Afterwards we can use the HTTP headers to read
104+
# the actual content size of the message
105+
if ($UnknownMessageSize) {
106+
Write-IcingaDebugMessage `
107+
-Message 'Message buffer for incoming network stream was 65536. Assuming we read all data' `
108+
-Objects $DebugReadLength, $ReadBytes, $ReadLength, $MessageSize;
109+
110+
break;
111+
}
112+
113+
# Just some debug context, allowing us to check what is going on with the API
114+
Write-IcingaDebugMessage `
115+
-Message 'Network stream output while parsing request. Request Size / Read Bytes / Remaining Size / Message Size' `
116+
-Objects $DebugReadLength, $ReadBytes, $ReadLength, $MessageSize;
117+
}
118+
119+
# Resize our array to the correct size, as we might have read
120+
# 65536 bytes because of our client not sending the correct length
22121
[byte[]]$resized = New-Object byte[] $MessageSize;
23122
[array]::Copy($bytes, 0, $resized, 0, $MessageSize);
24123

0 commit comments

Comments
 (0)