Implement and test bridge request chunking

- Minor cleanups included
This commit is contained in:
Dominion
2023-04-15 19:14:57 -04:00
parent 1b6d9d5998
commit 7cee137e43
17 changed files with 579 additions and 272 deletions
+1
View File
@@ -13,6 +13,7 @@
#include "v5\_defines.dm"
#include "v5\api.dm"
#include "v5\bridge.dm"
#include "v5\commands.dm"
#include "v5\serializers.dm"
#include "v5\undefs.dm"
+1
View File
@@ -5,6 +5,7 @@ This DMAPI implements bridge requests using HTTP GET requests to TGS. It has no
- [__interop_version.dm](./__interop_version.dm) contains the version of the API used between the DMAPI and TGS.
- [_defines.dm](./_defines.dm) contains constant definitions.
- [api.dm](./api.dm) contains the bulk of the API code.
- [bridge.dm](./bridge.dm) contains the functions related to making bridge requests.
- [commands.dm](./commands.dm) contains functions relating to `/datum/tgs_chat_command`s.
- [serializers.dm](./serializers.dm) contains function to help convert interop `/datum`s into a JSON encodable `list()` format.
- [undefs.dm](./undefs.dm) Undoes the work of `_defines.dm`.
+2
View File
@@ -12,6 +12,7 @@
#define DMAPI5_BRIDGE_COMMAND_REBOOT 3
#define DMAPI5_BRIDGE_COMMAND_KILL 4
#define DMAPI5_BRIDGE_COMMAND_CHAT_SEND 5
#define DMAPI5_BRIDGE_COMMAND_CHUNK 6
#define DMAPI5_PARAMETER_ACCESS_IDENTIFIER "accessIdentifier"
#define DMAPI5_PARAMETER_CUSTOM_COMMANDS "customCommands"
@@ -26,6 +27,7 @@
#define DMAPI5_BRIDGE_RESPONSE_NEW_PORT "newPort"
#define DMAPI5_BRIDGE_RESPONSE_RUNTIME_INFORMATION "runtimeInformation"
#define DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS "missingChunks"
#define DMAPI5_CHAT_MESSAGE_CHANNEL_IDS "channelIds"
+1 -33
View File
@@ -16,6 +16,7 @@
var/list/chat_channels
var/initialized = FALSE
var/chunked_requests = 0
/datum/tgs_api/v5/ApiVersion()
return new /datum/tgs_version(
@@ -244,39 +245,6 @@
return TopicResponse("Unknown command: [command]")
/datum/tgs_api/v5/proc/Bridge(command, list/data)
if(!data)
data = list()
data[DMAPI5_BRIDGE_PARAMETER_COMMAND_TYPE] = command
data[DMAPI5_PARAMETER_ACCESS_IDENTIFIER] = access_identifier
var/json = json_encode(data)
var/encoded_json = url_encode(json)
// This is an infinite sleep until we get a response
var/export_response = world.Export("http://127.0.0.1:[server_port]/Bridge?[DMAPI5_BRIDGE_DATA]=[encoded_json]")
if(!export_response)
TGS_ERROR_LOG("Failed export request: [json]")
return
var/response_json = file2text(export_response["CONTENT"])
if(!response_json)
TGS_ERROR_LOG("Failed export request, missing content!")
return
var/list/bridge_response = json_decode(response_json)
if(!bridge_response)
TGS_ERROR_LOG("Failed export request, bad json: [response_json]")
return
var/error = bridge_response[DMAPI5_RESPONSE_ERROR_MESSAGE]
if(error)
TGS_ERROR_LOG("Failed export request, bad request: [error]")
return
return bridge_response
/datum/tgs_api/v5/OnReboot()
var/list/result = Bridge(DMAPI5_BRIDGE_COMMAND_REBOOT)
if(!result)
+105
View File
@@ -0,0 +1,105 @@
/datum/tgs_api/v5/proc/Bridge(command, list/data)
if(!data)
data = list()
var/single_bridge_request = CreateBridgeRequest(command, data)
if(length(single_bridge_request) <= DMAPI5_BRIDGE_REQUEST_LIMIT)
return PerformBridgeRequest(single_bridge_request)
// chunking required
var/payload_id = ++chunked_requests
var/raw_data = CreateBridgeData(command, data, FALSE)
var/data_length = length(raw_data)
var/chunk_count
var/list/chunk_requests
for(chunk_count = 2; !chunk_requests; ++chunk_count);
var/max_chunk_size = -round(-(data_length / chunk_count))
if(max_chunk_size > DMAPI5_BRIDGE_REQUEST_LIMIT)
continue
chunk_requests = list()
for(var/i in 1 to chunk_count)
var/startIndex = 1 + ((i - 1) * max_chunk_size)
var/endIndex = min(1 + (i * max_chunk_size), data_length + 1)
var/chunk_payload = copytext(raw_data, startIndex, endIndex)
var/list/chunk = list("payloadId" = payload_id, "sequenceId" = (i - 1), "totalChunks" = chunk_count, payload = chunk_payload)
var/chunk_request = CreateBridgeRequest(DMAPI5_BRIDGE_COMMAND_CHUNK, list("chunk" = chunk))
if(length(chunk_request) > DMAPI5_BRIDGE_REQUEST_LIMIT)
// Screwed by url encoding, no way to preempt it though
chunk_requests = null
break
chunk_requests += chunk_request
var/list/response
for(var/bridge_request in chunk_requests)
response = PerformBridgeRequest(bridge_request)
if(!response)
// Abort
return
var/list/missing_sequence_ids = response[DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS]
if(length(missing_sequence_ids))
do
TGS_WARNING_LOG("Server is missing some chunks of payload [payload_id]! Sending missing chunks...")
if(!istype(missing_sequence_ids))
TGS_ERROR_LOG("Did not receive a list() for [DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS]!")
return
for(var/missing_sequence_id in missing_sequence_ids)
if(!isnum(missing_sequence_id))
TGS_ERROR_LOG("Did not receive a num in [DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS]!")
return
var/missing_chunk_request = chunk_requests[missing_sequence_id + 1]
response = PerformBridgeRequest(missing_chunk_request)
if(!response)
// Abort
return
missing_sequence_ids = response[DMAPI5_BRIDGE_RESPONSE_MISSING_CHUNKS]
while(length(missing_sequence_ids))
return response
/datum/tgs_api/v5/proc/CreateBridgeRequest(command, list/data)
var/json = CreateBridgeData(command, data, TRUE)
var/encoded_json = url_encode(json)
var/url = "http://127.0.0.1:[server_port]/Bridge?[DMAPI5_BRIDGE_DATA]=[encoded_json]"
return url
/datum/tgs_api/v5/proc/CreateBridgeData(command, list/data, needs_auth)
data[DMAPI5_BRIDGE_PARAMETER_COMMAND_TYPE] = command
if(needs_auth)
data[DMAPI5_PARAMETER_ACCESS_IDENTIFIER] = access_identifier
var/json = json_encode(data)
return json
/datum/tgs_api/v5/proc/PerformBridgeRequest(bridge_request)
// This is an infinite sleep until we get a response
var/export_response = world.Export(bridge_request)
if(!export_response)
TGS_ERROR_LOG("Failed bridge request: [bridge_request]")
return
var/response_json = file2text(export_response["CONTENT"])
if(!response_json)
TGS_ERROR_LOG("Failed bridge request, missing content!")
return
var/list/bridge_response = json_decode(response_json)
if(!bridge_response)
TGS_ERROR_LOG("Failed bridge request, bad json: [response_json]")
return
var/error = bridge_response[DMAPI5_RESPONSE_ERROR_MESSAGE]
if(error)
TGS_ERROR_LOG("Failed bridge request, bad request: [error]")
return
return bridge_response
@@ -34,5 +34,10 @@
/// DreamDaemon requesting a <see cref="ChatMessage"/> be sent.
/// </summary>
ChatSend,
/// <summary>
/// DreamDaemon attempting to send a longer bridge message.
/// </summary>
Chunk,
}
}
@@ -40,5 +40,10 @@ namespace Tgstation.Server.Host.Components.Interop.Bridge
/// The <see cref="Interop.ChatMessage"/> for <see cref="BridgeCommandType.ChatSend"/> requests.
/// </summary>
public ChatMessage ChatMessage { get; set; }
/// <summary>
/// The <see cref="ChunkData"/> for <see cref="BridgeCommandType.Chunk"/> requests.
/// </summary>
public ChunkData Chunk { get; set; }
}
}
@@ -0,0 +1,131 @@
using System;
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
namespace Tgstation.Server.Host.Components.Interop.Bridge
{
/// <summary>
/// Processes chunked bridge requests.
/// </summary>
abstract class BridgeRequestChunker : IBridgeDispatcher
{
/// <summary>
/// The cache of chunked bridge requests.
/// </summary>
/// <remarks>If the DMAPI is erroring, this can present a memory leak. Worth expiring entries if they aren't completed after some minutes.</remarks>
readonly Dictionary<uint, Tuple<ChunkedRequestInfo, string[]>> bridgeChunks;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="BridgeRequestChunker"/>.
/// </summary>
protected ILogger<BridgeRequestChunker> Logger { get; }
/// <summary>
/// Initializes a new instance of the <see cref="BridgeRequestChunker"/> class.
/// </summary>
/// <param name="logger">The value of <see cref="Logger"/>.</param>
protected BridgeRequestChunker(ILogger<BridgeRequestChunker> logger)
{
Logger = logger ?? throw new ArgumentNullException(nameof(logger));
bridgeChunks = new Dictionary<uint, Tuple<ChunkedRequestInfo, string[]>>();
}
/// <inheritdoc />
public abstract Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken);
/// <summary>
/// Process a given <paramref name="chunk"/>.
/// </summary>
/// <param name="chunk">The <see cref="ChunkData"/>.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="BridgeResponse"/> for the chunked request.</returns>
protected async Task<BridgeResponse> ProcessBridgeChunk(ChunkData chunk, CancellationToken cancellationToken)
{
if (chunk == null)
return BridgeError("Missing chunk!");
ChunkedRequestInfo requestInfo;
string[] payloads;
lock (bridgeChunks)
{
if (!bridgeChunks.TryGetValue(chunk.PayloadId, out var tuple))
{
// first time seeing this payload
if (chunk.TotalChunks == 0)
return BridgeError("Receieved chunked request with 0 totalChunks!");
tuple = Tuple.Create<ChunkedRequestInfo, string[]>(chunk, new string[chunk.TotalChunks]);
bridgeChunks.Add(chunk.PayloadId, tuple);
}
requestInfo = tuple.Item1;
payloads = tuple.Item2;
Logger.LogTrace("Received bridge payload chunk P{payloadId}: {sequenceId}/{totalChunks}", requestInfo.PayloadId, chunk.SequenceId + 1, requestInfo.TotalChunks);
if (chunk.TotalChunks != requestInfo.TotalChunks)
{
bridgeChunks.Remove(requestInfo.PayloadId);
return BridgeError("Received differing total chunks for same payloadId! Invalidating payloadId!");
}
if (payloads[chunk.SequenceId] != null && payloads[chunk.SequenceId] != chunk.Payload)
{
bridgeChunks.Remove(requestInfo.PayloadId);
return BridgeError("Received differing payload for same sequenceId! Invalidating payloadId!");
}
payloads[chunk.SequenceId] = chunk.Payload;
var missingPayloads = new List<uint>();
for (uint i = 0; i < payloads.Length; ++i)
if (payloads[i] == null)
missingPayloads.Add(i);
if (missingPayloads.Count > 0)
return new BridgeResponse
{
MissingChunks = missingPayloads,
};
Logger.LogTrace("Received all bridge chunks for P{payloadId}, processing request...", requestInfo.PayloadId);
bridgeChunks.Remove(requestInfo.PayloadId);
}
BridgeParameters completedRequest;
var fullRequestJson = String.Concat(payloads);
try
{
completedRequest = JsonConvert.DeserializeObject<BridgeParameters>(fullRequestJson, DMApiConstants.SerializerSettings);
}
catch (Exception ex)
{
Logger.LogWarning(ex, "Bad chunked bridge request for payload {payloadId}!", requestInfo.PayloadId);
return BridgeError("Chunked request completed with bad JSON!", false);
}
return await ProcessBridgeRequest(completedRequest, cancellationToken);
}
/// <summary>
/// Create and logs an errored <see cref="BridgeResponse"/>.
/// </summary>
/// <param name="message">The error message.</param>
/// <param name="log">If <paramref name="message"/> should be written to the <see cref="Logger"/>.</param>
/// <returns>A new errored <see cref="BridgeResponse"/>.</returns>
protected BridgeResponse BridgeError(string message, bool log = true)
{
if (log)
Logger.LogWarning("Bridge processing error: {errorMessage}", message);
return new BridgeResponse
{
ErrorMessage = message,
};
}
}
}
@@ -1,4 +1,6 @@
namespace Tgstation.Server.Host.Components.Interop.Bridge
using System.Collections.Generic;
namespace Tgstation.Server.Host.Components.Interop.Bridge
{
/// <summary>
/// A response to a bridge request.
@@ -14,5 +16,10 @@
/// The <see cref="Bridge.RuntimeInformation"/> for <see cref="BridgeCommandType.Startup"/> requests.
/// </summary>
public RuntimeInformation RuntimeInformation { get; set; }
/// <summary>
/// The <see cref="ChunkData.SequenceId"/>s missing from a chunked request.
/// </summary>
public IReadOnlyCollection<uint> MissingChunks { get; set; }
}
}
@@ -0,0 +1,18 @@
namespace Tgstation.Server.Host.Components.Interop.Bridge
{
/// <summary>
/// A packet of a split serialized set of <see cref="BridgeParameters"/>.
/// </summary>
public sealed class ChunkData : ChunkedRequestInfo
{
/// <summary>
/// The sequence ID of the chunk.
/// </summary>
public uint SequenceId { get; set; }
/// <summary>
/// The partial JSON payload of the chunk.
/// </summary>
public string Payload { get; set; }
}
}
@@ -0,0 +1,18 @@
namespace Tgstation.Server.Host.Components.Interop.Bridge
{
/// <summary>
/// Information about a chunked bridge request.
/// </summary>
public abstract class ChunkedRequestInfo
{
/// <summary>
/// The ID of the full request to differentiate different chunkings.
/// </summary>
public uint PayloadId { get; set; }
/// <summary>
/// The total number of chunks in the request.
/// </summary>
public uint TotalChunks { get; set; }
}
}
@@ -57,7 +57,7 @@ namespace Tgstation.Server.Host.Components.Interop
/// <summary>
/// <see cref="JsonSerializerSettings"/> for use when communicating with the DMAPI.
/// </summary>
public static readonly JsonSerializerSettings SerializerSettings = new JsonSerializerSettings
public static readonly JsonSerializerSettings SerializerSettings = new ()
{
ContractResolver = new DefaultContractResolver
{
@@ -24,7 +24,7 @@ using Tgstation.Server.Host.System;
namespace Tgstation.Server.Host.Components.Session
{
/// <inheritdoc />
sealed class SessionController : ISessionController, IBridgeHandler, IChannelSink
sealed class SessionController : BridgeRequestChunker, ISessionController, IBridgeHandler, IChannelSink
{
/// <inheritdoc />
public DMApiParameters DMApiParameters => ReattachInformation;
@@ -120,11 +120,6 @@ namespace Tgstation.Server.Host.Components.Session
/// </summary>
readonly IChatManager chat;
/// <summary>
/// The <see cref="ILogger"/> for the <see cref="SessionController"/>.
/// </summary>
readonly ILogger<SessionController> logger;
/// <summary>
/// <see langword="lock"/> <see cref="object"/> for port updates and <see cref="disposed"/>.
/// </summary>
@@ -182,7 +177,7 @@ namespace Tgstation.Server.Host.Components.Session
/// <param name="chat">The value of <see cref="chat"/>.</param>
/// <param name="chatTrackingContext">The value of <see cref="chatTrackingContext"/>.</param>
/// <param name="assemblyInformationProvider">The <see cref="IAssemblyInformationProvider"/> for the <see cref="SessionController"/>.</param>
/// <param name="logger">The value of <see cref="logger"/>.</param>
/// <param name="logger">The value of <see cref="BridgeRequestChunker.Logger"/>.</param>
/// <param name="postLifetimeCallback">The <see cref="Func{TResult}"/> returning a <see cref="Task"/> to be run after the <paramref name="process"/> ends.</param>
/// <param name="startupTimeout">The optional time to wait before failing the <see cref="LaunchResult"/>.</param>
/// <param name="reattached">If this is a reattached session.</param>
@@ -202,6 +197,7 @@ namespace Tgstation.Server.Host.Components.Session
uint? startupTimeout,
bool reattached,
bool apiValidate)
: base(logger)
{
ReattachInformation = reattachInformation ?? throw new ArgumentNullException(nameof(reattachInformation));
this.metadata = metadata ?? throw new ArgumentNullException(nameof(metadata));
@@ -212,7 +208,6 @@ namespace Tgstation.Server.Host.Components.Session
if (bridgeRegistrar == null)
throw new ArgumentNullException(nameof(bridgeRegistrar));
this.chat = chat ?? throw new ArgumentNullException(nameof(chat));
this.logger = logger ?? throw new ArgumentNullException(nameof(logger));
portClosedForReboot = false;
disposed = false;
@@ -268,7 +263,7 @@ namespace Tgstation.Server.Host.Components.Session
disposed = true;
}
logger.LogTrace("Disposing...");
Logger.LogTrace("Disposing...");
if (!released)
{
process.Terminate();
@@ -288,158 +283,23 @@ namespace Tgstation.Server.Host.Components.Session
}
/// <inheritdoc />
public Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
public override async Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
{
if (parameters == null)
throw new ArgumentNullException(nameof(parameters));
static Task<BridgeResponse> Error(string message) => Task.FromResult(new BridgeResponse
{
ErrorMessage = message,
});
using (LogContext.PushProperty("Instance", metadata.Id))
{
logger.LogTrace("Handling bridge request...");
initialBridgeRequestTcs.TrySetResult();
Logger.LogTrace("Handling bridge request...");
var response = new BridgeResponse();
switch (parameters.CommandType)
try
{
case BridgeCommandType.ChatSend:
if (parameters.ChatMessage == null)
return Error("Missing chatMessage field!");
if (parameters.ChatMessage.ChannelIds == null)
return Error("Missing channelIds field in chatMessage!");
if (parameters.ChatMessage.ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _)))
return Error("Invalid channelIds in chatMessage!");
if (parameters.ChatMessage.Text == null)
return Error("Missing message field in chatMessage!");
var anyFailed = false;
var parsedChannels = parameters.ChatMessage.ChannelIds.Select(
channelString =>
{
anyFailed |= !UInt64.TryParse(channelString, out var channelId);
return channelId;
});
if (anyFailed)
return Error("Failed to parse channelIds as U64!");
chat.QueueMessage(
parameters.ChatMessage,
parsedChannels);
break;
case BridgeCommandType.Prime:
var oldPrimeTcs = primeTcs;
primeTcs = new TaskCompletionSource();
oldPrimeTcs.SetResult();
break;
case BridgeCommandType.Kill:
logger.LogInformation("Bridge requested process termination!");
TerminationWasRequested = true;
process.Terminate();
break;
case BridgeCommandType.PortUpdate:
lock (synchronizationLock)
{
if (!parameters.CurrentPort.HasValue)
{
/////UHHHH
logger.LogWarning("DreamDaemon sent new port command without providing it's own!");
return Error("Missing stringified port as data parameter!");
}
var currentPort = parameters.CurrentPort.Value;
if (!nextPort.HasValue)
ReattachInformation.Port = parameters.CurrentPort.Value; // not ready yet, so what we'll do is accept the random port DD opened on for now and change it later when we decide to
else
{
// nextPort is ready, tell DD to switch to that
// if it fails it'll kill itself
response.NewPort = nextPort.Value;
ReattachInformation.Port = nextPort.Value;
nextPort = null;
// we'll also get here from SetPort so complete that task
var tmpTcs = portAssignmentTcs;
portAssignmentTcs = null;
tmpTcs.SetResult(true);
}
portClosedForReboot = false;
}
break;
case BridgeCommandType.Startup:
apiValidationStatus = ApiValidationStatus.BadValidationRequest;
if (parameters.Version == null)
return Error("Missing dmApiVersion field!");
DMApiVersion = parameters.Version;
if (DMApiVersion.Major != DMApiConstants.InteropVersion.Major)
{
apiValidationStatus = ApiValidationStatus.Incompatible;
return Error("Incompatible dmApiVersion!");
}
switch (parameters.MinimumSecurityLevel)
{
case DreamDaemonSecurity.Ultrasafe:
apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
break;
case DreamDaemonSecurity.Safe:
apiValidationStatus = ApiValidationStatus.RequiresSafe;
break;
case DreamDaemonSecurity.Trusted:
apiValidationStatus = ApiValidationStatus.RequiresTrusted;
break;
case null:
return Error("Missing minimumSecurityLevel field!");
default:
return Error("Invalid minimumSecurityLevel!");
}
logger.LogTrace("ApiValidationStatus set to {0}", apiValidationStatus);
response.RuntimeInformation = new RuntimeInformation(
chatTrackingContext,
ReattachInformation.Dmb,
ReattachInformation.RuntimeInformation.ServerVersion,
ReattachInformation.RuntimeInformation.InstanceName,
ReattachInformation.RuntimeInformation.SecurityLevel,
ReattachInformation.RuntimeInformation.Visibility,
ReattachInformation.RuntimeInformation.ServerPort,
ReattachInformation.RuntimeInformation.ApiValidateOnly);
// Load custom commands
chatTrackingContext.CustomCommands = parameters.CustomCommands;
break;
case BridgeCommandType.Reboot:
if (ClosePortOnReboot)
{
chatTrackingContext.Active = false;
response.NewPort = 0;
portClosedForReboot = true;
}
var oldRebootTcs = rebootTcs;
rebootTcs = new TaskCompletionSource();
oldRebootTcs.SetResult();
break;
case null:
response.ErrorMessage = "Missing commandType!";
break;
default:
response.ErrorMessage = "Requested commandType not supported!";
break;
return await ProcessBridgeCommand(parameters, cancellationToken);
}
finally
{
initialBridgeRequestTcs.TrySetResult();
}
return Task.FromResult(response);
}
}
@@ -466,7 +326,7 @@ namespace Tgstation.Server.Host.Components.Session
if (Lifetime.IsCompleted)
{
logger.LogWarning(
Logger.LogWarning(
"Attempted to send a command to an inactive SessionController: {0}",
parameters.CommandType);
return null;
@@ -474,14 +334,14 @@ namespace Tgstation.Server.Host.Components.Session
if (!DMApiAvailable)
{
logger.LogTrace("Not sending topic request {0} to server without/with incompatible DMAPI!", parameters.CommandType);
Logger.LogTrace("Not sending topic request {0} to server without/with incompatible DMAPI!", parameters.CommandType);
return null;
}
parameters.AccessIdentifier = ReattachInformation.AccessIdentifier;
var json = JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings);
logger.LogTrace("Topic request: {0}", json);
Logger.LogTrace("Topic request: {0}", json);
try
{
var commandString = String.Format(
@@ -506,21 +366,21 @@ namespace Tgstation.Server.Host.Components.Session
interopResponse = JsonConvert.DeserializeObject<Interop.Topic.TopicResponse>(topicReturn, DMApiConstants.SerializerSettings);
if (interopResponse.ErrorMessage != null)
{
logger.LogWarning("Errored topic response for command {0}: {1}", parameters.CommandType, interopResponse.ErrorMessage);
Logger.LogWarning("Errored topic response for command {0}: {1}", parameters.CommandType, interopResponse.ErrorMessage);
}
logger.LogTrace("Interop response: {0}", topicReturn);
Logger.LogTrace("Interop response: {0}", topicReturn);
}
catch (Exception ex)
{
logger.LogWarning(ex, "Invalid interop response: {0}", topicReturn);
Logger.LogWarning(ex, "Invalid interop response: {0}", topicReturn);
}
return new CombinedTopicResponse(topicResponse, interopResponse);
}
catch (OperationCanceledException ex)
{
logger.LogTrace(
Logger.LogTrace(
ex,
"Topic request {0}!",
cancellationToken.IsCancellationRequested
@@ -530,7 +390,7 @@ namespace Tgstation.Server.Host.Components.Session
}
catch (Exception e)
{
logger.LogWarning(e, "Send command exception!");
Logger.LogWarning(e, "Send command exception!");
}
return null;
@@ -577,7 +437,7 @@ namespace Tgstation.Server.Host.Components.Session
if (RebootState == newRebootState)
return true;
logger.LogTrace("Changing reboot state to {0}", newRebootState);
Logger.LogTrace("Changing reboot state to {0}", newRebootState);
ReattachInformation.RebootState = newRebootState;
var result = await SendCommand(
@@ -592,7 +452,7 @@ namespace Tgstation.Server.Host.Components.Session
public void ResetRebootState()
{
CheckDisposed();
logger.LogTrace("Resetting reboot state...");
Logger.LogTrace("Resetting reboot state...");
ReattachInformation.RebootState = RebootState.Normal;
}
@@ -654,7 +514,7 @@ namespace Tgstation.Server.Host.Components.Session
if (startupTimeout.HasValue)
toAwait = Task.WhenAny(toAwait, Task.Delay(TimeSpan.FromSeconds(startupTimeout.Value)));
logger.LogTrace(
Logger.LogTrace(
"Waiting for LaunchResult based on {0}{1}...",
useBridgeRequestForLaunchResult ? "initial bridge request" : "process startup",
startupTimeout.HasValue ? $" with a timeout of {startupTimeout.Value}s" : String.Empty);
@@ -667,7 +527,7 @@ namespace Tgstation.Server.Host.Components.Session
StartupTime = startupTask.IsCompleted ? (DateTimeOffset.UtcNow - startTime) : null,
};
logger.LogTrace("Launch result: {0}", result);
Logger.LogTrace("Launch result: {0}", result);
if (!result.ExitCode.HasValue && reattached && !disposed)
{
@@ -683,7 +543,7 @@ namespace Tgstation.Server.Host.Components.Session
if (reattachResponse.InteropResponse?.CustomCommands != null)
chatTrackingContext.CustomCommands = reattachResponse.InteropResponse.CustomCommands;
else if (reattachResponse.InteropResponse != null)
logger.LogWarning(
Logger.LogWarning(
"DMAPI v{0} isn't returning the TGS custom commands list. Functionality added in v5.2.0.",
CompileJob.DMApiVersion.Semver());
}
@@ -700,5 +560,152 @@ namespace Tgstation.Server.Host.Components.Session
if (disposed)
throw new ObjectDisposedException(nameof(SessionController));
}
/// <summary>
/// Handle a set of bridge <paramref name="parameters"/>.
/// </summary>
/// <param name="parameters">The <see cref="BridgeParameters"/> to handle.</param>
/// <param name="cancellationToken">The <see cref="CancellationToken"/> for the operation.</param>
/// <returns>A <see cref="Task{TResult}"/> resulting in the <see cref="BridgeResponse"/> for the request or <see langword="null"/> if the request could not be dispatched.</returns>
async Task<BridgeResponse> ProcessBridgeCommand(BridgeParameters parameters, CancellationToken cancellationToken)
{
var response = new BridgeResponse();
switch (parameters.CommandType)
{
case BridgeCommandType.ChatSend:
if (parameters.ChatMessage == null)
return BridgeError("Missing chatMessage field!");
if (parameters.ChatMessage.ChannelIds == null)
return BridgeError("Missing channelIds field in chatMessage!");
if (parameters.ChatMessage.ChannelIds.Any(channelIdString => !UInt64.TryParse(channelIdString, out var _)))
return BridgeError("Invalid channelIds in chatMessage!");
if (parameters.ChatMessage.Text == null)
return BridgeError("Missing message field in chatMessage!");
var anyFailed = false;
var parsedChannels = parameters.ChatMessage.ChannelIds.Select(
channelString =>
{
anyFailed |= !UInt64.TryParse(channelString, out var channelId);
return channelId;
});
if (anyFailed)
return BridgeError("Failed to parse channelIds as U64!");
chat.QueueMessage(
parameters.ChatMessage,
parsedChannels);
break;
case BridgeCommandType.Prime:
var oldPrimeTcs = primeTcs;
primeTcs = new TaskCompletionSource();
oldPrimeTcs.SetResult();
break;
case BridgeCommandType.Kill:
Logger.LogInformation("Bridge requested process termination!");
TerminationWasRequested = true;
process.Terminate();
break;
case BridgeCommandType.PortUpdate:
lock (synchronizationLock)
{
if (!parameters.CurrentPort.HasValue)
{
/////UHHHH
Logger.LogWarning("DreamDaemon sent new port command without providing it's own!");
return BridgeError("Missing stringified port as data parameter!");
}
var currentPort = parameters.CurrentPort.Value;
if (!nextPort.HasValue)
ReattachInformation.Port = parameters.CurrentPort.Value; // not ready yet, so what we'll do is accept the random port DD opened on for now and change it later when we decide to
else
{
// nextPort is ready, tell DD to switch to that
// if it fails it'll kill itself
response.NewPort = nextPort.Value;
ReattachInformation.Port = nextPort.Value;
nextPort = null;
// we'll also get here from SetPort so complete that task
var tmpTcs = portAssignmentTcs;
portAssignmentTcs = null;
tmpTcs.SetResult(true);
}
portClosedForReboot = false;
}
break;
case BridgeCommandType.Startup:
apiValidationStatus = ApiValidationStatus.BadValidationRequest;
if (parameters.Version == null)
return BridgeError("Missing dmApiVersion field!");
DMApiVersion = parameters.Version;
if (DMApiVersion.Major != DMApiConstants.InteropVersion.Major)
{
apiValidationStatus = ApiValidationStatus.Incompatible;
return BridgeError("Incompatible dmApiVersion!");
}
switch (parameters.MinimumSecurityLevel)
{
case DreamDaemonSecurity.Ultrasafe:
apiValidationStatus = ApiValidationStatus.RequiresUltrasafe;
break;
case DreamDaemonSecurity.Safe:
apiValidationStatus = ApiValidationStatus.RequiresSafe;
break;
case DreamDaemonSecurity.Trusted:
apiValidationStatus = ApiValidationStatus.RequiresTrusted;
break;
case null:
return BridgeError("Missing minimumSecurityLevel field!");
default:
return BridgeError("Invalid minimumSecurityLevel!");
}
Logger.LogTrace("ApiValidationStatus set to {0}", apiValidationStatus);
response.RuntimeInformation = new RuntimeInformation(
chatTrackingContext,
ReattachInformation.Dmb,
ReattachInformation.RuntimeInformation.ServerVersion,
ReattachInformation.RuntimeInformation.InstanceName,
ReattachInformation.RuntimeInformation.SecurityLevel,
ReattachInformation.RuntimeInformation.Visibility,
ReattachInformation.RuntimeInformation.ServerPort,
ReattachInformation.RuntimeInformation.ApiValidateOnly);
// Load custom commands
chatTrackingContext.CustomCommands = parameters.CustomCommands;
break;
case BridgeCommandType.Reboot:
if (ClosePortOnReboot)
{
chatTrackingContext.Active = false;
response.NewPort = 0;
portClosedForReboot = true;
}
var oldRebootTcs = rebootTcs;
rebootTcs = new TaskCompletionSource();
oldRebootTcs.SetResult();
break;
case BridgeCommandType.Chunk:
return await ProcessBridgeChunk(parameters.Chunk, cancellationToken);
case null:
return BridgeError("Missing commandType!");
default:
return BridgeError($"commandType {parameters.CommandType} not supported!");
}
return response;
}
}
}
+8 -2
View File
@@ -173,6 +173,12 @@ var/lastTgsError
set waitfor = FALSE
CheckBridgeLimitsImpl()
/proc/BridgeWithoutChunking(command, list/data)
var/datum/tgs_api/v5/api = TGS_READ_GLOBAL(tgs)
var/bridge_request = api.CreateBridgeRequest(command, data)
return api.PerformBridgeRequest(bridge_request)
/proc/CheckBridgeLimitsImpl()
sleep(30)
@@ -192,7 +198,7 @@ var/lastTgsError
for(i = 1; ; i = base + (2 ** nextPow))
var/payload = create_payload(i)
var/list/result = api.Bridge(0, list("chatMessage" = list("text" = "payload:[payload]")))
var/list/result = BridgeWithoutChunking(0, list("chatMessage" = list("text" = "payload:[payload]")))
if(!result || lastTgsError || result["integrationHack"] != "ok")
if(i == lastI + 1)
@@ -206,7 +212,7 @@ var/lastTgsError
lastI = i
++nextPow
var/finalResult = api.Bridge(0, list("chatMessage" = list("text" = "done")))
var/finalResult = api.Bridge(0, list("chatMessage" = list("text" = "done:[create_payload(DMAPI5_BRIDGE_REQUEST_LIMIT * 3)]")))
if(!finalResult || lastTgsError || finalResult["integrationHack"] != "ok")
text2file("Failed to end bridge limit test!", "test_fail_reason.txt")
del(world)
@@ -0,0 +1,85 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using System.Web;
using Microsoft.Extensions.Logging;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
using Newtonsoft.Json;
using Tgstation.Server.Host.Components.Interop;
using Tgstation.Server.Host.Components.Interop.Bridge;
namespace Tgstation.Server.Tests.Instance
{
sealed class TestBridgeHandler : BridgeRequestChunker, IBridgeHandler
{
class DMApiParametersImpl : DMApiParameters { }
class BridgeResponseHack : BridgeResponse
{
public string IntegrationHack { get; set; }
}
public DMApiParameters DMApiParameters => new DMApiParametersImpl
{
AccessIdentifier = "tgs_integration_test"
};
long lastBridgeRequestSize = 0;
readonly TaskCompletionSource bridgeTestsTcs;
readonly ushort serverPort;
public TestBridgeHandler(TaskCompletionSource tcs, ILogger<TestBridgeHandler> logger, ushort serverPort)
: base(logger)
{
bridgeTestsTcs = tcs;
this.serverPort = serverPort;
}
public override async Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
{
try
{
Assert.AreEqual(DMApiParameters.AccessIdentifier, parameters.AccessIdentifier);
if (parameters.CommandType == BridgeCommandType.Chunk)
return await ProcessBridgeChunk(parameters.Chunk, cancellationToken);
Assert.AreEqual((BridgeCommandType)0, parameters.CommandType);
Assert.IsNotNull(parameters.ChatMessage?.Text);
var splits = parameters.ChatMessage.Text.Split(':', StringSplitOptions.RemoveEmptyEntries);
Assert.AreEqual(2, splits.Length);
var coreMessage = splits[0];
Assert.IsFalse(String.IsNullOrWhiteSpace(coreMessage));
if (coreMessage == "done")
{
Assert.AreEqual(DMApiConstants.MaximumBridgeRequestLength, lastBridgeRequestSize);
Assert.AreEqual(new string('a', (int)(DMApiConstants.MaximumBridgeRequestLength * 3)), splits[1]);
bridgeTestsTcs.SetResult();
return new BridgeResponseHack
{
IntegrationHack = "ok"
};
}
Assert.AreEqual("payload", coreMessage);
lastBridgeRequestSize = $"http://127.0.0.1:{serverPort}/Bridge?data=".Length + HttpUtility.UrlEncode(
JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings)).Length;
return new BridgeResponseHack
{
IntegrationHack = "ok"
};
}
catch (Exception ex)
{
bridgeTestsTcs.SetException(ex);
return null;
}
}
}
}
@@ -34,7 +34,7 @@ using Tgstation.Server.Host.System;
namespace Tgstation.Server.Tests.Instance
{
sealed class WatchdogTest : JobsRequiredTest, IBridgeHandler
sealed class WatchdogTest : JobsRequiredTest
{
readonly IInstanceClient instanceClient;
readonly InstanceManager instanceManager;
@@ -336,66 +336,7 @@ namespace Tgstation.Server.Tests.Instance
return await instanceClient.DreamDaemon.Start(cancellationToken);
}
class DMApiParametersImpl : DMApiParameters { }
public DMApiParameters DMApiParameters => new DMApiParametersImpl
{
AccessIdentifier = "tgs_integration_test"
};
TaskCompletionSource bridgeTestsTcs;
public class BridgeResponseHack : BridgeResponse
{
public string IntegrationHack { get; set; }
}
public class ResponseTestData : TestData
{
public bool Continue { get; set; }
public string PayloadId { get; set; }
}
long lastBridgeRequestSize = 0;
public Task<BridgeResponse> ProcessBridgeRequest(BridgeParameters parameters, CancellationToken cancellationToken)
{
try
{
Assert.AreEqual(DMApiParameters.AccessIdentifier, parameters.AccessIdentifier);
Assert.AreEqual((BridgeCommandType)0, parameters.CommandType);
Assert.IsNotNull(parameters.ChatMessage?.Text);
var splits = parameters.ChatMessage.Text.Split(':', StringSplitOptions.RemoveEmptyEntries);
var coreMessage = splits[0];
Assert.IsFalse(String.IsNullOrWhiteSpace(coreMessage));
if (coreMessage == "done")
{
Assert.AreEqual(DMApiConstants.MaximumBridgeRequestLength, lastBridgeRequestSize);
bridgeTestsTcs.SetResult();
return Task.FromResult<BridgeResponse>(
new BridgeResponseHack
{
IntegrationHack = "ok"
});
}
Assert.AreEqual("payload", coreMessage);
lastBridgeRequestSize = $"http://127.0.0.1:{serverPort}/Bridge?data=".Length + HttpUtility.UrlEncode(
JsonConvert.SerializeObject(parameters, DMApiConstants.SerializerSettings)).Length;
return Task.FromResult<BridgeResponse>(
new BridgeResponseHack
{
IntegrationHack = "ok"
});
}
catch (Exception ex)
{
bridgeTestsTcs.SetException(ex);
return Task.FromResult<BridgeResponse>(null);
}
}
public class TestData
class TestData
{
public string Size { get; set; }
public string Payload { get; set; }
@@ -408,17 +349,23 @@ namespace Tgstation.Server.Tests.Instance
await WaitForJob(startJob, 40, false, null, cancellationToken);
// first check the bridge limits
bridgeTestsTcs = new TaskCompletionSource();
var bridgeTestsTcs = new TaskCompletionSource();
BridgeController.LogContent = false;
using (var bridgeRegistration = instanceManager.RegisterHandler(this))
using (var loggerFactory = LoggerFactory.Create(builder =>
{
System.Console.WriteLine("TEST: Sending Bridge tests topic...");
var bridgeTestTopicResult = await topicClient.SendTopic(IPAddress.Loopback, "tgs_integration_test_tactics2=1", IntegrationTest.DDPort, cancellationToken);
Assert.AreEqual("ack2", bridgeTestTopicResult.StringData);
builder.AddConsole();
builder.SetMinimumLevel(LogLevel.Trace);
}))
{
var bridgeProcessor = new TestBridgeHandler(bridgeTestsTcs, loggerFactory.CreateLogger<TestBridgeHandler>(), serverPort);
using (var bridgeRegistration = instanceManager.RegisterHandler(bridgeProcessor))
{
System.Console.WriteLine("TEST: Sending Bridge tests topic...");
var bridgeTestTopicResult = await topicClient.SendTopic(IPAddress.Loopback, "tgs_integration_test_tactics2=1", IntegrationTest.DDPort, cancellationToken);
Assert.AreEqual("ack2", bridgeTestTopicResult.StringData);
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
// this test doesn't take long
await bridgeTestsTcs.Task.WithToken(cancellationToken);
await bridgeTestsTcs.Task.WithToken(cancellationToken);
}
}
BridgeController.LogContent = true;
@@ -776,7 +723,7 @@ namespace Tgstation.Server.Tests.Instance
return ddProc != null;
}
TopicClient topicClient = new (new SocketParameters
readonly TopicClient topicClient = new (new SocketParameters
{
SendTimeout = TimeSpan.FromSeconds(30),
ReceiveTimeout = TimeSpan.FromSeconds(30),
+1
View File
@@ -138,6 +138,7 @@ EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "v5", "v5", "{FAEAD3B5-2EAB-465C-A9C3-E8CB6AAA7131}"
ProjectSection(SolutionItems) = preProject
src\DMAPI\tgs\v5\api.dm = src\DMAPI\tgs\v5\api.dm
src\DMAPI\tgs\v5\bridge.dm = src\DMAPI\tgs\v5\bridge.dm
src\DMAPI\tgs\v5\commands.dm = src\DMAPI\tgs\v5\commands.dm
src\DMAPI\tgs\v5\README.md = src\DMAPI\tgs\v5\README.md
src\DMAPI\tgs\v5\serializers.dm = src\DMAPI\tgs\v5\serializers.dm