Created
November 28, 2023 08:35
-
-
Save RickStrahl/f9ab2d8fa9366d4f628e903d78bb796f to your computer and use it in GitHub Desktop.
A minimal, app-specific TCP/IP Web Server using .NET Code. Very simplistic manual request parsing and 'routing' for a few simple operations against the server.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
using System; | |
using System.Collections.Generic; | |
using System.IO; | |
using System.Net; | |
using System.Net.Security; | |
using System.Net.Sockets; | |
using System.Text; | |
using System.Threading; | |
using System.Threading.Tasks; | |
using MarkdownMonster.Controls; | |
using Newtonsoft.Json; | |
using Westwind.Utilities; | |
namespace MarkdownMonster.Services | |
{ | |
/// <summary> | |
/// Extremely simple Web Server that allows message based request handling | |
/// | |
/// Used for automating some Markdown Monster Tasks from Web sites | |
/// or other applications via simple HTTP requests over non-https | |
/// requests. | |
/// </summary> | |
public class WebServer | |
{ | |
/// <summary> | |
/// Connection Timeout in milliseconds. Resets connection if waiting | |
/// longer than this timeout. | |
/// </summary> | |
public int ConnectionTimeout { get; set; } = 10_000; | |
bool Cancelled { get; set; } = false; | |
Thread SocketThread { get; set; } | |
TcpListener TcpServer { get; set; } | |
WebRequestContext RequestContext = null; | |
string IpAddress { get; set; } = "127.0.0.1"; | |
int ServerPort { get; set; } = 5009; | |
/// <summary> | |
/// If true uses SSL | |
/// </summary> | |
bool Secure { get; set; } | |
public Func<WebServerOperation,Task<WebServerResult>> OnMarkdownMonsterOperation { get; set; } | |
/// <summary> | |
/// Last exception that occurred when starting the server or intercepting | |
/// request data. | |
/// </summary> | |
public Exception LastException { get; set; } | |
#region Start and Stop Server | |
public bool StartServer(string ipAddress = "127.0.0.1", int serverPort = 5009) | |
{ | |
try | |
{ | |
SocketThread = new Thread(RunServer); | |
SocketThread.Start(); | |
mmApp.Configuration.WebServer.IsRunning = true; | |
} | |
catch (Exception ex) | |
{ | |
LastException = ex; | |
SocketThread = null; | |
return false; | |
} | |
return true; | |
} | |
public void StopServer() | |
{ | |
Cancelled = true; | |
Thread.Sleep(80); | |
CloseConnection(); | |
try | |
{ | |
TcpServer?.Stop(); | |
TcpServer = null; | |
} | |
catch | |
{ | |
TcpServer = null; | |
} | |
try | |
{ | |
#if NETFULL | |
SocketThread?.Abort(); | |
#endif | |
} | |
finally | |
{ | |
SocketThread = null; | |
} | |
} | |
public void RunServer() | |
{ | |
Task.Run(async () => | |
{ | |
TcpServer?.Stop(); | |
try | |
{ | |
TcpServer = new TcpListener(IPAddress.Parse(IpAddress), ServerPort); | |
TcpServer.Start(); | |
} | |
catch (Exception ex) | |
{ | |
var window = mmApp.Model?.Window; | |
if (window != null) | |
window.Dispatcher.InvokeAsync(() => | |
{ | |
mmApp.Model.Configuration.WebServer.IsRunning = false; | |
window.ShowStatusError( | |
$"Failed to start Web Server on `localhost:{ServerPort}`: {ex.Message}"); | |
}).Task.FireAndForget(); | |
return; | |
} | |
// enter to an infinite cycle to be able to handle every change in stream | |
while (!Cancelled) | |
{ | |
try | |
{ | |
RequestContext = OpenConnection(); | |
var capturedData = new List<byte>(); | |
WaitForConnectionData(); | |
if (RequestContext == null) | |
{ | |
Cancelled = true; | |
continue; | |
} | |
if (!RequestContext.Connection.Connected) | |
continue; | |
if (RequestContext.Connection.Available == 0) | |
continue; | |
if (!ParseRequest()) | |
continue; | |
// *** manual routing | |
// Send CORS header so this can be accessed | |
if (RequestContext.Verb == "OPTIONS") | |
{ | |
WriteResponseInternal(null, | |
"HTTP/1.1 200 OK\r\n" + | |
"Access-Control-Allow-Origin: *\r\n" + | |
"Access-Control-Allow-Methods: GET,POST,PUT,DELETE,OPTIONS\r\n" + | |
"Access-Control-Allow-Headers: *\r\n"); | |
continue; // done here | |
} | |
else if (RequestContext.Verb == "POST" && | |
(RequestContext.Path == "/markdownmonster" || | |
RequestContext.Path.StartsWith("/markdownmonster/"))) | |
{ | |
var result = new WebServerResult(); | |
try | |
{ | |
var operation = | |
JsonConvert.DeserializeObject(RequestContext.RequestContent, | |
typeof(WebServerOperation)) as WebServerOperation; | |
if (operation == null) | |
throw new ArgumentException("Invalid json request data: " + | |
RequestContext.RequestContent); | |
try | |
{ | |
result = await OnMarkdownMonsterOperation?.Invoke(operation); | |
} | |
catch | |
{ | |
mmApp.Model?.Window?.ShowStatusError( | |
"Web Server Client Request failed. Operation: " + operation.Operation); | |
} | |
} | |
catch (Exception ex) | |
{ | |
WriteErrorResponse("Markdown Monster Message Processing failed: " + ex.Message + | |
"\r\n\r\n" + | |
RequestContext.RequestContent); | |
continue; | |
} | |
WriteResponse(result); | |
} | |
else if (RequestContext.Path.StartsWith("/ping")) | |
{ | |
WriteDataResponse("OK"); | |
} | |
else | |
{ | |
WriteErrorResponse("Invalid URL access", 404); | |
} | |
} | |
catch (Exception ex) | |
{ | |
WriteErrorResponse("An error occurred: " + ex.Message); | |
} | |
finally | |
{ | |
// close connection | |
CloseConnection(); | |
RequestContext = null; | |
} | |
} | |
}); | |
} | |
#endregion | |
#region Connection Operations | |
/// <summary> | |
/// Returns a raw stream which can be SSL encoded and the original | |
/// Network stream so both are accessible. Use the raw stream | |
/// for read/write and the Network Stream for checking data | |
/// availability | |
/// </summary> | |
/// <param name="secure"></param> | |
/// <returns></returns> | |
WebRequestContext OpenConnection(bool secure = false) | |
{ | |
var requestContext = new WebRequestContext(); | |
try | |
{ | |
requestContext.Connection = TcpServer.AcceptTcpClient(); | |
requestContext.Connection.ReceiveTimeout = 3000; | |
requestContext.Connection.SendTimeout = 3000; | |
requestContext.NetworkStream = requestContext.Connection.GetStream(); | |
if (secure) | |
requestContext.Stream = new SslStream(requestContext.NetworkStream); | |
else | |
requestContext.Stream = requestContext.NetworkStream; | |
} | |
catch | |
{ | |
return null; | |
} | |
return requestContext; | |
} | |
void CloseConnection() | |
{ | |
// close connection | |
RequestContext?.Close(); | |
RequestContext = null; | |
} | |
#endregion | |
#region Receive Processing | |
private void WaitForConnectionData() | |
{ | |
var dt = DateTime.UtcNow.AddMilliseconds(ConnectionTimeout); | |
while (RequestContext?.NetworkStream != null && | |
!RequestContext.NetworkStream.DataAvailable) | |
{ | |
if (TcpServer == null) | |
return; | |
Thread.Sleep(1); // don't hog CPU | |
if(RequestContext?.Connection == null) | |
return; | |
while ( RequestContext?.Connection != null && | |
RequestContext.Connection.Connected && | |
RequestContext.Connection.Available < 3) | |
{ | |
if (dt < DateTime.UtcNow) | |
return; // break and restart connection in case it's hung | |
Thread.Sleep(10); | |
} | |
} | |
} | |
private bool ParseRequest() | |
{ | |
if (RequestContext.Connection == null || | |
!RequestContext.Connection.Connected) | |
return false; | |
// Read initial buffer to get the headers | |
int available = RequestContext.Connection.Available; | |
byte[] bytes = new byte[available]; | |
int bytesRead = RequestContext.Stream.Read(bytes, 0, available); | |
if (bytesRead < available) | |
Array.Resize(ref bytes, bytesRead); | |
var sb = new StringBuilder(); | |
sb.Append(Encoding.UTF8.GetString(bytes)); | |
var firstLine = GetFirstHeaderLine(sb.ToString()); | |
if (firstLine == null) | |
return false; // invalid | |
//Read multiple buffers for large content | |
while (RequestContext.NetworkStream.DataAvailable) | |
{ | |
try | |
{ | |
RequestContext.Stream.Read(bytes, 0, RequestContext.Connection.Available); | |
sb.Append(Encoding.UTF8.GetString(bytes)); | |
} | |
catch | |
{ | |
// TODO: needs specific exception handling for disconnects | |
return false; | |
} | |
} | |
var tokens = firstLine.Split(' '); | |
if (tokens.Length < 3) | |
return false; // invalid GET /path HTTP/1.1 | |
RequestContext.Verb = tokens[0].ToUpper(); | |
RequestContext.Path = tokens[1]; | |
string fullRequest = sb.ToString(); | |
int at = fullRequest.IndexOf("\r\n\r\n"); | |
if (at < 1) | |
return false; | |
RequestContext.RequestHeaders = fullRequest.Substring(0, at); | |
if (fullRequest.Length > at + 4) | |
RequestContext.RequestContent = fullRequest.Substring(at + 4); | |
return true; | |
} | |
/// <summary> | |
/// | |
/// </summary> | |
/// <param name="action"></param> | |
/// <param name="data"></param> | |
/// <param name="type"></param> | |
public void HandleOperation(string action, string data = null, string type = "text") | |
{ | |
var operation = new WebServerOperation() {Operation = action, Data = data, Type="text" }; | |
OnMarkdownMonsterOperation?.Invoke(operation); | |
} | |
public void HandleOperation(string action, object data) | |
{ | |
var jsonData = JsonConvert.SerializeObject(data); | |
var operation = new WebServerOperation() {Operation = action, Data = jsonData, Type = "json" }; | |
OnMarkdownMonsterOperation?.Invoke(operation); | |
} | |
#endregion | |
#region Response Output Wrappers | |
internal void WriteResponseInternal(string data, string headers) | |
{ | |
byte[] content = null; | |
if (!string.IsNullOrEmpty(data)) | |
content = Encoding.UTF8.GetBytes(data); | |
if (string.IsNullOrEmpty(headers)) | |
headers = "HTTP/1.1 200 OK\r\n" + | |
"Content-Type: application/json\r\n"; | |
if (content != null) | |
headers += "Content-Length: " + content.Length; | |
byte[] response = Encoding.UTF8.GetBytes(headers.TrimEnd('\r', '\n') + "\r\n\r\n"); | |
try | |
{ | |
if (RequestContext?.NetworkStream == null) | |
return; | |
RequestContext.NetworkStream.Write(response, 0, response.Length); | |
if (content != null) | |
RequestContext.NetworkStream.Write(content, 0, content.Length); | |
} | |
catch(Exception ex) | |
{ | |
// socket write failure | |
mmApp.Model?.Window?.ShowStatusError("Web Server failed to send response: " + ex.Message); | |
} | |
} | |
/// <summary> | |
/// Creates a response with a data result | |
/// </summary> | |
/// <param name="data"></param> | |
public void WriteDataResponse(object data) | |
{ | |
var result = new WebServerResult(data); | |
WriteResponse(result); | |
} | |
/// <summary> | |
/// Writes a 204 response with no data | |
/// </summary> | |
public void WriteNoDataResponse() | |
{ | |
var result = new WebServerResult(); | |
WriteResponse(result); | |
} | |
/// <summary> | |
/// Writes a response with a full WebServerResult structure | |
/// that allows maximum control over the response output. | |
/// </summary> | |
/// <param name="result"></param> | |
public void WriteResponse(WebServerResult result) | |
{ | |
var status = "OK"; | |
if (result.HttpStatusCode == 500) | |
status = "Server Error"; | |
else if (result.HttpStatusCode == 404) | |
status = "Not Found"; | |
else if (result.HttpStatusCode == 401) | |
status = "Unauthorized"; | |
else if (result.HttpStatusCode == 204) | |
status = "No Content"; | |
string headers = | |
$"HTTP/1.1 {result.HttpStatusCode} {status}\r\n" + | |
"Access-Control-Allow-Origin: *\r\n" + | |
"Access-Control-Allow-Methods: GET,POST,PUT,OPTIONS\r\n" + | |
"Access-Control-Allow-Headers: *\r\n"; | |
if (result.hasNoData) | |
{ | |
WriteResponseInternal(null, headers); | |
} | |
else | |
{ | |
var json = JsonSerializationUtils.Serialize(result, formatJsonOutput: true); | |
WriteResponseInternal(json, headers + "Content-Type: application/json\r\n"); | |
} | |
} | |
public void WriteErrorResponse(string message = null, int httpStatusCode = 500) | |
{ | |
var result = new WebServerResult(message); | |
result.HttpStatusCode = httpStatusCode; | |
WriteResponse(result); | |
} | |
#endregion | |
string GetFirstHeaderLine(string s) | |
{ | |
var at = s.IndexOf("\r\n"); | |
if (at < 0) | |
at = s.IndexOf("\n"); | |
if (at == 0) | |
return null; | |
return s.Substring(0, at - 1); | |
} | |
} | |
internal class WebRequestContext | |
{ | |
internal TcpClient Connection { get; set; } | |
internal NetworkStream NetworkStream { get; set; } | |
internal Stream Stream { get; set; } | |
internal string Verb { get; set; } | |
internal string Path { get; set; } | |
internal string RequestHeaders { get; set; } | |
internal string RequestContent {get; set; } | |
internal void Close() | |
{ | |
NetworkStream?.Close(); | |
Connection?.Close(); | |
Stream = null; | |
NetworkStream = null; | |
Connection = null; | |
} | |
} | |
/// <summary> | |
/// Operations that are to be performed on the server | |
/// </summary> | |
public class WebServerOperation | |
{ | |
public string Operation { get; set; } = "open"; | |
public string Data { get; set; } | |
public string Type { get; set; } = "text"; // text, json | |
public bool Activate { get; set; } = false; | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment