DeviceHub: /device WS with bearer auth + hello/hello_ack + ping/pong

This commit is contained in:
2026-06-11 21:55:33 +00:00
parent 4dc48bd9fd
commit 9023509b01
4 changed files with 233 additions and 0 deletions
+21
View File
@@ -0,0 +1,21 @@
using backend.Data;
using backend.Devices;
using Microsoft.EntityFrameworkCore;
namespace backend.DeviceHub;
public class DeviceAuth(AppDbContext db, DeviceTokenService tokens)
{
public async Task<Device?> AuthenticateAsync(string? authHeader, CancellationToken ct)
{
if (string.IsNullOrEmpty(authHeader)) return null;
const string prefix = "Bearer ";
if (!authHeader.StartsWith(prefix, StringComparison.Ordinal)) return null;
var token = authHeader[prefix.Length..].Trim();
if (string.IsNullOrEmpty(token)) return null;
var hash = tokens.Hash(token);
var device = await db.Devices.Include(d => d.Config)
.FirstOrDefaultAsync(d => d.TokenHash == hash && !d.IsRevoked, ct);
return device;
}
}
+116
View File
@@ -0,0 +1,116 @@
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using backend.Data;
using backend.Devices;
using Microsoft.EntityFrameworkCore;
namespace backend.DeviceHub;
public static class DeviceHubEndpoint
{
public static IEndpointRouteBuilder MapDeviceHub(this IEndpointRouteBuilder app)
{
app.Map("/device", async (HttpContext ctx, DeviceAuth auth, DeviceRegistry reg, AppDbContext db) =>
{
if (!ctx.WebSockets.IsWebSocketRequest)
{
ctx.Response.StatusCode = StatusCodes.Status400BadRequest;
return;
}
var device = await auth.AuthenticateAsync(
ctx.Request.Headers.Authorization.ToString(), ctx.RequestAborted);
if (device is null)
{
ctx.Response.StatusCode = StatusCodes.Status401Unauthorized;
return;
}
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
await using var active = new ActiveDevice(device.Id, ws);
if (!reg.TryRegister(active))
{
await ws.CloseAsync(WebSocketCloseStatus.PolicyViolation,
"device already connected", ctx.RequestAborted);
return;
}
try
{
await HandleAsync(active, device, db, ctx.RequestAborted);
}
finally
{
reg.Unregister(device.Id);
}
});
return app;
}
private static async Task HandleAsync(ActiveDevice active, Device device, AppDbContext db, CancellationToken ct)
{
var buffer = new byte[8192];
var ws = active.Ws;
while (ws.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
using var msg = new MemoryStream();
WebSocketReceiveResult res;
while (true)
{
res = await ws.ReceiveAsync(buffer, ct);
if (res.MessageType == WebSocketMessageType.Close) return;
msg.Write(buffer, 0, res.Count);
if (res.EndOfMessage) break;
}
if (res.MessageType == WebSocketMessageType.Binary)
{
if (msg.Length != 3840) continue;
// Binary routing into a session lives in Task 18.
continue;
}
var text = Encoding.UTF8.GetString(msg.ToArray());
using var doc = JsonDocument.Parse(text);
var type = doc.RootElement.GetProperty("type").GetString();
switch (type)
{
case "hello":
await SendHelloAckAsync(active, device, ct);
break;
case "ping":
device.LastSeenAt = DateTimeOffset.UtcNow;
await db.SaveChangesAsync(ct);
await active.SendEnvelopeAsync(new PongEnvelope("pong"), ct);
break;
}
}
}
private static async Task SendHelloAckAsync(ActiveDevice active, Device device, CancellationToken ct)
{
var enabled = device.Config?.EnabledToolsJson ?? "[]";
var config = JsonNode.Parse($$"""
{
"voice": "{{Escape(device.Config?.Voice)}}",
"model": "{{Escape(device.Config?.Model)}}",
"system_prompt": "{{Escape(device.Config?.SystemPrompt)}}",
"idle_timeout_seconds": {{device.Config?.IdleTimeoutSeconds ?? 30}},
"enabled_tools": {{enabled}}
}
""")!.AsObject();
await active.WriteEnvelopeAsync(new JsonObject
{
["type"] = "hello_ack",
["config"] = config,
}, ct);
}
private static string Escape(string? s) =>
(s ?? "").Replace("\\", "\\\\").Replace("\"", "\\\"");
}