279 lines
10 KiB
C#
279 lines
10 KiB
C#
using System.IO.Pipes;
|
|
using System.Security.AccessControl;
|
|
using System.Security.Cryptography;
|
|
using System.Security.Principal;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using KronaStaffTerminal.Models;
|
|
|
|
namespace KronaStaffTerminal.Services;
|
|
|
|
public sealed class IpcRequest
|
|
{
|
|
public string Command { get; set; } = "";
|
|
public string? Payload { get; set; }
|
|
public long IssuedAtUnixSeconds { get; set; }
|
|
public string Nonce { get; set; } = "";
|
|
public string Signature { get; set; } = "";
|
|
}
|
|
|
|
public sealed class IpcResponse
|
|
{
|
|
public bool Success { get; set; }
|
|
public string? ErrorMessage { get; set; }
|
|
public IpcServiceStatus? Status { get; set; }
|
|
public int? SyncedCount { get; set; }
|
|
public string? Payload { get; set; }
|
|
}
|
|
|
|
public sealed class IpcServiceStatus
|
|
{
|
|
public bool ServiceRunning { get; set; } = true;
|
|
public string ServiceVersion { get; set; } = "0.3.2";
|
|
public bool ScannerInSession0Supported { get; set; }
|
|
public string ScannerDiagnostic { get; set; } = "";
|
|
public int PendingSyncCount { get; set; }
|
|
public DateTimeOffset? LastSyncUtc { get; set; }
|
|
public string? LastSyncError { get; set; }
|
|
public string LicenseStatus { get; set; } = "Active";
|
|
}
|
|
|
|
public static class TerminalIpcConstants
|
|
{
|
|
public const string PipeName = "KronaStaffTerminalPipe";
|
|
public const string PipeServerName = ".";
|
|
}
|
|
|
|
public sealed class TerminalIpcClient
|
|
{
|
|
private readonly TerminalStore _store = new();
|
|
|
|
public async Task<bool> IsServiceAvailableAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var response = await SendCommandAsync("Ping", null, cancellationToken);
|
|
return response?.Success == true;
|
|
}
|
|
|
|
public async Task<IpcServiceStatus?> GetStatusAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var response = await SendCommandAsync("GetStatus", null, cancellationToken);
|
|
return response?.Status;
|
|
}
|
|
|
|
public async Task<bool> EnqueueAttendanceAsync(AttendanceRecord record, CancellationToken cancellationToken = default)
|
|
{
|
|
var payload = JsonSerializer.Serialize(record);
|
|
var response = await SendCommandAsync("EnqueueAttendance", payload, cancellationToken);
|
|
return response?.Success == true;
|
|
}
|
|
|
|
public async Task<AttendanceSyncResult?> TriggerSyncAsync(CancellationToken cancellationToken = default)
|
|
{
|
|
var response = await SendCommandAsync("TriggerSync", null, cancellationToken);
|
|
if (response?.Success == true && !string.IsNullOrWhiteSpace(response.Payload))
|
|
{
|
|
return JsonSerializer.Deserialize<AttendanceSyncResult>(response.Payload);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
public async Task<IpcResponse?> SendCommandAsync(string command, string? payload, CancellationToken cancellationToken = default)
|
|
{
|
|
try
|
|
{
|
|
await using var pipe = new NamedPipeClientStream(TerminalIpcConstants.PipeServerName, TerminalIpcConstants.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(2));
|
|
|
|
await pipe.ConnectAsync(timeoutCts.Token);
|
|
|
|
var state = await _store.LoadAsync();
|
|
var request = new IpcRequest
|
|
{
|
|
Command = command,
|
|
Payload = payload,
|
|
IssuedAtUnixSeconds = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
|
Nonce = Convert.ToHexString(RandomNumberGenerator.GetBytes(16))
|
|
};
|
|
request.Signature = IpcRequestAuthenticator.Sign(request, state.Settings.IpcAuthenticationKey);
|
|
var json = JsonSerializer.Serialize(request);
|
|
var bytes = Encoding.UTF8.GetBytes(json);
|
|
var lengthPrefix = BitConverter.GetBytes(bytes.Length);
|
|
await pipe.WriteAsync(lengthPrefix, cancellationToken);
|
|
await pipe.WriteAsync(bytes, cancellationToken);
|
|
await pipe.FlushAsync(cancellationToken);
|
|
|
|
var respLenBytes = new byte[4];
|
|
var read = await pipe.ReadAsync(respLenBytes, cancellationToken);
|
|
if (read < 4) return null;
|
|
var respLen = BitConverter.ToInt32(respLenBytes);
|
|
if (respLen is <= 0 or > 10 * 1024 * 1024) return null;
|
|
|
|
var respBytes = new byte[respLen];
|
|
var totalRead = 0;
|
|
while (totalRead < respLen)
|
|
{
|
|
var r = await pipe.ReadAsync(respBytes.AsMemory(totalRead), cancellationToken);
|
|
if (r == 0) return null;
|
|
totalRead += r;
|
|
}
|
|
|
|
var responseJson = Encoding.UTF8.GetString(respBytes);
|
|
if (string.IsNullOrWhiteSpace(responseJson)) return null;
|
|
|
|
return JsonSerializer.Deserialize<IpcResponse>(responseJson);
|
|
}
|
|
catch (Exception ex) when (ex is IOException or TimeoutException or OperationCanceledException)
|
|
{
|
|
// Service not running or unreachable
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
public sealed class TerminalIpcServer : IDisposable
|
|
{
|
|
private readonly Func<IpcRequest, Task<IpcResponse>> _handler;
|
|
private CancellationTokenSource? _cancellation;
|
|
private Task? _listenTask;
|
|
private bool _disposed;
|
|
|
|
public TerminalIpcServer(Func<IpcRequest, Task<IpcResponse>> handler)
|
|
{
|
|
_handler = handler;
|
|
}
|
|
|
|
public void Start()
|
|
{
|
|
_cancellation = new CancellationTokenSource();
|
|
_listenTask = Task.Run(() => ListenLoopAsync(_cancellation.Token));
|
|
}
|
|
|
|
private async Task ListenLoopAsync(CancellationToken token)
|
|
{
|
|
while (!token.IsCancellationRequested)
|
|
{
|
|
NamedPipeServerStream? server = null;
|
|
try
|
|
{
|
|
server = CreatePipeServer();
|
|
await server.WaitForConnectionAsync(token);
|
|
|
|
// Handle client in background task so server can accept next client
|
|
var clientStream = server;
|
|
_ = Task.Run(() => HandleClientAsync(clientStream, token), token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
server?.Dispose();
|
|
break;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
CrashLogService.WriteDiagnostic($"IPC server loop error: {ex.Message}");
|
|
server?.Dispose();
|
|
await Task.Delay(500, token);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async Task HandleClientAsync(NamedPipeServerStream stream, CancellationToken token)
|
|
{
|
|
await using (stream)
|
|
{
|
|
try
|
|
{
|
|
var reqLenBytes = new byte[4];
|
|
var read = await stream.ReadAsync(reqLenBytes, token);
|
|
if (read < 4) return;
|
|
var reqLen = BitConverter.ToInt32(reqLenBytes);
|
|
if (reqLen is <= 0 or > 256 * 1024) return;
|
|
|
|
var reqBytes = new byte[reqLen];
|
|
var totalRead = 0;
|
|
while (totalRead < reqLen)
|
|
{
|
|
var r = await stream.ReadAsync(reqBytes.AsMemory(totalRead), token);
|
|
if (r == 0) return;
|
|
totalRead += r;
|
|
}
|
|
|
|
var line = Encoding.UTF8.GetString(reqBytes);
|
|
if (string.IsNullOrWhiteSpace(line)) return;
|
|
|
|
var request = JsonSerializer.Deserialize<IpcRequest>(line);
|
|
var response = request is not null
|
|
? await _handler(request)
|
|
: new IpcResponse { Success = false, ErrorMessage = "Invalid request" };
|
|
|
|
var responseJson = JsonSerializer.Serialize(response);
|
|
var responseBytes = Encoding.UTF8.GetBytes(responseJson);
|
|
var lengthPrefix = BitConverter.GetBytes(responseBytes.Length);
|
|
|
|
await stream.WriteAsync(lengthPrefix, token);
|
|
await stream.WriteAsync(responseBytes, token);
|
|
await stream.FlushAsync(token);
|
|
}
|
|
catch (Exception ex) when (ex is not OperationCanceledException)
|
|
{
|
|
CrashLogService.WriteDiagnostic($"IPC client handle error: {ex.Message}");
|
|
}
|
|
}
|
|
}
|
|
|
|
private static NamedPipeServerStream CreatePipeServer()
|
|
{
|
|
var pipeSecurity = new PipeSecurity();
|
|
var systemSid = new SecurityIdentifier(WellKnownSidType.LocalSystemSid, null);
|
|
var adminSid = new SecurityIdentifier(WellKnownSidType.BuiltinAdministratorsSid, null);
|
|
var interactiveSid = new SecurityIdentifier(WellKnownSidType.InteractiveSid, null);
|
|
|
|
pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
|
pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
|
pipeSecurity.AddAccessRule(new PipeAccessRule(interactiveSid, PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
|
|
|
return NamedPipeServerStreamAcl.Create(
|
|
@"global\" + TerminalIpcConstants.PipeName,
|
|
PipeDirection.InOut,
|
|
NamedPipeServerStream.MaxAllowedServerInstances,
|
|
PipeTransmissionMode.Byte,
|
|
PipeOptions.Asynchronous,
|
|
inBufferSize: 4096,
|
|
outBufferSize: 4096,
|
|
pipeSecurity);
|
|
}
|
|
|
|
public void Dispose()
|
|
{
|
|
if (_disposed) return;
|
|
_disposed = true;
|
|
_cancellation?.Cancel();
|
|
_cancellation?.Dispose();
|
|
}
|
|
}
|
|
|
|
public static class IpcRequestAuthenticator
|
|
{
|
|
public static string Sign(IpcRequest request, string key)
|
|
{
|
|
var canonical = $"{request.Command}\n{request.IssuedAtUnixSeconds}\n{request.Nonce}\n{request.Payload ?? string.Empty}";
|
|
using var hmac = new HMACSHA256(Convert.FromBase64String(key));
|
|
return Convert.ToBase64String(hmac.ComputeHash(Encoding.UTF8.GetBytes(canonical)));
|
|
}
|
|
|
|
public static bool Verify(IpcRequest request, string key, DateTimeOffset now)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(key) || request.Nonce.Length != 32) return false;
|
|
if (Math.Abs(now.ToUnixTimeSeconds() - request.IssuedAtUnixSeconds) > 60) return false;
|
|
try
|
|
{
|
|
var actual = Convert.FromBase64String(request.Signature);
|
|
var expected = Convert.FromBase64String(Sign(request, key));
|
|
return CryptographicOperations.FixedTimeEquals(actual, expected);
|
|
}
|
|
catch (FormatException)
|
|
{
|
|
return false;
|
|
}
|
|
}
|
|
}
|