4 Commits

Author SHA1 Message Date
127612d408 smartass 2026-03-03 22:27:56 +01:00
3ff7a33695 something pog 2026-03-03 22:26:19 +01:00
04ec2ebec1 holy fuck shit finally works!!!
alpha build 0039
2026-03-02 21:38:28 +01:00
36c8c95efe amethyst 0018 2026-01-30 08:41:30 +01:00
5 changed files with 201 additions and 120 deletions

View File

@@ -6,4 +6,27 @@ 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 most of them will be published.
Once a milestone is hit (e.g. a new feature fully implemented), I'll publish a release! Once a milestone is hit (e.g. a new feature fully implemented), I'll publish a release!
## Currently working features:
* New configuration is ~75% done, most features work.
* Fixed **A LOT** of unreported bugs from the old code.
* More resilliency against errors.
* Improved security.
## Project status:
Amethyst will stay in beta for a while, I want all features to work, put I will make pre-release versions that are mostly stable.
They can be found as the `amethyst-prerel-0.a.b` releases. I won't guarantee 100% stability, but waay more than just some random build.
## Install instructions:
Install Python, and change the provided config.
## Minimum requirements:
Python 3.8+
And whatever PC that happens to run that.
I recommend Python 3.10 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! ## Currently W.I.P. Check back later!

View File

@@ -4,10 +4,11 @@
<title>Test page</title> <title>Test page</title>
</head> </head>
<body> <body>
<h1>Hey there!</h1> <center>
<h2>You're seeing this page because you haven't set up PyWebServer yet!</h2> <h1>Hello from Amethyst!</h1>
<h2>This page confirms that PyWebServer can read and serve files from your PC.</h2> <h2>This page confirms Amethyst can read files from your PC and serve them to your browser!</h2>
<h2>To make this go away, please edit the file `pywebsrv.conf` and edit the `directory` key to your directory of choice!</h2> <p>If you see this page and you're not the server owner, tell them they misconfigured something!</p>
<p>Here you can simulate a 404 error: <a href="/uuh">Click me for a 404 error!</a></p> <p>This server runs Amethyst Alpha Build 0039</p>
</center>
</body> </body>
</html> </html>

View File

@@ -1,23 +1,23 @@
# WARNING: This is an alpha spec of NSCL 2.0!! # WARNING: This is an alpha spec of NSCL 2.0!!
host example.com { host 192.168.2.196 {
directory:/home/nova/Downloads/test/html directory:/home/nova/Downloads/test/html
allowed-methods:GET allowed-methods:GET
block-ip:match-ip("192.168",2) block-ua:match("Discordbot"),match("Google")
block-ua:match("Discordbot",0),match("Google",0)
} }
host cdn.example.com { host localhost {
directory:/home/nova/Downloads/test/cdn directory:/home/nova/PyWebServer/html2
allowed-methods:GET,PUT allowed-methods:GET,PUT
block-ip:10.1.100.2 block-ip:10.1.100.2
block-ua:match("Discordbot",0) block-ua:match("Discordbot")
} }
host modem.example.com { host 192.168.1.213 {
proxy:192.168.2.254 directory:/home/nova/PyWebServer/html
key-file:/home/nova/Downloads/test/proxykey.pem allowed-methods:GET,PUT
cert-file:/home/nova/Downloads/test/proxycert.pem block-ip:10.1.100.2
block-ua:match("Discordbot")
} }
globals { globals {
@@ -25,6 +25,7 @@ globals {
https:1 https:1
port:8080 port:8080
https-port:8443 https-port:8443
global-key:/home/nova/Downloads/test/key.pem allow-localhost:1
global-cert:/home/nova/Downloads/test/cert.pem key:/home/nova/PyWebServer/ssl/key.pem
cert:/home/nova/PyWebServer/ssl/cert.pem
} }

View File

@@ -4,11 +4,11 @@
port:8080 port:8080
port-https:8443 port-https:8443
# Here you choose what directory PyWebServer looks in for files. # Here you choose what directory PyWebServer looks in for files.
directory:<Enter directory here> directory:/home/nova/PyWebServer/html
# Host defenition, what hosts you can connect via. # Host defenition, what hosts you can connect via.
# You can use FQDNs, IP-addresses and localhost, # You can use FQDNs, IP-addresses and localhost,
# Support for multiple hosts is coming. # Support for multiple hosts is coming.
host:localhost host:localhost,10.185.213.118
# Enables HTTP support. (Only enables/disables the HTTP port.) # Enables HTTP support. (Only enables/disables the HTTP port.)
http:1 http:1
# Enables HTTPS support. (Only enables/disables the HTTPS port.) # Enables HTTPS support. (Only enables/disables the HTTPS port.)
@@ -21,8 +21,8 @@ allow-localhost:1
disable-autocertgen:0 disable-autocertgen:0
# If you wish to block IP-addresses, this function is coming though. # If you wish to block IP-addresses, this function is coming though.
# block-ip:0.0.0.0,1.1.1.1,2.2.2.2 # block-ip:0.0.0.0,1.1.1.1,2.2.2.2
# If you wish to block User-Agents, this function is coming though. # If you wish to block User-Agents.
# block-ua:(NULL) block-ua:match(Discordbot),match(google)
# TEST: experimental non-defined keys go here: # TEST: experimental non-defined keys go here:
# keyfile key # keyfile key

View File

@@ -35,6 +35,8 @@ Library aswell as a standalone script:
You can easily get access to other parts of the script if you need it. You can easily get access to other parts of the script if you need it.
TODO: actually put normal comments in TODO: actually put normal comments in
TODO: INPROG: add typing to all code, new code will feature it by default.
""" """
import os import os
@@ -42,7 +44,8 @@ import mimetypes
import threading import threading
import ssl import ssl
import socket import socket
import re
# import re
import signal import signal
import sys import sys
@@ -56,18 +59,74 @@ except ImportError:
# ) # )
pass pass
AMETHYST_BUILD_NUMBER = "0001" AMETHYST_BUILD_NUMBER = "0046"
AMETHYST_REPO = "https://git.novacow.ch/Nova/PyWebServer/" AMETHYST_REPO = "https://git.novacow.ch/Nova/PyWebServer/"
class ConfigParser:
def __init__(self, text):
self.data = {"hosts": {}, "globals": {}}
self._parse(text)
def _parse(self, text):
lines = [
line.strip()
for line in text.splitlines()
if line.strip() and not line.strip().startswith("#")
]
current_block = None
current_name = None
for line in lines:
if line.startswith("host ") and line.endswith("{"):
current_name = line.split()[1]
self.data["hosts"][current_name] = {}
current_block = ("host", current_name)
continue
if line == "globals {":
current_block = ("globals", None)
continue
if line == "}":
current_block = None
current_name = None
continue
if ":" in line and current_block:
key, value = line.split(":", 1)
key = key.strip()
value = value.strip()
if "," in value:
value = [v.strip() for v in value.split(",")]
if current_block[0] == "host":
self.data["hosts"][current_name][key] = value
else:
self.data["globals"][key] = value
def query_config(self, key, host=None):
if host:
return self.data["hosts"].get(host, {}).get(key)
if 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)
class FileHandler: class FileHandler:
CONFIG_FILE = "pywebsrv.conf" CONFIG_FILE = "pywebsrv.conf"
new_conf = "new_conf.conf" new_conf = "new_conf.conf"
def __init__(self, base_dir=None): def __init__(self, base_dir=None):
# this is a fucking clusterfuck.
self.config_path = os.path.join(os.getcwd(), self.CONFIG_FILE) self.config_path = os.path.join(os.getcwd(), self.CONFIG_FILE)
self.new_conf = os.path.join(os.getcwd(), self.new_conf) self.new_conf = os.path.join(os.getcwd(), self.new_conf)
self.base_dir = self.read_config("directory") self.base_dir = self.read_config("directory")
self.cached_conf = None with open(self.new_conf, "r") as f:
self.cfg = ConfigParser(f.read())
if not os.path.exists(self.config_path): if not os.path.exists(self.config_path):
print( print(
"The pywebsrv.conf file needs to be in the same directory " "The pywebsrv.conf file needs to be in the same directory "
@@ -75,11 +134,15 @@ class FileHandler:
"https://git.novacow.ch/Nova/PyWebServer/raw/branch/main/pywebsrv.conf" "https://git.novacow.ch/Nova/PyWebServer/raw/branch/main/pywebsrv.conf"
) )
exit(1) exit(1)
# TODO: fix this please!!
def read_file(self, file_path): def read_file(self, file_path, directory=None):
if "../" in file_path: if "../" in file_path or "%" in file_path:
return 403, None return 403, None
if directory is not None:
full_path = os.path.join(directory, file_path.lstrip("/"))
else:
full_path = os.path.join(self.base_dir, file_path.lstrip("/")) full_path = os.path.join(self.base_dir, file_path.lstrip("/"))
if not os.path.isfile(full_path): if not os.path.isfile(full_path):
return 404, None return 404, None
@@ -92,8 +155,8 @@ class FileHandler:
print(f"Error reading file {full_path}: {e}") print(f"Error reading file {full_path}: {e}")
return 500, None return 500, None
def write_file(self, file_path, data): def write_file(self, file_path, data, directory=None):
if "../" in file_path: if "../" in file_path or "%" in file_path:
return 403 return 403
full_path = os.path.join(self.base_dir, file_path.lstrip("/")) full_path = os.path.join(self.base_dir, file_path.lstrip("/"))
with open(full_path, "a") as f: with open(full_path, "a") as f:
@@ -117,7 +180,7 @@ class FileHandler:
"disable-autocertgen", "disable-autocertgen",
"key-file", "key-file",
"cert-file", "cert-file",
"block-ua" "block-ua",
] ]
if option not in valid_options: if option not in valid_options:
return None return None
@@ -165,57 +228,19 @@ class FileHandler:
return value return value
return None return None
def read_new_config(self, option, host=None): def read_new_config(self, key, host_name=None):
""" print(
Reads the configuration file and returns a dict f"\n\n\nQuery!\nkey: {key}\nhost_name: {host_name}\nret: {self.cfg.query_config(key, host_name)}"
"""
if self.cached_conf is None:
with open(self.new_conf, "r", encoding="utf-8") as fh:
text = fh.read()
blocks = re.findall(
r'^(host\s+(\S+)|globals)\s*\{([^}]*)\}', text, re.MULTILINE
) )
parsed = {} return self.cfg.query_config(key, host_name)
host_list = []
print(f"Blocks: {blocks}")
for tag, hostname, body in blocks:
section = hostname if hostname else "globals"
if hostname:
host_list.append(hostname)
kv = {}
for line in body.splitlines():
line = line.strip()
if not line or ":" not in line or line.startswith("#"):
continue
key, rest = line.split(":", 1)
key = key.strip()
rest = rest.strip()
# Split comma-separated values (e.g. GET,PUT)
if "," in rest:
kv[key] = [item.strip() for item in rest.split(",")]
else:
kv[key] = rest
parsed[section] = kv
parsed["globals"]["hosts"] = host_list
self.cached_conf = parsed
else:
parsed = self.cached_conf
if option == "host":
try:
return host_list
except Exception:
return parsed["globals"]["hosts"]
section = parsed.get(host or "globals", {})
return section.get(option)
def autocert(self): def autocert(self):
""" """
Generate some self-signed certificates using AutoCertGen Generate some self-signed certificates using AutoCertGen
TODO: doesn't work, need to fix. probably add `./` to $PATH TODO: doesn't work, need to fix. probably add `./` to $PATH
""" """
if not os.getcwd() in sys.path:
sys.path.append(f"{os.getcwd()}")
autocert = AutoCertGen() autocert = AutoCertGen()
autocert.gen_cert() autocert.gen_cert()
@@ -223,7 +248,7 @@ class FileHandler:
class RequestParser: class RequestParser:
def __init__(self): def __init__(self):
self.file_handler = FileHandler() self.file_handler = FileHandler()
self.hosts = self.file_handler.read_new_config("host") self.hosts = self.file_handler.read_new_config("hosts")
print(f"Hosts: {self.hosts}") print(f"Hosts: {self.hosts}")
def parse_request_line(self, line): def parse_request_line(self, line):
@@ -231,21 +256,25 @@ class RequestParser:
try: try:
method, path, version = line.split(" ") method, path, version = line.split(" ")
except ValueError: except ValueError:
return None, None, None return "DELETE", "/this/is/a/bogus/request", "HTTP/1.0"
if path.endswith("/"): if path.endswith("/") and "." not in path:
path += "index.html" path += "index.html"
return method, path, version return method, path, version
def ua_blocker(self, ua, host=None): def ua_is_allowed(self, ua, host=None):
"""Parses and matches UA to block""" """Parses and matches UA to block"""
del host
match, literal = self.file_handler.read_config("block-ua")
if ua in literal:
return False
for _ua in match:
if _ua.lower() in ua.lower():
return False
return True return True
# del host
# _list = self.file_handler.read_config("block-ua")
# if _list is None:
# return True
# match, literal = self.file_handler.parse_match_blocks(_list)
# if ua in literal:
# return False
# for _ua in match:
# if _ua.lower() in ua.lower():
# return False
# return True
def is_method_allowed(self, method): def is_method_allowed(self, method):
""" """
@@ -267,20 +296,23 @@ class RequestParser:
Mfw im in an ugly code writing contest and my opponent is nova while writing a side project Mfw im in an ugly code writing contest and my opponent is nova while writing a side project
""" """
host = f"{host}" host = f"{host}"
print(f"hosts: {self.hosts}, host: {host}") print(f"hosts: {self.hosts}, host: {host}, split: {host.rsplit(':', 1)[0]}")
if ":" in host: if ":" in host:
host = host.split(":", 1)[0] host = host.rsplit(":", 1)[0]
host = host.lstrip() host = host.lstrip()
host = host.rstrip() host = host.rstrip()
if ( if (
host == "localhost" or host == "127.0.0.1" host == "localhost" or host == "127.0.0.1" or host == "[::1]"
) and self.file_handler.read_config("allow-localhost"): ) and self.file_handler.read_new_config("allow-localhost"):
return True
if self.hosts is None:
return True return True
if host not in self.hosts: if host not in self.hosts:
return False return False
else: else:
return True return True
# #
# class ProxyServer: # class ProxyServer:
# def __init__( # def __init__(
@@ -294,10 +326,10 @@ class WebServer:
): ):
self.http_port = int(http_port) self.http_port = int(http_port)
self.https_port = int(https_port) self.https_port = int(https_port)
self.cert_file = cert_file
self.key_file = key_file
self.file_handler = FileHandler() self.file_handler = FileHandler()
self.parser = RequestParser() self.parser = RequestParser()
self.cert_file = self.file_handler.read_new_config("cert") or cert_file
self.key_file = self.file_handler.read_new_config("key") or key_file
self.skip_ssl = False self.skip_ssl = False
# me when no certificate and key file # me when no certificate and key file
@@ -324,14 +356,22 @@ class WebServer:
"This host cannot be reached without sending a `Host` header." "This host cannot be reached without sending a `Host` header."
) )
# ipv6 when????/??//?????//? # TODO: enable experimental ipv6 support in config
self.http_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.http_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.http_socket.bind(("0.0.0.0", self.http_port))
self.https_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # ipv6 when????/??//?????//?
self.https_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) # self.http_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.https_socket.bind(("0.0.0.0", self.https_port)) # self.http_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# self.http_socket.bind(("0.0.0.0", self.http_port))
#
# self.https_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
# self.https_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
# self.https_socket.bind(("0.0.0.0", self.https_port))
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))
if self.skip_ssl is False: if self.skip_ssl is False:
# https gets the ssl treatment!! yaaaay :3 # https gets the ssl treatment!! yaaaay :3
@@ -372,6 +412,8 @@ class WebServer:
if self.skip_ssl is True: if self.skip_ssl is True:
print("WARN: You have enabled HTTPS without SSL!!") print("WARN: You have enabled HTTPS without SSL!!")
yn = input("Is this intended behaviour? [y/N] ") yn = input("Is this intended behaviour? [y/N] ")
if yn.lower() == "n":
exit(1)
https_thread.start() https_thread.start()
if http is True: if http is True:
http_thread.start() http_thread.start()
@@ -410,7 +452,9 @@ class WebServer:
data = conn.recv(512) data = conn.recv(512)
request = data.decode(errors="ignore") request = data.decode(errors="ignore")
if not data: if not data:
response = self.build_response(400, "Bad Request") # user did fucky-wucky response = self.build_response(
400, "Bad Request"
) # user did fucky-wucky
elif len(data) > 8192: elif len(data) > 8192:
response = self.build_response(413, "Request too long") response = self.build_response(413, "Request too long")
else: else:
@@ -422,6 +466,13 @@ class WebServer:
conn.sendall(response) conn.sendall(response)
except Exception as e: except Exception as e:
print(f"Error handling connection: {e}") print(f"Error handling connection: {e}")
response = self.build_response(
500,
"Amethyst is currently unable to serve your request. Below is debug info.\r\n"
f"Error: {e}; Version: amethyst-b{AMETHYST_BUILD_NUMBER}\r\n"
"You cannot do anything at this time, the server owner has made a misconfiguration or there is a bug in the program",
)
conn.sendall(response)
finally: finally:
conn.close() conn.close()
@@ -440,23 +491,19 @@ class WebServer:
) )
break break
else: else:
return self.build_response( return self.build_response(400, self.no_host_req_response.encode())
400, self.no_host_req_response.encode()
)
for line in data.splitlines(): for line in data.splitlines():
if "User-Agent" in line: if "User-Agent" in line:
ua = line.split(":", 1)[1].strip() ua = line.split(":", 1)[1].strip()
allowed = self.parser.ua_blocker(ua) allowed = self.parser.ua_is_allowed(ua)
if not allowed: if not allowed:
return self.build_response( return self.build_response(
403, "This UA has been blocked by the owner of this site." 403, "This UA has been blocked by the owner of this site."
) )
break break
else: else:
return self.build_response( return self.build_response(400, "You cannot connect without a User-Agent.")
400, "You cannot connect without a User-Agent."
)
method, path, version = self.parser.parse_request_line(request_line) method, path, version = self.parser.parse_request_line(request_line)
@@ -470,12 +517,20 @@ class WebServer:
self.parser = RequestParser() self.parser = RequestParser()
return self.build_response(302, "") return self.build_response(302, "")
if not self.parser.is_method_allowed( if not self.parser.is_method_allowed(method):
method
):
return self.build_response(405, self.http_405_html) return self.build_response(405, self.http_405_html)
file_content, mimetype = self.file_handler.read_file(path) if ":" in host:
host2 = host.rsplit(":", 1)[0]
else:
host2 = host
directory = (
self.file_handler.read_new_config("directory", host2)
or self.file_handler.base_dir
)
file_content, mimetype = self.file_handler.read_file(path, directory)
if file_content == 403: if file_content == 403:
print("WARN: Directory traversal attack prevented.") # look ma, security!! print("WARN: Directory traversal attack prevented.") # look ma, security!!
@@ -485,16 +540,16 @@ class WebServer:
if file_content == 500: if file_content == 500:
return self.build_response( return self.build_response(
500, 500,
"PyWebServer has encountered a fatal error and cannot serve " "Amethyst has encountered a fatal error and cannot serve "
"your request. Contact the owner with this error: FATAL_FILE_RO_ACCESS", "your request. Contact the owner with this error: FATAL_FILE_RO_ACCESS",
) # When there was an issue with reading we throw this. ) # When there was an issue with reading we throw this.
# A really crude implementation of binary files. Later in 2.0 I'll actually
# make this useful.
mimetype = mimetype[0] mimetype = mimetype[0]
if mimetype is None: if mimetype is None:
# We have to assume it's binary. # We have to assume it's binary.
return self.build_binary_response(200, file_content, "application/octet-stream") return self.build_binary_response(
200, file_content, "application/octet-stream"
)
if "text/" not in mimetype: if "text/" not in mimetype:
return self.build_binary_response(200, file_content, mimetype) return self.build_binary_response(200, file_content, mimetype)
@@ -508,7 +563,7 @@ class WebServer:
403: "Forbidden", 403: "Forbidden",
404: "Not Found", 404: "Not Found",
405: "Method Not Allowed", 405: "Method Not Allowed",
500: "Internal Server Error" 500: "Internal Server Error",
} }
status_message = messages.get(status_code) status_message = messages.get(status_code)
headers = ( headers = (
@@ -519,7 +574,6 @@ class WebServer:
f"Connection: close\r\n\r\n" f"Connection: close\r\n\r\n"
# Connection close is done because it is way easier to implement. # Connection close is done because it is way easier to implement.
# It's not like this program will see production use anyway. # It's not like this program will see production use anyway.
# Tbh when i'll implement HTTP2
) )
return headers.encode() + binary_data return headers.encode() + binary_data
@@ -539,7 +593,7 @@ class WebServer:
405: "Method Not Allowed", 405: "Method Not Allowed",
413: "Payload Too Large", 413: "Payload Too Large",
500: "Internal Server Error", 500: "Internal Server Error",
635: "Go Away" 635: "Go Away",
} }
status_message = messages.get(status_code) status_message = messages.get(status_code)
@@ -560,7 +614,9 @@ class WebServer:
# Why not 307, Moved Permanently? Because browsers will cache the # Why not 307, Moved Permanently? Because browsers will cache the
# response and not send the reload command. # response and not send the reload command.
host = self.file_handler.read_config("host")[0] host = self.file_handler.read_config("host")[0]
port = self.file_handler.read_config("port-https") or self.file_handler.read_config("port") port = self.file_handler.read_config(
"port-https"
) or self.file_handler.read_config("port")
if port != 80 and port != 443: if port != 80 and port != 443:
if port == 8443: if port == 8443:
host = f"https://{host}:{port}/" host = f"https://{host}:{port}/"