seb-win-refactoring/SafeExamBrowser.Server/ServerProxy.cs

648 lines
19 KiB
C#

/*
* Copyright (c) 2022 ETH Zürich, Educational Development and Technology (LET)
*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
*/
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Threading.Tasks;
using System.Timers;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using SafeExamBrowser.Configuration.Contracts;
using SafeExamBrowser.Logging.Contracts;
using SafeExamBrowser.Server.Contracts;
using SafeExamBrowser.Server.Contracts.Data;
using SafeExamBrowser.Server.Contracts.Events;
using SafeExamBrowser.Server.Data;
using SafeExamBrowser.Settings.Logging;
using SafeExamBrowser.Settings.Server;
using SafeExamBrowser.SystemComponents.Contracts;
using SafeExamBrowser.SystemComponents.Contracts.Network;
using SafeExamBrowser.SystemComponents.Contracts.PowerSupply;
using Timer = System.Timers.Timer;
namespace SafeExamBrowser.Server
{
public class ServerProxy : ILogObserver, IServerProxy
{
private readonly AppConfig appConfig;
private readonly FileSystem fileSystem;
private readonly ConcurrentQueue<string> instructionConfirmations;
private readonly ILogger logger;
private readonly ConcurrentQueue<ILogContent> logContent;
private readonly Timer logTimer;
private readonly Parser parser;
private readonly Timer pingTimer;
private readonly IPowerSupply powerSupply;
private readonly ISystemInfo systemInfo;
private readonly IUserInfo userInfo;
private readonly INetworkAdapter networkAdapter;
private ApiVersion1 api;
private string connectionToken;
private int currentPowerSupplyValue;
private bool connectedToPowergrid;
private int currentWlanValue;
private string examId;
private int handNotificationId;
private HttpClient httpClient;
private string oauth2Token;
private int pingNumber;
private ServerSettings settings;
public event ServerEventHandler HandConfirmed;
public event ProctoringConfigurationReceivedEventHandler ProctoringConfigurationReceived;
public event ProctoringInstructionReceivedEventHandler ProctoringInstructionReceived;
public event TerminationRequestedEventHandler TerminationRequested;
public ServerProxy(
AppConfig appConfig,
ILogger logger,
ISystemInfo systemInfo,
IUserInfo userInfo,
IPowerSupply powerSupply = default,
INetworkAdapter networkAdapter = default)
{
this.api = new ApiVersion1();
this.appConfig = appConfig;
this.fileSystem = new FileSystem(appConfig, logger);
this.instructionConfirmations = new ConcurrentQueue<string>();
this.logger = logger;
this.logContent = new ConcurrentQueue<ILogContent>();
this.logTimer = new Timer();
this.networkAdapter = networkAdapter;
this.parser = new Parser(logger);
this.pingTimer = new Timer();
this.powerSupply = powerSupply;
this.systemInfo = systemInfo;
this.userInfo = userInfo;
}
public ServerResponse Connect()
{
var success = TryExecute(HttpMethod.Get, settings.ApiUrl, out var response);
var message = response.ToLogString();
if (success && parser.TryParseApi(response.Content, out api))
{
logger.Info("Successfully loaded server API.");
success = TryRetrieveOAuth2Token(out message);
}
else
{
logger.Error("Failed to load server API!");
}
return new ServerResponse(success, message);
}
public ServerResponse Disconnect()
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var content = "delete=true";
var contentType = "application/x-www-form-urlencoded";
var token = ("SEBConnectionToken", connectionToken);
var success = TryExecute(HttpMethod.Delete, api.HandshakeEndpoint, out var response, content, contentType, authorization, token);
var message = response.ToLogString();
if (success)
{
logger.Info("Successfully terminated connection.");
}
else
{
logger.Error("Failed to terminate connection!");
}
return new ServerResponse(success, message);
}
public ServerResponse<IEnumerable<Exam>> GetAvailableExams(string examId = default)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var clientInfo = $"client_id={userInfo.GetUserName()}&seb_machine_name={systemInfo.Name}";
var versionInfo = $"seb_os_name={systemInfo.OperatingSystemInfo}&seb_version={appConfig.ProgramInformationalVersion}";
var content = $"institutionId={settings.Institution}&{clientInfo}&{versionInfo}{(examId == default ? "" : $"&examId={examId}")}";
var contentType = "application/x-www-form-urlencoded";
var exams = default(IList<Exam>);
var success = TryExecute(HttpMethod.Post, api.HandshakeEndpoint, out var response, content, contentType, authorization);
var message = response.ToLogString();
if (success)
{
var hasExams = parser.TryParseExams(response.Content, out exams);
var hasToken = parser.TryParseConnectionToken(response, out connectionToken);
success = hasExams && hasToken;
if (success)
{
logger.Info("Successfully retrieved connection token and available exams.");
}
else if (!hasExams)
{
logger.Error("Failed to retrieve available exams!");
}
else if (!hasToken)
{
logger.Error("Failed to retrieve connection token!");
}
}
else
{
logger.Error("Failed to load connection token and available exams!");
}
return new ServerResponse<IEnumerable<Exam>>(success, exams, message);
}
public ServerResponse<Uri> GetConfigurationFor(Exam exam)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var token = ("SEBConnectionToken", connectionToken);
var uri = default(Uri);
var success = TryExecute(HttpMethod.Get, $"{api.ConfigurationEndpoint}?examId={exam.Id}", out var response, default, default, authorization, token);
var message = response.ToLogString();
if (success)
{
logger.Info("Successfully retrieved exam configuration.");
success = fileSystem.TrySaveFile(response.Content, out uri);
if (success)
{
logger.Info($"Successfully saved exam configuration as '{uri}'.");
}
else
{
logger.Error("Failed to save exam configuration!");
}
}
else
{
logger.Error("Failed to retrieve exam configuration!");
}
return new ServerResponse<Uri>(success, uri, message);
}
public ConnectionInfo GetConnectionInfo()
{
return new ConnectionInfo
{
Api = JsonConvert.SerializeObject(api),
ConnectionToken = connectionToken,
Oauth2Token = oauth2Token
};
}
public void Initialize(ServerSettings settings)
{
this.settings = settings;
httpClient = new HttpClient();
httpClient.BaseAddress = new Uri(settings.ServerUrl);
if (settings.RequestTimeout > 0)
{
httpClient.Timeout = TimeSpan.FromMilliseconds(settings.RequestTimeout);
}
}
public void Initialize(string api, string connectionToken, string examId, string oauth2Token, ServerSettings settings)
{
this.api = JsonConvert.DeserializeObject<ApiVersion1>(api);
this.connectionToken = connectionToken;
this.examId = examId;
this.oauth2Token = oauth2Token;
Initialize(settings);
}
public ServerResponse LowerHand()
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var contentType = "application/json;charset=UTF-8";
var token = ("SEBConnectionToken", connectionToken);
var json = new JObject
{
["type"] = "NOTIFICATION_CONFIRMED",
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
["numericValue"] = handNotificationId,
};
var content = json.ToString();
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, contentType, authorization, token);
if (success)
{
logger.Info("Successfully sent lower hand notification.");
}
else
{
logger.Error("Failed to send lower hand notification!");
}
return new ServerResponse(success, response.ToLogString());
}
public void Notify(ILogContent content)
{
logContent.Enqueue(content);
}
public ServerResponse RaiseHand(string message = null)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var contentType = "application/json;charset=UTF-8";
var token = ("SEBConnectionToken", connectionToken);
var json = new JObject
{
["type"] = "NOTIFICATION",
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
["numericValue"] = ++handNotificationId,
["text"] = $"<raisehand> {message}"
};
var content = json.ToString();
var success = TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, contentType, authorization, token);
if (success)
{
logger.Info("Successfully sent raise hand notification.");
}
else
{
logger.Error("Failed to send raise hand notification!");
}
return new ServerResponse(success, response.ToLogString());
}
public ServerResponse SendSessionIdentifier(string identifier)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var content = $"examId={examId}&seb_user_session_id={identifier}";
var contentType = "application/x-www-form-urlencoded";
var token = ("SEBConnectionToken", connectionToken);
var success = TryExecute(HttpMethod.Put, api.HandshakeEndpoint, out var response, content, contentType, authorization, token);
var message = response.ToLogString();
if (success)
{
logger.Info("Successfully sent session identifier.");
}
else
{
logger.Error("Failed to send session identifier!");
}
return new ServerResponse(success, message);
}
public void StartConnectivity()
{
foreach (var item in logger.GetLog())
{
logContent.Enqueue(item);
}
logger.Subscribe(this);
logTimer.AutoReset = false;
logTimer.Elapsed += LogTimer_Elapsed;
logTimer.Interval = 500;
logTimer.Start();
logger.Info("Started sending log items.");
pingTimer.AutoReset = false;
pingTimer.Elapsed += PingTimer_Elapsed;
pingTimer.Interval = settings.PingInterval;
pingTimer.Start();
logger.Info("Started sending pings.");
if (powerSupply != default && networkAdapter != default)
{
powerSupply.StatusChanged += PowerSupply_StatusChanged;
networkAdapter.Changed += NetworkAdapter_Changed;
logger.Info("Started monitoring system components.");
}
}
public void StopConnectivity()
{
if (powerSupply != default && networkAdapter != default)
{
powerSupply.StatusChanged -= PowerSupply_StatusChanged;
networkAdapter.Changed -= NetworkAdapter_Changed;
logger.Info("Stopped monitoring system components.");
}
logger.Unsubscribe(this);
logTimer.Stop();
logTimer.Elapsed -= LogTimer_Elapsed;
logger.Info("Stopped sending log items.");
pingTimer.Stop();
pingTimer.Elapsed -= PingTimer_Elapsed;
logger.Info("Stopped sending pings.");
}
private void LogTimer_Elapsed(object sender, ElapsedEventArgs args)
{
try
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var contentType = "application/json;charset=UTF-8";
var token = ("SEBConnectionToken", connectionToken);
while (!logContent.IsEmpty)
{
if (logContent.TryDequeue(out var c) && c is ILogMessage message)
{
var json = new JObject
{
["type"] = message.Severity.ToLogType(),
["timestamp"] = message.DateTime.ToUnixTimestamp(),
["text"] = message.Message
};
var content = json.ToString();
TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, content, contentType, authorization, token);
}
}
}
catch (Exception e)
{
logger.Error("Failed to send log!", e);
}
logTimer.Start();
}
private void PingTimer_Elapsed(object sender, ElapsedEventArgs args)
{
try
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var content = $"timestamp={DateTime.Now.ToUnixTimestamp()}&ping-number={++pingNumber}";
var contentType = "application/x-www-form-urlencoded";
var token = ("SEBConnectionToken", connectionToken);
if (instructionConfirmations.TryDequeue(out var confirmation))
{
content = $"{content}&instruction-confirm={confirmation}";
}
var success = TryExecute(HttpMethod.Post, api.PingEndpoint, out var response, content, contentType, authorization, token);
if (success)
{
if (parser.TryParseInstruction(response.Content, out var attributes, out var instruction, out var instructionConfirmation))
{
switch (instruction)
{
case Instructions.NOTIFICATION_CONFIRM when attributes.Type == "raisehand" && attributes.Id == handNotificationId:
Task.Run(() => HandConfirmed?.Invoke());
break;
case Instructions.PROCTORING:
Task.Run(() => ProctoringInstructionReceived?.Invoke(attributes.Instruction));
break;
case Instructions.PROCTORING_RECONFIGURATION:
Task.Run(() => ProctoringConfigurationReceived?.Invoke(attributes.AllowChat, attributes.ReceiveAudio, attributes.ReceiveVideo));
break;
case Instructions.QUIT:
Task.Run(() => TerminationRequested?.Invoke());
break;
}
if (instructionConfirmation != default)
{
instructionConfirmations.Enqueue(instructionConfirmation);
}
}
}
else
{
logger.Error($"Failed to send ping: {response.ToLogString()}");
}
}
catch (Exception e)
{
logger.Error("Failed to send ping!", e);
}
pingTimer.Start();
}
private void PowerSupply_StatusChanged(IPowerSupplyStatus status)
{
try
{
var value = Convert.ToInt32(status.BatteryCharge * 100);
var connected = status.IsOnline;
if (value != currentPowerSupplyValue)
{
var chargeInfo = $"{status.BatteryChargeStatus} at {value}%";
var gridInfo = $"{(status.IsOnline ? "connected to" : "disconnected from")} the power grid";
var text = $"<battery> {chargeInfo}, {status.BatteryTimeRemaining} remaining, {gridInfo}";
SendPowerSupplyStatus(text, value);
currentPowerSupplyValue = value;
}
else if (connected != connectedToPowergrid)
{
var text = $"<battery> Device has been {(connected ? "connected to" : "disconnected from")} power grid";
SendPowerSupplyStatus(text, value);
connectedToPowergrid = connected;
}
}
catch (Exception e)
{
logger.Error("Failed to send power supply status!", e);
}
}
private void SendPowerSupplyStatus(string text, int value)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var contentType = "application/json;charset=UTF-8";
var token = ("SEBConnectionToken", connectionToken);
var json = new JObject
{
["type"] = LogLevel.Info.ToLogType(),
["timestamp"] = DateTime.Now.ToUnixTimestamp(),
["text"] = text,
["numericValue"] = value
};
var content = json.ToString();
TryExecute(HttpMethod.Post, api.LogEndpoint, out _, content, contentType, authorization, token);
}
private void NetworkAdapter_Changed()
{
const int NOT_CONNECTED = -1;
try
{
var network = networkAdapter.GetWirelessNetworks().FirstOrDefault(n => n.Status == ConnectionStatus.Connected);
if (network?.SignalStrength != currentWlanValue)
{
var authorization = ("Authorization", $"Bearer {oauth2Token}");
var contentType = "application/json;charset=UTF-8";
var token = ("SEBConnectionToken", connectionToken);
var json = new JObject { ["type"] = LogLevel.Info.ToLogType(), ["timestamp"] = DateTime.Now.ToUnixTimestamp() };
if (network != default(IWirelessNetwork))
{
json["text"] = $"<wlan> {network.Name}: {network.Status}, {network.SignalStrength}%";
json["numericValue"] = network.SignalStrength;
}
else
{
json["text"] = "<wlan> not connected";
}
TryExecute(HttpMethod.Post, api.LogEndpoint, out var response, json.ToString(), contentType, authorization, token);
currentWlanValue = network?.SignalStrength ?? NOT_CONNECTED;
}
}
catch (Exception e)
{
logger.Error("Failed to send wireless status!", e);
}
}
private bool TryRetrieveOAuth2Token(out string message)
{
var secret = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{settings.ClientName}:{settings.ClientSecret}"));
var authorization = ("Authorization", $"Basic {secret}");
var content = "grant_type=client_credentials&scope=read write";
var contentType = "application/x-www-form-urlencoded";
var success = TryExecute(HttpMethod.Post, api.AccessTokenEndpoint, out var response, content, contentType, authorization);
message = response.ToLogString();
if (success && parser.TryParseOauth2Token(response.Content, out oauth2Token))
{
logger.Info("Successfully retrieved OAuth2 token.");
}
else
{
logger.Error("Failed to retrieve OAuth2 token!");
}
return success;
}
private bool TryExecute(
HttpMethod method,
string url,
out HttpResponseMessage response,
string content = default,
string contentType = default,
params (string name, string value)[] headers)
{
response = default;
for (var attempt = 0; attempt < settings.RequestAttempts && (response == default || !response.IsSuccessStatusCode); attempt++)
{
var request = BuildRequest(method, url, content, contentType, headers);
try
{
response = httpClient.SendAsync(request).GetAwaiter().GetResult();
if (request.RequestUri.AbsolutePath != api.LogEndpoint && request.RequestUri.AbsolutePath != api.PingEndpoint)
{
logger.Debug($"Completed request: {request.Method} '{request.RequestUri}' -> {response.ToLogString()}");
}
if (response.StatusCode == HttpStatusCode.Unauthorized && parser.IsTokenExpired(response.Content))
{
logger.Info("OAuth2 token has expired, attempting to retrieve new one...");
if (TryRetrieveOAuth2Token(out var message))
{
headers = UpdateOAuth2Token(headers);
}
}
}
catch (TaskCanceledException)
{
logger.Debug($"Request {request.Method} '{request.RequestUri}' did not complete within {settings.RequestTimeout}ms!");
break;
}
catch (Exception e)
{
logger.Debug($"Request {request.Method} '{request.RequestUri}' failed due to {e}");
}
}
return response != default && response.IsSuccessStatusCode;
}
private HttpRequestMessage BuildRequest(
HttpMethod method,
string url,
string content = default,
string contentType = default,
params (string name, string value)[] headers)
{
var request = new HttpRequestMessage(method, url);
if (content != default)
{
request.Content = new StringContent(content, Encoding.UTF8);
if (contentType != default)
{
request.Content.Headers.ContentType = MediaTypeHeaderValue.Parse(contentType);
}
}
request.Headers.Add("Accept", "application/json, */*");
foreach (var (name, value) in headers)
{
request.Headers.Add(name, value);
}
return request;
}
private (string name, string value)[] UpdateOAuth2Token((string name, string value)[] headers)
{
var result = new List<(string name, string value)>();
foreach (var header in headers)
{
if (header.name == "Authorization")
{
result.Add(("Authorization", $"Bearer {oauth2Token}"));
}
else
{
result.Add(header);
}
}
return result.ToArray();
}
}
}