Adam Pippin
2 years ago
6 changed files with 591 additions and 0 deletions
@ -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) |
|||
{ |
|||
} |
|||
} |
|||
} |
@ -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(); |
|||
} |
|||
} |
|||
} |
@ -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<byte> data = new List<byte>(); |
|||
data.AddRange(Encoding.UTF8.GetBytes(this.Status.ToString() + ' ' + this.Meta + "\r\n")); |
|||
if (this.Body != null) |
|||
{ |
|||
data.AddRange(this.Body); |
|||
} |
|||
|
|||
return data.ToArray(); |
|||
} |
|||
} |
|||
} |
@ -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); |
|||
} |
|||
} |
|||
|
|||
} |
|||
} |
@ -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<string, X509Certificate2> _certificates; |
|||
|
|||
public Server(IPEndPoint endpoint, X509Certificate2 defaultCertificate) |
|||
{ |
|||
this._endpoint = endpoint; |
|||
this._certificates = new Dictionary<string, X509Certificate2>(); |
|||
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<TlsCipherSuite> cipherSuites = new List<TlsCipherSuite>();
|
|||
// 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); |
|||
} |
|||
} |
|||
} |
@ -0,0 +1,7 @@ |
|||
<Project Sdk="Microsoft.NET.Sdk"> |
|||
|
|||
<PropertyGroup> |
|||
<TargetFramework>net5.0</TargetFramework> |
|||
</PropertyGroup> |
|||
|
|||
</Project> |
Loading…
Reference in new issue