diff --git a/Cuipod.sln b/Cuipod.sln new file mode 100644 index 0000000..a994de8 --- /dev/null +++ b/Cuipod.sln @@ -0,0 +1,31 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30621.155 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Cuipod", "Cuipod\Cuipod.csproj", "{2B6AD5A5-F10B-4FC3-BF12-5DD26FAF3796}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "CuipodExample", "CuipodExample\CuipodExample.csproj", "{BD343B0B-29EB-4498-8CD2-D9483B6BDA98}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2B6AD5A5-F10B-4FC3-BF12-5DD26FAF3796}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2B6AD5A5-F10B-4FC3-BF12-5DD26FAF3796}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2B6AD5A5-F10B-4FC3-BF12-5DD26FAF3796}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2B6AD5A5-F10B-4FC3-BF12-5DD26FAF3796}.Release|Any CPU.Build.0 = Release|Any CPU + {BD343B0B-29EB-4498-8CD2-D9483B6BDA98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BD343B0B-29EB-4498-8CD2-D9483B6BDA98}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BD343B0B-29EB-4498-8CD2-D9483B6BDA98}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BD343B0B-29EB-4498-8CD2-D9483B6BDA98}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {F2127615-5183-476F-8771-BF9710EB3B2C} + EndGlobalSection +EndGlobal diff --git a/Cuipod/App.cs b/Cuipod/App.cs new file mode 100644 index 0000000..e253e70 --- /dev/null +++ b/Cuipod/App.cs @@ -0,0 +1,155 @@ +using System; +using System.Net; +using System.Net.Sockets; +using System.Collections.Generic; +using System.Net.Security; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Security.Authentication; +using System.IO; + +namespace Cuipod +{ + public class App + { + private readonly string _directoryToServe; + private readonly TcpListener _listener; + private readonly X509Certificate2 _serverCertificate; + private readonly Dictionary> _requestCallbacks; + + private Action _onBadRequestCallback; + + public App(string directoryToServe, string certificateFile, string privateRSAKeyFilePath) + { + _directoryToServe = directoryToServe; + IPAddress localAddress = IPAddress.Parse("127.0.0.1"); + _listener = new TcpListener(localAddress, 1965); + _requestCallbacks = new Dictionary>(); + _serverCertificate = CertificateUtils.LoadCertificate(certificateFile, privateRSAKeyFilePath); + } + + public void OnRequest(string route, Action callback) + { + _requestCallbacks.Add(route, callback); + } + + public void OnBadRequest(Action callback) + { + _onBadRequestCallback = callback; + } + + public void Run() + { + Console.WriteLine("Serving capsule on 127.0.0.1:1965"); + try + { + _listener.Start(); + while (true) + { + ProcessRequest(_listener.AcceptTcpClient()); + } + } + catch (SocketException e) + { + Console.WriteLine("SocketException: {0}", e); + } + finally + { + _listener.Stop(); + } + } + + private void ProcessRequest(TcpClient client) + { + SslStream sslStream = new SslStream(client.GetStream(), false); + try + { + sslStream.AuthenticateAsServer(_serverCertificate, false, SslProtocols.Tls | SslProtocols.Tls11 | SslProtocols.Tls12, false); + + // Read a message from the client. + string rawURI = ReadRequest(sslStream); + + Response response = new Response(_directoryToServe); + + int protocolDelimiter = rawURI.IndexOf("://"); + if (protocolDelimiter == -1) + { + response.Status = StatusCode.BadRequest; + } + + string protocol = rawURI.Substring(0, protocolDelimiter); + if (protocol != "gemini") + { + response.Status = StatusCode.BadRequest; + } + + string url = rawURI.Substring(protocolDelimiter + 3); + int domainNameDelimiter = url.IndexOf("/"); + if (domainNameDelimiter == -1) + { + response.Status = StatusCode.BadRequest; + } + string domainName = url.Substring(0, domainNameDelimiter); + // TODO: validate domain name from cert? + + string route = url.Substring(domainNameDelimiter); + + if (response.Status == StatusCode.Success) + { + Action callback = null; + _requestCallbacks.TryGetValue(route, out callback); + if (callback != null) + { + callback(response); + } else if (_onBadRequestCallback != null) + { + _onBadRequestCallback(response); + } else + { + response.Status = StatusCode.BadRequest; + } + } + + sslStream.Write(response.Encode()); + } + catch (AuthenticationException e) + { + Console.WriteLine("Exception: {0}", e.Message); + if (e.InnerException != null) + { + Console.WriteLine("Inner exception: {0}", e.InnerException.Message); + } + Console.WriteLine("Authentication failed - closing the connection."); + } + catch (IOException e) + { + Console.WriteLine("Exception: {0}", e.Message); + } + finally + { + sslStream.Close(); + client.Close(); + } + } + + private string ReadRequest(SslStream sslStream) + { + byte[] buffer = new byte[2048]; + Decoder decoder = Encoding.UTF8.GetDecoder(); + + StringBuilder requestData = new StringBuilder(); + string line; + do + { + int bytes = sslStream.Read(buffer, 0, buffer.Length); + char[] chars = new char[decoder.GetCharCount(buffer, 0, bytes)]; + decoder.GetChars(buffer, 0, bytes, chars, 0); + line = new string(chars); + requestData.Append(line); + } while (!line.EndsWith("\r\n")); + + + return requestData.ToString().TrimEnd('\r', '\n'); ; + } + } +} diff --git a/Cuipod/CertificateUtils.cs b/Cuipod/CertificateUtils.cs new file mode 100644 index 0000000..58ca86f --- /dev/null +++ b/Cuipod/CertificateUtils.cs @@ -0,0 +1,34 @@ +using System; +using System.IO; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; + +namespace Cuipod +{ + public static class CertificateUtils + { + static public X509Certificate2 LoadCertificate(string certFilePath, string privateRSAKeyFilePath) + { + X509Certificate2 cert = new X509Certificate2(certFilePath); + + string[] lines = File.ReadAllLines(privateRSAKeyFilePath, Encoding.UTF8); + string privateKeyData = ""; + for (int i = 1; i < lines.Length - 1; ++i) + { + privateKeyData += lines[i]; + } + byte[] bytes = Convert.FromBase64String(privateKeyData); + + RSA rsa = RSA.Create(); + // For now we always expect to have cert with BEGIN PRIVATE KEY label + // TODO: add handling for other types, maybe?? + rsa.ImportPkcs8PrivateKey(bytes, out _); + + X509Certificate2 pubPrivEphemeral = cert.CopyWithPrivateKey(rsa); + cert = new X509Certificate2(pubPrivEphemeral.Export(X509ContentType.Pfx)); + + return cert; + } + } +} diff --git a/Cuipod/Cuipod.csproj b/Cuipod/Cuipod.csproj new file mode 100644 index 0000000..f7731aa --- /dev/null +++ b/Cuipod/Cuipod.csproj @@ -0,0 +1,7 @@ + + + + netcoreapp3.0 + + + diff --git a/Cuipod/Response.cs b/Cuipod/Response.cs new file mode 100644 index 0000000..5e1f739 --- /dev/null +++ b/Cuipod/Response.cs @@ -0,0 +1,41 @@ +using System; +using System.IO; +using System.Text; + +namespace Cuipod +{ + public class Response + { + public StatusCode Status { get; internal set; } + + private string _directoryToServe; + private string _requestBody = ""; + + public Response(string directoryToServe) + { + _directoryToServe = directoryToServe; + Status = StatusCode.Success; + } + + public void RenderPlainTextLine(string text) + { + _requestBody += text + "\r\n"; + } + + public void RenderFileContent(string relativePathToFile) + { + _requestBody += File.ReadAllText(_directoryToServe + relativePathToFile, Encoding.UTF8) + "\r\n"; + } + + internal static string WriteHeader(StatusCode statusCode) + { + return ((int)statusCode).ToString() + " text/gemini\r\n"; + } + + internal byte[] Encode() + { + string wholeResponse = WriteHeader(Status) + _requestBody; + return Encoding.UTF8.GetBytes(wholeResponse); + } + } +} diff --git a/Cuipod/StatusCode.cs b/Cuipod/StatusCode.cs new file mode 100644 index 0000000..9121fc8 --- /dev/null +++ b/Cuipod/StatusCode.cs @@ -0,0 +1,25 @@ + +namespace Cuipod +{ + public enum StatusCode + { + Input = 10, + SensitiveInput = 11, + Success = 20, + RedirectTemp = 30, + RedirectPerm = 31, + FailureTemp = 40, + ServerUnavailable = 41, + CGIError = 42, + ProxyError = 43, + SlowDown = 44, + FailurePerm = 50, + NotFound = 51, + Gone = 52, + ProxyRefused = 53, + BadRequest = 59, + ClientCertRequired = 60, + CertNotAuthorised = 61, + CertNotValid = 62 + } +} diff --git a/CuipodExample/CuipodExample.csproj b/CuipodExample/CuipodExample.csproj new file mode 100644 index 0000000..613bc36 --- /dev/null +++ b/CuipodExample/CuipodExample.csproj @@ -0,0 +1,12 @@ + + + + Exe + netcoreapp3.0 + + + + + + + diff --git a/CuipodExample/Server.cs b/CuipodExample/Server.cs new file mode 100644 index 0000000..028110a --- /dev/null +++ b/CuipodExample/Server.cs @@ -0,0 +1,39 @@ +using Cuipod; + +namespace CuipodExample +{ + class Server + { + static void Main(string[] args) + { + App app = new App( + "/", // directory to serve + "/certificate.crt", // path to certificate + "/privatekey.key" // path to private Pkcs8 RSA key + ); + + // Serve files + app.OnRequest("/", response => { + response.RenderFileContent("index.gmi"); + }); + + app.OnRequest("/about/", response => { + response.RenderFileContent("about_me.gmi"); + }); + + // Or dynamically render content + app.OnRequest("/dynamic/content/", response => { + response.RenderPlainTextLine("# woah much content!"); + response.RenderPlainTextLine("More utilities to render content will come soon!"); + }); + + // Optional but nice. In case it is specified and client will do a bad route + // request we will respond with Success status and render result from this lambda + app.OnBadRequest(response => { + response.RenderPlainTextLine("# Ohh No!!! Request is bad :("); + }); + + app.Run(); + } + } +} diff --git a/README.md b/README.md index af4e5c7..eee58f8 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,49 @@ # cuipod -Simple yet flexible framework for Gemini protocol server +Simple yet flexible framework for Gemini protocol servers + +Framework is written in C# and based on .NET Core 3.0 framework. +The project is still in very early stage so bugs are expected. Feel free to raise an issue ticket or even raise PR! + +## Example + +``` +using Cuipod; + +namespace CuipodExample +{ + class Server + { + static void Main(string[] args) + { + App app = new App( + "/", // directory to serve + "/certificate.crt", // path to certificate + "/privatekey.key" // path to private Pkcs8 RSA key + ); + + // Serve files + app.OnRequest("/", response => { + response.RenderFileContent("index.gmi"); + }); + + app.OnRequest("/about/", response => { + response.RenderFileContent("about_me.gmi"); + }); + + // Or dynamically render content + app.OnRequest("/dynamic/content/", response => { + response.RenderPlainTextLine("# woah much content!"); + response.RenderPlainTextLine("More utilities to render content will come soon!"); + }); + + // Optional but nice. In case it is specified and client will do a bad route + // request we will respond with Success status and render result from this lambda + app.OnBadRequest(response => { + response.RenderPlainTextLine("# Ohh No!!! Request is bad :("); + }); + + app.Run(); + } + } +} +``` \ No newline at end of file