using System.Net;
using System.Net.Sockets;
using System.Net.WebSockets;
using System.Security.Cryptography;
using System.Text;
namespace DodoSSH.Client.Session.Tests;
///
/// Spelunk's two tunnel endpoints, as far as the wire is concerned: HTTP/1.1 over a real loopback socket.
///
///
///
/// Real sockets rather than a stubbed handler, because what is being tested is the client's own HTTP and
/// WebSocket stack talking to something: the header the key goes in, the subprotocols the upgrade offers,
/// a refused upgrade's status read back through CollectHttpResponseDetails, and a close code
/// arriving through . A handler stub would skip exactly those.
///
///
/// Hand-rolled rather than Kestrel, so this client suite takes no dependency on the ASP.NET Core framework
/// for one fixture. It speaks only what the client sends: one request per connection, answered with
/// Connection: close, and the one upgrade.
///
///
internal sealed class FakeSpelunk : IAsyncDisposable
{
internal const string Ticket = "dGlja2V0LWZvci1hLXR1bm5lbA";
private readonly TcpListener listener = new(IPAddress.Loopback, 0);
private readonly CancellationTokenSource stopping = new();
private readonly Task accepting;
internal FakeSpelunk()
{
listener.Start();
accepting = AcceptAsync();
}
/// The API base a client is given.
internal string ApiBase => $"http://127.0.0.1:{((IPEndPoint)listener.LocalEndpoint).Port}";
/// Every request that arrived, in order.
internal List Requests { get; } = [];
/// How the ticket endpoint answers. By default, with a ticket.
internal Func OnTicket { get; set; } = request => Answer.Json(
HttpStatusCode.OK,
$$"""
{"ticket":"{{Ticket}}","expiresAt":"2026-09-28T12:00:30Z","path":"{{request.Path.Replace("tunnel-tickets", "tunnel", StringComparison.Ordinal)}}","subprotocol":"spelunk.tunnel.v1"}
""");
/// How the upgrade is answered: null accepts it, anything else refuses it with that status.
internal Func OnUpgrade { get; set; } = _ => null;
/// What Spelunk does with an accepted tunnel — by default nothing, until the client closes.
internal Func OnTunnel { get; set; } = DrainAsync;
public async ValueTask DisposeAsync()
{
await stopping.CancelAsync();
listener.Stop();
try
{
await accepting;
}
catch (Exception exception) when (exception is OperationCanceledException or SocketException or ObjectDisposedException)
{
// Stopped.
}
stopping.Dispose();
}
/// Reads until the client closes, and answers the close.
internal static async Task DrainAsync(WebSocket socket)
{
var buffer = new byte[4096];
while ((await socket.ReceiveAsync(buffer, CancellationToken.None)).MessageType != WebSocketMessageType.Close)
{
}
await socket.CloseOutputAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
}
private async Task AcceptAsync()
{
while (!stopping.IsCancellationRequested)
{
var client = await listener.AcceptTcpClientAsync(stopping.Token);
_ = ServeAsync(client);
}
}
private async Task ServeAsync(TcpClient client)
{
using (client)
{
var stream = client.GetStream();
var request = await ReadRequestAsync(stream);
lock (Requests)
{
Requests.Add(request);
}
if (request.Headers.TryGetValue("Upgrade", out var upgrade)
&& upgrade.Equals("websocket", StringComparison.OrdinalIgnoreCase))
{
await UpgradeAsync(stream, request);
return;
}
var answer = OnTicket(request);
var body = Encoding.UTF8.GetBytes(answer.Body);
await WriteAsync(
stream,
$"HTTP/1.1 {(int)answer.Status} {answer.Status}\r\nContent-Type: {answer.ContentType}\r\n"
+ $"Content-Length: {body.Length}\r\nConnection: close\r\n\r\n");
await stream.WriteAsync(body);
}
}
private async Task UpgradeAsync(NetworkStream stream, Request request)
{
if (OnUpgrade(request) is { } refusal)
{
await WriteAsync(
stream,
$"HTTP/1.1 {(int)refusal} {refusal}\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
return;
}
await WriteAsync(
stream,
"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n"
+ $"Sec-WebSocket-Accept: {AcceptFor(request.Headers["Sec-WebSocket-Key"])}\r\n"
+ "Sec-WebSocket-Protocol: spelunk.tunnel.v1\r\n\r\n");
using var socket = WebSocket.CreateFromStream(
stream,
new WebSocketCreationOptions
{
IsServer = true,
SubProtocol = "spelunk.tunnel.v1",
KeepAliveInterval = TimeSpan.Zero,
});
try
{
await OnTunnel(socket);
}
catch (Exception exception) when (exception is WebSocketException or IOException)
{
// The client went away; nothing here is under test.
}
}
/// The handshake's accept value, as RFC 6455 section 4.2.2 defines it.
private static string AcceptFor(string key)
{
#pragma warning disable RS0030, CA5350 // RFC 6455 fixes SHA-1 for this value. It authenticates nothing; it proves the peer read the key.
return Convert.ToBase64String(SHA1.HashData(Encoding.ASCII.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")));
#pragma warning restore RS0030, CA5350
}
private static async Task ReadRequestAsync(NetworkStream stream)
{
var head = new List();
var one = new byte[1];
while (!(head.Count >= 4 && head[^4] == '\r' && head[^3] == '\n' && head[^2] == '\r' && head[^1] == '\n'))
{
if (await stream.ReadAsync(one) == 0)
{
break;
}
head.Add(one[0]);
}
var lines = Encoding.ASCII.GetString([.. head]).Split("\r\n", StringSplitOptions.RemoveEmptyEntries);
var requestLine = lines[0].Split(' ');
var headers = new Dictionary(StringComparer.OrdinalIgnoreCase);
foreach (var line in lines.Skip(1))
{
var colon = line.IndexOf(':', StringComparison.Ordinal);
headers[line[..colon].Trim()] = line[(colon + 1)..].Trim();
}
var body = string.Empty;
if (headers.TryGetValue("Content-Length", out var length) && int.Parse(length, System.Globalization.CultureInfo.InvariantCulture) is > 0 and var count)
{
var bytes = new byte[count];
await stream.ReadExactlyAsync(bytes);
body = Encoding.UTF8.GetString(bytes);
}
return new Request(requestLine[0], requestLine[1], headers, body);
}
private static Task WriteAsync(NetworkStream stream, string text) =>
stream.WriteAsync(Encoding.ASCII.GetBytes(text)).AsTask();
/// One request as it arrived.
internal sealed record Request(string Method, string Path, Dictionary Headers, string Body);
/// What the ticket endpoint says back.
internal sealed record Answer(HttpStatusCode Status, string ContentType, string Body)
{
internal static Answer Json(HttpStatusCode status, string body) => new(status, "application/json", body);
internal static Answer Problem(HttpStatusCode status, string title, string? detail = null) => new(
status,
"application/problem+json",
detail is null
? $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)status}}}"""
: $$"""{"type":"about:blank","title":"{{title}}","status":{{(int)status}},"detail":"{{detail}}"}""");
}
}