From 23b43f15b50de0e77d828ff4baed200781f5ecd2 Mon Sep 17 00:00:00 2001 From: acite <1498045907@qq.com> Date: Fri, 12 Sep 2025 22:51:27 +0800 Subject: [PATCH] [feat] Abyss protocol --- .idea/.idea.Abyss/.idea/dataSources.local.xml | 2 +- .idea/.idea.Abyss/.idea/workspace.xml | 52 +- Abyss.sln.DotSettings.user | 3 + Abyss/Components/Services/AbyssService.cs | 190 +++++++ Abyss/Components/Services/ConfigureService.cs | 1 + Abyss/Components/Tools/AbyssStream.cs | 524 ++++++++++++++++++ Abyss/Components/Tools/HttpHelper.cs | 208 +++++++ Abyss/Components/Tools/HttpReader.cs | 277 +++++++++ Abyss/Program.cs | 8 +- Abyss/Properties/launchSettings.json | 14 +- 10 files changed, 1232 insertions(+), 47 deletions(-) create mode 100644 Abyss/Components/Services/AbyssService.cs create mode 100644 Abyss/Components/Tools/AbyssStream.cs create mode 100644 Abyss/Components/Tools/HttpHelper.cs create mode 100644 Abyss/Components/Tools/HttpReader.cs diff --git a/.idea/.idea.Abyss/.idea/dataSources.local.xml b/.idea/.idea.Abyss/.idea/dataSources.local.xml index df6e771..10b29c6 100644 --- a/.idea/.idea.Abyss/.idea/dataSources.local.xml +++ b/.idea/.idea.Abyss/.idea/dataSources.local.xml @@ -1,6 +1,6 @@ - + " diff --git a/.idea/.idea.Abyss/.idea/workspace.xml b/.idea/.idea.Abyss/.idea/workspace.xml index 8d7c248..874fbeb 100644 --- a/.idea/.idea.Abyss/.idea/workspace.xml +++ b/.idea/.idea.Abyss/.idea/workspace.xml @@ -10,13 +10,16 @@ - + + + + + - - - - + + + + + @@ -40,12 +45,15 @@ + - + + + @@ -74,7 +82,7 @@ { "keyToString": { - ".NET Launch Settings Profile.Abyss: http.executor": "Run", + ".NET Launch Settings Profile.Abyss: http.executor": "Debug", ".NET Launch Settings Profile.Abyss: https.executor": "Debug", ".NET Project.AbyssCli.executor": "Run", "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true", @@ -96,7 +104,7 @@ "vue.rearranger.settings.migration": "true" } } - + @@ -152,25 +160,8 @@ - - - @@ -208,7 +199,14 @@ - + + + + + + + + @@ -228,7 +226,7 @@ - diff --git a/Abyss.sln.DotSettings.user b/Abyss.sln.DotSettings.user index 152a239..d7b6e6e 100644 --- a/Abyss.sln.DotSettings.user +++ b/Abyss.sln.DotSettings.user @@ -1,7 +1,10 @@  + ForceIncluded ForceIncluded + ForceIncluded ForceIncluded ForceIncluded ForceIncluded ForceIncluded + ForceIncluded ForceIncluded \ No newline at end of file diff --git a/Abyss/Components/Services/AbyssService.cs b/Abyss/Components/Services/AbyssService.cs new file mode 100644 index 0000000..98ae16b --- /dev/null +++ b/Abyss/Components/Services/AbyssService.cs @@ -0,0 +1,190 @@ +using System.Net; +using System.Net.Sockets; +using System.Text; +using Abyss.Components.Tools; + +namespace Abyss.Components.Services; + +public class AbyssService(ILogger logger, ConfigureService config) : IHostedService, IDisposable +{ + private Task? _executingTask; + private CancellationTokenSource? _cts; + private readonly TcpListener _listener = new TcpListener(IPAddress.Any, 4096); + public readonly int[] AllowedPorts = config.AllowedPorts.Split(' ').Select(int.Parse).ToArray(); + + public Task StartAsync(CancellationToken cancellationToken) + { + _cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _executingTask = ExecuteAsync(_cts.Token); + return _executingTask.IsCompleted ? _executingTask : Task.CompletedTask; + } + + private async Task UpStreamTunnelAsync(AbyssStream client, NetworkStream upstream, CancellationToken token) + { + var tunnelUp = Task.Run(async () => + { + byte[] buffer = new byte[4096]; + while (!token.IsCancellationRequested) + { + int bytesRead = await client.ReadAsync(buffer, 0, buffer.Length, token); + if (bytesRead == 0) + break; + await upstream.WriteAsync(buffer, 0, bytesRead, token); + } + }); + + var tunnelDown = Task.Run(async () => + { + byte[] buffer = new byte[4096]; + while (!token.IsCancellationRequested) + { + int bytesRead = await upstream.ReadAsync(buffer, 0, buffer.Length, token); + if (bytesRead == 0) + break; + await client.WriteAsync(buffer, 0, bytesRead, token); + } + }); + + await Task.WhenAny(tunnelUp, tunnelDown); + return; + } + + private async Task ClientHandlerAsync(TcpClient client, CancellationToken cancellationToken) + { + var stream = await client.GetAbyssStreamAsync(ct: cancellationToken); + + try + { + var request = HttpHelper.Parse(await HttpReader.ReadHttpMessageAsync(stream, cancellationToken)); + var port = 80; + var sp = request.RequestUri?.ToString().Split(':') ?? []; + if (sp.Length == 2) + { + port = int.Parse(sp[1]); + } + if (request.Method == "CONNECT") + { + TcpClient upClient = new TcpClient(); + await upClient.ConnectAsync("127.0.0.1", port, cancellationToken); + + if (!upClient.Connected) + { + var err1 = HttpHelper.BuildHttpResponse( + 504, + "Gateway Timeout", + new Dictionary + { + ["Proxy-Agent"] = "Abyss/0.1", + ["Content-Length"] = "0" + }); + await stream.WriteAsync(Encoding.UTF8.GetBytes(err1), cancellationToken); + throw new Exception("Gateway Timeout"); + } + + var upstream = upClient.GetStream(); + var response = HttpHelper.BuildHttpResponse( + 200, + "Connection established", + new Dictionary + { + ["Proxy-Agent"] = "Abyss/0.1", + ["Connection"] = "keep-alive" + }); + await stream.WriteAsync(Encoding.UTF8.GetBytes(response), cancellationToken); + // Connection established + + logger.LogInformation($"Tunnel for {client.Client.RemoteEndPoint} and upstream {upClient.Client.RemoteEndPoint} created"); + await UpStreamTunnelAsync(stream, upstream, cancellationToken); + logger.LogInformation($"Tunnel for {client.Client.RemoteEndPoint} and upstream {upClient.Client.RemoteEndPoint} will be release"); + + upstream.Close(); + upClient.Close(); + upClient.Dispose(); + } + else + { + string htmlContent = """ + + + 405 Method Not Allowed + + +

Method Not Allowed

+

The requested HTTP method is not supported by this proxy server.

+ + + """; + byte[] responseBytes = Encoding.UTF8.GetBytes(htmlContent); + + var response = HttpHelper.BuildHttpResponse( + 405, + "Method Not Allowed", + new Dictionary + { + ["Allow"] = "CONNECT", + ["Content-Type"] = "text/html; charset=utf-8", + ["Content-Length"] = responseBytes.Length.ToString() + }, htmlContent); + + await stream.WriteAsync(Encoding.UTF8.GetBytes(response), cancellationToken); + throw new Exception("Method Not Allowed"); + } + } + catch (Exception e) + { + logger.LogError(e.Message); + } + finally + { + stream.Close(); + client.Close(); + client.Dispose(); + } + } + + private async Task ExecuteAsync(CancellationToken stoppingToken) + { + _listener.Start(); + while (!stoppingToken.IsCancellationRequested) + { + try + { + var c = await _listener.AcceptTcpClientAsync(stoppingToken); + _ = Task.Run(() => ClientHandlerAsync(c, stoppingToken), stoppingToken); + } + catch (OperationCanceledException) + { + break; + } + catch (Exception ex) + { + logger.LogError(ex, "Error occurred in background service"); + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + } + + _listener.Stop(); + logger.LogInformation("TCP listener stopped"); + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + if (_executingTask == null) + return; + + try + { + _cts?.CancelAsync(); + } + finally + { + await Task.WhenAny(_executingTask, + Task.Delay(Timeout.Infinite, cancellationToken)); + } + } + + public void Dispose() + { + _cts?.Dispose(); + } +} \ No newline at end of file diff --git a/Abyss/Components/Services/ConfigureService.cs b/Abyss/Components/Services/ConfigureService.cs index 6738090..a14c0be 100644 --- a/Abyss/Components/Services/ConfigureService.cs +++ b/Abyss/Components/Services/ConfigureService.cs @@ -4,6 +4,7 @@ public class ConfigureService { public string MediaRoot { get; set; } = Environment.GetEnvironmentVariable("MEDIA_ROOT") ?? "/opt"; public string DebugMode { get; set; } = Environment.GetEnvironmentVariable("DEBUG_MODE") ?? "Production"; + public string AllowedPorts { get; set; } = Environment.GetEnvironmentVariable("ALLOWED_PORTS") ?? "443"; // Split with ' ' public string Version { get; } = "Alpha v0.1"; public string UserDatabase { get; set; } = "user.db"; public string RaDatabase { get; set; } = "ra.db"; diff --git a/Abyss/Components/Tools/AbyssStream.cs b/Abyss/Components/Tools/AbyssStream.cs new file mode 100644 index 0000000..ffa7c85 --- /dev/null +++ b/Abyss/Components/Tools/AbyssStream.cs @@ -0,0 +1,524 @@ +// Target: .NET 9 +// NuGet: NSec.Cryptography (for X25519) +// Note: ChaCha20Poly1305 is used from System.Security.Cryptography (available in .NET 7+ / .NET 9) + +using System.Buffers; +using System.Buffers.Binary; +using System.Collections.Concurrent; + +using System.Net.Sockets; +using System.Runtime.InteropServices; +using System.Security.Cryptography; + +using NSec.Cryptography; + +using ChaCha20Poly1305 = System.Security.Cryptography.ChaCha20Poly1305; + +namespace Abyss.Components.Tools +{ + public sealed class AbyssStream : NetworkStream, IDisposable + { + private const int PublicKeyLength = 32; + private const int AeadKeyLen = 32; + private const int NonceSaltLen = 4; + private const int AeadTagLen = 16; + private const int NonceLen = 12; // 4-byte salt + 8-byte counter + private const int MaxPlaintextFrame = 64 * 1024; // 64 KiB per frame + + private readonly ChaCha20Poly1305 _aead; + private readonly byte[] _sendNonceSalt = new byte[NonceSaltLen]; + private readonly byte[] _recvNonceSalt = new byte[NonceSaltLen]; + + // Counters and locks + private ulong _sendCounter; + private ulong _recvCounter; + private readonly object _sendLock = new(); + private readonly object _aeadLock = new(); + + // Inbound leftover cache (FIFO) + private readonly ConcurrentQueue _leftoverQueue = new(); + private byte[]? _currentLeftoverSegment; + private int _currentLeftoverOffset; + + private bool _disposed; + + private AbyssStream(Socket socket, bool ownsSocket, byte[] aeadKey, byte[] sendSalt, byte[] recvSalt) + : base(socket, ownsSocket) + { + if (aeadKey == null || aeadKey.Length != AeadKeyLen) throw new ArgumentException(nameof(aeadKey)); + if (sendSalt == null || sendSalt.Length != NonceSaltLen) throw new ArgumentException(nameof(sendSalt)); + if (recvSalt == null || recvSalt.Length != NonceSaltLen) throw new ArgumentException(nameof(recvSalt)); + + Array.Copy(sendSalt, 0, _sendNonceSalt, 0, NonceSaltLen); + Array.Copy(recvSalt, 0, _recvNonceSalt, 0, NonceSaltLen); + + // ChaCha20Poly1305 is in System.Security.Cryptography in .NET 9 + _aead = new ChaCha20Poly1305(aeadKey); + } + + /// + /// Create an AbyssStream over an established TcpClient. + /// Handshake: X25519 public exchange (raw) -> shared secret -> HKDF -> AEAD key + saltA + saltB + /// send/recv salts are assigned deterministically by lexicographic comparison of raw public keys. + /// + public static async Task CreateAsync(TcpClient client, byte[]? privateKeyRaw = null, CancellationToken cancellationToken = default) + { + if (client == null) throw new ArgumentNullException(nameof(client)); + var socket = client.Client ?? throw new ArgumentException("TcpClient has no underlying socket"); + + // 1) Prepare local X25519 key (NSec) + Key? localKey = null; + try + { + if (privateKeyRaw != null) + { + if (privateKeyRaw.Length != KeyAgreementAlgorithm.X25519.PrivateKeySize) + throw new ArgumentException($"privateKeyRaw must be {KeyAgreementAlgorithm.X25519.PrivateKeySize} bytes"); + localKey = Key.Import(KeyAgreementAlgorithm.X25519, privateKeyRaw, KeyBlobFormat.RawPrivateKey); + } + else + { + var creationParams = new KeyCreationParameters { ExportPolicy = KeyExportPolicies.AllowPlaintextExport }; + localKey = Key.Create(KeyAgreementAlgorithm.X25519, creationParams); + } + } + catch + { + localKey?.Dispose(); + throw; + } + + var localPublic = localKey.Export(KeyBlobFormat.RawPublicKey); + + // 2) Exchange public keys using raw socket APIs + var remotePublic = new byte[PublicKeyLength]; + + var sent = 0; + while (sent < PublicKeyLength) + { + var toSend = new ReadOnlyMemory(localPublic, sent, PublicKeyLength - sent); + sent += await socket.SendAsync(toSend, SocketFlags.None, cancellationToken).ConfigureAwait(false); + } + + await ReadExactFromSocketAsync(socket, remotePublic, 0, PublicKeyLength, cancellationToken).ConfigureAwait(false); + + // 3) Compute shared secret (X25519) + PublicKey remotePub; + try + { + remotePub = PublicKey.Import(KeyAgreementAlgorithm.X25519, remotePublic, KeyBlobFormat.RawPublicKey); + } + catch (Exception ex) + { + localKey.Dispose(); + throw new InvalidOperationException("Failed to import remote public key", ex); + } + + byte[] aeadKey; + byte[] saltA; + byte[] saltB; + + using (var shared = KeyAgreementAlgorithm.X25519.Agree(localKey, remotePub)) + { + if (shared == null) + throw new InvalidOperationException("Failed to agree remote public key"); + + // Derive AEAD key and two independent nonce salts directly from the SharedSecret, + // using HKDF-SHA256 within NSec (no raw shared-secret export). + aeadKey = KeyDerivationAlgorithm.HkdfSha256.DeriveBytes( + shared, + salt: null, + info: System.Text.Encoding.ASCII.GetBytes("Abyss-AEAD-Key"), + count: AeadKeyLen); + + saltA = KeyDerivationAlgorithm.HkdfSha256.DeriveBytes( + shared, + salt: null, + info: System.Text.Encoding.ASCII.GetBytes("Abyss-Nonce-Salt-A"), + count: NonceSaltLen); + + saltB = KeyDerivationAlgorithm.HkdfSha256.DeriveBytes( + shared, + salt: null, + info: System.Text.Encoding.ASCII.GetBytes("Abyss-Nonce-Salt-B"), + count: NonceSaltLen); + } + +// localKey no longer needed + localKey.Dispose(); + +// Deterministic assignment by lexicographic comparison of raw public keys + byte[] sendSalt, recvSalt; + int cmp = CompareByteArrayLexicographic(localPublic, remotePublic); + if (cmp < 0) + { + sendSalt = saltA; + recvSalt = saltB; + } + else if (cmp > 0) + { + sendSalt = saltB; + recvSalt = saltA; + } + else + { + // extremely unlikely: identical public keys; fallback + sendSalt = saltA; + recvSalt = saltB; + } + + Array.Clear(localPublic, 0, localPublic.Length); + Array.Clear(remotePublic, 0, remotePublic.Length); + + var abyss = new AbyssStream(socket, ownsSocket: true, aeadKey: aeadKey, sendSalt: sendSalt, recvSalt: recvSalt); + + Array.Clear(aeadKey, 0, aeadKey.Length); + Array.Clear(saltA, 0, saltA.Length); + Array.Clear(saltB, 0, saltB.Length); + + return abyss; + } + + public override int Read(byte[] buffer, int offset, int count) + { + return ReadAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + } + + public override async Task ReadAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + if (offset < 0 || count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(); + ThrowIfDisposed(); + + // Serve leftover first if any (immediately return any available bytes) + if (EnsureCurrentLeftoverSegment()) + { + var seg = _currentLeftoverSegment; + var avail = seg!.Length - _currentLeftoverOffset; + var toCopy = Math.Min(avail, count); + Array.Copy(seg, _currentLeftoverOffset, buffer, offset, toCopy); + _currentLeftoverOffset += toCopy; + if (_currentLeftoverOffset >= seg.Length) + { + _currentLeftoverSegment = null; + _currentLeftoverOffset = 0; + } + return toCopy; + } + + // No leftover -> read exactly one frame and decrypt + var plaintext = await ReadOneFrameAndDecryptAsync(cancellationToken).ConfigureAwait(false); + if (plaintext == null || plaintext.Length == 0) + { + // EOF + return 0; + } + + if (plaintext.Length <= count) + { + Array.Copy(plaintext, 0, buffer, offset, plaintext.Length); + return plaintext.Length; + } + else + { + Array.Copy(plaintext, 0, buffer, offset, count); + var leftoverLen = plaintext.Length - count; + var leftover = new byte[leftoverLen]; + Array.Copy(plaintext, count, leftover, 0, leftoverLen); + _leftoverQueue.Enqueue(leftover); + return count; + } + } + + private async Task ReadOneFrameAndDecryptAsync(CancellationToken cancellationToken) + { + var header = new byte[4]; + await ReadExactFromBaseAsync(header, 0, 4, cancellationToken).ConfigureAwait(false); + + var payloadLen = (int)BinaryPrimitives.ReadUInt32BigEndian(header); + if (payloadLen > 64 * 1024) throw new InvalidDataException("payload too big"); + if (payloadLen < AeadTagLen) throw new InvalidDataException("payload too small"); + + var payload = new byte[payloadLen]; + await ReadExactFromBaseAsync(payload, 0, payloadLen, cancellationToken).ConfigureAwait(false); + + var ciphertextLen = payloadLen - AeadTagLen; + var ciphertext = new byte[ciphertextLen]; + var tag = new byte[AeadTagLen]; + if (ciphertextLen > 0) Array.Copy(payload, 0, ciphertext, 0, ciphertextLen); + Array.Copy(payload, ciphertextLen, tag, 0, AeadTagLen); + + // compute remote nonce using recv counter (no role bit) + ulong remoteCounterValue = _recvCounter; + _recvCounter++; + + var nonce = new byte[NonceLen]; + Array.Copy(_recvNonceSalt, 0, nonce, 0, NonceSaltLen); + BinaryPrimitives.WriteUInt64BigEndian(nonce.AsSpan(NonceSaltLen), remoteCounterValue); + + var plaintext = new byte[ciphertextLen]; + try + { + lock (_aeadLock) + { + _aead.Decrypt(nonce, ciphertext, tag, plaintext); + } + } + catch (CryptographicException) + { + Dispose(); + throw new CryptographicException("AEAD authentication failed; connection closed."); + } + finally + { + Array.Clear(nonce, 0, nonce.Length); + Array.Clear(payload, 0, payload.Length); + Array.Clear(ciphertext, 0, ciphertext.Length); + Array.Clear(tag, 0, tag.Length); + } + + return plaintext; + } + + private async Task ReadExactFromBaseAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (count == 0) return; + var read = 0; + while (read < count) + { + var n = await base.ReadAsync(buffer, offset + read, count - read, cancellationToken).ConfigureAwait(false); + if (n == 0) + { + if (read == 0) + throw new EndOfStreamException("Remote closed connection while reading."); + throw new EndOfStreamException("Remote closed connection unexpectedly during read."); + } + read += n; + } + } + + private static async Task ReadExactFromSocketAsync(Socket socket, byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (count == 0) return; + var received = 0; + while (received < count) + { + var mem = new Memory(buffer, offset + received, count - received); + var r = await socket.ReceiveAsync(mem, SocketFlags.None, cancellationToken).ConfigureAwait(false); + if (r == 0) + { + if (received == 0) + throw new EndOfStreamException("Remote closed connection while reading from socket."); + throw new EndOfStreamException("Remote closed connection unexpectedly during socket read."); + } + received += r; + } + } + + private static int CompareByteArrayLexicographic(byte[] a, byte[] b) + { + if (a == null || b == null) throw new ArgumentNullException(); + var min = Math.Min(a.Length, b.Length); + for (int i = 0; i < min; i++) + { + if (a[i] < b[i]) return -1; + if (a[i] > b[i]) return 1; + } + if (a.Length < b.Length) return -1; + if (a.Length > b.Length) return 1; + return 0; + } + + private bool EnsureCurrentLeftoverSegment() + { + if (_currentLeftoverSegment != null && _currentLeftoverOffset < _currentLeftoverSegment.Length) return true; + if (_leftoverQueue.TryDequeue(out var next)) + { + _currentLeftoverSegment = next; + _currentLeftoverOffset = 0; + return true; + } + return false; + } + + public override void Write(byte[] buffer, int offset, int count) + { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + if (offset < 0 || count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(); + ThrowIfDisposed(); + WriteAsync(buffer, offset, count, CancellationToken.None).GetAwaiter().GetResult(); + } + + public override async Task WriteAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) + { + if (buffer == null) throw new ArgumentNullException(nameof(buffer)); + if (offset < 0 || count < 0 || offset + count > buffer.Length) throw new ArgumentOutOfRangeException(); + ThrowIfDisposed(); + + int remaining = count; + int idx = offset; + + while (remaining > 0) + { + var chunk = Math.Min(remaining, MaxPlaintextFrame); + var mem = new ReadOnlyMemory(buffer, idx, chunk); + await SendPlaintextChunkAsync(mem, cancellationToken).ConfigureAwait(false); + idx += chunk; + remaining -= chunk; + } + } + + public override Task FlushAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + private async Task SendPlaintextChunkAsync(ReadOnlyMemory plaintext, CancellationToken cancellationToken) + { + ThrowIfDisposed(); + + var ciphertext = new byte[plaintext.Length]; + var tag = new byte[AeadTagLen]; + var nonce = new byte[NonceLen]; + ulong counterValue; + + lock (_sendLock) + { + counterValue = _sendCounter; + _sendCounter++; + } + + Array.Copy(_sendNonceSalt, 0, nonce, 0, NonceSaltLen); + BinaryPrimitives.WriteUInt64BigEndian(nonce.AsSpan(NonceSaltLen), counterValue); + + lock (_aeadLock) + { + _aead.Encrypt(nonce, plaintext.Span, ciphertext, tag); + } + + var payloadLen = unchecked((uint)(ciphertext.Length + tag.Length)); + var header = new byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(header, payloadLen); + + await base.WriteAsync(header, 0, header.Length, cancellationToken).ConfigureAwait(false); + if (ciphertext.Length > 0) + await base.WriteAsync(ciphertext, 0, ciphertext.Length, cancellationToken).ConfigureAwait(false); + await base.WriteAsync(tag, 0, tag.Length, cancellationToken).ConfigureAwait(false); + await base.FlushAsync(cancellationToken).ConfigureAwait(false); + + Array.Clear(nonce, 0, nonce.Length); + Array.Clear(tag, 0, tag.Length); + Array.Clear(ciphertext, 0, ciphertext.Length); + } + + protected override void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + lock (_aeadLock) + { + _aead.Dispose(); + } + + while (_leftoverQueue.TryDequeue(out var seg)) Array.Clear(seg, 0, seg.Length); + } + _disposed = true; + } + base.Dispose(disposing); + } + + void IDisposable.Dispose() => Dispose(); + + private void ThrowIfDisposed() + { + if (_disposed) throw new ObjectDisposedException(nameof(AbyssStream)); + } + + public override void Write(ReadOnlySpan buffer) + { + var tmp = ArrayPool.Shared.Rent(buffer.Length); + try + { + buffer.CopyTo(tmp); + Write(tmp, 0, buffer.Length); + } + finally + { + ArrayPool.Shared.Return(tmp, clearArray: true); + } + } + + public override ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (MemoryMarshal.TryGetArray(buffer, out ArraySegment seg)) + { + return new ValueTask(WriteAsync(seg.Array!, seg.Offset, seg.Count, cancellationToken)); + } + else + { + return SlowWriteAsync(buffer, cancellationToken); + } + + async ValueTask SlowWriteAsync(ReadOnlyMemory buf, CancellationToken ct) + { + var tmp = ArrayPool.Shared.Rent(buf.Length); + try + { + buf.Span.CopyTo(tmp); + await WriteAsync(tmp, 0, buf.Length, ct).ConfigureAwait(false); + } + finally + { + ArrayPool.Shared.Return(tmp, clearArray: true); + } + } + } + + public override int Read(Span buffer) + { + var tmp = ArrayPool.Shared.Rent(buffer.Length); + try + { + int n = Read(tmp, 0, buffer.Length); + new ReadOnlySpan(tmp, 0, n).CopyTo(buffer); + return n; + } + finally + { + ArrayPool.Shared.Return(tmp, clearArray: true); + } + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + if (MemoryMarshal.TryGetArray(buffer, out ArraySegment seg)) + { + return new ValueTask(ReadAsync(seg.Array!, seg.Offset, seg.Count, cancellationToken)); + } + else + { + return SlowReadAsync(buffer, cancellationToken); + } + + async ValueTask SlowReadAsync(Memory buf, CancellationToken ct) + { + var tmp = ArrayPool.Shared.Rent(buf.Length); + try + { + int n = await ReadAsync(tmp, 0, buf.Length, ct).ConfigureAwait(false); + new ReadOnlySpan(tmp, 0, n).CopyTo(buf.Span); + return n; + } + finally + { + ArrayPool.Shared.Return(tmp, clearArray: true); + } + } + } + } + + public static class TcpClientAbyssExtensions + { + public static Task GetAbyssStreamAsync(this TcpClient client, byte[]? privateKeyRaw = null, CancellationToken ct = default) + => AbyssStream.CreateAsync(client, privateKeyRaw, ct); + } +} diff --git a/Abyss/Components/Tools/HttpHelper.cs b/Abyss/Components/Tools/HttpHelper.cs new file mode 100644 index 0000000..9f85279 --- /dev/null +++ b/Abyss/Components/Tools/HttpHelper.cs @@ -0,0 +1,208 @@ + +using System.Text; + +namespace Abyss.Components.Tools; + +public class HttpHelper +{ + private const int MaxHeaderCount = 100; + private const int MaxHeaderLineLength = 8192; + private const int MaxBodySize = 10 * 1024 * 1024; // 10 MB + + public static string BuildHttpResponse( + int statusCode, + string statusDescription, + Dictionary? headers = null, + string? body = null, + string httpVersion = "HTTP/1.1") + { + var responseBuilder = new StringBuilder(); + + // Sanitize status description (prevent CRLF injection) + statusDescription = SanitizeHeaderValue(statusDescription); + + responseBuilder.Append($"{httpVersion} {statusCode} {statusDescription}\r\n"); + + headers ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + + // Ensure correct Content-Length + if (!string.IsNullOrEmpty(body)) + { + int contentLength = Encoding.UTF8.GetByteCount(body); + headers["Content-Length"] = contentLength.ToString(); + if (!headers.ContainsKey("Content-Type")) + { + headers["Content-Type"] = "text/plain; charset=utf-8"; + } + } + + foreach (var header in headers) + { + string name = SanitizeHeaderName(header.Key); + string value = SanitizeHeaderValue(header.Value); + responseBuilder.AppendLine($"{name}: {value}"); + } + + responseBuilder.AppendLine(); + + if (!string.IsNullOrEmpty(body)) + { + responseBuilder.Append(body); + } + + return responseBuilder.ToString(); + } + + public static HttpRequest Parse(string requestText) + { + if (string.IsNullOrEmpty(requestText)) + throw new ArgumentException("Request text cannot be empty"); + + using var reader = new StringReader(requestText); + var request = new HttpRequest(); + + string requestLine = reader.ReadLine() ?? ""; + if (string.IsNullOrWhiteSpace(requestLine)) + throw new FormatException("Invalid HTTP request: missing request line"); + + ParseRequestLine(requestLine, request); + ParseHeaders(reader, request); + ParseBody(reader, request); + + return request; + } + + private static void ParseRequestLine(string requestLine, HttpRequest request) + { + var parts = requestLine.Split(' ', 3); + if (parts.Length < 3) + throw new FormatException("Invalid request line format"); + + request.Method = parts[0].Trim(); + + if (!Uri.TryCreate(parts[1], UriKind.RelativeOrAbsolute, out var uri)) + { + throw new FormatException("Invalid or unsupported URI"); + } + request.RequestUri = uri; + + request.HttpVersion = parts[2].Trim(); + } + + private static void ParseHeaders(StringReader reader, HttpRequest request) + { + string? line; + int headerCount = 0; + + while (!string.IsNullOrEmpty(line = reader.ReadLine())) + { + if (++headerCount > MaxHeaderCount) + throw new InvalidOperationException("Too many headers"); + + if (line.Length > MaxHeaderLineLength) + throw new InvalidOperationException("Header line too long"); + + int colonIndex = line.IndexOf(':'); + if (colonIndex <= 0) + throw new FormatException($"Invalid header format: {line}"); + + string headerName = SanitizeHeaderName(line.Substring(0, colonIndex).Trim()); + string headerValue = SanitizeHeaderValue(line.Substring(colonIndex + 1).Trim()); + + if (request.Headers.ContainsKey(headerName)) + throw new InvalidOperationException($"Duplicate header not allowed: {headerName}"); + + request.Headers[headerName] = headerValue; + } + } + + private static void ParseBody(StringReader reader, HttpRequest request) + { + if (request.Headers.TryGetValue("Content-Length", out var contentLengthStr) && + long.TryParse(contentLengthStr, out var contentLength) && + contentLength > 0) + { + if (contentLength > MaxBodySize) + throw new InvalidOperationException("Request body too large"); + + var buffer = new char[contentLength]; + int read = reader.ReadBlock(buffer, 0, (int)contentLength); + request.Body = new string(buffer, 0, read); + } + else if (request.Headers.TryGetValue("Transfer-Encoding", out var encoding) && + encoding.Equals("chunked", StringComparison.OrdinalIgnoreCase)) + { + throw new NotSupportedException("Chunked transfer encoding is not supported"); + } + } + + private static string SanitizeHeaderName(string name) + { + if (name.Contains("\r") || name.Contains("\n")) + throw new FormatException("Invalid header name"); + return name; + } + + private static string SanitizeHeaderValue(string value) + { + return value.Replace("\r", "").Replace("\n", ""); + } +} + +public class HttpRequest +{ + public string Method { get; set; } = ""; + public Uri? RequestUri { get; set; } + public string HttpVersion { get; set; } = ""; + public Dictionary Headers { get; set; } + public string Body { get; set; } = ""; + + public HttpRequest() + { + Headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Get header value by name (case-insensitive) + /// + public string? GetHeader(string headerName) + { + return Headers.TryGetValue(headerName, out var value) ? value : null; + } + + /// + /// Check if header exists (case-insensitive) + /// + public bool HasHeader(string headerName) + { + return Headers.ContainsKey(headerName); + } + + /// + /// Convert back to HTTP request string + /// + public override string ToString() + { + var builder = new StringBuilder(); + + // Request line + builder.AppendLine($"{Method} {RequestUri} {HttpVersion}"); + + // Headers + foreach (var header in Headers) + { + builder.AppendLine($"{header.Key}: {header.Value}"); + } + + // Empty line + builder.AppendLine(); + + // Body + if (!string.IsNullOrEmpty(Body)) + { + builder.Append(Body); + } + + return builder.ToString(); + } +} \ No newline at end of file diff --git a/Abyss/Components/Tools/HttpReader.cs b/Abyss/Components/Tools/HttpReader.cs new file mode 100644 index 0000000..33bd153 --- /dev/null +++ b/Abyss/Components/Tools/HttpReader.cs @@ -0,0 +1,277 @@ +using System; +using System.IO; +using System.Net.Sockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using System.Globalization; + +namespace Abyss.Components.Tools +{ + public static class HttpReader + { + private const int DefaultBufferSize = 8192; + private const int MaxHeaderBytes = 64 * 1024; // 64 KB header max + private const long MaxBodyBytes = 10L * 1024 * 1024; // 10 MB body max + private const int MaxLineLength = 8 * 1024; // 8 KB per line max + + /// + /// Read a full HTTP message (headers + body) from a NetworkStream and return as a string. + /// This method enforces size limits and parses chunked encoding correctly. + /// + public static async Task ReadHttpMessageAsync(AbyssStream stream, CancellationToken cancellationToken) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + if (!stream.CanRead) throw new ArgumentException("Stream is not readable", nameof(stream)); + + // 1) Read header bytes until CRLFCRLF or header size limit is exceeded + var headerBuffer = new MemoryStream(); + var tmp = new byte[DefaultBufferSize]; + int headerEndIndex = -1; + while (true) + { + int n = await stream.ReadAsync(tmp.AsMemory(0, tmp.Length), cancellationToken).ConfigureAwait(false); + if (n == 0) + throw new IOException("Stream closed before HTTP header was fully read."); + + headerBuffer.Write(tmp, 0, n); + + if (headerBuffer.Length > MaxHeaderBytes) + throw new InvalidOperationException("HTTP header exceeds maximum allowed size."); + + // search for CRLFCRLF in the accumulated bytes + var bytes = headerBuffer.ToArray(); + headerEndIndex = IndexOfDoubleCrlf(bytes); + if (headerEndIndex >= 0) + { + // headerEndIndex is the index of the first '\r' of "\r\n\r\n" + // stop reading further here; remaining bytes (if any) are part of body initial chunk + break; + } + + // continue reading + } + + var allHeaderBytes = headerBuffer.ToArray(); + int bodyStartIndex = headerEndIndex + 4; + string headerPart = Encoding.ASCII.GetString(allHeaderBytes, 0, headerEndIndex + 4); + + // 2) parse headers to find Content-Length / Transfer-Encoding + int contentLength = 0; + bool isChunked = false; + + foreach (var line in headerPart.Split(new[] { "\r\n" }, StringSplitOptions.RemoveEmptyEntries)) + { + if (line.StartsWith("Content-Length:", StringComparison.OrdinalIgnoreCase)) + { + var raw = line.Substring("Content-Length:".Length).Trim(); + if (int.TryParse(raw, NumberStyles.None, CultureInfo.InvariantCulture, out int len)) + { + if (len < 0) throw new FormatException("Negative Content-Length not allowed."); + contentLength = len; + } + else + { + throw new FormatException("Invalid Content-Length value."); + } + } + else if (line.StartsWith("Transfer-Encoding:", StringComparison.OrdinalIgnoreCase)) + { + if (line.IndexOf("chunked", StringComparison.OrdinalIgnoreCase) >= 0) + { + isChunked = true; + } + } + } + + // 3) Create a buffered reader that first consumes bytes already read after header + var initialTail = new ArraySegment(allHeaderBytes, bodyStartIndex, allHeaderBytes.Length - bodyStartIndex); + var reader = new BufferedNetworkReader(stream, initialTail, DefaultBufferSize, cancellationToken); + + // 4) Read body according to encoding + byte[] bodyBytes; + if (isChunked) + { + using var bodyMs = new MemoryStream(); + while (true) + { + string sizeLine = await reader.ReadLineAsync(MaxLineLength).ConfigureAwait(false); + if (string.IsNullOrWhiteSpace(sizeLine)) + { + // skip empty lines (robustness) + continue; + } + + // chunk-size [; extensions] + var semi = sizeLine.IndexOf(';'); + var sizeToken = semi >= 0 ? sizeLine.Substring(0, semi) : sizeLine; + if (!long.TryParse(sizeToken.Trim(), NumberStyles.HexNumber, CultureInfo.InvariantCulture, out long chunkSize)) + throw new IOException("Invalid chunk size in chunked encoding."); + + if (chunkSize < 0) throw new IOException("Negative chunk size."); + + if (chunkSize == 0) + { + // read and discard any trailer headers until an empty line + while (true) + { + var trailerLine = await reader.ReadLineAsync(MaxLineLength).ConfigureAwait(false); + if (string.IsNullOrEmpty(trailerLine)) break; + } + break; + } + + if (chunkSize > MaxBodyBytes || (bodyMs.Length + chunkSize) > MaxBodyBytes) + throw new InvalidOperationException("Chunked body exceeds maximum allowed size."); + + await reader.ReadExactAsync(bodyMs, chunkSize).ConfigureAwait(false); + + // after chunk data there must be CRLF; consume it + var crlf = await reader.ReadLineAsync(MaxLineLength).ConfigureAwait(false); + if (crlf != string.Empty) + throw new IOException("Missing CRLF after chunk data."); + } + + bodyBytes = bodyMs.ToArray(); + } + else if (contentLength > 0) + { + if (contentLength > MaxBodyBytes) + throw new InvalidOperationException("Content-Length exceeds maximum allowed size."); + + using var bodyMs = new MemoryStream(); + long remaining = contentLength; + // If there were initial tail bytes, BufferedNetworkReader will supply them first + await reader.ReadExactAsync(bodyMs, remaining).ConfigureAwait(false); + bodyBytes = bodyMs.ToArray(); + } + else + { + // no body + bodyBytes = Array.Empty(); + } + + // 5) combine headerPart and body decoded as UTF-8 string + string bodyPart = Encoding.UTF8.GetString(bodyBytes); + return headerPart + bodyPart; + } + + private static int IndexOfDoubleCrlf(byte[] data) + { + // find sequence \r\n\r\n + for (int i = 0; i + 3 < data.Length; i++) + { + if (data[i] == 13 && data[i + 1] == 10 && data[i + 2] == 13 && data[i + 3] == 10) + return i; + } + return -1; + } + + /// + /// BufferedNetworkReader merges an initial buffer (already-read bytes) with later reads from NetworkStream. + /// It provides ReadLineAsync and ReadExactAsync semantics used by HTTP parsing. + /// + private sealed class BufferedNetworkReader + { + private readonly AbyssStream _stream; + private readonly CancellationToken _cancellation; + private readonly int _bufferSize; + private byte[] _buffer; + private int _offset; + private int _count; // valid data range [_offset, _offset + _count) + + public BufferedNetworkReader(AbyssStream stream, ArraySegment initial, int bufferSize, CancellationToken cancellation) + { + _stream = stream ?? throw new ArgumentNullException(nameof(stream)); + _cancellation = cancellation; + _bufferSize = Math.Max(512, bufferSize); + // initialize buffer and copy initial tail bytes + _buffer = new byte[Math.Max(_bufferSize, initial.Count)]; + Array.Copy(initial.Array ?? Array.Empty(), initial.Offset, _buffer, 0, initial.Count); + _offset = 0; + _count = initial.Count; + } + + /// + /// Read a line terminated by CRLF. Returns the line without CRLF. + /// Throws if the line length exceeds maxLineLength. + /// + public async Task ReadLineAsync(int maxLineLength) + { + var ms = new MemoryStream(); + int seen = 0; + while (true) + { + if (_count == 0) + { + // refill buffer + int n = await _stream.ReadAsync(new Memory(_buffer, 0, _buffer.Length), _cancellation).ConfigureAwait(false); + if (n == 0) + throw new IOException("Unexpected end of stream while reading line."); + _offset = 0; + _count = n; + } + + // scan for '\n' + int i; + for (i = 0; i < _count; i++) + { + byte b = _buffer[_offset + i]; + seen++; + if (seen > maxLineLength) throw new InvalidOperationException("Line length exceeds maximum allowed."); + if (b == (byte)'\n') + { + // write bytes up to this position + ms.Write(_buffer, _offset, i + 1); + _offset += i + 1; + _count -= i + 1; + // convert to string and remove CRLF if present + var lineBytes = ms.ToArray(); + if (lineBytes.Length >= 2 && lineBytes[lineBytes.Length - 2] == (byte)'\r') + return Encoding.ASCII.GetString(lineBytes, 0, lineBytes.Length - 2); + else if (lineBytes.Length >= 1 && lineBytes[lineBytes.Length - 1] == (byte)'\n') + return Encoding.ASCII.GetString(lineBytes, 0, lineBytes.Length - 1); + else + return Encoding.ASCII.GetString(lineBytes); + } + } + + // no newline found in buffer; write all and continue + ms.Write(_buffer, _offset, _count); + _offset = 0; + _count = 0; + } + } + + /// + /// Read exactly 'length' bytes and write them to destination stream. + /// Throws if stream ends before length bytes are read or size exceeds limits. + /// + public async Task ReadExactAsync(Stream destination, long length) + { + if (length < 0) throw new ArgumentOutOfRangeException(nameof(length)); + long remaining = length; + var tmp = new byte[_bufferSize]; + + // first consume from internal buffer + if (_count > 0) + { + int take = (int)Math.Min(_count, remaining); + destination.Write(_buffer, _offset, take); + _offset += take; + _count -= take; + remaining -= take; + } + + while (remaining > 0) + { + int toRead = (int)Math.Min(tmp.Length, remaining); + int n = await _stream.ReadAsync(tmp.AsMemory(0, toRead), _cancellation).ConfigureAwait(false); + if (n == 0) throw new IOException("Unexpected end of stream while reading body."); + destination.Write(tmp, 0, n); + remaining -= n; + } + } + } + } +} diff --git a/Abyss/Program.cs b/Abyss/Program.cs index b2c2d19..ad36158 100644 --- a/Abyss/Program.cs +++ b/Abyss/Program.cs @@ -13,13 +13,13 @@ public class Program builder.Services.AddAuthorization(); builder.Services.AddMemoryCache(); - builder.Services.AddOpenApi(); builder.Services.AddControllers(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); + builder.Services.AddHostedService(); builder.Services.AddRateLimiter(options => { @@ -42,14 +42,8 @@ public class Program var app = builder.Build(); - if (app.Environment.IsDevelopment()) - { - app.MapOpenApi(); - } - // app.UseHttpsRedirection(); app.UseAuthorization(); - app.MapStaticAssets(); app.MapControllers(); app.UseRateLimiter(); diff --git a/Abyss/Properties/launchSettings.json b/Abyss/Properties/launchSettings.json index 7dd8538..49991ba 100644 --- a/Abyss/Properties/launchSettings.json +++ b/Abyss/Properties/launchSettings.json @@ -5,21 +5,11 @@ "commandName": "Project", "dotnetRunMessages": true, "launchBrowser": false, - "applicationUrl": "http://192.168.1.244:5198", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "MEDIA_ROOT" : "/storage" - } - }, - "https": { - "commandName": "Project", - "dotnetRunMessages": true, - "launchBrowser": false, - "applicationUrl": "https://localhost:7013;http://localhost:5198", + "applicationUrl": "http://localhost:3000", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "MEDIA_ROOT" : "/storage", - "DEBUG_MODE" : "Debug" + "ALLOWED_PORTS" : "3000" } } }