From 5a4b3a4b4eb9bf7f1df4f730fa6b466b2f990c88 Mon Sep 17 00:00:00 2001 From: Adam Pippin Date: Thu, 30 Dec 2021 21:04:11 -0800 Subject: [PATCH] Gemini library --- .../Exception/ProtocolViolationException.cs | 16 ++ pyxis.gemini/Protocol/Request.cs | 86 +++++++ pyxis.gemini/Protocol/Response.cs | 200 ++++++++++++++++ pyxis.gemini/Protocol/Server.cs | 68 ++++++ pyxis.gemini/Server/Server.cs | 214 ++++++++++++++++++ pyxis.gemini/pyxis.gemini.csproj | 7 + 6 files changed, 591 insertions(+) create mode 100644 pyxis.gemini/Protocol/Exception/ProtocolViolationException.cs create mode 100644 pyxis.gemini/Protocol/Request.cs create mode 100644 pyxis.gemini/Protocol/Response.cs create mode 100644 pyxis.gemini/Protocol/Server.cs create mode 100644 pyxis.gemini/Server/Server.cs create mode 100644 pyxis.gemini/pyxis.gemini.csproj diff --git a/pyxis.gemini/Protocol/Exception/ProtocolViolationException.cs b/pyxis.gemini/Protocol/Exception/ProtocolViolationException.cs new file mode 100644 index 0000000..b1ed517 --- /dev/null +++ b/pyxis.gemini/Protocol/Exception/ProtocolViolationException.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace pyxis.gemini.Protocol.Exception +{ + public class ProtocolViolationException : System.Exception + { + public ProtocolViolationException(string message) + : base(message) + { + } + } +} diff --git a/pyxis.gemini/Protocol/Request.cs b/pyxis.gemini/Protocol/Request.cs new file mode 100644 index 0000000..27f53d2 --- /dev/null +++ b/pyxis.gemini/Protocol/Request.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace pyxis.gemini.Protocol +{ + public class Request + { + // 1024 meta + // 2 status code + // 1 separator space + // 2 \r\n + private const int MAX_REQUEST_LENGTH = 1024 + 2 + 1 + 2; + + public string Url { private set; get; } + // TODO: Provide properties for fetching specific parts of the URL + + public void setUrl(string url) + { + this.Url = url; + } + + public static int Parse(byte[] data, int length, out Request request) + { + int i; + bool found_cr = false, found_lf = false; + request = null; + + // Minimum request length would be 3 -- one byte url and \r\n. If it's less than that, just + // bail now. This guards against any potential bug later when we start trying to trim the + // crlf, etc. + if (length < 3) + { + return 0; + } + + for (i = 0; i < length; i++) + { + if (i >= MAX_REQUEST_LENGTH) + { + throw new Exception.ProtocolViolationException(String.Format("Request length exceeded maximum: %d", MAX_REQUEST_LENGTH)); + } + + if (data[i] == '\r') + { + found_cr = true; + } + else if (found_cr && data[i] == '\n') + { + found_lf = true; + break; + } + else + { + found_cr = false; + found_lf = false; + } + } + + if (!found_cr || !found_lf) + { + return 0; + } + + request = new Request(); + request.setUrl(Encoding.UTF8.GetString(data, 0, i - 2 /* trim \r\n */)); + return i + 1; + } + + public byte[] Serialize() + { + System.IO.MemoryStream ms = new System.IO.MemoryStream(); + System.IO.StreamWriter sw = new System.IO.StreamWriter(ms); + + // TODO: Validate URL + + sw.Write(this.Url); + sw.Write("\r\n"); + sw.Close(); + + return ms.ToArray(); + } + } +} diff --git a/pyxis.gemini/Protocol/Response.cs b/pyxis.gemini/Protocol/Response.cs new file mode 100644 index 0000000..fa91268 --- /dev/null +++ b/pyxis.gemini/Protocol/Response.cs @@ -0,0 +1,200 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace pyxis.gemini.Protocol +{ + public class Response + { + private const int MAX_HEADER_LENGTH = 1029; + + public int Status { private set; get; } + public string Meta { private set; get; } + public byte[] Body { private set; get; } + + public void setStatus(int status) + { + this.Status = status; + } + + public void setMeta(string meta) + { + this.Meta = meta; + } + + public void setBody(byte[] body) + { + this.Body = body; + } + + public static int Parse(byte[] data, int length, out Response response) + { + int i; + bool found_cr = false, found_lf = false; + response = null; + + // Minimum response length would be 6 bytes (status=2, space=1, meta=1, crlf=2) + // Anything less just bail early. + if (length < 6) + { + return 0; + } + + for (i = 0; i < length; i++) + { + // If we haven't found the header terminating crlf by this point, then it can't be a valid response + if (i >= MAX_HEADER_LENGTH) + { + throw new Exception.ProtocolViolationException(String.Format("Request header length exceeded maximum: %d", MAX_HEADER_LENGTH)); + } + + if (data[i] == '\r') + { + found_cr = true; + } + + if (found_cr && data[i] == '\n') + { + found_lf = true; + break; + } + else + { + found_cr = false; + } + } + + if (!(found_cr && found_lf)) + { + return 0; + } + + int code; + string meta; + + (code, meta) = Response.ParseHeader(data, 0, i); + + response = new Response(); + response.setStatus(code); + response.setMeta(meta); + + return i + 1; + } + + private static (int, string) ParseHeader(byte[] buffer, int start, int count) + { + int state = 0; + // 0 - reading code + // 1 - reading separator + // 2 - reading meta + // 3 - reading cr + // 4 - reading lf + + int field_start = start; + + string code = ""; + string meta = ""; + + for (int i = start; i <= start + count; i++) + { + switch (state) + { + case 0: // reading code + if (buffer[i] == ' ') + { + if (field_start == i) + { + throw new Exception.ProtocolViolationException("Found separator in header before reading status code."); + } + code = Encoding.UTF8.GetString(buffer, field_start, i - field_start); + field_start = i; + state = 1; + i--; + } + else + { + if (i - field_start > 2) + { + throw new Exception.ProtocolViolationException("Status code too long"); + } + } + break; + case 1: // reading separator + if (buffer[i] != ' ') + { + field_start = i; + state = 2; + i--; + } + else + { + if (i - field_start > 0) + { + throw new Exception.ProtocolViolationException("Found multiple spaces in header separator."); + } + } + break; + case 2: // reading meta + if (buffer[i] == '\r') + { + meta = Encoding.UTF8.GetString(buffer, field_start, i - field_start); + field_start = i; + state = 3; + i--; + } + else + { + if (i - field_start > 1024) + { + throw new Exception.ProtocolViolationException("Meta field too long."); + } + } + break; + case 3: // reading cr + if (buffer[i] == '\r') + { + state = 4; + } + else + { + throw new Exception.ProtocolViolationException("Could not find carriage return to terminate header."); + } + break; + case 4: // reading lf + if (buffer[i] == '\n') + { + state = 5; + } + else + { + throw new Exception.ProtocolViolationException("Could not find line feed to terminate header."); + } + break; + case 5: // shouldn't get here, read past \r\n + throw new Exception.ProtocolViolationException("Header contained data past terminated crlf."); + break; + } + } + + return (int.Parse(code), meta); + } + + public byte[] Serialize() + { + // TODO: Validate Meta is <=1024 bytes + // TODO: Validate Meta is valid for specific response codes? + // TODO: Validate no response body unless Status >=20 && <=29 + + List data = new List(); + data.AddRange(Encoding.UTF8.GetBytes(this.Status.ToString() + ' ' + this.Meta + "\r\n")); + if (this.Body != null) + { + data.AddRange(this.Body); + } + + return data.ToArray(); + } + } +} diff --git a/pyxis.gemini/Protocol/Server.cs b/pyxis.gemini/Protocol/Server.cs new file mode 100644 index 0000000..dd88265 --- /dev/null +++ b/pyxis.gemini/Protocol/Server.cs @@ -0,0 +1,68 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using System.IO; + +namespace pyxis.gemini.Protocol +{ + public class Server + { + private Request _request; + + private const int CONNECTION_BUFFER_SIZE = 2048; + private byte[] _connection_buffer; + private int _connection_buffer_length; + + public delegate void RequestEventDelegate(Server sender, Request request); + public event RequestEventDelegate OnRequest; + + public Server() + { + this._connection_buffer = new byte[CONNECTION_BUFFER_SIZE]; + this._connection_buffer_length = 0; + } + + public void Receive(byte[] data, int length) + { + // Copy read bytes into connection buffer + for (int i = 0; i < length; i++) + { + if (i >= CONNECTION_BUFFER_SIZE) + { + throw new System.Exception("Connection buffer overflow"); + } + this._connection_buffer[i + this._connection_buffer_length] = data[i]; + } + + this._connection_buffer_length += length; + + Request r; + int consumed = Request.Parse(this._connection_buffer, this._connection_buffer_length, out r); + + if (consumed == 0) + { + if (length == 0) + { + throw new System.Exception("Stream closed without receiving request"); + } + else + { + return; + } + } + + if (consumed != this._connection_buffer_length) + { + throw new Exception.ProtocolViolationException("Received trailing bytes after request"); + } + + if (this.OnRequest != null) + { + this.OnRequest(this, r); + } + } + + } +} diff --git a/pyxis.gemini/Server/Server.cs b/pyxis.gemini/Server/Server.cs new file mode 100644 index 0000000..e39d3a3 --- /dev/null +++ b/pyxis.gemini/Server/Server.cs @@ -0,0 +1,214 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +using System.Net; +using System.Net.Sockets; +using System.Net.Security; +using System.Security.Authentication; +using System.Security.Cryptography.X509Certificates; +using System.Threading; + +namespace pyxis.gemini.Server +{ + public class Server + { + const int READ_BUFFER_SIZE = 2048; + + struct Transaction + { + public TcpClient client; + public SslStream stream; + public State state; + public Protocol.Request request; + public Protocol.Response response; + public Protocol.Server server; + public byte[] buffer; + + public enum State + { + INITIALIZE, + GET_REQUEST, + PROCESS_REQUEST, + SEND_RESPONSE, + CLOSE, + DISPOSE + } + + } + + private IPEndPoint _endpoint; + private TcpListener _listener; + + public delegate Protocol.Response ProcessRequestDelegate(Server sender, Protocol.Request request); + public ProcessRequestDelegate OnProcessRequest; + + Dictionary _certificates; + + public Server(IPEndPoint endpoint, X509Certificate2 defaultCertificate) + { + this._endpoint = endpoint; + this._certificates = new Dictionary(); + this._certificates.Add("default", defaultCertificate); + } + + public void AddCertificate(string hostname, X509Certificate2 cert) + { + this._certificates.Add(hostname, cert); + } + + public void Start() + { + this._listener = new TcpListener(this._endpoint); + this._listener.Start(); + this._listener.BeginAcceptTcpClient(this.ListenerAccept, null); + } + + public void Stop() + { + this._listener.Stop(); + } + + private void ListenerAccept(IAsyncResult result) + { + TcpClient tcp = this._listener.EndAcceptTcpClient(result); + if (tcp == null) + { + return; + } + + Transaction t = new Transaction(); + t.client = tcp; + t.stream = new SslStream(tcp.GetStream()); + t.state = Transaction.State.INITIALIZE; + t.request = null; + t.response = null; + t.server = new Protocol.Server(); + t.server.OnRequest += (Protocol.Server server, Protocol.Request request) => + { + this.ServerEmitRequest(t, request); + }; + t.buffer = new byte[READ_BUFFER_SIZE]; + ThreadPool.QueueUserWorkItem(this.TickTransaction, t); + + _listener.BeginAcceptTcpClient(this.ListenerAccept, null); + } + + private void TickTransaction(object state) + { + Transaction t = (Transaction)state; + + switch (t.state) + { + case Transaction.State.INITIALIZE: + // List cipherSuites = new List(); + // TODO: Go through and list all the valid ciphersuites + // https://docs.microsoft.com/en-us/windows/win32/secauthn/cipher-suites-in-schannel -- how to decode these + // https://gemini.circumlunar.space/docs/best-practices.gmi -- bottom includes info on best practices for encryption + SslServerAuthenticationOptions sslauth = new SslServerAuthenticationOptions() + { + //CipherSuitesPolicy = new CipherSuitesPolicy(cipherSuites), + //ClientCertificateRequired = true, // _request_ a client cert if available, can use this in the future for client auth, docblock says it won't fail if one isn't provided + //EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13, + ServerCertificateSelectionCallback = this.SelectServerCertificate + }; + try + { + t.stream.AuthenticateAsServer(sslauth); + } + catch + { + t.state = Transaction.State.DISPOSE; + ThreadPool.QueueUserWorkItem(this.TickTransaction, t); + return; + } + t.state = Transaction.State.GET_REQUEST; + ThreadPool.QueueUserWorkItem(this.TickTransaction, t); + break; + case Transaction.State.GET_REQUEST: + t.stream.BeginRead(t.buffer, 0, READ_BUFFER_SIZE, this.StreamRead, t); + break; + case Transaction.State.PROCESS_REQUEST: + if (this.OnProcessRequest == null) + { + // ?! + throw new Exception("Cannot process request -- no listener registered."); + } + + try + { + t.response = this.OnProcessRequest(this, t.request); + } + catch (Exception ex) + { + t.response = new Protocol.Response(); + t.response.setStatus(40); + t.response.setMeta("Internal Server Error"); + t.response.setBody(Encoding.UTF8.GetBytes("Internal Server Error: " + ex.Message)); + } + + t.state = Transaction.State.SEND_RESPONSE; + ThreadPool.QueueUserWorkItem(this.TickTransaction, t); + break; + case Transaction.State.SEND_RESPONSE: + byte[] serialized = t.response.Serialize(); + string tmp = Encoding.UTF8.GetString(serialized); + t.stream.Write(t.response.Serialize()); + t.state = Transaction.State.CLOSE; + ThreadPool.QueueUserWorkItem(this.TickTransaction, t); + break; + case Transaction.State.CLOSE: + t.stream.Close(); + t.state = Transaction.State.DISPOSE; + ThreadPool.QueueUserWorkItem(this.TickTransaction, t); + break; + case Transaction.State.DISPOSE: + t.client.Close(); + // Any other cleanup? + break; + } + } + + private X509Certificate SelectServerCertificate(object sender, string? hostname) + { + X509Certificate2 cert = null; + if (this._certificates.ContainsKey(hostname)) + { + cert = this._certificates[hostname]; + } + else + { + cert = this._certificates["default"]; + } + return cert; + } + + private void StreamRead(IAsyncResult result) + { + Transaction t = (Transaction)result.AsyncState; + int bytesRead; + try + { + bytesRead = t.stream.EndRead(result); + } + catch (ObjectDisposedException ex) + { + return; + } + t.server.Receive(t.buffer, bytesRead); + if (t.request == null) + { + t.stream.BeginRead(t.buffer, 0, READ_BUFFER_SIZE, this.StreamRead, t); + } + } + + private void ServerEmitRequest(Transaction transaction, Protocol.Request request) + { + transaction.request = request; + transaction.state = Transaction.State.PROCESS_REQUEST; + ThreadPool.QueueUserWorkItem(this.TickTransaction, transaction); + } + } +} diff --git a/pyxis.gemini/pyxis.gemini.csproj b/pyxis.gemini/pyxis.gemini.csproj new file mode 100644 index 0000000..563e6f9 --- /dev/null +++ b/pyxis.gemini/pyxis.gemini.csproj @@ -0,0 +1,7 @@ + + + + net5.0 + + +