162 lines
6.1 KiB
Python
162 lines
6.1 KiB
Python
"""Small non-blocking password-protected HTTP configuration interface."""
|
|
|
|
import ubinascii
|
|
import socket
|
|
|
|
|
|
class WebServer:
|
|
def __init__(self, settings, on_save, on_volume):
|
|
self.settings = settings
|
|
self.on_save = on_save
|
|
self.on_volume = on_volume
|
|
self._client = None
|
|
self._buffer = b""
|
|
self._socket = socket.socket()
|
|
self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
self._socket.bind(("0.0.0.0", 80))
|
|
self._socket.listen(1)
|
|
self._socket.settimeout(0)
|
|
|
|
def poll(self):
|
|
if self._client is None:
|
|
try:
|
|
self._client, _ = self._socket.accept()
|
|
self._client.settimeout(0)
|
|
self._buffer = b""
|
|
except OSError:
|
|
return
|
|
try:
|
|
data = self._client.recv(512)
|
|
if data:
|
|
self._buffer += data
|
|
elif not self._buffer:
|
|
self._close()
|
|
return
|
|
except OSError:
|
|
pass
|
|
|
|
if len(self._buffer) > 4096:
|
|
self._reply(413, "Request too large")
|
|
return
|
|
marker = self._buffer.find(b"\r\n\r\n")
|
|
if marker < 0:
|
|
return
|
|
headers = self._buffer[:marker].decode("utf-8", "replace")
|
|
length = self._content_length(headers)
|
|
if len(self._buffer) < marker + 4 + length:
|
|
return
|
|
body = self._buffer[marker + 4:marker + 4 + length].decode("utf-8", "replace")
|
|
self._handle(headers, body)
|
|
|
|
def _handle(self, headers, body):
|
|
lines = headers.split("\r\n")
|
|
request = lines[0].split(" ")
|
|
if len(request) < 2:
|
|
self._reply(400, "Malformed request")
|
|
return
|
|
if not self._authorized(lines):
|
|
self._reply(401, "Authentication required", {"WWW-Authenticate": 'Basic realm="PolterHID"'})
|
|
return
|
|
|
|
if request[0] == "POST" and request[1] == "/":
|
|
try:
|
|
from settings import apply_form
|
|
form = _parse_form(body)
|
|
apply_form(self.settings, form)
|
|
action = form.get("action")
|
|
if action in ("volume_up", "volume_down"):
|
|
self.on_volume(action)
|
|
self.on_save()
|
|
self._reply(303, "", {"Location": "/"})
|
|
except ValueError as error:
|
|
self._reply(400, str(error))
|
|
return
|
|
self._reply(200, _page(self.settings), {"Content-Type": "text/html; charset=utf-8"})
|
|
|
|
def _authorized(self, lines):
|
|
expected = "Basic " + ubinascii.b2a_base64(
|
|
("admin:" + self.settings["web_password"]).encode()
|
|
).strip().decode()
|
|
for line in lines[1:]:
|
|
if line.lower().startswith("authorization:"):
|
|
return line.split(":", 1)[1].strip() == expected
|
|
return False
|
|
|
|
@staticmethod
|
|
def _content_length(headers):
|
|
for line in headers.split("\r\n")[1:]:
|
|
if line.lower().startswith("content-length:"):
|
|
try:
|
|
return int(line.split(":", 1)[1].strip())
|
|
except ValueError:
|
|
return 0
|
|
return 0
|
|
|
|
def _reply(self, status, body, extra=None):
|
|
reasons = {200: "OK", 303: "See Other", 400: "Bad Request", 401: "Unauthorized", 413: "Payload Too Large"}
|
|
encoded = body.encode()
|
|
headers = {"Content-Length": str(len(encoded)), "Connection": "close"}
|
|
if extra:
|
|
headers.update(extra)
|
|
response = "HTTP/1.1 %d %s\r\n" % (status, reasons[status])
|
|
for key, value in headers.items():
|
|
response += "%s: %s\r\n" % (key, value)
|
|
try:
|
|
self._client.send(response.encode() + b"\r\n" + encoded)
|
|
except OSError:
|
|
pass
|
|
self._close()
|
|
|
|
def _close(self):
|
|
try:
|
|
self._client.close()
|
|
except OSError:
|
|
pass
|
|
self._client = None
|
|
self._buffer = b""
|
|
|
|
|
|
def _parse_form(body):
|
|
result = {}
|
|
for pair in body.split("&"):
|
|
if "=" in pair:
|
|
key, value = pair.split("=", 1)
|
|
result[_unquote(key)] = _unquote(value)
|
|
return result
|
|
|
|
|
|
def _unquote(value):
|
|
value = value.replace("+", " ")
|
|
parts = value.split("%")
|
|
result = parts[0]
|
|
for part in parts[1:]:
|
|
try:
|
|
result += chr(int(part[:2], 16)) + part[2:]
|
|
except ValueError:
|
|
result += "%" + part
|
|
return result
|
|
|
|
|
|
def _checked(settings, name):
|
|
return " checked" if settings[name] else ""
|
|
|
|
|
|
def _page(settings):
|
|
return """<!doctype html><meta name=viewport content='width=device-width,initial-scale=1'>
|
|
<title>PolterHID</title><style>body{font:16px sans-serif;max-width:38rem;margin:2rem auto;padding:0 1rem}label{display:block;margin:.7rem 0}input[type=number]{width:6rem}button{padding:.6rem 1rem}</style>
|
|
<h1>PolterHID</h1><p>Awareness-training HID configuration</p>
|
|
<form method=post>
|
|
<label><input type=checkbox name=enabled%s> Enable all injection</label>
|
|
<label><input type=checkbox name=return_enabled%s> Inject Return key</label>
|
|
<label>Return interval <input name=return_min_seconds type=number min=1 value=%d> to <input name=return_max_seconds type=number min=1 value=%d> seconds</label>
|
|
<label><input type=checkbox name=mouse_enabled%s> Inject mouse jitter</label>
|
|
<label>Mouse interval <input name=mouse_min_seconds type=number min=1 value=%d> to <input name=mouse_max_seconds type=number min=1 value=%d> seconds</label>
|
|
<label>Mouse movement distance <input name=mouse_distance type=number min=1 max=127 value=%d> pixels per event</label>
|
|
<button>Save</button> <button name=action value=volume_up>Volume up</button> <button name=action value=volume_down>Volume down</button></form>
|
|
<p><small>Changes take effect immediately. This HTTP interface has no TLS; use only a trusted training network.</small></p>""" % (
|
|
_checked(settings, "enabled"), _checked(settings, "return_enabled"),
|
|
settings["return_min_seconds"], settings["return_max_seconds"],
|
|
_checked(settings, "mouse_enabled"), settings["mouse_min_seconds"],
|
|
settings["mouse_max_seconds"], settings["mouse_distance"],
|
|
)
|