diff --git a/README.md b/README.md index c980429..d574fc5 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ ## A word of warning! Currently Amethyst is in very early alpha stage, a lot of things will be broken, names won't be correct, promised features missing, but I'm very much working on it live! -Every save I do increments the build number by 1, I won't publish all of them, but most of them will be published. +Every save I do increments the build number by 1, I won't publish all of them, but some of them will be published. Once a milestone is hit (e.g. a new feature fully implemented), I'll publish a release! ## Currently working features: @@ -21,13 +21,49 @@ They can be found as the `amethyst-prerel-0.a.b` releases. I won't guarantee 100 Install Python, execute `amethyst.py` and change the provided config. ## Minimum requirements: -Python 3.8+ +Python 3.10+ And whatever PC that happens to run that. -I recommend Python 3.10 or above though, with a PC running: +I recommend Python 3.12 or above though, with a PC running: * Windows 8.1+ * macOS 10.15+ * Linux 4.19+ * FreeBSD 13.2R+ * Some other somewhat recent OS. -## Currently W.I.P. Check back later! +## The webserver itself: +The Amethyst webserver is meant to be easy to use and configure. Its configuration takes inspiration from nginx and Caddyfile. +The language the configuration is made in is AmethystConf. +The default config is as follows: +```amethystconf +host * { + directory:./html + apimode:0 + block-ua:match("Discordbot") + index:index2.html +} + +globals { + http:1 + https:1 + port:8080 + https-port:8443 + key:./key.pem + cert:./cert.pem + max-length:8192 +} +} +``` +It uses a key-value syntax, and uses a `:` as its seperator. A few key directives: +`host`, followed by a hostname signifies a host that will be available. Similar to nginx's `server_name` directive. +`globals` signifies all values that are of global importance, like the key and certificate file. +`directory` signifies the directory to look in for files on that specific host. +`index`, while not in the default config, signifies what path should be returned if the client only asks for `/` (or any subpaths without files). +`apimode` signifies if the API mode must be enabled, allowing the server to run custom Python code to manipulate the request or file further. +`block-ua` signifies if a specific (or loosely matched) User-Agent must be blocked from accessing the site. +`proxy` signifies if a the server needs to get the response from a different (remote) server but still needs to be available at this host. +`max-length` signifies the maximum length a request may have. +AmethystConf has only 4 datatypes: `String`, `Boolean`, `Function` and `None`. A quick rundown: +`String` is the everything datatype. Everything is assumed to be a `String` unless it falls under the other categories. +`Boolean` is the datatype used to enable/disable features. A `Boolean` can have one of two possible values: `1` or `0`. +`Function` is the datatype used in `match()`, it signifies that the parser has to do some work on this string before it can use it. +`None` is the datatype assigned to any key without a value. diff --git a/amethyst.conf b/amethyst.conf index 1946a62..a897979 100644 --- a/amethyst.conf +++ b/amethyst.conf @@ -4,6 +4,7 @@ host * { directory:./html apimode:0 block-ua:match("Discordbot") + index:index2.html } globals { @@ -12,5 +13,6 @@ globals { port:8080 https-port:8443 key:./key.pem - cert./cert.pem + cert:./cert.pem + max-length:8192 } diff --git a/amethyst.py b/amethyst.py index af0a512..4ed2f52 100644 --- a/amethyst.py +++ b/amethyst.py @@ -61,7 +61,7 @@ except ImportError: ) # pass -AMETHYST_BUILD_NUMBER = "b0.2.0-0072" +AMETHYST_BUILD_NUMBER = "0083" AMETHYST_REPO = "https://git.novacow.ch/Nova/PyWebServer/" @@ -111,11 +111,15 @@ class ConfigParser: def query_config(self, key, host=None): if host: - return self.data["hosts"].get(host, {}).get(key) - if key == "hosts": + value = self.data["hosts"].get(host, {}).get(key) + elif key == "hosts": print(f"\n\n\nHosts!\nHosts: {self.data['hosts']}\n\n\n") - return list(self.data["hosts"].keys()) - return self.data["globals"].get(key) + value = list(self.data["hosts"].keys()) + else: + value = self.data["globals"].get(key) + if value == "0" or value == "1": + value = int(value) + return value class FileHandler: @@ -161,7 +165,7 @@ class FileHandler: if "../" in file_path or "%" in file_path: return 403 full_path = os.path.join(self.base_dir, file_path.lstrip("/")) - with open(full_path, "a") as f: + with open(full_path, "wb") as f: f.write(data) return 0 @@ -186,6 +190,16 @@ class RequestParser: self.hosts = self.file_handler.read_config("hosts") print(f"Hosts: {self.hosts}") + def extract_header(self, header: str, request: bytes | str): + if isinstance(request, bytes): + request = request.decode("utf-8", "ignore") + lines = request.splitlines() + for line in lines: + if line.startswith(header): + value = line.split(":")[1][1:] + return value + return None + def parse_request_line(self, line, host): """Parses the HTTP request line.""" try: @@ -263,6 +277,19 @@ class ProxyServer: def __init__(self, fh): self.file_handler: FileHandler = fh + @staticmethod + def recv_all(sock): + chunks = [] + while True: + try: + data = sock.recv(4096) + if not data: + break + chunks.append(data) + except socket.timeout: + break + return b"".join(chunks) + def try_connection( self, host: str, port: int, data: bytes, chost: str, force_tls: bool = None ): @@ -336,10 +363,12 @@ class ProxyServer: ) as ssock: ssock.sendall(data) print("data reached") - return ssock.recv(512000) + return self.recv_all(ssock) else: raw_sock.sendall(data) - return raw_sock.recv(512000) + resp = self.recv_all(raw_sock) + print(f"resp = {resp}") + return resp except Exception: raise @@ -354,6 +383,7 @@ class WebServer: self.parser = RequestParser() self.cert_file = self.file_handler.read_config("cert") or cert_file self.key_file = self.file_handler.read_config("key") or key_file + self.max_length = int(self.file_handler.read_config("max-length")) or 8192 self.skip_ssl = False # me when no certificate and key file @@ -383,8 +413,8 @@ class WebServer: self.http_socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) self.http_socket.bind(("::", self.http_port)) - self.https_socket = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) - self.https_socket.bind(("::", self.https_port)) + self.https_socket_raw = socket.socket(socket.AF_INET6, socket.SOCK_STREAM) + self.https_socket_raw.bind(("::", self.https_port)) self.proxy_handler = ProxyServer(self.file_handler) @@ -395,7 +425,7 @@ class WebServer: certfile=self.cert_file, keyfile=self.key_file ) self.https_socket = self.ssl_context.wrap_socket( - self.https_socket, server_side=True + self.https_socket_raw, server_side=True ) self.http_404_html = ( @@ -430,8 +460,12 @@ class WebServer: if yn.lower() == "n": exit(1) https_thread.start() + else: + self.https_socket.close() if http is True: http_thread.start() + else: + self.http_socket.close() http_thread.join() https_thread.join() @@ -464,13 +498,57 @@ class WebServer: def handle_connection(self, conn, addr): try: - data = conn.recv(32768) + data = b"" + # Read headers + while b"\r\n\r\n" not in data: + chunk = conn.recv(4096) + if not chunk: + break + data += chunk + + headers, _, rest = data.partition(b"\r\n\r\n") + + # Parse Content-Length + content_length = 0 + for line in headers.split(b"\r\n"): + if line.lower().startswith(b"content-length:"): + content_length = int(line.split(b":")[1].strip()) + + # print(f"Content-Length to server: {content_length}") + + # Read body + body = rest + print(f"Rest length: {len(rest)}") + while len(body) < content_length: + chunk = conn.recv(4096) + # print(f"\n\nrecv returned {len(chunk)}\n\n") + if not chunk: + print("\n\nsocket closed\n\n") + break + body += chunk + + data += body + # + # print(f"\n\nbody length {len(body)}\n\n") + # print("headers len", len(headers)) + # print("rest len", len(rest)) + # print("body len", len(body)) + # print("content_length", content_length) + # + # print("last 200 bytes of body:") + # print(repr(body[-200:])) + # print("body starts with:") + # print(repr(body[:100])) + # print(f"body: {body}") + print(b"data: " + headers + b"\r\n\r\n" + body) + # # data = conn.recv(32768) + # print(f"len(data) = {len(data)}") request = data.decode(errors="ignore") if not data: response = self.build_response( 400, "Bad Request" ) # user did fucky-wucky - elif len(data) > 8192: + elif len(data) > self.max_length: response = self.build_response(413, "Request too long") else: response = self.handle_request(request, addr) @@ -493,7 +571,7 @@ class WebServer: conn.close() def handle_request(self, data, addr): - print(f"data: {data}") + # print(f"data: {data}") request_line = data.splitlines()[0] # Extract host from headers, never works though @@ -703,11 +781,11 @@ def main(): input("Press to continue. ") file_handler = FileHandler() file_handler.base_dir = file_handler.read_config("directory") - http_port = file_handler.read_config("port") or 8080 - https_port = file_handler.read_config("https-port") or 8443 - http_enabled = bool(file_handler.read_config("http")) or True + http_port = file_handler.read_config("port") + https_port = file_handler.read_config("https-port") + http_enabled = bool(file_handler.read_config("http")) print(http_enabled) - https_enabled = bool(file_handler.read_config("https")) or False + https_enabled = bool(file_handler.read_config("https")) print(https_enabled) server = WebServer(http_port=http_port, https_port=https_port) server.start(http_enabled, https_enabled) diff --git a/html/index.html b/html/index.html index 8372002..6af8333 100644 --- a/html/index.html +++ b/html/index.html @@ -8,7 +8,7 @@

Hello from Amethyst!

This page confirms Amethyst can read files from your PC or server and serve them to your browser!

This is a test page, if you aren't the server owner, they might not have finished setting up their site, be patient. If this doesn't go away after a while, tell them they've made an oopsie

-

This server runs Amethyst Pre-Rel 0.2.0-0072

+

This server runs Amethyst build 0080