2 Commits

Author SHA1 Message Date
0e406ab360 Quick patch to get certgen running 2025-05-04 14:57:58 +02:00
f29ad0d6b4 Backport urgent fix to 1.0 2025-05-04 00:03:08 +02:00
4 changed files with 101 additions and 393 deletions

View File

@@ -1,97 +1,2 @@
# PyWebServer
## Current state
Currently I'm in the middle of bringing major improvements to PyWebServer, like a significantly better config
automatic HTTPS certificate generation using Let's Encrypt, HTTP `PUT`, `POST` and `DELETE` support,
HTTP2 support and auto-indexing.
This all will be part of major release 2, version 2.0. These improvements will partially make their way into 1.x
versions, from 1.4 onwards, mostly as testing ground and compatibility with 2.0 reasons. Most will probably be dead code
until 2.0 properly comes out. Until then, parts of the code may be pretty cluttered. I'll probably enable you to test some feautres
like the new config, but code quality is currently not a main priority.
## GitHub
The host of this project is on my own [Gitea instance](https://git.novacow.ch/Nova/PyWebServer/).
Because of that I'll mostly reply to issues and PRs there, you can submit issues and PRs on GitHub, but it might take longer before I read it.
## Installing
### The little tiny easier route
Installing and running PyWebServer is very simple.
First off, download the latest release from the 'Releases' tab, choose the Zip variant if unsure.
When it's done downloading, unpack the files in a directory of choice, for the purpose of this README,
I've chosen `./pywebserver/` (for Windows: `.\pywebserver\`).
From there, open up your favorite text editor and open the file `pywebsrv.conf` in the directory you unpacked PyWebServer.
In there, you should see this somewhere:
```
# Here you choose what directory PyWebServer looks in for files.
directory:<Enter directory here>
```
After the colon, enter your directory where you have your website stored.
After that, make sure you have installed Python. Here's how you can install Python:
Linux:
```bash
sudo apt install python3 # Debian / Ubuntu
sudo dnf install python3 # Fedora / Nobara
sudo pacman -S python3 # Arch and derivatives.
```
macOS:
```bash
brew install python3
```
Windows:
```powershell
# You can change the `3.12` with whatever version you want/need.
winget install -e --id Python.Python.3.12 --scope machine
```
Then, in the terminal window you have open, go to the directory you unpacked PyWebServer and type this:
```
python3 ./pywebsrv.py
# For Windows users, if the above command doesn't work, try this:
py ./pywebsrv.py
```
And there you go! You've now set up PyWebServer!
### The little tiny harder route
Installing and running PyWebServer is very simple.
Assuming you're running Linux:
```bash
git clone https://git.novacow.ch/Nova/PyWebServer.git
cd ./PyWebServer/
```
Windows users, make sure you have installed Git, from there:
```powershell
git clone https://git.novacow.ch/Nova/PyWebServer.git
Set-Location .\PyWebServer\
```
Then, open `pywebsrv.conf` in your favorite text editor and change the `directory` key to the full path where your files are stored.
After that, put your files in and run this:
Linux:
```bash
python3 /path/to/pywebsrv.py
```
Windows:
```powershell
# If you have installed Python via the Microsoft Store:
python3 \path\to\pywebsrv.py
# Via the python.org website:
py \path\to\pywebsrv.py
```
## SSL Support
PyWebServer supports SSL/TLS for authentication via HTTPS. In the config file, you should enable the HTTPS port. After that you need to create the certificate.
Currently PyWebServer looks for the `cert.pem` and the `key.pem` files in the root directory of the installation.
## HTTP support
Currently PyWebServer only supports HTTP/1.1, this is very unlikely to change, as most of the modern web today still uses HTTP/1.1.
For methods PyWebServer only supports `GET`, this is being reworked though, check issue [#3](https://git.novacow.ch/Nova/PyWebServer/issues/3) for progress.
## Files support
Unlike other small web servers, PyWebServer has full support for binary files being sent and received (once that logic is put in) over HTTP(S).
## Support
PyWebServer will follow a standard support scheme.
### 1.x
For every 1.x version there will be support until 2 newer versions come out.
So that means that 1.0 will still be supported when 1.1 comes out, but no longer be supported when 1.2 comes out.
### 2.x
I am planning on releasing a 2.x version with will have a lot more advanced features, like nginx's server block emulation amongst other things.
When 2.0 will come out, 1.x will be developed further, but 2.0 will be the main focus.

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<title>Test page</title>
</head>
<body>
<h1>Hey there!</h1>
<h2>You're seeing this page because you haven't set up PyWebServer yet!</h2>
<h2>This page confirms that PyWebServer can read and serve files from your PC.</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>Here you can simulate a 404 error: <a href="/uuh">Click me for a 404 error!</a></p>
</body>
</html>

View File

@@ -1,33 +1,17 @@
# Using NSCL 1.3
# Port defenition. What ports to use.
# port is the HTTP port, port-https is the HTTPS port
port:8080
port-https:8443
# Here you choose what directory PyWebServer looks in for files.
directory:<Enter directory here>
# Host defenition, what hosts you can connect via.
# You can use FQDNs, IP-addresses and localhost,
# Support for multiple hosts is coming.
directory:/home/nova/Documents/html
host:localhost
# Enables HTTP support. (Only enables/disables the HTTP port.)
# DANGER: NEVER EVER TURN THIS ON IN PROD!!!!!!!!!!!!
allow-all:1
# DANGER!!
port-https:8443
http:1
# Enables HTTPS support. (Only enables/disables the HTTPS port.)
https:1
# Allows the use of localhost to connect.
# The default is on, this is seperate of the host defenition.
allow-localhost:1
# If you're using the webserver in a library form,
# you can disable the AutoCertGen and never trigger it.
disable-autocertgen:0
# If you wish to block IP-addresses, this function is coming though.
allow-localhost:0
# for use in libraries
# disable-autocertgen:0
# 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.
# block-ua:(NULL)
# TEST: experimental non-defined keys go here:
# keyfile key
key-file:/home/nova/PyWebServer/key.pem
# certfile keys
cert-file:/home/nova/PyWebServer/cert.pem
# allowed-methods, csv's
allowed-methods:GET
# allow-nohost:0
# In libraries you can disable everything you don't need.

View File

@@ -1,19 +1,4 @@
"""
License:
PyWebServer
Copyright (C) 2025 Nova
This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
You should have received a copy of the GNU General Public License along with this program. If not, see <https://www.gnu.org/licenses/>.
Contact:
E-mail: nova@novacow.ch
NOTE: Once 2.0 is released, PyWebServer will become the Amethyst Web Server
This is PyWebServer, an ultra minimalist webserver, meant to still have
a lot standard webserver features. A comprehensive list is below:
Features:
@@ -33,60 +18,60 @@ Simple to understand and mod codebase.
All GNU GPL-3-or-above license. (Do with it what you want.)
Library aswell as a standalone script:
You can easily get access to other parts of the script if you need it.
TODO: actually put normal comments in
"""
import os
import mimetypes
import threading
import ssl
import socket
import re
import signal
import sys
try:
from certgen import AutoCertGen
except ImportError:
# just do nothing, it's not working anyway.
# print(
# "WARN: You need the AutoCertGen plugin! Please install it from\n"
# "https://git.novacow.ch/Nova/AutoCertGen/"
# )
pass
print(
"WARN: You need the AutoCertGen plugin! Please install it from\n"
"https://git.novacow.ch/Nova/AutoCertGen/"
)
class FileHandler:
CONFIG_FILE = "pywebsrv.conf"
DEFAULT_CONFIG = (
"port:8080\nport-https:8443\nhttp:1"
"\nhttps:0\ndirectory:{cwd}\nhost:localhost"
"allow-all:1\nallow-localhost:1"
)
def __init__(self, base_dir=None):
self.base_dir = base_dir or os.path.join(os.getcwd(), "html")
self.config_path = os.path.join(os.getcwd(), self.CONFIG_FILE)
self.base_dir = self.read_config("directory")
self.cached_conf = None
if not os.path.exists(self.config_path):
print(
"The pywebsrv.conf file needs to be in the same directory "
"as pywebsrv.py! Get the default config file from:\n"
"https://git.novacow.ch/Nova/PyWebServer/raw/branch/main/pywebsrv.conf"
)
exit(1)
def check_first_run(self):
if not os.path.isfile(self.config_path):
self.on_first_run()
return True
return False
def on_first_run(self):
with open(self.config_path, "w") as f:
f.write(self.DEFAULT_CONFIG.format(cwd=os.getcwd()))
def read_file(self, file_path):
if "../" in file_path:
return 403, None
return 403
full_path = os.path.join(self.base_dir, file_path.lstrip("/"))
if not os.path.isfile(full_path):
return 404, None
return 404
try:
mimetype = mimetypes.guess_type(full_path)
with open(full_path, "rb") as f:
return f.read(), mimetype
return f.read()
except Exception as e:
print(f"Error reading file {full_path}: {e}")
return 500, None
return 500
def write_file(self, file_path, data):
if "../" in file_path:
@@ -109,11 +94,9 @@ class FileHandler:
"http",
"https",
"port-https",
"allow-all",
"allow-localhost",
"disable-autocertgen",
"key-file",
"cert-file",
"block-ua"
]
if option not in valid_options:
return None
@@ -128,90 +111,24 @@ class FileHandler:
key = key.lower()
if key == option:
if option == "host":
seperated_values = value.split(",", -1)
seperated_values = value.split(",", 0)
return [value.lower() for value in seperated_values]
if option == "block-ua":
seperated_values = value.split(",", -1)
host_to_match = []
literal_blocks = []
for val in seperated_values:
if val.startswith("match(") and val.endswith(")"):
idx = val.index("(")
idx2 = val.index(")")
ua_to_match = val[idx+1:idx2]
host_to_match.append(ua_to_match)
else:
literal_blocks.append(val)
return host_to_match, literal_blocks
if option == "port" or option == "port-https":
return int(value)
if (
option == "http"
or option == "https"
or option == "allow-all"
or option == "allow-localhost"
or option == "disable-autocertgen"
):
return bool(int(value))
if option == "directory":
if value == "<Enter directory here>":
return os.path.join(os.getcwd(), "html")
if value.endswith("/"):
value = value.rstrip("/")
return value
return value
if option == "block-ua":
return [], []
return None
def read_new_config(self, option, host=None):
"""
Reads the configuration file and returns a dict
"""
if self.cached_conf is None:
with open(self.config_path, "r", encoding="utf-8") as fh:
text = fh.read()
blocks = re.findall(
r'^(host\s+(\S+)|globals)\s*\{([^}]*)\}', text, re.MULTILINE
)
parsed = {}
host_list = []
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.starswith("#"):
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):
"""
Generate some self-signed certificates using AutoCertGen
TODO: doesn't work, need to fix. probably add `./` to $PATH
"""
autocert = AutoCertGen()
autocert.gen_cert()
@@ -219,28 +136,20 @@ class FileHandler:
class RequestParser:
def __init__(self):
self.allowed_methods_file = "allowedmethods.conf"
self.file_handler = FileHandler()
self.hosts = self.file_handler.read_config("host")
self.all_allowed = self.file_handler.read_config("allow-all")
def parse_request_line(self, line):
"""Parses the HTTP request line."""
try:
method, path, version = line.split(" ")
except ValueError:
return None, None, None
if path.endswith("/"):
path += "index.html"
return method, path, version
def ua_blocker(self, ua):
"""Parses and matches UA to block"""
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
except ValueError:
return None, None, None
def is_method_allowed(self, method):
"""
@@ -250,10 +159,9 @@ class RequestParser:
Should (for now) only be GET as I haven't implemented the logic for PUT
"""
allowed_methods = ["GET"]
# While the logic for PUT, DELETE, etc. is not added, we shouldn't
# allow for it to attempt it.
# Prepatched for new update.
# allowed_methods = self.file_handler.read_config("allowed-methods")
if os.path.isfile(self.allowed_methods_file):
with open(self.allowed_methods_file, "r") as f:
allowed_methods = [line.strip() for line in f]
return method in allowed_methods
def host_parser(self, host):
@@ -261,26 +169,19 @@ class RequestParser:
Parses the host and makes sure it's allowed in
Mfw im in an ugly code writing contest and my opponent is nova while writing a side project
"""
host = f"{host}"
host = str(host)
if ":" in host:
host = host.split(":", 1)[0]
host = host.lstrip()
host = host.rstrip()
if (
host == "localhost" or host == "127.0.0.1"
) and self.file_handler.read_config("allow-localhost"):
return True
if host not in self.hosts:
if host not in self.hosts and self.all_allowed is False:
return False
else:
elif host not in self.hosts and self.all_allowed is True:
return True
#
# class ProxyServer:
# def __init__(
# self,
# ):
class WebServer:
def __init__(
@@ -296,13 +197,6 @@ class WebServer:
# me when no certificate and key file
if not os.path.exists(self.cert_file) or not os.path.exists(self.key_file):
if not os.path.exists(self.cert_file) and not os.path.exists(self.key_file):
pass
# maybe warn users we purge their key/cert files? xdd
elif not os.path.exists(self.cert_file):
os.remove(self.key_file)
elif not os.path.exists(self.key_file):
os.remove(self.cert_file)
print("WARN: No HTTPS certificate was found!")
if self.file_handler.read_config("disable-autocertgen") is True:
print("WARN: AutoCertGen is disabled, ignoring...")
@@ -314,8 +208,11 @@ class WebServer:
else:
self.skip_ssl = True
# TODO: change this to something like oh no you fucked up, go fix idiot
self.no_host_req_response = (
"This host cannot be reached without sending a `Host` header."
"Connecting via this host is disallowed\r\n"
"You may also be using a very old browser!\r\n"
"Ask the owner of this website to set allow-all to 1!"
)
# ipv6 when????/??//?????//?
@@ -337,22 +234,6 @@ class WebServer:
self.https_socket, server_side=True
)
self.http_404_html = (
"<html><head><title>HTTP 404 - PyWebServer</title></head>"
"<body><center><h1>HTTP 404 - Not Found!</h1><p>Running PyWebServer/1.2.1</p>"
"</center></body></html>"
)
self.http_403_html = (
"<html><head><title>HTTP 403 - PyWebServer</title></head>"
"<body><center><h1>HTTP 403 - Forbidden</h1><p>Running PyWebServer/1.2.1</p>"
"</center></body></html>"
)
self.http_405_html = (
"<html><head><title>HTTP 405 - PyWebServer</title></head>"
"<body><center><h1>HTTP 405 - Method not allowed</h1><p>Running PyWebServer/1.2.1</p>"
"</center></body></html>"
)
self.running = True
def start(self, http, https):
@@ -363,13 +244,14 @@ class WebServer:
https_thread = threading.Thread(target=self.start_https, daemon=True)
if https is True:
if self.skip_ssl is True:
print("WARN: You have enabled HTTPS without SSL!!")
yn = input("Is this intended behaviour? [y/N] ")
https_thread.start()
if http is True:
http_thread.start()
print(
f"Server running:\n - HTTP on port {self.http_port}\n - HTTPS on port {self.https_port}"
)
http_thread.join()
https_thread.join()
@@ -379,6 +261,7 @@ class WebServer:
while self.running:
try:
conn, addr = self.http_socket.accept()
print(f"HTTP connection received from {addr}")
self.handle_connection(conn, addr)
except Exception as e:
print(f"HTTP error: {e}")
@@ -391,6 +274,7 @@ class WebServer:
while self.running:
try:
conn, addr = self.https_socket.accept()
print(f"HTTPS connection received from {addr}")
self.handle_connection(conn, addr)
except Exception as e:
print(
@@ -401,17 +285,12 @@ class WebServer:
def handle_connection(self, conn, addr):
try:
data = conn.recv(512)
data = conn.recv(512) # why? well internet and tutiorials
request = data.decode(errors="ignore")
if not data:
response = self.build_response(400, "Bad Request") # user did fucky-wucky
elif len(data) > 8192:
response = self.build_response(413, "Request too long")
else:
response = self.handle_request(request, addr)
if isinstance(response, str):
response = response.encode()
response = response.encode() # if we send text this shouldn't explode
conn.sendall(response)
except Exception as e:
@@ -420,7 +299,9 @@ class WebServer:
conn.close()
def handle_request(self, data, addr):
print(f"data: {data}")
if not data:
return self.build_response(400, "Bad Request") # user did fucky-wucky
request_line = data.splitlines()[0]
# Extract host from headers, never works though
@@ -434,141 +315,92 @@ class WebServer:
)
break
else:
if (
self.file_handler.read_config("allow-nohost") is True
): # no host is stupid
pass
return self.build_response(
400, self.no_host_req_response.encode()
)
for line in data.splitlines():
if "User-Agent" in line:
ua = line.split(":", 1)[1].strip()
allowed = self.parser.ua_blocker(ua)
if not allowed:
return self.build_response(
403, "This UA has been blocked by the owner of this site."
)
break
else:
return self.build_response(
400, "You cannot connect without a User-Agent."
)
403, self.no_host_req_response.encode()
) # the default (i hope to god)
method, path, version = self.parser.parse_request_line(request_line)
if not all([method, path, version]):
return self.build_response(400, "Bad Request")
# Figure out a better way to reload config
if path == "/?pywebsrv_reload_conf=1":
print("Got reload command! Reloading configuration...")
self.file_handler = FileHandler()
self.parser = RequestParser()
return self.build_response(302, "")
if not self.parser.is_method_allowed(
if not all([method, path, version]) or not self.parser.is_method_allowed(
method
):
return self.build_response(405, self.http_405_html)
return self.build_response(405, "Method Not Allowed")
file_content, mimetype = self.file_handler.read_file(path)
file_content = self.file_handler.read_file(path)
if file_content == 403:
print("WARN: Directory traversal attack prevented.") # look ma, security!!
return self.build_response(403, self.http_403_html)
return self.build_response(403, "Forbidden")
if file_content == 404:
return self.build_response(404, self.http_404_html)
return self.build_response(404, "Not Found")
if file_content == 500:
return self.build_response(
500,
"PyWebServer has encountered a fatal error and cannot serve "
"your request. Contact the owner with this error: FATAL_FILE_RO_ACCESS",
) # When there was an issue with reading we throw this.
) # The user did no fucky-wucky, but the server fucking exploded.
# A really crude implementation of binary files. Later in 2.0 I'll actually
# make this useful.
mimetype = mimetype[0]
if mimetype is None:
# We have to assume it's binary.
return self.build_binary_response(200, file_content, "application/octet-stream")
if "text/" not in mimetype:
return self.build_binary_response(200, file_content, mimetype)
# (try to) detect binary files (eg, mp3) and serve them correctly
if path.endswith((".mp3", ".png", ".jpg", ".jpeg", ".gif")):
return self.build_binary_response(200, file_content, path)
return self.build_response(200, file_content)
@staticmethod
def build_binary_response(status_code, binary_data, content_type):
def build_binary_response(status_code, binary_data, filename):
"""Handles binary files like MP3s."""
messages = {
200: "OK",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
500: "Internal Server Error"
500: "Internal Server Error",
}
status_message = messages.get(status_code)
# In the spirit of keeping stuff small, we'll just guess and see.
content_type = "application/octet-stream"
if filename.endswith(".mp3"):
content_type = "audio/mpeg"
elif filename.endswith(".png"):
content_type = "image/png"
elif filename.endswith(".jpg") or filename.endswith(".jpeg"):
content_type = "image/jpeg"
elif filename.endswith(".gif"):
content_type = "image/gif"
headers = (
f"HTTP/1.1 {status_code} {status_message}\r\n"
f"Server: PyWebServer/1.4\r\n"
f"Server: PyWebServer/1.0\r\n"
f"Content-Type: {content_type}\r\n"
f"Content-Length: {len(binary_data)}\r\n"
f"Connection: close\r\n\r\n"
# Connection close is done because it is way easier to implement.
# It's not like this program will see production use anyway.
# Tbh when i'll implement HTTP2
f"Connection: close\r\n\r\n" # connection close bcuz im lazy
)
return headers.encode() + binary_data
def build_response(self, status_code, body):
"""
For textfiles we'll not have to guess MIME-types, though the other function
build_binary_response will be merged in here anyway.
"""
@staticmethod
def build_response(status_code, body):
messages = {
200: "OK",
204: "No Content",
302: "Found",
304: "Not Modified", # TODO KEKL
400: "Bad Request",
403: "Forbidden",
404: "Not Found",
405: "Method Not Allowed",
413: "Payload Too Large",
500: "Internal Server Error",
635: "Go Away"
}
status_message = messages.get(status_code)
if isinstance(body, str):
body = body.encode()
# TODO: dont encode yet, and i encode. awesome comments here.
# Don't encode yet, if 302 status code we have to include location.
headers = (
f"HTTP/1.1 {status_code} {status_message}\r\n"
f"Server: PyWebServer/1.4\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n\r\n"
).encode()
if status_code == 302:
# 302 currently only happens when the reload is triggered.
# Why not 307, Moved Permanently? Because browsers will cache the
# response and not send the reload command.
host = self.file_handler.read_config("host")[0]
port = self.file_handler.read_config("port-https") or self.file_handler.read_config("port")
if port != 80 and port != 443:
if port == 8443:
host = f"https://{host}:{port}/"
else:
host = f"http://{host}:{port}/"
else:
if port == 443:
host = f"https://{host}/"
else:
host = f"http://{host}/"
headers = (
f"HTTP/1.1 {status_code} {status_message}\r\n"
f"Location: {host}\r\n"
f"Server: PyWebServer/1.2.1\r\n"
f"Server: PyWebServer/1.0\r\n"
f"Content-Length: {len(body)}\r\n"
f"Connection: close\r\n\r\n"
).encode()
@@ -576,7 +408,8 @@ class WebServer:
return headers + body
def shutdown(self, signum, frame):
print("\nRecieved signal to exit!\nShutting down server...")
print(f"\nRecieved signal {signum}")
print("\nShutting down server...")
self.running = False
self.http_socket.close()
self.https_socket.close()
@@ -585,8 +418,7 @@ class WebServer:
def main():
file_handler = FileHandler()
# file_handler.check_first_run()
file_handler.base_dir = file_handler.read_config("directory")
file_handler.check_first_run()
http_port = file_handler.read_config("port") or 8080
https_port = file_handler.read_config("port-https") or 8443
http_enabled = file_handler.read_config("http") or True