Files
RTLPlayground/tools/httpd_sim.py
T
2026-10-25 09:07:14 +00:00

505 lines
16 KiB
Python
Executable File

#!/usr/bin/env python3
"""Serve files from the current directory and provide the example device API."""
import os
import secrets
import time
import re
from email.parser import BytesParser
from email.policy import default
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, unquote, urlsplit
HOST = "0.0.0.0"
PORT = 8081
PASSWORD = "1234"
PORTS = 6
NSFP = 1
N_COUNTERS = 52
VLAN_MAX_ID = 4094
L2_MAX_TRANSFER = 5
ROOT = Path.cwd().resolve()
phys_to_log_port = []
if PORTS == 9:
phys_to_log_port = [ 0, 1, 2, 3, 4, 5, 6, 7, 8 ]
else:
phys_to_log_port = [ 4, 5, 6, 7, 3, 8 ]
l2_entries = [
{ "mac": "00:01:2f:00:00:01", "vlan":"001", "type":'l', "port":7, "idx":0x10 },
{ "mac": "00:06:78:00:00:02", "vlan":"001", "type":'l', "port":7, "idx":0x20 },
{ "mac": "00:01:2e:00:00:03", "vlan":"001", "type":'s', "port":7, "idx":0x30 },
{ "mac": "60:7d:09:00:00:04", "vlan":"001", "type":'l', "port":7, "idx":0x40 },
{ "mac": "1c:2a:a3:00:00:05", "vlan":"001", "type":'l', "port":7, "idx":0x50 },
{ "mac": "1c:2a:a3:00:00:06", "vlan":"001", "type":'s', "port":7, "idx":0x60 },
{ "mac": "02:e0:4c:00:00:07", "vlan":"001", "type":'l', "port":7, "idx":0x70 },
{ "mac": "00:e0:4c:00:00:08", "vlan":"001", "type":'l', "port":4, "idx":0x80 },
{ "mac": "1c:2a:a3:00:00:09", "vlan":"001", "type":'l', "port":9, "idx":0x90 }, # CPU-Port
{ "mac": "3c:8c:f8:00:00:0a", "vlan":"001", "type":'l', "port":7, "idx":0xa0 },
{ "mac": "00:01:2e:00:00:0b", "vlan":"001", "type":'l', "port":7, "idx":0xb0 },
{ "mac": "00:01:2f:00:01:01", "vlan":"001", "type":'l', "port":7, "idx":0xc0 }
]
state = {
"lldp_enabled": False,
"vlans": {1: {"active": True, "name": ""}}
}
cmd_history = []
tx_g = [0] * PORTS
rx_g = [0] * PORTS
tx_b = [0] * PORTS
rx_b = [0] * PORTS
last_called = int(time.time())
sessions = set()
uploaded_config = None
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(ROOT), **kwargs)
def api_handlers(self,query):
return {
"/status.json": self._status,
"/eee.json": self._eee,
"/bandwidth.json": self._bandwidth,
"/information.json": self._information,
"/mirror.json": self._mirror,
"/lag.json": self._lag,
"/l2.json": lambda: self._l2(query),
"/mtu.json": self._mtu,
"/vlan.json": lambda: self._vlan(query),
"/vlanlist": self._vlanlist,
"/lldp.json": self._lldp,
"/cmd_log": self._cmd_log,
"/counters.json": self._counters,
}
def _send(self, status, body=b"", content_type="text/plain",
headers=()):
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
for name, value in headers:
self.send_header(name, value)
self.end_headers()
if body:
self.wfile.write(body)
def _json(self, value, headers=()):
import json
body = json.dumps(value, separators=(",", ":")).encode("utf-8")
return self._send(
200, body, "application/json", headers=headers
)
def _redirect(self, location, headers=()):
return self._send(
302, headers=(("Location", location), *headers)
)
def _authenticated(self):
for item in self.headers.get("Cookie", "").split(";"):
name, _, value = item.strip().partition("=")
if name == "session" and value in sessions:
return True
return False
def _protected(self):
if not self._authenticated():
self._send(401, b"Unauthorized\n")
return True
return False
@staticmethod
def _int_param(query, name):
try:
return int(query.get(name, ["0"])[0], 0)
except ValueError:
return 0
def _api_response(self, path, query):
handler = self.api_handlers(query).get(path)
return handler() if handler else {}
def do_GET(self):
global uploaded_config
parsed = urlsplit(self.path)
path = unquote(parsed.path)
query = parse_qs(parsed.query)
if path in self.api_handlers(None).keys():
if self._protected():
return
result = self._api_response(path, query)
if isinstance(result, tuple):
body, content_type = result
return self._send(200, body, content_type)
return self._json(
result,
headers=(("Cache-Control", "no-cache"),)
if path in ("/status.json", "/counters.json")
else (),
)
if path == "/config" and uploaded_config is not None:
if self._protected():
return
return self._send(200, uploaded_config, "text/plain")
if not self._authenticated() and path not in ("/login.html", "/favicon.ico"):
return self._redirect("/login.html")
# Resolve files under ROOT only; reject path traversal.
relative = path.lstrip("/") or "index.html"
target = (ROOT / relative).resolve()
if target != ROOT and ROOT not in target.parents:
return self._send(404, b"Not found\n")
if not target.is_file():
return self._send(404, b"Not found\n")
try:
data = target.read_bytes()
except OSError:
return self._send(404, b"Not found\n")
import mimetypes
mime = mimetypes.guess_type(str(target))[0] or "application/octet-stream"
return self._send(
200,
data,
f"{mime}",
headers=(("Cache-Control", "max-age=60, must-revalidate"),),
)
def do_POST(self):
global uploaded_config
path = urlsplit(self.path).path
if path == "/login":
form = self._read_urlencoded_form()
if form.get("pwd", [""])[0] == PASSWORD:
token = secrets.token_urlsafe(32)
sessions.add(token)
return self._redirect(
"/index.html",
headers=((
"Set-Cookie",
f"session={token}; Path=/; SameSite=Strict; HttpOnly",
),),
)
return self._redirect("/login.html")
if self._protected():
return
if path == "/cmd":
form = self._read_urlencoded_form()
command = form.get("cmd", [""])[0]
cmd_history.append(command)
self.parse_cmd(command);
return self._send(
200,
b"<!DOCTYPE html> <html><head><title>Upload OK</title></head>"
b"<body><h1>Command executed successully</h1></html>",
"text/html",
)
if path in ("/upload", "/config"):
if self.headers.get_content_type() != "multipart/form-data":
return self._send(400, b"Expected multipart/form-data\n")
parts = self._read_multipart()
if parts is None:
return self._send(400, b"Bad multipart request\n")
file_data = next(
(
data for content_type, data in parts
if content_type == "application/octet-stream"
),
None,
)
if file_data is None:
return self._send(
400, b"No application/octet-stream part\n"
)
if path == "/config":
uploaded_config = file_data
return self._send(
200,
b"<!DOCTYPE html> <html><head><title>Upload OK</title></head>"
b"<h1>File uploaded successfully</h1></body></html>",
"text/html",
)
return self._send(404, b"Not found\n")
def _read_urlencoded_form(self):
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length).decode("utf-8", errors="replace")
return parse_qs(raw)
def _read_multipart(self):
length = int(self.headers.get("Content-Length", "0"))
raw = self.rfile.read(length)
content_type = self.headers.get("Content-Type", "")
message = BytesParser(policy=default).parsebytes(
b"Content-Type: "
+ content_type.encode("utf-8")
+ b"\r\nMIME-Version: 1.0\r\n\r\n"
+ raw
)
if not message.is_multipart():
return None
parts = []
for part in message.iter_parts():
parts.append((
part.get_content_type(),
part.get_payload(decode=True) or b"",
))
return parts
def parse_cmd(self, cmd):
pieces = cmd.split(" ")
handler = self.get_parsers().get(pieces[0])
return handler(pieces[1:]) if handler else {}
def get_parsers(self):
return {
"vlan": self._parse_vlan,
"lldp": self._parse_lldp
}
def _parse_vlan(self, pieces): #vlan 2 name 6 3 8t or vlan 2 6 3 8t or vlan 2 d
global state
vid = int(pieces[1]);
startIndex=2
if pieces[2] == "d":
return remove_vlan(vid)
elif re.match(pieces[2], '^[A-Za-z]+$'):
name = pieces[2]
startIndex=3
state.vlans[vid] = {"active":True, "name":name, "untagged":[], "tagged":[]}
# list of ports in either 3 or 5t form
for i in pieces[startIndex:]:
if pieces[i][-1] == "t":
state.vlans[vid].tagged.append(pieces[i])
else:
state.vlans[vid].untagged.append(pieces[i])
def _parse_lldp(self,pieces):
global state
state.lldp_enabled = (pieces[0] == "on")
def _status(self):
global last_called
now = max(int(time.time()), last_called + 1)
elapsed = now - last_called
ports = []
for i in range(1, PORTS + 1):
n = i - 1
if i % 2:
rate = 2_400_000_000 if i == 1 else 950_000_000
tx_g[n] += rate * elapsed
rx_g[n] += rate * elapsed
tx_b[n] += rate * elapsed // 10_000_000
rx_b[n] += rate * elapsed // 10_000_000
item = {
"portNum": i,
"logPort": phys_to_log_port[n],
"isSFP": int(i > PORTS - NSFP),
"enabled": int(i % 4 != 0),
"link": (5 if i == 1 else 2) if i % 2 else 0,
"txG": f"0x{tx_g[n]:016x}",
"txB": f"0x{tx_b[n]:016x}",
"rxG": f"0x{rx_g[n]:016x}",
"rxB": f"0x{rx_b[n]:016x}",
}
if i >= PORTS - NSFP:
item.update({
"sfp_vendor": "OEM",
"sfp_model": "10G-SFP+",
"sfp_serial": "12345678",
"sfp_options": "0x68",
"sfp_temp": f"0x{0x28fb + secrets.randbelow(100):04x}",
"sfp_vcc": f"0x{0x7eda + secrets.randbelow(100):04x}",
"sfp_txbias": f"0x{0x0d24 + secrets.randbelow(100):04x}",
"sfp_txpower": f"0x{0x14bd + secrets.randbelow(100):04x}",
"sfp_rxpower": "0x0000",
"sfp_laser": "0x0000",
})
else:
item["adv"] = (
"100000" if i == 1
else "000011" if i == 2
else "000100"
)
ports.append(item)
last_called = now
return ports
def _eee(self):
return [
{
"portNum": i,
"isSFP": int(i >= 5),
"eee": "00000010",
"eee_lp": "00000100",
"active": int(i % 2 == 1),
}
for i in range(1, PORTS + 1)
]
def _bandwidth(self):
ports = []
for i in range(1, PORTS + 1):
ingress_limited = i % 2 == 1
egress_limited = i % 4 != 0
ports.append({
"portNum": i,
"iLimited": int(ingress_limited),
"iFC": int(i % 4 != 0) if ingress_limited else 0,
"iBW": f"{0x100 if ingress_limited else 0xfffff:08x}",
"eLimited": int(egress_limited),
"eBW": f"{0x1000 if egress_limited else 0xfffff:08x}",
})
return ports
def _mirror(self):
return {
"mPort": 1,
"enabled": 1,
"mirror_tx": f"{0b000110:016b}",
"mirror_rx": f"{0b000010:016b}",
}
def _lag(self):
return [
{
"lagNum": i,
"members": f"{(0x30 << (i * 2)) & 0x1f8:016b}",
"hash": "0x7e",
}
for i in range(4)
]
def _mtu(self):
return [
{
"portNum": i,
"mtu": f"0x{16383 if i % 2 else 1522:04x}",
}
for i in range(1, PORTS + 1)
]
def _lldp(self):
return {
"on": 1,
"portStatus": [
{
"portNum": i,
"logPort": i - 1,
"name": "",
"permitted": 1,
}
for i in range(1, PORTS + 1)
],
}
def _counters(self):
return [f"0x{0x1234 + i:016x}" for i in range(N_COUNTERS)]
def _vlanlist(self):
global state
vlan_list = []
for i in state.get("vlans").keys():
if i in state.get("vlans") and state.get("vlans")[i]["active"]:
vlan_list.append({
"id": i,
"name": state.get("vlans")[i]["name"],
})
return {"mgmt": 1, "vlan": vlan_list}
def _vlan(self,query):
global state
vlan_id = self._int_param(query, "vid")
if vlan_id == 1:
return {
"members": "0x0207ffff",
"name": "native",
"pvid": "0x01ff",
}
vlan = state.get("vlans").get(vlan_id)
if vlan and vlan.get("active"):
return {
"name": vlan["name"],
"members": "0x0000",
"pvid": "0x0000",
}
return {}
def _l2(self,query):
idx = self._int_param(query, "idx")
entries = sorted(l2_entries, key=lambda entry: entry["idx"])
start = next(
(n for n, entry in enumerate(entries) if entry["idx"] >= idx),
len(entries),
)
result = []
for entry in entries[start:start + L2_MAX_TRANSFER]:
result.append({
"mac": entry["mac"],
"vlan": entry["vlan"],
"type": "l" if entry.get("type") == "l" else "s",
"port": entry["port"],
"idx": f"{entry['idx']:04x}",
})
return result
def _cmd_log(self):
text = "\n".join(cmd_history)
if cmd_history:
text += "\n"
return text.encode("utf-8"), "text/plain"
def _information(self):
return {
"ip_address":"192.168.10.247",
"ip_gateway":"192.168.2.22",
"ip_netmask":"255.255.255.0",
"mac_address":"1c:2a:a3:23:00:02",
"sw_ver":"0.1-sim",
"hw_ver":"SWGT024-V2.0"
}
if __name__ == "__main__":
print(f"Serving {ROOT} on http://{HOST}:{PORT}")
ThreadingHTTPServer((HOST, PORT), Handler).serve_forever()