feat(ipc): add NamedPipe contracts, secure IPC server, and client
This commit is contained in:
parent
5bf634278d
commit
94753c3105
1 changed files with 204 additions and 0 deletions
204
apps/KronaStaffTerminal/Services/TerminalIpc.cs
Normal file
204
apps/KronaStaffTerminal/Services/TerminalIpc.cs
Normal file
|
|
@ -0,0 +1,204 @@
|
||||||
|
using System.IO.Pipes;
|
||||||
|
using System.Security.AccessControl;
|
||||||
|
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 sealed class IpcResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? ErrorMessage { get; set; }
|
||||||
|
public IpcServiceStatus? Status { get; set; }
|
||||||
|
public int? SyncedCount { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class IpcServiceStatus
|
||||||
|
{
|
||||||
|
public bool ServiceRunning { get; set; } = true;
|
||||||
|
public string ServiceVersion { get; set; } = "0.2.5";
|
||||||
|
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 sealed class TerminalIpcClient
|
||||||
|
{
|
||||||
|
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.PipeName, PipeDirection.InOut, PipeOptions.Asynchronous);
|
||||||
|
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
|
timeoutCts.CancelAfter(TimeSpan.FromSeconds(2));
|
||||||
|
|
||||||
|
await pipe.ConnectAsync(timeoutCts.Token);
|
||||||
|
|
||||||
|
var request = new IpcRequest { Command = command, Payload = payload };
|
||||||
|
var json = JsonSerializer.Serialize(request) + "\n";
|
||||||
|
var bytes = Encoding.UTF8.GetBytes(json);
|
||||||
|
await pipe.WriteAsync(bytes, cancellationToken);
|
||||||
|
await pipe.FlushAsync(cancellationToken);
|
||||||
|
|
||||||
|
using var reader = new StreamReader(pipe, Encoding.UTF8);
|
||||||
|
var responseJson = await reader.ReadLineAsync(cancellationToken);
|
||||||
|
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
|
||||||
|
{
|
||||||
|
using var reader = new StreamReader(stream, Encoding.UTF8, leaveOpen: true);
|
||||||
|
var line = await reader.ReadLineAsync(token);
|
||||||
|
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) + "\n";
|
||||||
|
var responseBytes = Encoding.UTF8.GetBytes(responseJson);
|
||||||
|
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 usersSid = new SecurityIdentifier(WellKnownSidType.AuthenticatedUserSid, null);
|
||||||
|
|
||||||
|
pipeSecurity.AddAccessRule(new PipeAccessRule(systemSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||||
|
pipeSecurity.AddAccessRule(new PipeAccessRule(adminSid, PipeAccessRights.FullControl, AccessControlType.Allow));
|
||||||
|
pipeSecurity.AddAccessRule(new PipeAccessRule(usersSid, PipeAccessRights.ReadWrite, AccessControlType.Allow));
|
||||||
|
|
||||||
|
return NamedPipeServerStreamAcl.Create(
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue