feat: first version
check / tests and shellcheck (push) Failing after 56s

This commit is contained in:
Felitendo committed 2026-10-02 10:39:46 +02:00
commit 3e98dd1e7e
48 files changed
+13025

No files matched your search

+212
View File
@@ -0,0 +1,212 @@
#!/usr/bin/env python3
#
# A fake RD Web Access server for the tests: the feed behind the NTLM login,
# the forms ticket it hands out, .rdp files and icons. It checks the NTLMv2
# answer for real, against one user and password.
#
# python3 tests/fake_rdweb.py [port] serve until Ctrl+C
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
import base64
import hmac
import http.server
import os
import socketserver
import struct
import sys
import threading
import urllib.parse
import importlib.machinery # noqa: E402
import importlib.util # noqa: E402
# rdfeed-fetch has no .py ending, so it is loaded by path.
_loader = importlib.machinery.SourceFileLoader(
"rdfeed_fetch", os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "src", "fetch", "rdfeed-fetch"))
_spec = importlib.util.spec_from_loader("rdfeed_fetch", _loader)
fetch = importlib.util.module_from_spec(_spec)
_loader.exec_module(fetch)
DOMAIN = "CONTOSO"
USER = "jdoe"
PASSWORD = "Correct horse 1"
TOKEN = "5EC2E7F00DBA11ADE0F7EED5ACC0FFEE"
APPS = [
("winword-RemoteApps-CmsRdsh", "Word", "RemoteApp"),
("excel-RemoteApps-CmsRdsh", "Excel", "RemoteApp"),
("powerpnt-RemoteApps-CmsRdsh", "PowerPoint", "RemoteApp"),
("desktop-RemoteApps-CmsRdsh", "Contoso Desktop", "Desktop"),
]
def extensions(alias):
"""Excel comes with its file types, as an administrator would enter them."""
if not alias.startswith("excel"):
return "<FileExtensions />"
return ('<FileExtensions><FileExtension Name=".xlsx" PrimaryHandler="True" />'
'<FileExtension Name=".CSV" PrimaryHandler="False" /></FileExtensions>')
def feed_xml():
resources = ""
for alias, title, kind in APPS:
resources += f"""
<Resource ID="{alias}" Alias="{alias}" Title="{title}" LastUpdated="2026-10-01T08:00:00Z" Type="{kind}" ShowByDefault="True">
<Icons>
<IconRaw FileType="Ico" FileURL="/RDWeb/Pages/rdp/{alias}.ico" />
<Icon32 Dimensions="32x32" FileType="Png" FileURL="/RDWeb/Pages/rdp/{alias}.png" />
</Icons>
{extensions(alias)}
<Folders><Folder Name="/" /></Folders>
<HostingTerminalServers>
<HostingTerminalServer>
<ResourceFile FileExtension=".rdp" URL="/RDWeb/Pages/rdp/cpub-{alias}.rdp" />
<TerminalServerRef Ref="RDSH01.contoso.test" />
</HostingTerminalServer>
</HostingTerminalServers>
</Resource>"""
return f"""<?xml version="1.0" encoding="utf-8"?>
<ResourceCollection PubDate="2026-10-01T08:00:00Z" SchemaVersion="2.1" xmlns="http://schemas.microsoft.com/ts/2007/05/tswf">
<Publisher LastUpdated="2026-10-01T08:00:00Z" Name="Contoso Apps" ID="RDSH01.contoso.test" Description="">
<Resources>{resources}
</Resources>
</Publisher>
</ResourceCollection>
""".encode()
def rdp_file(alias, title, kind):
lines = [
"full address:s:RDBROKER.contoso.test",
"gatewayhostname:s:gateway.contoso.test",
"gatewayusagemethod:i:1",
"promptcredentialonce:i:0",
"prompt for credentials on client:i:1",
"drivestoredirect:s:*",
"devicestoredirect:s:*",
"camerastoredirect:s:*",
"loadbalanceinfo:s:tsv://MS Terminal Services Plugin.1.RemoteApps",
]
if kind == "RemoteApp":
lines += ["remoteapplicationmode:i:1", f"remoteapplicationprogram:s:||{alias.split('-')[0]}",
f"remoteapplicationname:s:{title}"]
return ("\r\n".join(lines) + "\r\n").encode("utf-16")
def ico_32bpp(size=48, rgba=(30, 110, 220, 255)):
"""An .ico with one 32-bit bitmap frame, the way Windows writes them."""
r, g, b, a = rgba
header = struct.pack("<IiiHHIIiiII", 40, size, size * 2, 1, 32, 0, 0, 0, 0, 0, 0)
pixels = bytes((b, g, r, a)) * (size * size)
mask = b"\x00" * (((size + 31) // 32) * 4 * size)
dib = header + pixels + mask
return (struct.pack("<HHH", 0, 1, 1)
+ struct.pack("<BBBBHHII", size, size, 0, 0, 1, 32, len(dib), 22) + dib)
def ntlm_challenge():
def av(kind, value):
data = value.encode("utf-16-le")
return struct.pack("<HH", kind, len(data)) + data
info = (av(2, DOMAIN) + av(1, "RDWEB01") + av(4, "contoso.test") + av(3, "rdweb01.contoso.test")
+ struct.pack("<HH", 7, 8) + struct.pack("<Q", 133000000000000000) + struct.pack("<HH", 0, 0))
server_challenge = os.urandom(8)
target = DOMAIN.encode("utf-16-le")
head = 56
message = (b"NTLMSSP\x00" + struct.pack("<I", 2) + struct.pack("<HHI", len(target), len(target), head)
+ struct.pack("<I", 0xE2898215) + server_challenge + b"\x00" * 8
+ struct.pack("<HHI", len(info), len(info), head + len(target)) + fetch.NTLM_VERSION
+ target + info)
return message, server_challenge
def ntlm_check(message, server_challenge):
"""True if the AUTHENTICATE message proves the password."""
def field(i):
length, _, offset = struct.unpack("<HHI", message[12 + 8 * i:20 + 8 * i])
return message[offset:offset + length]
nt, domain, user = field(1), field(2).decode("utf-16-le"), field(3).decode("utf-16-le")
if user.lower() != USER or domain.upper() != DOMAIN or len(nt) < 32:
return False
key = fetch.ntowfv2(user, domain, PASSWORD)
proof = hmac.new(key, server_challenge + nt[16:], "md5").digest()
return hmac.compare_digest(proof, nt[:16])
class Handler(http.server.BaseHTTPRequestHandler):
protocol_version = "HTTP/1.1"
def log_message(self, *args):
pass
def reply(self, status, body=b"", headers=None):
self.send_response(status)
for k, v in (headers or {}).items():
if isinstance(v, list):
for one in v:
self.send_header(k, one)
else:
self.send_header(k, v)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def signed_in(self):
cookies = self.headers.get("Cookie", "")
return f"TSWAFeedAuthCookie={TOKEN}" in cookies
def do_GET(self):
path = urllib.parse.urlsplit(self.path).path
lower = path.lower()
if lower == "/rdweb/feed/webfeed.aspx":
if self.signed_in():
return self.reply(200, feed_xml(), {"Content-Type": "application/x-msts-radc+xml; charset=utf-8"})
return self.reply(302, b"", {"Location": "/RDWeb/FeedLogin/WebFeedLogin.aspx"})
if lower == "/rdweb/feedlogin/webfeedlogin.aspx":
auth = self.headers.get("Authorization", "")
if auth.startswith("NTLM "):
message = base64.b64decode(auth[5:])
kind = struct.unpack("<I", message[8:12])[0]
if kind == 1:
challenge, self.server_challenge = ntlm_challenge()
return self.reply(401, b"", {"WWW-Authenticate": "NTLM " + base64.b64encode(challenge).decode()})
if kind == 3 and ntlm_check(message, getattr(self, "server_challenge", b"")):
return self.reply(200, TOKEN.encode(), {"Content-Type": "application/x-msts-webfeed-login; charset=utf-8"})
return self.reply(401, b"denied", {"WWW-Authenticate": ["NTLM", "Negotiate"], "Content-Type": "text/html"})
if lower.startswith("/rdweb/pages/rdp/"):
if not self.signed_in():
return self.reply(302, b"", {"Location": "/RDWeb/Pages/en-US/login.aspx?ReturnUrl=" + path})
name = path.rsplit("/", 1)[1]
for alias, title, kind in APPS:
if name == f"cpub-{alias}.rdp":
return self.reply(200, rdp_file(alias, title, kind), {"Content-Type": "application/x-rdp"})
if name == f"{alias}.ico":
return self.reply(200, ico_32bpp(), {"Content-Type": "image/x-icon"})
if name == f"{alias}.png":
return self.reply(404)
return self.reply(404, b"<html>not here</html>", {"Content-Type": "text/html"})
class Server(socketserver.ThreadingMixIn, http.server.HTTPServer):
daemon_threads = True
allow_reuse_address = True
def serve(port=0):
"""Starts the server in a thread. Returns it; its URL base is server.base."""
server = Server(("127.0.0.1", port), Handler)
server.base = f"http://127.0.0.1:{server.server_address[1]}"
threading.Thread(target=server.serve_forever, daemon=True).start()
return server
if __name__ == "__main__":
srv = serve(int(sys.argv[1]) if len(sys.argv) > 1 else 0)
print(srv.base + "/RDWeb/Feed/webfeed.aspx", flush=True)
try:
threading.Event().wait()
except KeyboardInterrupt:
pass