From 6d368e940eb06906120710a2af019993d9e9c225 Mon Sep 17 00:00:00 2001 From: disarray2077 <86157825+disarray2077@users.noreply.github.com> Date: Mon, 15 Nov 2021 02:21:12 -0300 Subject: [PATCH] Initial commit --- BeefProj.toml | 11 + BeefSpace.toml | 12 + LICENSE | 16 + README.md | 133 +++++ src/Constants.bf | 51 ++ src/Extensions/Char8.bf | 15 + src/Extensions/Socket.bf | 252 +++++++++ src/Extensions/String.bf | 134 +++++ src/Extensions/StringView.bf | 71 +++ src/Helpers/Base64Encoder.bf | 86 +++ src/Helpers/HtmlHelper.bf | 35 ++ src/Helpers/HttpHelper.bf | 180 ++++++ src/Helpers/MiscHelper.bf | 79 +++ src/Net/Connection.bf | 848 ++++++++++++++++++++++++++++ src/Net/HttpListener.Settings.bf | 148 +++++ src/Net/HttpListener.bf | 314 ++++++++++ src/Net/Protocol/HttpMethod.bf | 9 + src/Net/Protocol/Request.bf | 182 ++++++ src/Net/Protocol/Response.bf | 58 ++ src/Net/Protocol/ResponseBuilder.bf | 140 +++++ src/Net/SocketSelector.Defs.bf | 120 ++++ src/Net/SocketSelector.bf | 135 +++++ src/Platform/UnixPlatform.bf | 33 ++ src/Platform/WinPlatform.bf | 54 ++ src/Program.bf | 202 +++++++ src/libc.bf | 36 ++ 26 files changed, 3354 insertions(+) create mode 100644 BeefProj.toml create mode 100644 BeefSpace.toml create mode 100644 LICENSE create mode 100644 README.md create mode 100644 src/Constants.bf create mode 100644 src/Extensions/Char8.bf create mode 100644 src/Extensions/Socket.bf create mode 100644 src/Extensions/String.bf create mode 100644 src/Extensions/StringView.bf create mode 100644 src/Helpers/Base64Encoder.bf create mode 100644 src/Helpers/HtmlHelper.bf create mode 100644 src/Helpers/HttpHelper.bf create mode 100644 src/Helpers/MiscHelper.bf create mode 100644 src/Net/Connection.bf create mode 100644 src/Net/HttpListener.Settings.bf create mode 100644 src/Net/HttpListener.bf create mode 100644 src/Net/Protocol/HttpMethod.bf create mode 100644 src/Net/Protocol/Request.bf create mode 100644 src/Net/Protocol/Response.bf create mode 100644 src/Net/Protocol/ResponseBuilder.bf create mode 100644 src/Net/SocketSelector.Defs.bf create mode 100644 src/Net/SocketSelector.bf create mode 100644 src/Platform/UnixPlatform.bf create mode 100644 src/Platform/WinPlatform.bf create mode 100644 src/Program.bf create mode 100644 src/libc.bf diff --git a/BeefProj.toml b/BeefProj.toml new file mode 100644 index 0000000..dcf42e7 --- /dev/null +++ b/BeefProj.toml @@ -0,0 +1,11 @@ +FileVersion = 1 + +[Project] +Name = "darkredhttpd" +StartupObject = "darkredhttpd.Program" + +[Configs.Debug.Win64] +DebugCommandArguments = "D:\\YouTube\\Fortissimo --timeout 0" + +[Configs.Release.Win64] +DebugCommandArguments = "D:\\YouTube\\Fortissimo --timeout 0" diff --git a/BeefSpace.toml b/BeefSpace.toml new file mode 100644 index 0000000..839cead --- /dev/null +++ b/BeefSpace.toml @@ -0,0 +1,12 @@ +FileVersion = 1 +Projects = {darkredhttpd = {Path = "."}} + +[Workspace] +StartupProject = "darkredhttpd" + +[Configs.Debug.Win64] +AllocStackTraceDepth = 15 + +[Configs.Release.Win64] +Toolset = "LLVM" +LTOType = "Thin" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..64e513a --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +Copyright (c) 2021 disarray +Copyright (c) 2003-2021 Emil Mikulic + +Permission to use, copy, modify, and distribute this software for any +purpose with or without fee is hereby granted, provided that the +above copyright notice and this permission notice appear in all +copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL +WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE +AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL +DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR +PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER +TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..8363b9f --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# darkredhttpd + +A version of [darkhttpd](https://github.com/emikulic/darkhttpd) for BeefLang. + +Features: + +* Almost every feature of [darkhttpd](https://github.com/emikulic/darkhttpd), features not implemented are left as TODO. +* Works on Windows and Linux, and should also work on others operating systems that Beef supports (But some code changes may be necessary). +* Can throttle the upload speed with the argument `--throttle` + +Security: + +* Almost every security feature of [darkhttpd](https://github.com/emikulic/darkhttpd), features not implemented are left as TODO. +* Rate-limited authentication + +Limitations: + +* Only serves static content - no CGI. + +## How to build darkredhttpd + +To build darkredhttpd you need to have the BeefLang compiler, if you're on Windows you can download it [here](https://www.beeflang.org/#releases), for any other operating system it's necessary to build from source, you can find BeefLang repository [here](https://github.com/beefytech/Beef). + +## How to run darkredhttpd + +Serve /var/www/htdocs on the default port (80 if running as root, else 8080): + +``` +./darkhttpd /var/www/htdocs +``` + +Serve `~/public_html` on port 8081: + +``` +./darkhttpd ~/public_html --port 8081 +``` + +Only bind to one IP address (useful on multi-homed systems): + +``` +./darkhttpd ~/public_html --addr 192.168.0.1 +``` + +Serve at most 4 simultaneous connections: + +``` +./darkhttpd ~/public_html --maxconn 4 +``` + +Log accesses to a file: + +``` +./darkhttpd ~/public_html --log access.log +``` + +Chroot for extra security (you need root privs for chroot): + +``` +./darkhttpd /var/www/htdocs --chroot +``` + +Use default.htm instead of index.html: + +``` +./darkhttpd /var/www/htdocs --index default.htm +``` + +Add mimetypes - in this case, serve .dat files as text/plain: + +``` +$ cat extramime +text/plain dat +$ ./darkhttpd /var/www/htdocs --mimetypes extramime +``` + +Drop privileges: + +``` +./darkhttpd /var/www/htdocs --uid www --gid www +``` + +Use acceptfilter (FreeBSD only): + +``` +kldload accf_http +./darkhttpd /var/www/htdocs --accf +``` + +Run in the background and create a pidfile: + +``` +./darkhttpd /var/www/htdocs --pidfile /var/run/httpd.pid --daemon +``` + +Web forward (301) requests for some hosts: + +``` +./darkhttpd /var/www/htdocs --forward example.com http://www.example.com \ + --forward secure.example.com https://www.example.com/secure +``` + +Web forward (301) requests for all hosts: + +``` +./darkhttpd /var/www/htdocs --forward example.com http://www.example.com \ + --forward-all http://catchall.example.com +``` + +Commandline options can be combined: + +``` +./darkhttpd ~/public_html --port 8080 --addr 127.0.0.1 +``` + +To see a full list of commandline options, +run darkhttpd without any arguments: + +``` +./darkhttpd +``` + +## How to run darkhttpd in Docker + +First, build the image. +``` +docker build -t darkhttpd . +``` +Then run using volumes for the served files and port mapping for access. + +For example, the following would serve files from the current user's dev/mywebsite directory on http://localhost:8080/ +``` +docker run -p 8080:80 -v ~/dev/mywebsite:/var/www/htdocs:ro darkhttpd +``` \ No newline at end of file diff --git a/src/Constants.bf b/src/Constants.bf new file mode 100644 index 0000000..d5f0420 --- /dev/null +++ b/src/Constants.bf @@ -0,0 +1,51 @@ +using System; + +namespace darkredhttpd +{ + public static class Constants + { + public static readonly String PKG_NAME = "darkredhttpd/1.13.from.git"; + public static readonly String COPYRIGHT = "copyright (c) 2021 disarray, 2003-2021 Emil Mikulic (darkhttpd)"; + +#if DEBUG + public const bool DEBUG = true; +#else + public const bool DEBUG = false; +#endif + + // Be aware that many strings are allocated on the stack, so a very large MAX_REQUEST_VALUE + // would require refactoring them from scope to new:ScopedAlloc! + public static readonly int MAX_REQUEST_LENGTH = 4096; + + public static readonly String DEFAULT_EXTENSION_MAP = + """ + application/emg emg + application/pdf pdf + application/wasm wasm + application/xml xsl xml + application/xml-dtd dtd + application/xslt+xml xslt + application/zip zip + audio/flac flac + audio/mpeg mp2 mp3 mpga + audio/ogg ogg + audio/opus opus + image/gif gif + image/jpeg jpeg jpe jpg + image/png png + image/svg+xml svg + text/css css + text/html html htm + text/javascript js + text/plain txt asc + text/vtt vtt + video/mpeg mpeg mpe mpg + video/ogg daala ogv + video/divx divx + video/quicktime qt mov + video/x-matroska mkv + video/x-msvideo avi + video/mp4 mp4 + """; + } +} diff --git a/src/Extensions/Char8.bf b/src/Extensions/Char8.bf new file mode 100644 index 0000000..bef9b22 --- /dev/null +++ b/src/Extensions/Char8.bf @@ -0,0 +1,15 @@ +namespace System +{ + extension Char8 + { + public bool IsXDigit + { + get + { + return ((this >= '0' && this <= '9') || + (this >= 'a' && this <= 'f') || + (this >= 'A' && this <= 'F')); + } + } + } +} diff --git a/src/Extensions/Socket.bf b/src/Extensions/Socket.bf new file mode 100644 index 0000000..58627bb --- /dev/null +++ b/src/Extensions/Socket.bf @@ -0,0 +1,252 @@ +using System.Diagnostics; + +namespace System.Net +{ + extension Socket + { + private SockAddr_in mAddress; + //public in_addr Address => mAddress.sin_addr; + + private int32 mPort; + public int32 Port + { + get => mPort; + set + { + if (mHandle != INVALID_SOCKET) + Runtime.FatalError(); + mPort = value; + } + } + + private bool mReuseAddr; + public bool ReuseAddr + { + get => mReuseAddr; + set + { + mReuseAddr = value; + if (mHandle != INVALID_SOCKET) + SetReuseAddr(mReuseAddr); + } + } + + private bool mNoDelay; + public bool NoDelay + { + get => mNoDelay; + set + { + mNoDelay = value; + if (mHandle != INVALID_SOCKET) + SetNoDelay(mNoDelay); + } + } + + private int mReceiveBufferSize = 8192; + public int ReceiveBufferSize + { + get => mReceiveBufferSize; + set + { + mReceiveBufferSize = value; + if (mHandle != INVALID_SOCKET) + SetReceiveBufferSize(mReceiveBufferSize); + } + } + + private int mSendBufferSize = 8192; + public int SendBufferSize + { + get => mSendBufferSize; + set + { + mSendBufferSize = value; + if (mHandle != INVALID_SOCKET) + SetSendBufferSize(mSendBufferSize); + } + } + + [CLink, CallingConvention(.Stdcall)] + static extern int32 setsockopt(HSocket s, int32 level, int32 optionName, void* optionValue, uint32 optionLen); + + [CLink, CallingConvention(.Stdcall)] + static extern int32 getsockname(HSocket s, SockAddr* name, int32* nameLen); + + [CLink, CallingConvention(.Stdcall)] + static extern char8* inet_ntoa(in_addr addr); + + [CLink, CallingConvention(.Stdcall)] + static extern uint16 ntohs(uint16 netshort); + + [CLink, CallingConvention(.Stdcall)] + static extern int32 shutdown(HSocket s, int32 how); + +#if BF_PLATFORM_WINDOWS + public const int SOL_SOCKET = 0xffff; /* options for socket level */ + public const int SO_REUSEADDR = 0x0004; /* allow local address reuse */ + public const int SO_SNDBUF = 0x1001; + public const int SO_RCVBUF = 0x1002; +#else + public const int SOL_SOCKET = 1; /* options for socket level */ + public const int SO_REUSEADDR = 2; /* allow local address reuse */ + public const int SO_SNDBUF = 7; + public const int SO_RCVBUF = 8; +#endif + + public const int TCP_NODELAY = 1; + + public const int SD_RECEIVE = 0; // Further receives are disallowed + public const int SD_SEND = 1; // Further sends are disallowed + public const int SD_BOTH = 2; // Further sends and receives are disallowed + + public void GetAddressText(String outString) + { + outString.Append(inet_ntoa(mAddress.sin_addr)); + } + + new void RehupSettings() + { + SetBlocking(mIsBlocking); + SetReuseAddr(mReuseAddr); + SetNoDelay(mNoDelay); + if (SendBufferSize != 8192) + SetSendBufferSize(SendBufferSize); + if (ReceiveBufferSize != 8192) + SetReceiveBufferSize(ReceiveBufferSize); + } + + void SetReuseAddr(bool reuse) + { + int32 param = reuse ? 1 : 0; + setsockopt(mHandle, SOL_SOCKET, SO_REUSEADDR, ¶m, sizeof(int32)); + } + + void SetNoDelay(bool noDelay) + { + int32 param = noDelay ? 1 : 0; + setsockopt(mHandle, IPPROTO_TCP, TCP_NODELAY, ¶m, sizeof(int32)); + } + + void SetReceiveBufferSize(int size) + { + var size; + setsockopt(mHandle, SOL_SOCKET, SO_RCVBUF, &size, sizeof(int)); + } + + void SetSendBufferSize(int size) + { + var size; + setsockopt(mHandle, SOL_SOCKET, SO_SNDBUF, &size, sizeof(int)); + } + + public Result Listen(int32 backlog = 5) + { + Debug.Assert(mHandle == INVALID_SOCKET); + + mHandle = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); + + if (mHandle == INVALID_SOCKET) + { + int32 err = GetLastError(); + return .Err(err); + } + + RehupSettings(); + + SockAddr_in service = ?; + service.sin_family = AF_INET; + service.sin_addr = in_addr(0, 0, 0, 0);//in_addr(127, 0, 0, 1); + service.sin_port = (uint16)htons((int16)mPort); + + if (bind(mHandle, &service, sizeof(SockAddr_in)) == SOCKET_ERROR) + { + int32 err = GetLastError(); + Close(); + return .Err(err); + } + + if (listen(mHandle, backlog) == SOCKET_ERROR) + { + int32 err = GetLastError(); + Close(); + return .Err(err); + } + + mAddress = service; + + return .Ok; + } + + public new Result AcceptFrom(Socket listenSocket) + { + SockAddr_in clientAddr = ?; + int32 clientAddrLen = sizeof(SockAddr_in); + mHandle = accept(listenSocket.mHandle, &clientAddr, &clientAddrLen); + if (mHandle == INVALID_SOCKET) + { + int32 err = GetLastError(); + return .Err(err); + } + + RehupSettings(); + mAddress = clientAddr; + mIsConnected = true; + return .Ok; + } + + public new void Close() + { + mAddress = .(); + mIsConnected = false; + + int32 status = shutdown(mHandle, SD_BOTH); +#if BF_PLATFORM_WINDOWS + if (status == 0) + closesocket(mHandle); +#else + if (status == 0) + close(mHandle); +#endif + mHandle = INVALID_SOCKET; + } + + public new int32 Recv(void* ptr, int size) + { + int32 result = recv(mHandle, ptr, (int32)size, 0); + if (result == 0) + mIsConnected = false; + else if (result == -1) + CheckDisconnected(); + return result; + } + + public new int32 Send(void* ptr, int size) + { + int32 result = send(mHandle, ptr, (int32)size, 0); + if (result == -1) + CheckDisconnected(); + return result; + } + +#if BF_PLATFORM_WINDOWS + [Import("mswsock.lib"), CLink, CallingConvention(.Stdcall)] + static extern int32 TransmitFile(HSocket socket, Windows.Handle file, uint32 numberOfBytesToWrite, uint32 numberOfBytesPerSend, void* overlapped, void* transmitBuffers, uint32 reserved); + + public int32 SendFile(Windows.Handle file, uint32 count) + { + Debug.Assert(count <= int32.MaxValue - 1); + return TransmitFile(mHandle, file, count, 0, null, null, 0) == 1 ? (.)count : 0; + } +#elif BF_PLATFORM_LINUX + [CLink] + public static extern int32 sendfile(int32 outfd, int32 infd, int32* offset, int32 count); + + public int32 SendFile(int fd, int32 offset, int32 count) + { + var offset; + return sendfile((.)mHandle, (.)fd, &offset, count); + } +#endif + } +} diff --git a/src/Extensions/String.bf b/src/Extensions/String.bf new file mode 100644 index 0000000..0bd7eda --- /dev/null +++ b/src/Extensions/String.bf @@ -0,0 +1,134 @@ +using System.Text; +using System.Diagnostics; +namespace System +{ + extension String + { + public void PadLeft(int totalWidth, char8 paddingChar) + { + Insert(0, paddingChar, totalWidth - Length); + } + + public void PadRight(int totalWidth, char8 paddingChar) + { + Append(paddingChar, totalWidth - Length); + } + + public mixin ToScopedNativeWCStr() + { + int encodedLen = UTF16.GetEncodedLen(this); + char16* buf; + if (encodedLen < 128) + { + buf = scope:mixin char16[encodedLen + 1]* ( ? ); + } + else + { + buf = new char16[encodedLen + 1]* ( ? ); + defer:mixin delete buf; + } + + UTF16.Encode(this, buf, encodedLen); + buf[encodedLen] = 0; + buf + } + + public int UTF8Length + { + get + { + int length = 0; + for (let ch in RawChars) + { + if (((int)ch & 0xc0) != 0x80) + length += 1; + } + return length; + } + } + + // Returns the number slice of the string starting at the supplied index. + public Result GetNumberSlice(int index = 0, bool supportHex = false) + { + Debug.Assert(Length > index && index >= 0); + + int i = index; + for (; i < Length; i++) + { + if (!this[i].IsDigit || (supportHex && !this[i].IsXDigit)) + break; + } + + if (i == index) + return .Err; + + return .Ok(.(Ptr + index, i - index)); + } + + + // Code ported from wine's StrCmpLogicalW implementation. + public static int CompareNumeric(String str, String other) + { + if (!String.IsNullOrEmpty(str) && !String.IsNullOrEmpty(other)) + { + int strIndex = 0, otherIndex = 0; + + while (strIndex < str.Length) + { + if (otherIndex >= other.Length) + return 1; + + if (str[strIndex].IsDigit) + { + if (!other[otherIndex].IsDigit) + return -1; + + StringView strValueView = str.GetNumberSlice(strIndex); + StringView otherValueView = other.GetNumberSlice(otherIndex); + + int64 strValue = 0L, otherValue = 0L; + + if (!(int64.Parse(strValueView) case .Ok(out strValue))) + { + // overflow? + strValue = int64.MaxValue; + } + + if (!(int64.Parse(otherValueView) case .Ok(out otherValue))) + { + // overflow? + otherValue = int64.MaxValue; + } + + if (strValue < otherValue) + return -1; + else if (strValue > otherValue) + return 1; + + strIndex += strValueView.Length; + otherIndex += otherValueView.Length; + } + else if (other[otherIndex].IsDigit) + return 1; + else + { + int diff = String.Compare(str.Ptr + strIndex, 1, other.Ptr + otherIndex, 1, true); + + if (diff > 0) + return 1; + else if (diff < 0) + return -1; + + strIndex++; + otherIndex++; + } + } + + if (otherIndex < other.Length) + return -1; + } + + return 0; + } + } +} diff --git a/src/Extensions/StringView.bf b/src/Extensions/StringView.bf new file mode 100644 index 0000000..2f2dcdd --- /dev/null +++ b/src/Extensions/StringView.bf @@ -0,0 +1,71 @@ +using System.Diagnostics; +using System.Text; + +namespace System +{ + extension StringView + { + public Result GetFirstLine() + { + if (IsEmpty) + return .Err; + + int i = 0; + repeat + { + char8 ch = this[i]; + // Note the following common line feed char8s: + // \n - UNIX \r\n - DOS \r - Mac + if (ch == '\r' || ch == '\n') + { + StringView strView = .(Ptr, i); + return .Ok(strView); + } + i++; + } + while (i < Length); + + StringView strView = .(Ptr, Length); + return .Ok(strView); + } + + public Result GetNextLine(StringView previousLine) + { + if (IsEmpty) + return .Err; + + Debug.Assert(previousLine.Ptr == null || (Ptr <= previousLine.Ptr && EndPtr >= previousLine.EndPtr)); + + int charPos = previousLine.Ptr == null ? 0 : ((int)(void*)previousLine.EndPtr - (int)(void*)Ptr); + char8 ch = this[charPos]; + + if (charPos > 0 && (ch == '\r' || ch == '\n')) + { + charPos += 1; + if (ch == '\r' && charPos < Length) + { + if (this[charPos] == '\n') + charPos++; + } + } + + int i = charPos; + repeat + { + ch = this[i]; + // Note the following common line feed char8s: + // \n - UNIX \r\n - DOS \r - Mac + if (ch == '\r' || ch == '\n') + { + StringView strView = .(Ptr + charPos, i - charPos); + return .Ok(strView); + } + i++; + } + while (i < Length); + + StringView strView = .(Ptr + charPos, Length - charPos); + return .Ok(strView); + } + } +} diff --git a/src/Helpers/Base64Encoder.bf b/src/Helpers/Base64Encoder.bf new file mode 100644 index 0000000..ed1bf0a --- /dev/null +++ b/src/Helpers/Base64Encoder.bf @@ -0,0 +1,86 @@ +using System; +using System.Diagnostics; + +namespace darkredhttpd.Helpers +{ + // Base64 encoder from: https://github.com/ramonsmits/Base64Encoder + // (with some changes to simplify it) + public static class Base64Encoder + { + const char8 PaddingChar = '='; + static readonly uint8[123] Map = CreateMap(); + + const String CharacterSet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + const bool PaddingEnabled = true; + + private static uint8[123] CreateMap() + { + Debug.Assert(CharacterSet.Length <= uint8.MaxValue); + + uint8[123] map = .(); + for (uint8 i < (.)CharacterSet.Length) + map[(int)CharacterSet[i]] = i; + + return map; + } + + public static void Encode(Span data, String outString) => + Encode(Span((.)data.Ptr, data.Length), outString); + + public static void Encode(Span data, String outString) + { + int length; + if (0 == (length = data.Length)) + { + outString.Clear(); + return; + } + + uint8* d = data.Ptr; + + int padding = length % 3; + if (padding > 0) + padding = 3 - padding; + int blocks = (length - 1) / 3 + 1; + + int l = blocks * 4; + + outString.Reserve(l); + + char8* sp = outString.Ptr; + uint8 b1, b2, b3; + + for (int i = 1; i < blocks; i++) + { + b1 = *d++; + b2 = *d++; + b3 = *d++; + + *sp++ = CharacterSet[(b1 & 0xFC) >> 2]; + *sp++ = CharacterSet[(b2 & 0xF0) >> 4 | (b1 & 0x03) << 4]; + *sp++ = CharacterSet[(b3 & 0xC0) >> 6 | (b2 & 0x0F) << 2]; + *sp++ = CharacterSet[b3 & 0x3F]; + } + + bool pad2 = padding == 2; + bool pad1 = padding > 0; + + b1 = *d++; + b2 = pad2 ? (uint8)0 : *d++; + b3 = pad1 ? (uint8)0 : *d++; + + *sp++ = CharacterSet[(b1 & 0xFC) >> 2]; + *sp++ = CharacterSet[(b2 & 0xF0) >> 4 | (b1 & 0x03) << 4]; + *sp++ = pad2 ? '=' : CharacterSet[(b3 & 0xC0) >> 6 | (b2 & 0x0F) << 2]; + *sp++ = pad1 ? '=' : CharacterSet[b3 & 0x3F]; + + if (!PaddingEnabled) + { + if (pad2) l--; + if (pad1) l--; + } + + outString.[Friend]mLength = (.)l; + } + } +} diff --git a/src/Helpers/HtmlHelper.bf b/src/Helpers/HtmlHelper.bf new file mode 100644 index 0000000..6583a2a --- /dev/null +++ b/src/Helpers/HtmlHelper.bf @@ -0,0 +1,35 @@ +using System; + +namespace darkredhttpd.Helpers +{ + public static class HtmlHelper + { + public static void EscapeString(String inString, String outString) + { + for (int pos < inString.Length) + { + switch (inString[pos]) + { + case '<': + outString.Append("<"); + break; + case '>': + outString.Append(">"); + break; + case '&': + outString.Append("&"); + break; + case '\'': + outString.Append("'"); + break; + case '"': + outString.Append("""); + break; + default: + outString.Append(inString[pos]); + break; + } + } + } + } +} diff --git a/src/Helpers/HttpHelper.bf b/src/Helpers/HttpHelper.bf new file mode 100644 index 0000000..ba44227 --- /dev/null +++ b/src/Helpers/HttpHelper.bf @@ -0,0 +1,180 @@ +using System; +using System.Diagnostics; + +namespace darkredhttpd.Helpers +{ + public static class HttpHelper + { + // Set of safe chars, from RFC 1738.4 minus '+' + public static bool IsUrlSafeChar(char8 ch) + { + if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9')) + return true; + + switch (ch) + { + case '-', '_', '.', '!', '*', '(', ')': + return true; + } + + return false; + } + + private static char8 IntToHex(int n) + { + Debug.Assert(n < 0x10); + + if (n <= 9) + return (.)(n + (int)'0'); + else + return (.)(n - 10 + (int)'a'); + } + + // Encode string to be an RFC3986-compliant URL part. + // Copied from Microsoft repository and adapted to Beef with some modifications. + // (https://github.com/microsoft/referencesource/blob/master/System.ServiceModel.Internals/System/Runtime/UrlUtility.cs#L238) + public static void EncodeUrl(StringView urlStr, String outString) + { + int cSpaces = 0; + int cUnsafe = 0; + + // count them first + for (char8 ch in urlStr) + { + if (ch == ' ') + cSpaces++; + else if (!IsUrlSafeChar(ch)) + cUnsafe++; + } + + // nothing to expand? + if (cSpaces == 0 && cUnsafe == 0) + { + if (urlStr.Ptr != outString.Ptr) + outString.Append(urlStr); + return; + } + + // expand not 'safe' characters into %XX, spaces to %20 + outString.Reserve(urlStr.Length + (cUnsafe + cSpaces) * 2); + + for (char8 ch in urlStr) + { + if (IsUrlSafeChar(ch)) + { + outString.Append(ch); + } + else if (ch == ' ') + { + outString.Append("%20"); + } + else + { + outString.Append('%'); + outString.Append((char8)IntToHex(((int)ch >> 4) & 0xf)); + outString.Append((char8)IntToHex((int)ch & 0x0f)); + } + } + } + + // Decode URL by converting %XX (where XX are hexadecimal digits) to the + // character it represents. + public static void DecodeUrl(StringView urlStr, String outString) + { + outString.Reserve(urlStr.Length); + for (int i = 0; i < urlStr.Length; i++) + { + if (urlStr[i] == '%' && i+2 < urlStr.Length && + urlStr[i+1].IsXDigit && urlStr[i+2].IsXDigit) + { + // decode %XX + outString.Append((char8)int32.Parse(urlStr.Substring(i+1, 2), .AllowHexSpecifier)); + i += 2; + } + else if (urlStr[i] == '+') + { + // white-space + outString.Append(' '); + } + else + { + // straight copy + outString.Append(urlStr[i]); + } + } + } + + /* Resolve /./ and /../ in a URL, in-place. + * Returns NULL if the URL is invalid/unsafe, or the original buffer if + * successful. + * TODO: Make this method safer and more "beefy". + * (This is a kind of direct port from the C code) + */ + public static Result EnsureSafeUrl(String url) + { + mixin ends(var c) + { + c == '/' || c == '\0' + } + + char8* src = url, dst; + + /* URLs not starting with a slash are illegal. */ + if (src[0] != '/') + return .Err; + + /* Fast case: skip until first double-slash or dot-dir. */ + for (; src != url.Ptr + url.Length; ++src) + { + if (src[0] == '/') + { + if (src[1] == '/') + break; + else if (src[1] == '.') + { + if (ends!(src[2])) + break; + else if (src[2] == '.' && ends!(src[3])) + break; + } + } + } + + /* Copy to dst, while collapsing multi-slashes and handling dot-dirs. */ + dst = src; + while (src != url.Ptr + url.Length) + { + if (src[0] != '/') + (dst++)[0] = (src++)[0]; + else if ((++src)[0] == '/') + continue; + else if (src[0] != '.') + (dst++)[0] = '/'; + else if (ends!(src[1])) + /* Ignore single-dot component. */ + ++src; + else if (src[1] == '.' && ends!(src[2])) { + /* Double-dot component. */ + src += 2; + if (dst == url.Ptr) + return .Err; /* Illegal URL */ + else + { + /* Backtrack to previous slash. */ + while ((--dst)[0] != '/' && dst > url.Ptr) {} + } + } + else + (dst++)[0] = '/'; + } + + int removeLength = url.Length - (dst - url.Ptr); + if (removeLength > 0) + { + url.Remove(dst - url.Ptr, removeLength); + } + + return .Ok; + } + } +} diff --git a/src/Helpers/MiscHelper.bf b/src/Helpers/MiscHelper.bf new file mode 100644 index 0000000..a092e56 --- /dev/null +++ b/src/Helpers/MiscHelper.bf @@ -0,0 +1,79 @@ +using System; + +namespace darkredhttpd.Helpers +{ + public static class MiscHelper + { + // Truncate that can retain an specified digit count. + public static double Truncate(double value, int digits) + { + double mult = Math.Pow(10.0, digits); + return Math.Truncate(value * mult) / mult; + } + + /// Returns how many digits there are to the left of the decimal point. + public static int CountDigits(double value) + { + var value; + int digits = 0; + + while (value >= 1) + { + digits++; + value /= 10; + } + + return digits; + } + + /// Returns how many digits there are to the left of the decimal point. + public static int CountDigits(double value, int max) + { + var value; + int digits = 0; + + while (value >= 1 && digits < max) + { + digits++; + value /= 10; + } + + return digits; + } + + // Converts a numeric value into a string that represents the number expressed as a size value in bytes, kilobytes, megabytes, or gigabytes, depending on the size. + public static void FormatByteSize(int64 bytes, bool si, String outString) + { + var unit = si + ? 1000 + : 1024; + + if (bytes < unit) + { + outString.AppendF($"{bytes} B"); + return; + } + + var exp = (int) (Math.Log(bytes) / Math.Log(unit)); + var size = bytes / Math.Pow(unit, exp); + +#if BF_32_BIT + // Currently Beef gives a linker error in Truncate in 32 bits mode. + outString.AppendF($"{size:F2} "); +#else + var digits = CountDigits(size, 3); + if (digits == 1) + outString.AppendF($"{Truncate(size, 2):F2} "); + else if (digits == 2) + outString.AppendF($"{Truncate(size, 1):F1} "); + else if (digits >= 3) + outString.AppendF($"{Math.Truncate(size)} "); +#endif + + outString.Append((si ? "kMGTPE" : "KMGTPE")[exp - 1]); + if (si) + outString.Append('i'); + outString.Append('B'); + } + } +} diff --git a/src/Net/Connection.bf b/src/Net/Connection.bf new file mode 100644 index 0000000..c292696 --- /dev/null +++ b/src/Net/Connection.bf @@ -0,0 +1,848 @@ +using System; +using System.Diagnostics; +using System.Net; +using System.IO; +using System.Collections; +using darkredhttpd.Helpers; + +namespace darkredhttpd +{ + class Connection + { + public enum State + { + RecvRequest, // receiving request + SendHeader, // sending generated header */ + SendReply, // sending reply + Done // connection closed, need to remove from queue + } + + public Socket Socket; + public State State; + public DateTime LastActive; + public bool Close; + public Request Request; + public Response Response; + + public bool HeaderOnly; + public int64 HeaderSent; + public int64 ResponseSent; + + public DateTime LastBurst; + public int32 BurstSize; + + public bool IsWaitingNextBurst + { + get + { + return DateTime.Now - LastBurst <= TimeSpan(0, 0, 1) && + BurstSize == 0; + } + } + + public String KeepAliveHeader + { + get + { + return Close ? "Connection: close\r\n" : HttpListener.KeepAliveField; + } + } + + public this() + { + LastActive = DateTime.Now; + Close = true; + + // Make it harmless so it gets garbage-collected if it should, for some + // reason, fail to be correctly filled out. + State = .Done; + } + + public ~this() + { + Free(); + } + + public void Log() + { + if (HttpListener.Settings.NoLog || + Request == null || Response == null || Request.Method == .Unknown) + return; + + let logText = scope String(); + logText.AppendF("{0} - - [{1:dd/MMM/yyyy:H:mm:ss zzz}] \"{2} {3} HTTP/1.1\" {4} {5} \"{6}\" \"{7}\"", + Socket.GetAddressText(.. scope .()), + DateTime.Now, + Request.Method, + Request.Url, + Response.Code, + Response.Length, + Request.Headers.TryGetValue("referer", .. var s1) ?? "null", + Request.Headers.TryGetValue("user-agent", .. var s2) ?? "null"); + + if (HttpListener.LogFile != null) + { + HttpListener.LogFile.Write(logText); + HttpListener.LogFile.Write(Environment.NewLine); + HttpListener.LogFile.Flush(); + } + else + { + Console.WriteLine(logText); + } + } + + public void Free(bool freeSocket = true) + { + if (freeSocket && Constants.DEBUG) + Console.WriteLine("free connection({0})", (int32)Socket.NativeSocket); + + Log(); + + if (freeSocket && Socket != null) + { + Socket.Close(); + delete Socket; + } + + DeleteAndNullify!(Request); + DeleteAndNullify!(Response); + + /* If we ran out of sockets, try to resume accepting. */ + HttpListener.Accepting = true; + + if (!HttpListener.Settings.AuthKey.IsEmpty && freeSocket) + ClearLoginAttempts(); + } + + // Recycle a finished connection for HTTP/1.1 Keep-Alive. + public void Recycle() + { + if (Constants.DEBUG) + Console.WriteLine("recycle connection({0})", (int32)Socket.NativeSocket); + + Free(false); + + Close = true; + HeaderOnly = false; + HeaderSent = 0; + //ResponseStart = 0; + ResponseSent = 0; + + State = .RecvRequest; /* ready for another */ + } + + // If a connection has been idle for more than TimeoutSecs, it will be + // marked as .Done and killed off in Poll(). + public void PollCheckTimeout() + { + if (HttpListener.Settings.TimeoutSecs > 0) + { + if (DateTime.Now - LastActive >= TimeSpan(0, 0, HttpListener.Settings.TimeoutSecs)) + { + if (Constants.DEBUG) + Console.WriteLine("poll_check_timeout({0}) closing connection", (int32)Socket.NativeSocket); + Close = true; + State = .Done; + } + } + } + + // Receiving request. + public void PollRecvRequest() + { + Debug.Assert(State == .RecvRequest); + + char8[1<<15] buf = ?; + let recvd = Socket.Recv(&buf[0], buf.Count); + + if (recvd < 1) + { + if (recvd == -1) + { + int32 err = Socket.[Friend]GetLastError(); +#if BF_PLATFORM_WINDOWS + if (err == 10035) /* WSAEWOULDBLOCK */ +#elif BF_PLATFORM_LINUX + if (err == EAGAIN) +#endif + { + if (Constants.DEBUG) + Console.WriteLine("poll_recv_request would have blocked"); + return; + } + if (Constants.DEBUG) + Console.WriteLine("recv({0}) error: {1}", (int32)Socket.NativeSocket, CurrentPlatform.GetErrorMessage(err, .. scope .())); + } + Close = true; + State = .Done; + return; + } + + LastActive = DateTime.Now; + + if (Request == null) + Request = new .(); + + Request.AddData(buf, recvd); + + HttpListener.TotalIN += recvd; + + if (Request.Length > Constants.MAX_REQUEST_LENGTH) + { + /* Drop connection right away, we don't want to receive even more data. */ + SetDefaultReply(413, "Request Entity Too Large", + "Your request was dropped because it was too long."); + Close = true; + State = .SendHeader; + } + else if (Request.IsComplete) + { + HttpListener.NumRequests++; + ProcessRequest(); + } + + // if we've moved on to the next state, try to send right away, instead of + // going through another iteration of the select() loop. + if (State == .SendHeader) + PollSendHeader(); + } + + // Sending header. + public void PollSendHeader() + { + Debug.Assert(State == .SendHeader); + Debug.Assert(Response != null); + + int32 sent = Socket.Send( + Response.Headers.Ptr + HeaderSent, + Response.Headers.Length - HeaderSent); + + if (sent < 1) + { + if (sent == -1) + { + int32 err = Socket.[Friend]GetLastError(); +#if BF_PLATFORM_WINDOWS + if (err == 10035) /* WSAEWOULDBLOCK */ +#elif BF_PLATFORM_LINUX + if (err == EAGAIN) +#endif + { + if (Constants.DEBUG) + Console.WriteLine("poll_send_header would have blocked"); + return; + } + if (Constants.DEBUG) + Console.WriteLine("send({0}) error: {1}", (int32)Socket.NativeSocket, CurrentPlatform.GetErrorMessage(err, .. scope .())); + } + Close = true; + State = .Done; + return; + } + + if (Constants.DEBUG) + Console.WriteLine("poll_send_header({0}) sent {1} bytes", (int32)Socket.NativeSocket, sent); + + LastActive = DateTime.Now; + + Debug.Assert(sent > 0); + + HeaderSent += sent; + HttpListener.TotalOUT += sent; + + // check if we're done sending header + if (HeaderSent == Response.Headers.Length) + { + if (HeaderOnly) + State = .Done; + else + { + State = .SendReply; + // go straight on to body, don't go through another iteration of + // the select() loop. + PollSendReply(); + } + } + } + + // Sending reply. + public void PollSendReply() + { + Debug.Assert(State == .SendReply); + Debug.Assert(!HeaderOnly); + Debug.Assert(Response != null); + + int32 sent = 0; + + if (Response.Type == .Generated) + { + Debug.Assert(Response.Length >= ResponseSent); + sent = Socket.Send( + Response.TextContentView.Ptr + ResponseSent, + Response.TextContentView.Length - ResponseSent); + Debug.Assert(sent < 1 || sent == Response.TextContentView.Length - ResponseSent); + } + else + { + if (HttpListener.Settings.ThrottleBPS > 0) + { + if (IsWaitingNextBurst) + return; + + if (BurstSize == 0) + { + LastBurst = DateTime.Now; + BurstSize = HttpListener.Settings.ThrottleBPS; + } + } + + errno() = 0; + Debug.Assert(Response.Length >= ResponseSent); + Debug.Assert(Response.Start + ResponseSent >= 0); + + // Must be less or equal to int32.MaxValue - 1 (Windows' TransmitFile limitation) + const int maxChunkSize = 1 << 15; + + let offset = Response.Start + ResponseSent; + let size = Response.Length - ResponseSent; + int32 chunkSize = (.)Math.Min(maxChunkSize, size); + + if (HttpListener.Settings.ThrottleBPS > 0 && chunkSize > BurstSize) + chunkSize = BurstSize; + +#if BF_PLATFORM_WINDOWS + Response.ContentFileStream.Position = offset; + // TODO: Figure out why this is slower than the generic approach. + //sent = Socket.SendFile(Response.ContentFileStream.Handle, (.)chunkSize); + + uint8[maxChunkSize] buf = ?; + int numread; + if (!(Response.ContentFileStream.TryRead(.(&buf[0], chunkSize)) case .Ok(out numread)) || + numread != chunkSize) + { + Console.WriteLine("file read failed: {}", CurrentPlatform.GetLastErrorMessage(.. scope .())); + Close = true; + State = .Done; + return; + } + + sent = Socket.Send(&buf[0], chunkSize); +#elif BF_PLATFORM_LINUX + // TODO: This may give problems with offsets larger than 32-bits... + sent = Socket.SendFile(Response.ContentFileStream.Handle, (.)offset, chunkSize); +#endif + + Debug.Assert(sent < 1 || sent == chunkSize); + } + + LastActive = DateTime.Now; + + if (sent < 1) + { + if (sent == -1) + { + int32 err = Socket.[Friend]GetLastError(); +#if BF_PLATFORM_WINDOWS + if (err == 10035) /* WSAEWOULDBLOCK */ +#elif BF_PLATFORM_LINUX + if (err == EAGAIN) +#endif + { + if (Constants.DEBUG) + Console.WriteLine("poll_send_reply would have blocked"); + return; + } + if (Constants.DEBUG) + Console.WriteLine("send({0}) error: {1}", (int32)Socket.NativeSocket, CurrentPlatform.GetErrorMessage(err, .. scope .())); + } + else if (sent == 0) + { + if (Constants.DEBUG) + Console.WriteLine("sent({0}) closure", (int32)Socket.NativeSocket); + } + Close = true; + State = .Done; + return; + } + + if (Response.Type != .Generated && HttpListener.Settings.ThrottleBPS > 0) + { + Debug.Assert(BurstSize >= sent); + BurstSize -= sent; + } + + ResponseSent += sent; + HttpListener.TotalOUT += sent; + + if (Constants.DEBUG) + Console.WriteLine("poll_send_reply({0}) sent {1}: {2}+[{3}-{4}] of {5} (remaining: {6})", + (int32)Socket.NativeSocket, sent, + (int64)Response.Start + sent - 1, ResponseSent, + (int64)ResponseSent + sent - 1, Response.Length, + (int64)Response.Length - ResponseSent); + + if (ResponseSent == Response.Length) + State = .Done; + } + + // Process a request: build the header and reply, advance state. + private void ProcessRequest() + { + if (Request.Parse() case .Err) + { + SetDefaultReply(400, "Bad Request", + "You sent a request that the server couldn't understand."); + } + else if (HttpHelper.EnsureSafeUrl(Request.Url) case .Err) + { + SetDefaultReply(400, "Bad Request", + "You requested an invalid URL."); + } + else if (!HttpListener.Settings.AuthKey.IsEmpty && + CheckAuthRateLimit(Socket.GetAddressText(.. scope .()))) + { + SetDefaultReply(403, "Forbidden", + "Too many failed login attempts. Try again in 5 minutes."); + } + else if (!HttpListener.Settings.AuthKey.IsEmpty && + Request.Headers.TryGetValue("authorization", .. var auth) != HttpListener.Settings.AuthKey) + { + if (DoAuthRateLimit(Socket.GetAddressText(.. scope .()))) + { + SetDefaultReply(403, "Forbidden", + "Too many failed login attempts. Try again in 5 minutes."); + } + else + { + SetDefaultReply(401, "Unauthorized", + "Access denied due to invalid credentials."); + } + } + else + { + // the request is valid and is ready to be processed + if (Request.Protocol == "HTTP/1.1") + Close = false; + + if (Request.Headers.TryGetValue("connection", var val)) + { + if (val == "close") + Close = true; + else if (val == "keep-alive") + Close = false; + } + + // cmdline flag can be used to deny keep-alive + if (!HttpListener.Settings.WantKeepAlive) + Close = true; + + if (Request.Method == .GET) + { + ProcessGet(); + } + else if (Request.Method == .HEAD) + { + ProcessGet(); + HeaderOnly = true; + } + else + { + SetDefaultReply(501, "Not Implemented", + "The method you specified is not implemented."); + } + } + + // advance state + State = .SendHeader; + } + + private mixin GetRFC1123Date() + { + // TODO: The commented out code crashes on linux, it needs to be fixed in corlib. + DateTime.Now./*ToUniversalTime().*/ToString(.. scope:mixin .(), "R") + } + + // Process a GET/HEAD request. + private void ProcessGet() + { + String target = ?; + StringView mimeType = HttpListener.DefaultMimeType; + + // does it end in a slash? serve up url/index_name + if (Request.Url.EndsWith('/')) + { + target = scope:: $"{HttpListener.Settings.WWWRoot}{Request.Url}{HttpListener.Settings.IndexName}"; + if (!File.Exists(target)) + { + target.RemoveFromEnd(HttpListener.Settings.IndexName.Length); + if (!Directory.Exists(target) || HttpListener.Settings.NoListing) + { + // Return 404 instead of 403 to make --no-listing + // indistinguishable from the directory not existing. + // i.e.: Don't leak information. + SetDefaultReply(404, "Not Found", + "The URL you requested was not found."); + return; + } + GenerateDirListing(target); + return; + } + + if (HttpListener.Settings.IndexName.Contains(".")) + mimeType = HttpListener.GetContentType(HttpListener.Settings.IndexName); + } + else + { + target = scope:: $"{HttpListener.Settings.WWWRoot}{Request.Url}"; + + if (Request.Url.Contains(".")) + mimeType = HttpListener.GetContentType(Request.Url); + } + + if (Constants.DEBUG) + Console.WriteLine("url=\"{0}\", target=\"{1}\", content-type=\"{2}\"", + Request.Url, target, mimeType); + + if (Directory.Exists(target)) + { + Redirect(scope $"{Request.Url}/"); + return; + } + + UnbufferedFileStream fs = new .(); + defer { delete fs; } + + if (fs.Open(target, .Read, .ReadWrite) case .Err(let err)) + { + switch (err) + { + case .NotFile: + SetDefaultReply(403, "Forbidden", "Not a regular file."); + break; + case .NotFound: + SetDefaultReply(404, "Not Found", "The URL you requested was not found."); + break; + // TODO idk how to check if we have permission... + //case : + // SetDefaultReply(403, "Forbidden", "You don't have permission to access this URL."); + // break; + case .SharingViolation, .Unknown: + SetDefaultReply(500, "Internal Server Error", scope $"The URL you requested cannot be returned: {CurrentPlatform.GetLastErrorMessage(.. scope .())}."); + break; + } + return; + } + + DateTime lastWriteTime; + if (!(File.GetLastWriteTimeUtc(target) case .Ok(out lastWriteTime))) + { + SetDefaultReply(500, "Internal Server Error", + scope $"Failed to get file information: {CurrentPlatform.GetLastErrorMessage(.. scope .())}."); + return; + } + + String lastMod = lastWriteTime.ToString(.. scope .(), "R"); + + if (Request.Headers.TryGetValue("if-modified-since", let ifModSince) && + ifModSince == lastMod) + { + if (Constants.DEBUG) + Console.WriteLine("not modified since {0}", ifModSince); + + var builder = scope ResponseBuilder(304, "Not Modified") + ..Date(GetRFC1123Date!()) + ..AppendHeader(HttpListener.ServerHDR) + ..AcceptRanges("bytes") + ..AppendHeader(KeepAliveHeader); + + Response = builder.Build(); + + HeaderOnly = true; + return; + } + + if (Request.RangeBegin.HasValue || Request.RangeEnd.HasValue) + { + int64 from = ?, to = ?; + + if (Request.RangeBegin.HasValue && Request.RangeEnd.HasValue) + { + // 100-200 + from = Request.RangeBegin.Value; + to = Request.RangeEnd.Value; + + // clamp end to filestat.st_size-1 + if (to > (fs.Length - 1)) + to = fs.Length - 1; + } + else if (Request.RangeBegin.HasValue && !Request.RangeEnd.HasValue) + { + // 100- :: yields 100 to end + from = Request.RangeBegin.Value; + to = fs.Length - 1; + } + else if (!Request.RangeBegin.HasValue && Request.RangeEnd.HasValue) + { + // -200 :: yields last 200 + to = fs.Length - 1; + from = to - Request.RangeEnd.Value + 1; + + // clamp start + if (from < 0) + from = 0; + } + + if (from >= fs.Length) + { + SetDefaultReply(416, "Requested Range Not Satisfiable", + "You requested a range outside of the file."); + return; + } + + if (to < from) + { + SetDefaultReply(416, "Requested Range Not Satisfiable", + "You requested a backward range."); + return; + } + + var builder = scope ResponseBuilder(206, "Partial Content") + ..Date(GetRFC1123Date!()) + ..AppendHeader(HttpListener.ServerHDR) + ..AcceptRanges("bytes") + ..AppendHeader(KeepAliveHeader); + + builder.AddFileRange(fs, mimeType, from, to); + builder.LastModified(lastMod); + + Response = builder.Build(); + + if (Constants.DEBUG) + Console.WriteLine("sending {0}-{1}/{2}", from, to, fs.Length); + } + else + { + // no range stuff + var builder = scope ResponseBuilder(200, "OK") + ..Date(GetRFC1123Date!()) + ..AppendHeader(HttpListener.ServerHDR) + ..AcceptRanges("bytes") + ..AppendHeader(KeepAliveHeader); + + builder.AddFile(fs, mimeType); + builder.LastModified(lastMod); + + Response = builder.Build(); + } + + // Avoid fs being deleted. + fs = null; + } + + private static String _generatedOn = new .() ~ delete _; + private mixin GeneratedOn(String date) + { + String ret = ""; + if (HttpListener.Settings.WantServerID) + { + ret = _generatedOn; + ret.Clear(); + ret.AppendF("Generated by {} on {}", Constants.PKG_NAME, date); + } + ret + } + + private void Redirect(String targetUrl) + { + Debug.Assert(Response == null); + + String rfc1123Date = GetRFC1123Date!(); + + var builder = scope ResponseBuilder(301, "Moved Permanently") + ..Date(rfc1123Date) + ..AppendHeader(HttpListener.ServerHDR) + /* ..AcceptRanges("bytes") - not relevant here */ + ..Location(targetUrl) + ..AppendHeader(KeepAliveHeader); + + builder.AddTextContent( + new $""" + 301 Moved Permanently +

Moved Permanently

+ Moved to: {targetUrl} +
+ {GeneratedOn!(rfc1123Date)} + \n + """); + + Response = builder.Build(); + } + + /* A default reply for any (erroneous) occasion. */ + private void SetDefaultReply(int32 errCode, String errName, String errMsg) + { + Debug.Assert(Response == null); + + String rfc1123Date = GetRFC1123Date!(); + + var builder = scope ResponseBuilder(errCode, errName) + ..Date(rfc1123Date) + ..AppendHeader(HttpListener.ServerHDR) + ..AcceptRanges("bytes") + ..AppendHeader(KeepAliveHeader); + + builder.AddTextContent( + new $""" + {errCode} {errName} +

{errName}

+ {errMsg} +
+ {GeneratedOn!(rfc1123Date)} + \n + """); + + if (!HttpListener.Settings.AuthKey.IsEmpty) + builder.AppendHeader("WWW-Authenticate: Basic realm=\"User Visible Realm\"\r\n"); + + Response = builder.Build(); + } + + private void GenerateDirListing(String target) + { + Debug.Assert(Response == null); + + List<(String, bool, int64)> dirList = scope .(); + + for (var entry in Directory.EnumerateDirectories(target)) + { + dirList.Add((entry.GetFileName(.. scope:: .()), entry.IsDirectory, entry.GetFileSize())); + } + + for (var entry in Directory.EnumerateFiles(target)) + { + dirList.Add((entry.GetFileName(.. scope:: .()), entry.IsDirectory, entry.GetFileSize())); + } + + dirList.Sort(scope (lhs, rhs) => { + if (!lhs.1 && rhs.1) + return 1; + if (lhs.1 && !rhs.1) + return -1; + return String.CompareNumeric(lhs.0, rhs.0); + }); + dirList.Insert(0, ("..", true, 0)); + + int maxLen = 0; + for (var entry in dirList) + maxLen = Math.Max(maxLen, entry.0.UTF8Length); + + String listing = new .(4096); + listing.Append("\n\n"); + HtmlHelper.EscapeString(Request.Url, listing); + listing.Append( + """ + + + \n\n

+ """); + HtmlHelper.EscapeString(Request.Url, listing); + listing.Append("

\n
\n");
+
+			for (var entry in dirList)
+			{
+				listing.Append("");
+				HtmlHelper.EscapeString(entry.0, listing);
+				listing.Append("");
+
+				if (entry.1)
+					listing.Append("/\n");
+				else
+				{
+					listing.Append(' ', maxLen - entry.0.UTF8Length);
+					listing.AppendF("{0,10}\n", MiscHelper.FormatByteSize(entry.2, false, .. scope .()));
+				}
+			}
+
+			listing.Append(
+				"""
+				
+
\n + """); + + String rfc1123Date = GetRFC1123Date!(); + listing.Append(GeneratedOn!(rfc1123Date)); + listing.Append("\n\n"); + + var builder = scope ResponseBuilder(200, "OK") + ..Date(rfc1123Date) + ..AppendHeader(HttpListener.ServerHDR) + ..AcceptRanges("bytes") + ..AppendHeader(KeepAliveHeader); + + builder.AddTextContent(listing); + + Response = builder.Build(); + } + + private typealias LoginAttemptInfo = (DateTime firstAttempt, DateTime banTime, uint32 attemps); + private static readonly Dictionary _loginAttempts = new .() ~ delete _; + private static DateTime _lastLoginAttemptsClear; + + private static bool CheckAuthRateLimit(String ipAddress) + { + if (_loginAttempts.TryGetValue(ipAddress.GetHashCode(), let attempt)) + { + if (DateTime.Now - attempt.banTime <= TimeSpan(0, 5, 0)) + return true; + } + + return false; + } + + private static bool DoAuthRateLimit(String ipAddress) + { + if (_loginAttempts.ContainsKey(ipAddress.GetHashCode())) + { + var attemptRef = ref _loginAttempts[ipAddress.GetHashCode()]; + + if (DateTime.Now - attemptRef.banTime <= TimeSpan(0, 5, 0)) + return true; + + if (++attemptRef.attemps % 10 == 0) + { + attemptRef.banTime = DateTime.Now; + return true; + } + + return false; + } + + _loginAttempts[ipAddress.GetHashCode()] = (DateTime.Now, .(), 1); + + return false; + } + + private static void ClearLoginAttempts() + { + if (DateTime.Now - _lastLoginAttemptsClear <= TimeSpan(0, 1, 0)) + return; + + _lastLoginAttemptsClear = DateTime.Now; + + for (let attempt in _loginAttempts.Values) + { + TimeSpan timeSinceFirstAttempt = DateTime.Now - attempt.firstAttempt; + + if (timeSinceFirstAttempt >= TimeSpan(0, 5, 0) && + DateTime.Now - attempt.banTime >= TimeSpan(0, 5, 0)) + { + @attempt.Remove(); + continue; + } + } + } + } +} \ No newline at end of file diff --git a/src/Net/HttpListener.Settings.bf b/src/Net/HttpListener.Settings.bf new file mode 100644 index 0000000..72ea373 --- /dev/null +++ b/src/Net/HttpListener.Settings.bf @@ -0,0 +1,148 @@ +using System; +using System.IO; +using darkredhttpd.Helpers; + +namespace darkredhttpd +{ + extension HttpListener + { + public static class Settings + { + public static String BindAddr; +#if BF_PLATFORM_WINDOWS + public static int32 BindPort = 80; +#elif BF_PLATFORM_LINUX + public static int32 BindPort = 8080; +#endif + public static int32 MaxConnections = -1; + public static int32 TimeoutSecs = 30; + + public static bool WantKeepAlive = true; + public static bool WantServerID = true; + public static bool NoListing = false; + public static bool NoLog = false; + + public static readonly String LogFileName = new .() ~ delete _; + public static readonly String WWWRoot = new .() ~ delete _; + public static readonly String IndexName = new .("index.html") ~ delete _; + public static readonly String AuthKey = new .() ~ delete _; + + public static int32 ThrottleBPS = -1; + + private static int32 TryParseNumber(StringView str) + { + if (int32.Parse(str) case .Ok(let val)) + return val; + + Program.ExitWithError(1, "number \"{0}\" is invalid", str); + return 0; + } + + public static void ParseCommandLine(Span args) + { +#if BF_PLATFORM_LINUX + if (CurrentPlatform.IsRoot()) + HttpListener.Settings.BindPort = 80; +#endif + + HttpListener.Settings.WWWRoot.Set(args[0]); + + if (!Directory.Exists(HttpListener.Settings.WWWRoot)) + Program.ExitWithError(1, "specified wwwroot path doesn't exist"); + + // walk through the remainder of the arguments (if any) + for (int i = 1; i < args.Length; i++) + { + switch (args[i]) + { + case "--port": + if (++i >= args.Length) + Program.ExitWithError(1, "missing number after --port"); + BindPort = TryParseNumber(args[i]); + + case "--addr": + if (++i >= args.Length) + Program.ExitWithError(1, "missing ip after --addr"); + BindAddr = args[i]; + + case "--maxconn": + if (++i >= args.Length) + Program.ExitWithError(1, "missing number after --maxconn"); + MaxConnections = TryParseNumber(args[i]); + + case "--no-log": + NoLog = true; + + case "--log": + if (++i >= args.Length) + Program.ExitWithError(1, "missing filename after --log"); + LogFileName.Set(args[i]); + +#if BF_PLATFORM_LINUX + case "--chroot": + Runtime.NotImplemented(); // TODO + + case "--daemon": + Runtime.NotImplemented(); // TODO +#endif + + case "--index": + if (++i >= args.Length) + Program.ExitWithError(1, "missing filename after --index"); + IndexName.Set(args[i]); + + case "--no-listing": + NoListing = true; + + case "--mimetypes": + Runtime.NotImplemented(); // TODO + + case "--default-mimetype": + Runtime.NotImplemented(); // TODO + +#if BF_PLATFORM_LINUX + case "--uid": + Runtime.NotImplemented(); // TODO + + case "--pidfile": + Runtime.NotImplemented(); // TODO +#endif + + case "--no-keepalive": + WantKeepAlive = false; + + case "--forward": + Runtime.NotImplemented(); // TODO + + case "--forward-all": + Runtime.NotImplemented(); // TODO + + case "--no-server-id": + WantServerID = false; + + case "--timeout": + if (++i >= args.Length) + Program.ExitWithError(1, "missing number after --timeout"); + TimeoutSecs = TryParseNumber(args[i]); + + case "--throttle": + if (++i >= args.Length) + Program.ExitWithError(1, "missing number after --throttle"); + ThrottleBPS = TryParseNumber(args[i]); + + case "--auth": + if (++i >= args.Length || !args[i].Contains(':')) + Program.ExitWithError(1, "missing 'user:pass' after --auth"); + Base64Encoder.Encode(args[i], AuthKey); + AuthKey.Insert(0, "Basic "); + +#if HAVE_INET6 + case "--ipv6: + Runtime.NotImplemented(); // TODO +#endif + } + } + } + } + } +} diff --git a/src/Net/HttpListener.bf b/src/Net/HttpListener.bf new file mode 100644 index 0000000..a5ac2e2 --- /dev/null +++ b/src/Net/HttpListener.bf @@ -0,0 +1,314 @@ +using System; +using System.Diagnostics; +using System.Collections; +using System.Net; +using System.IO; + +namespace darkredhttpd +{ + [StaticInitAfter(typeof(HttpListener.Settings))] + public static class HttpListener + { + private static Socket _sockin = new .() ~ delete _; + private static SocketSelector _selector = new .() ~ delete _; + public static bool Running = false; + public static bool Accepting = false; + + private static readonly List _connections = new .() ~ DeleteContainerAndItems!(_); + + private static readonly Dictionary _mimeMap = new .() ~ DeleteDictionaryAndKeysAndValues!(_); + public const String OctetStream = "application/octet-stream"; + public static String DefaultMimeType = OctetStream; + + public static FileStream LogFile ~ delete _; + + public static readonly String ServerHDR = new .() ~ delete _; + public static readonly String KeepAliveField = new .() ~ delete _; + + public static int32 NumRequests; + public static int32 TotalIN; + public static int32 TotalOUT; + + public static ~this() + { + Socket.Uninit(); + } + + // Associates an extension with a mimetype in the mime_map. Entries are in + // unsorted order. Makes copies of extension and mimetype strings. + public static void AddMimeMapping(StringView ext, StringView mimeType) + { + Debug.Assert(ext.Length > 0); + Debug.Assert(mimeType.Length > 0); + + if (_mimeMap.TryAddAlt(ext, let keyPtr, let valuePtr)) + { + *keyPtr = new .(ext); + *valuePtr = new .(mimeType); + } + else + { + (*valuePtr).Set(mimeType); + } + } + + // Adds contents of default_extension_map[] to mime_map list. + public static void ParseDefaultExtensionMap() + { + for (let line in Constants.DEFAULT_EXTENSION_MAP.Split('\n')) + { + ParseMimetypeLine(line); + } + } + + // Parses a mime.types line and adds the parsed data to the mime_map. + private static void ParseMimetypeLine(StringView line) + { + if (line.IsEmpty) + return; + + var split = line.Split(' '); + + StringView mimeType; + if (!(split.GetNext() case .Ok(out mimeType))) + return; + + StringView ext; + while (split.GetNext() case .Ok(out ext)) + { + AddMimeMapping(ext, mimeType); + } + } + + public static void Initialize() + { + KeepAliveField.AppendF("Keep-Alive: timeout={}\r\n", Settings.TimeoutSecs); + + if (Settings.WantServerID) + ServerHDR.AppendF("Server: {}\r\n", Constants.PKG_NAME); + + Socket.Init(); + + _sockin.ReuseAddr = true; + _sockin.NoDelay = true; + + OpenLogfile(); + } + + public static void OpenLogfile() + { + if (Settings.LogFileName.IsEmpty) + return; + + LogFile = new .(); + if (LogFile.Open(Settings.LogFileName, .Append, .Write, .ReadWrite) case .Err) + DeleteAndNullify!(LogFile); + } + + public static void Listen() + { + _sockin.Port = Settings.BindPort; + + if (_sockin.Listen(Settings.MaxConnections) case .Err(let err)) + Program.ExitWithOSError(1, err, "failed to listen."); + + Console.WriteLine("listening on: http://{}:{}/", _sockin.GetAddressText(.. scope .()), Settings.BindPort); + Running = true; + Accepting = true; + +#if BF_PLATFORM_LINUX + if (signal(SIGPIPE, SIG_IGN) == SIG_ERR) + Program.ExitWithOSError(1, "signal(ignore SIGPIPE)"); +#endif + if (signal(SIGINT, => Stop) == SIG_ERR) + Program.ExitWithOSError(1, "signal(SIGINT)"); + if (signal(SIGTERM, => Stop) == SIG_ERR) + Program.ExitWithOSError(1, "signal(SIGTERM)"); + + while (Running) + { + Pool(); + } + } + + public static void Stop(int32 sig) + { + Running = false; + } + + // Main loop of the httpd - a select() and then delegation to accept + // connections, handle receiving of requests, and sending of replies. + public static void Pool() + { + _selector.Clear(); + + if (Accepting) + _selector.AddRecv(_sockin); + + bool botherWithTimeout = false; + int32 timeout = Settings.TimeoutSecs * 1000; + + for (let conn in _connections) + { + switch (conn.State) + { + case .Done: + /* do nothing */ + break; + + case .RecvRequest: + _selector.AddRecv(conn.Socket); + botherWithTimeout = true; + break; + + case .SendHeader: + _selector.AddSend(conn.Socket); + botherWithTimeout = true; + break; + + case .SendReply: + // If the current socket is waiting the next burst, + // we don't want to consider it in the current select loop. + if (Settings.ThrottleBPS <= 0 || !conn.IsWaitingNextBurst) + _selector.AddSend(conn.Socket); + else + { + // To allow a quick response, the timeout is changed to 10ms. + // Otherwise, if this was the only active connection, + // we would have to wait the full timeout before the next burst. + timeout = 10; + } + botherWithTimeout = true; + break; + } + } + + if (timeout == 0) + botherWithTimeout = false; + + /*Stopwatch sw = ?; + if (Constants.DEBUG) + { + Console.WriteLine("select() with max_fd {0} timeout {1}", (int32)_selector.[Friend]mMaxSocket, bother_with_timeout ? Settings.TimeoutSecs : 0); + sw = Stopwatch.StartNew(); + defer:: delete sw; + }*/ + + let ret = _selector.Wait(botherWithTimeout ? timeout : -1); + if (ret == 0) + { + if (!botherWithTimeout) + Program.ExitWithError(1, "select() timed out"); + } + else if (ret == -1) + { +#if BF_PLATFORM_LINUX + int32 err = errno(); + if (err == EINTR) + return; + else +#elif BF_PLATFORM_WINDOWS + int32 err = Windows.GetLastError(); +#endif + { + Program.ExitWithOSError(1, err, "select() failed."); + } + } + + // if (Constants.DEBUG) + // Console.WriteLine("select() returned after {} secs", sw.ElapsedMilliseconds / 1000); + + if (_selector.IsRecvReady(_sockin)) + AcceptConnection(); + + for (let conn in _connections) + { + conn.PollCheckTimeout(); + + switch (conn.State) + { + case .RecvRequest: + if (_selector.IsRecvReady(conn.Socket)) + conn.PollRecvRequest(); + break; + + case .SendHeader: + if (_selector.IsSendReady(conn.Socket)) + conn.PollSendHeader(); + break; + + case .SendReply: + if (_selector.IsSendReady(conn.Socket)) + conn.PollSendReply(); + break; + + case .Done: + // (handled later; ignore for now as it's a valid state) + break; + } + + // Handling SEND_REPLY could have set the state to done. + if (conn.State == .Done) + { + // clean out finished connection + if (conn.Close) + { + @conn.RemoveFast(); + delete conn; + } + else + { + conn.Recycle(); + } + } + } + } + + // Accept a connection from sockin and add it to the connection queue. + public static void AcceptConnection() + { + Socket sock = new .(); + + if (sock.AcceptFrom(_sockin) case .Err(let err)) + { + // Failed to accept, but try to keep serving existing connections. + if (err == EMFILE || err == ENFILE) + Accepting = false; + if (Constants.DEBUG) + Console.WriteLine("Failed to accept connection. ({}: {})", err, CurrentPlatform.GetErrorMessage(err, .. scope .())); + delete sock; + return; + } + + sock.Blocking = false; + + Connection conn = new .() + { + Socket = sock, + State = .RecvRequest + }; + + _connections.Add(conn); + + if (Constants.DEBUG) + { + Console.WriteLine("accepted connection from {0}:{1} (fd {2})", + StringView(Socket.[Friend]inet_ntoa(sock.[Friend]mAddress.sin_addr)), + Socket.[Friend]ntohs(sock.[Friend]mAddress.sin_port), + (int32)sock.NativeSocket); + } + + // Try to read straight away rather than going through another iteration + // of the select() loop. + conn.PollRecvRequest(); + } + + public static StringView GetContentType(String file) + { + String fileExt = scope .(file.Substring(file.LastIndexOf('.') + 1))..ToLower(); + if (_mimeMap.TryGetValue(fileExt, var mimeType)) + return mimeType; + return DefaultMimeType; + } + } +} diff --git a/src/Net/Protocol/HttpMethod.bf b/src/Net/Protocol/HttpMethod.bf new file mode 100644 index 0000000..3ec6adc --- /dev/null +++ b/src/Net/Protocol/HttpMethod.bf @@ -0,0 +1,9 @@ +namespace darkredhttpd +{ + enum HttpMethod + { + Unknown, + GET, + HEAD + } +} diff --git a/src/Net/Protocol/Request.bf b/src/Net/Protocol/Request.bf new file mode 100644 index 0000000..292c2c6 --- /dev/null +++ b/src/Net/Protocol/Request.bf @@ -0,0 +1,182 @@ +using System; +using System.IO; +using System.Diagnostics; +using System.Collections; +using darkredhttpd.Helpers; + +namespace darkredhttpd +{ + class Request + { + public HttpMethod Method; + public String Url = new .() ~ delete _; + public String Protocol = new .() ~ delete _; + public List Data = new .(512) ~ delete _; + public int32 Length; + public int64? RangeBegin; + public int64? RangeEnd; + + public Dictionary Query = new .() ~ DeleteDictionaryAndKeysAndValues!(_); + public Dictionary Headers = new .() ~ DeleteDictionaryAndKeysAndValues!(_); + + public bool IsComplete + { + get + { + return (Data.Count > 2 && Internal.MemCmp(Data.Ptr + Data.Count - 2, (.)"\n\n", 2) == 0) || + (Data.Count > 4 && Internal.MemCmp(Data.Ptr + Data.Count - 4, (.)"\r\n\r\n", 4) == 0); + } + } + + public this() + { + + } + + public void AddData(Span data, int32 length) + { + Data.AddRange(data.Slice(0, length)); + Length += length; + } + + // Parse an HTTP request like "GET / HTTP/1.1" to get the method (GET), the + // url (/), the referer (if given) and the user-agent (if given). + public Result Parse() + { + Debug.Assert(Data != null); + Debug.Assert(Length == Data.Count); + + StringView dataView = .(Data.Ptr, Data.Count); + StringView lineView = .(); + + if (dataView.GetFirstLine() case .Ok(out lineView)) + { + var splitter = lineView.Split(' '); + + StringView methodStr; + if (!(splitter.GetNext() case .Ok(out methodStr))) + return .Err; + + if (!(Enum.Parse(methodStr, true) case .Ok(out Method))) + return .Err; + + StringView urlStr; + if (!(splitter.GetNext() case .Ok(out urlStr))) + return .Err; + + if (urlStr.Contains('?')) + { + ParseQuery(urlStr.Substring(urlStr.IndexOf('?') + 1)); + HttpHelper.DecodeUrl(urlStr.Substring(0, urlStr.IndexOf('?')), Url); + } + else + HttpHelper.DecodeUrl(urlStr, Url); + + StringView protocolStr; + if (!(splitter.GetNext() case .Ok(out protocolStr))) + return .Err; + + Protocol.Set(protocolStr); + } + else + { + // couldn't understand first line + return .Err; + } + + while (dataView.GetNextLine(lineView) case .Ok(out lineView)) + { + if (lineView.IsEmpty) + break; + + var splitter = lineView.Split(':', 2); + + StringView headerKey; + if (!(splitter.GetNext() case .Ok(out headerKey))) + return .Err; + + StringView headerValue; + if (!(splitter.GetNext() case .Ok(out headerValue))) + return .Err; + + Headers.Add(new String(headerKey)..ToLower(), new String(headerValue..Trim())); + } + + // Data is not needed anymore. + DeleteAndNullify!(Data); + + if (Headers.TryGetValue("range", let val)) + ParseRangeField(val); + + return .Ok; + } + + // Parse a url query (anything after the "?") + private void ParseQuery(StringView queryStr) + { + // Url contains only "?" + if (queryStr.IsEmpty) + return; + + String decodedQueryStr = scope .(queryStr.Length); + HttpHelper.DecodeUrl(queryStr, decodedQueryStr); + + for (var kvPair in decodedQueryStr.Split('&')) + { + var splitter = kvPair.Split('=', 2); + + StringView keyStr; + if (!(splitter.GetNext() case .Ok(out keyStr))) + continue; + + StringView valueStr; + if (!(splitter.GetNext() case .Ok(out valueStr))) + Query.Add(new .(keyStr), null); + else + Query.Add(new .(keyStr), new .(valueStr)); + } + } + + // Parse a Range: field into range_begin and range_end. Only handles the + // first range if a list is given. + private void ParseRangeField(StringView rangeValue) + { + // Ignore if range format is invalid. + if (!rangeValue.StartsWith("bytes=")) + return; + + var rangeValue; + rangeValue.Adjust(6); // skip "bytes=" + + let commaIndex = rangeValue.IndexOf(','); + if (commaIndex != -1) + rangeValue.RemoveToEnd(commaIndex); + + var splitter = rangeValue.Split('-', 2); + + StringView rangeBeginStr; + if (!(splitter.GetNext() case .Ok(out rangeBeginStr))) + return; + + if (!rangeBeginStr.IsEmpty) + { + if (int64.Parse(rangeBeginStr) case .Ok(let val)) + RangeBegin = val; + else + return; + } + + StringView rangeEndStr; + if (!(splitter.GetNext() case .Ok(out rangeEndStr))) + return; + + if (!rangeEndStr.IsEmpty) + { + if (int64.Parse(rangeEndStr) case .Ok(let val)) + RangeEnd = val; + else + return; + } + } + } +} diff --git a/src/Net/Protocol/Response.bf b/src/Net/Protocol/Response.bf new file mode 100644 index 0000000..5af9b3f --- /dev/null +++ b/src/Net/Protocol/Response.bf @@ -0,0 +1,58 @@ +using System; +using System.IO; +using System.Diagnostics; + +namespace darkredhttpd +{ + class Response + { + public enum Type + { + Generated, + FromFile + } + + public Type Type; + public int32 Code; + public String Headers ~ delete _; + private String _content ~ delete _; + private UnbufferedFileStream _contentFileStream ~ delete _; + + public int64 Start; + public int64 Length; + + public StringView TextContentView + { + get + { + Debug.Assert(Type == .Generated); + return .(_content.Ptr + Start, (int)Length); + } + } + + public String TextContent + { + set + { + _content = value; + Length = value.Length; + Type = .Generated; + } + } + + public UnbufferedFileStream ContentFileStream + { + get + { + Debug.Assert(Type == .FromFile); + return _contentFileStream; + } + set + { + _contentFileStream = value; + Length = _contentFileStream.Length; + Type = .FromFile; + } + } + } +} diff --git a/src/Net/Protocol/ResponseBuilder.bf b/src/Net/Protocol/ResponseBuilder.bf new file mode 100644 index 0000000..1bf1a0b --- /dev/null +++ b/src/Net/Protocol/ResponseBuilder.bf @@ -0,0 +1,140 @@ +using System; +using System.Diagnostics; +using System.IO; + +namespace darkredhttpd +{ + class ResponseBuilder + { + public int32 Code; + public String Headers = new .(512) ~ delete _; + public String Content ~ delete _; + public UnbufferedFileStream FileStream ~ delete _; + public bool Range; + public int64 RangeStart; + public int64 RangeEnd; + public bool Finalized; + + public this(int32 code, String codeName) + { + Code = code; + Headers.AppendF("HTTP/1.1 {} {}\r\n", code, codeName); + } + + // Appends a text directly to the header. + public void AppendHeader(String str) + { + Debug.Assert(!Finalized); + Headers.Append(str); + } + + // Appends a custom field to the header. + public void AppendHeader(String key, String value) + { + Debug.Assert(!Finalized); + Headers.AppendF("{}: {}\r\n", key, value); + } + + public void AddTextContent(String str) + { + Debug.Assert(!Finalized); + Content = str; + Headers.AppendF("Content-Length: {}\r\n", Content.Length); + Headers.Append("Content-Type: text/html; charset=UTF-8\r\n"); + } + + public void AddFileRange(UnbufferedFileStream fileStream, StringView mimeType, int64 from, int64 to) + { + Debug.Assert(!Finalized); + FileStream = fileStream; + Range = true; + RangeStart = from; + RangeEnd = to; + Headers.AppendF("Content-Length: {}\r\n", to - from + 1); + Headers.AppendF("Content-Range: bytes {}-{}/{}\r\n", from, to, fileStream.Length); + Headers.AppendF("Content-Type: {}\r\n", mimeType); + } + + public void AddFile(UnbufferedFileStream fileStream, StringView mimeType) + { + Debug.Assert(!Finalized); + FileStream = fileStream; + Headers.AppendF("Content-Length: {}\r\n", fileStream.Length); + Headers.AppendF("Content-Type: {}\r\n", mimeType); + } + + public Response Build() + { + if (!Finalized) + { + Finalized = true; + Headers.Append("\r\n"); + } + else + { + Runtime.FatalError("Tried to use already finalized ResponseBuilder!"); + } + + Response response = new .() + { + Code = Code, + Headers = Headers + }; + + if (Content != null) + response.TextContent = Content; + + if (FileStream != null) + response.ContentFileStream = FileStream; + + if (Range) + { + response.Start = RangeStart; + response.Length = RangeEnd - RangeStart + 1; + } + + // Response is now the owner of these objects. + Headers = null; + Content = null; + FileStream = null; + + return response; + } + + public void Date(String str) + { + Debug.Assert(!Finalized); + Headers.AppendF("Date: {}\r\n", str); + } + + public void Server(String str) + { + Debug.Assert(!Finalized); + Headers.AppendF("Server: {}\r\n", str); + } + + public void AcceptRanges(String str) + { + Debug.Assert(!Finalized); + Headers.AppendF("Accept-Ranges: {}\r\n", str); + } + + public void Location(String str) + { + Debug.Assert(!Finalized); + Headers.AppendF("Location: {}\r\n", str); + } + + public void KeepAlive(String str) + { + Debug.Assert(!Finalized); + Headers.AppendF("Keep-Alive: {}\r\n", str); + } + + public void LastModified(String str) + { + Debug.Assert(!Finalized); + Headers.AppendF("Last-Modified: {}\r\n", str); + } + } +} diff --git a/src/Net/SocketSelector.Defs.bf b/src/Net/SocketSelector.Defs.bf new file mode 100644 index 0000000..81c219f --- /dev/null +++ b/src/Net/SocketSelector.Defs.bf @@ -0,0 +1,120 @@ +using System; +using System.Net; + +namespace darkredhttpd +{ + extension SocketSelector + { + /* + * Structure used in select() call, taken from the BSD file sys/time.h. + */ + [CRepr] + struct timeval + { + public int32 tv_sec; /* seconds */ + public int32 tv_usec; /* and microseconds */ + } + +#if BF_PLATFORM_WINDOWS + /* + * Select uses arrays of SOCKETs. These macros manipulate such + * arrays. FD_SETSIZE may be defined by the user before including + * this file, but the default here should be >= 64. + */ + const int FD_SETSIZE = 64; + + [CRepr] + struct fd_set + { + public uint32 fd_count; /* how many are SET? */ + public Socket.HSocket[FD_SETSIZE] fd_array; /* an array of SOCKETs */ + } + + static mixin FD_SET(Socket.HSocket fd, fd_set* set) + { + uint32 i; + for (i = 0; i < set.fd_count; i++) + { + if (set.fd_array[i] == fd) + break; + } + + if (i == set.fd_count) + { + if (set.fd_count < FD_SETSIZE) + { + set.fd_array[i] = fd; + set.fd_count++; + } + } + } + + static mixin FD_ZERO(fd_set* set) + { + set.fd_count = 0; + } + + [Import("wsock32.lib"), CLink, CallingConvention(.Stdcall)] + static extern int32 __WSAFDIsSet(Socket.HSocket fd, fd_set* set); + + static mixin FD_ISSET(Socket.HSocket fd, fd_set* set) + { + __WSAFDIsSet(fd, set) == 1 + } +#elif BF_PLATFORM_LINUX + /* + * Select uses bit masks of file descriptors in longs. These macros + * manipulate such bit fields (the filesystem macros use chars). + * FD_SETSIZE may be defined by the user, but the default here should + * be enough for most uses. + */ + const int FD_SETSIZE = 1024; + + /* + * We don't want to pollute the namespace with select(2) internals. + * Non-underscore versions are exposed later #if __BSD_VISIBLE + */ + const int __NBBY = 8; + typealias __fd_mask = uint32; + const int __NFDBITS = ((uint32)(sizeof(__fd_mask) * __NBBY)); /* bits per mask */ + + static int __howmany(int x, int y) + { + return (((x) + ((y) - 1)) / (y)); + } + + [CRepr] + struct fd_set + { + //public __fd_mask[__howmany(FD_SETSIZE, __NFDBITS)] fds_bits; + public __fd_mask[(((FD_SETSIZE) + ((__NFDBITS) - 1)) / (__NFDBITS))] fds_bits; + } + + static mixin FD_SET(Socket.HSocket fd, fd_set* p) + { + FD_SET!((int32)fd, p); + } + + static mixin FD_SET(int32 fd, fd_set* p) + { + p.fds_bits[fd / __NFDBITS] |= (1U << (fd % __NFDBITS)); + } + + static mixin FD_ISSET(Socket.HSocket fd, fd_set* p) + { + FD_ISSET!((int32)fd, p) + } + + static mixin FD_ISSET(int32 fd, fd_set* p) + { + (p.fds_bits[fd / __NFDBITS] & (1U << (fd % __NFDBITS))) != 0 + } + + static mixin FD_ZERO(fd_set* p) + { + for (int i < p.fds_bits.Count) + p.fds_bits[i] = 0; + } +#endif + } +} diff --git a/src/Net/SocketSelector.bf b/src/Net/SocketSelector.bf new file mode 100644 index 0000000..e93dbdc --- /dev/null +++ b/src/Net/SocketSelector.bf @@ -0,0 +1,135 @@ +// based on SocketSelector.cpp from SFML +// https://github.com/SFML/SFML/blob/master/src/SFML/Network/SocketSelector.cpp + +using System; +using System.Net; + +namespace darkredhttpd +{ + class SocketSelector + { + private fd_set mRecvSockets; + private fd_set mSendSockets; + private Socket.HSocket mMaxSocket; +#if BF_PLATFORM_WINDOWS + private int32 mSocketCount; +#endif + + public this() + { + Clear(); + } + + public void Clear() + { + FD_ZERO!(&mRecvSockets); + FD_ZERO!(&mSendSockets); + mMaxSocket = 0; +#if BF_PLATFORM_WINDOWS + mSocketCount = 0; +#endif + } + + public bool AddRecv(Socket socket) + { + if (!socket.IsOpen) + return false; + + let handle = socket.NativeSocket; + +#if BF_PLATFORM_WINDOWS + if (mSocketCount >= FD_SETSIZE) + return false; + + if (FD_ISSET!(handle, &mRecvSockets)) + return true; + + mSocketCount++; +#else + if ((int)handle >= FD_SETSIZE) + return false; + + // SocketHandle is an int in POSIX + mMaxSocket = Math.Max(mMaxSocket, handle); +#endif + + FD_SET!(handle, &mRecvSockets); + + return true; + } + + public bool AddSend(Socket socket) + { + if (!socket.IsOpen) + return false; + + let handle = socket.NativeSocket; + +#if BF_PLATFORM_WINDOWS + if (mSocketCount >= FD_SETSIZE) + return false; + + if (FD_ISSET!(handle, &mSendSockets)) + return true; + + mSocketCount++; +#else + if ((int)handle >= FD_SETSIZE) + return false; + + // SocketHandle is an int in POSIX + mMaxSocket = Math.Max(mMaxSocket, handle); +#endif + + FD_SET!(handle, &mSendSockets); + + return true; + } + + [CLink, CallingConvention(.Stdcall)] + static extern int32 select(int32 nfds, fd_set* readfds, fd_set* writefds, fd_set* exceptfds, timeval* timeout); + + public int32 Wait(int32 timeoutMS) + { + timeval time; + + if (timeoutMS >= 0) + { + time.tv_sec = timeoutMS / 1000; + time.tv_usec = (timeoutMS % 1000) * 1000; + } + + return select((.)mMaxSocket + 1, &mRecvSockets, &mSendSockets, null, timeoutMS >= 0 ? &time : null); + } + + public bool IsRecvReady(Socket socket) + { + if (!socket.IsOpen) + return false; + + let handle = socket.NativeSocket; + +#if !BF_PLATFORM_WINDOWS + if ((int)handle >= FD_SETSIZE) + return false; +#endif + + return FD_ISSET!(handle, &mRecvSockets); + } + + public bool IsSendReady(Socket socket) + { + if (!socket.IsOpen) + return false; + + let handle = socket.NativeSocket; + +#if !BF_PLATFORM_WINDOWS + if ((int)handle >= FD_SETSIZE) + return false; +#endif + + return FD_ISSET!(handle, &mSendSockets); + } + } +} diff --git a/src/Platform/UnixPlatform.bf b/src/Platform/UnixPlatform.bf new file mode 100644 index 0000000..ab7526f --- /dev/null +++ b/src/Platform/UnixPlatform.bf @@ -0,0 +1,33 @@ +using System; + +#if BF_PLATFORM_LINUX +namespace darkredhttpd +{ + class CurrentPlatform + { + [CLink, CallingConvention(.Stdcall)] + public static extern uint getuid(); + + public static bool IsRoot() + { + return getuid() == 0; + } + + public static int32 GetLastError() + { + return errno(); + } + + [CLink, CallingConvention(.Stdcall)] + public static extern char8* strerror(int32 errnum); + + public static void GetErrorMessage(int32 errno, String outString) + { + outString.Append(strerror(errno)); + } + + public static void GetLastErrorMessage(String outString) => + GetErrorMessage(GetLastError(), outString); + } +} +#endif \ No newline at end of file diff --git a/src/Platform/WinPlatform.bf b/src/Platform/WinPlatform.bf new file mode 100644 index 0000000..fff928a --- /dev/null +++ b/src/Platform/WinPlatform.bf @@ -0,0 +1,54 @@ +using System; + +#if BF_PLATFORM_WINDOWS +namespace darkredhttpd +{ + class CurrentPlatform + { + public static int32 GetLastError() + { + return Windows.GetLastError(); + } + + [Import("Kernel32.lib"), CLink, CallingConvention(.Stdcall)] + static extern int32 FormatMessageA(uint32 flags, void* source, uint32 messageId, uint32 languageId, char8* buffer, uint32 size, VarArgs* args); + + const int FORMAT_MESSAGE_ALLOCATE_BUFFER = 0x00000100; + const int FORMAT_MESSAGE_IGNORE_INSERTS = 0x00000200; + const int FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000; + + public const int LANG_NEUTRAL = 0x00; + public const int SUBLANG_DEFAULT = 0x01; // user default + + [Import("Kernel32.lib"), CLink, CallingConvention(.Stdcall)] + static extern void* LocalFree(void* mem); + + public static void GetErrorMessage(int32 errno, String outString) + { + mixin MAKELANGID(var p, var s) + { + ((((int16)(s)) << 10) | (int16)(p)) + } + + char8* messageBuffer = null; + + //Ask Win32 to give us the string version of that message ID. + //The parameters we pass in, tell Win32 to create the buffer that holds the message for us (because we don't yet know how long the message string will be). + int32 size = FormatMessageA(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS, + null, (.)errno, MAKELANGID!(LANG_NEUTRAL, SUBLANG_DEFAULT), (.)&messageBuffer, 0, null); + + //Copy the error message into the output string. + outString.Append(messageBuffer, size); + + //Remove line-breaks. + outString.Replace("\r\n", ""); + + //Free the Win32's string's buffer. + LocalFree(messageBuffer); + } + + public static void GetLastErrorMessage(String outString) => + GetErrorMessage(GetLastError(), outString); + } +} +#endif \ No newline at end of file diff --git a/src/Program.bf b/src/Program.bf new file mode 100644 index 0000000..d620915 --- /dev/null +++ b/src/Program.bf @@ -0,0 +1,202 @@ +using System; +using System.Collections; + +namespace darkredhttpd +{ + class Program + { + public static int Main(String[] args) + { + Console.WriteLine("{0}, {1}.", Constants.PKG_NAME, Constants.COPYRIGHT); + + if (args.Count < 1 || (args.Count == 1 && args[0] == "--help")) + { + WriteUsage(Environment.GetExecutableFilePath(.. scope .())); + Environment.Exit(0); + } + + HttpListener.ParseDefaultExtensionMap(); + HttpListener.Settings.ParseCommandLine(args); + HttpListener.Initialize(); + HttpListener.Listen(); + return 0; + } + + public static void ExitWithError(int32 code, String fmt, params Object[] args) + { + Console.WriteLine(fmt, params args); + Environment.Exit(code); + } + + public static void ExitWithError(int32 code, String message) + { + Console.WriteLine(message); + Environment.Exit(code); + } + + public static void ExitWithOSError(int32 code, String fmt, params Object[] args) + { + int32 err = CurrentPlatform.GetLastError(); + Console.Write(fmt, params args); + Console.WriteLine(" ({0}: {1})", err, CurrentPlatform.GetErrorMessage(err, .. scope .())); + Environment.Exit(code); + } + + public static void ExitWithOSError(int32 code, String message) + { + int32 err = CurrentPlatform.GetLastError(); + Console.WriteLine("{0} ({1}: {2})", message, err, CurrentPlatform.GetErrorMessage(err, .. scope .())); + Environment.Exit(code); + } + + public static void ExitWithOSError(int32 code, int32 err, String fmt, params Object[] args) + { + Console.Write(fmt, params args); + Console.WriteLine(" ({0}: {1})", err, CurrentPlatform.GetErrorMessage(err, .. scope .())); + Environment.Exit(code); + } + + public static void ExitWithOSError(int32 code, int32 err, String message) + { + Console.WriteLine("{0} ({1}: {2})", message, err, CurrentPlatform.GetErrorMessage(err, .. scope .())); + Environment.Exit(code); + } + + public static void WriteUsage(String arg0) + { + Console.WriteLine("usage:\t{0} /path/to/wwwroot [flags]\n", arg0); + Console.WriteLine( + """ + flags:\t--port number (default: {0}, or 80 if running as root) + \t\tSpecifies which port to listen on for connections. + \t\tPass 0 to let the system choose any free port for you.\n + """, + HttpListener.Settings.BindPort); + Console.WriteLine( + """ + \t--addr ip (default: all) + \t\tIf multiple interfaces are present, specifies + \t\twhich one to bind the listening port to.\n + """); + Console.WriteLine( + """ + \t--maxconn number (default: system maximum) + \t\tSpecifies how many concurrent connections to accept.\n + """); + Console.WriteLine( + """ + \t--no-log + \t\tDisables any kind of logging (even to the stdout).\n + """); + Console.WriteLine( + """ + \t--log filename (default: stdout) + \t\tSpecifies which file to append the request log to.\n + """); + Console.WriteLine( + """ + \t--syslog + \t\tUse syslog for request log.\n + """); +#if BF_PLATFORM_LINUX + Console.WriteLine( + """ + \t--chroot (default: don't chroot) + \t\tLocks server into wwwroot directory for added security.\n + """); + Console.WriteLine( + """ + \t--daemon (default: don't daemonize) + \t\tDetach from the controlling terminal and run in the background.\n + """); +#endif + Console.WriteLine( + """ + \t--index filename (default: {0}) + \t\tDefault file to serve when a directory is requested.\n + """, + HttpListener.Settings.IndexName); + Console.WriteLine(""" + \t--no-listing + \t\tDo not serve listing if directory is requested.\n + """); + Console.WriteLine( + """ + \t--mimetypes filename (optional) + \t\tParses specified file for extension-MIME associations.\n + """); + Console.WriteLine( + """ + \t--default-mimetype string (optional, default: {0}) + \t\tFiles with unknown extensions are served as this mimetype.\n + """, + HttpListener.OctetStream); +#if BF_PLATFORM_LINUX + Console.WriteLine( + """ + \t--uid uid/uname, --gid gid/gname (default: don't privdrop) + \t\tDrops privileges to given uid:gid after initialization.\n + """); + Console.WriteLine( + """ + \t--pidfile filename (default: no pidfile) + \t\tWrite PID to the specified file. Note that if you are + \t\tusing --chroot, then the pidfile must be relative to, + \t\tand inside the wwwroot.\n + """); +#endif + Console.WriteLine( + """ + \t--no-keepalive + \t\tDisables HTTP Keep-Alive functionality.\n + """); + Console.WriteLine( + """ + \t--forward host url (default: don't forward) + \t\tWeb forward (301 redirect). + \t\tRequests to the host are redirected to the corresponding url. + \t\tThe option may be specified multiple times, in which case + \t\tthe host is matched in order of appearance.\n + """); + Console.WriteLine( + """ + \t--forward-all url (default: don't forward) + \t\tWeb forward (301 redirect). + \t\tAll requests are redirected to the corresponding url.\n + """); + Console.WriteLine( + """ + \t--no-server-id + \t\tDon't identify the server type in headers + \t\tor directory listings.\n + """); + Console.WriteLine( + """ + \t--timeout secs (default: {0}) + \t\tIf a connection is idle for more than this many seconds, + \t\tit will be closed. Set to zero to disable timeouts.\n + """, + HttpListener.Settings.TimeoutSecs); + Console.WriteLine( + """ + \t--throttle BytesPerSec (default: don't throttle) + \t\tSets a limit on how many bytes can be sent per second.\n + """, + HttpListener.Settings.TimeoutSecs); + Console.WriteLine( + """ + \t--auth username:password + \t\tEnable basic authentication.\n + """); +#if HAVE_INET6 + Console.WriteLine( + """ + \t--ipv6 + \t\tListen on IPv6 address.\n + """); +#else + Console.WriteLine("\t(This binary was built without IPv6 support: -DNO_IPV6)\n"); +#endif + } + } +} \ No newline at end of file diff --git a/src/libc.bf b/src/libc.bf new file mode 100644 index 0000000..4dbd38b --- /dev/null +++ b/src/libc.bf @@ -0,0 +1,36 @@ +using System; + +namespace darkredhttpd +{ + static + { +#if BF_PLATFORM_WINDOWS + [CLink] +#elif BF_PLATFORM_LINUX + [LinkName("__errno_location")] +#elif BF_PLATFORM_MACOS + [LinkName("__error")] +#endif + static extern int32* _errno(); + public static ref int32 errno() => ref *_errno(); + + public const int EINTR = 4; + public const int EAGAIN = 11; + + public const int ENFILE = 23; + public const int EMFILE = 24; + + // signal.h + typealias signal_t = function void(int32); + + public const int SIGINT = 2; // interrupt + public const int SIGPIPE = 13; + public const int SIGTERM = 15; // Software termination signal from kill + + public static readonly signal_t SIG_IGN = ((signal_t)(void*)1); // ignore signal + public static readonly signal_t SIG_ERR = ((signal_t)(void*)-1); // signal error value + + [CLink] + public static extern signal_t signal(int32 signal, signal_t func); + } +}