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
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env bash
#
# The rdfeed command from start to end, against tests/fake_rdweb.py: add a
# workspace, list, refresh, start an app, remove it again.
#
# Everything happens in a temporary home. secret-tool, systemctl, notify-send,
# kbuildsycoca6 and xfreerdp3 are fakes on PATH, so the real keyring, timers,
# notifications and app menu stay untouched.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
set -uo pipefail
root="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)"
tmp="$(mktemp -d)"
server=''
trap '[[ -n $server ]] && kill "$server" 2>/dev/null; rm -rf -- "$tmp"' EXIT
failed=0
export HOME="$tmp/home" XDG_CONFIG_HOME="$tmp/config" XDG_DATA_HOME="$tmp/data" XDG_CACHE_HOME="$tmp/cache"
export RF_LIBDIR="$root/src/lib" RF_FETCH="$root/src/fetch/rdfeed-fetch" RF_LOCALEDIR="$tmp/locale"
RF_HOOK="$(readlink -f "${RDFEED_HOOK:-$root/build/rdfeed-hook.so}")"
export RF_HOOK
export LANG=C LC_ALL=C NO_COLOR=1 XDG_CURRENT_DESKTOP=test XDG_RUNTIME_DIR="$tmp/run"
mkdir -p "$HOME" "$tmp/bin"
# The fakes. Each writes what it was asked into $tmp, as a log or a file.
cat > "$tmp/bin/secret-tool" <<- 'EOF'
#!/usr/bin/env bash
store="${0%/bin/*}/secrets"; mkdir -p "$store"
cmd="$1"; shift; ws=''
while (( $# )); do [[ $1 == workspace ]] && ws="$2"; shift; done
case "$cmd" in
store) cat > "$store/$ws" ;;
lookup) [[ -f $store/$ws ]] && cat "$store/$ws" ;;
clear) rm -f "$store/$ws" ;;
esac
EOF
cat > "$tmp/bin/xfreerdp3" <<- 'EOF'
#!/usr/bin/env bash
out="${0%/bin/*}"
cat > "$out/freerdp-args"
cp "$(head -n1 "$out/freerdp-args")" "$out/freerdp-rdp"
env > "$out/freerdp-env"
echo "[INFO] connected"
echo "[rdfeed] gateway-delay 12" >&2
exit "${FAKE_FREERDP_EXIT:-0}"
EOF
for fake in systemctl notify-send kbuildsycoca6 update-desktop-database; do
# shellcheck disable=SC2016 # the script expands when it runs, not here
printf '#!/usr/bin/env bash\necho "%s $*" >> "${0%%/bin/*}/calls"\n[[ $1 == --user && $2 == cat ]] && exit 1\nexit 0\n' "$fake" > "$tmp/bin/$fake"
done
chmod +x "$tmp/bin"/*
export PATH="$tmp/bin:$PATH"
rdfeed() { bash "$root/src/rdfeed" "$@"; }
check() {
if eval "$2"; then
printf 'ok %s\n' "$1"
else
printf 'FAIL %s\n' "$1"
failed=1
fi
}
# The URL helpers, on their own.
url_helpers() (
source "$RF_LIBDIR/common.sh"; source "$RF_LIBDIR/config.sh"; source "$RF_LIBDIR/workspace.sh"
for pair in "rdweb.contoso.com|https://rdweb.contoso.com/RDWeb/Feed/webfeed.aspx" \
"office.contoso.com/rdweb/feed/webfeed|https://office.contoso.com/rdweb/feed/webfeed.aspx" \
"https://x.test/RDWeb/Pages/en-US/Default.aspx|https://x.test/RDWeb/Feed/webfeed.aspx" \
"HTTPS://x.test:8443/RDWeb/|https://x.test:8443/RDWeb/Feed/webfeed.aspx" \
"https://x.test/custom/feed.aspx|https://x.test/custom/feed.aspx"; do
rf_feed_url "${pair%%|*}"
[[ $RF_URL == "${pair#*|}" ]] || { echo " $RF_URL != ${pair#*|}"; exit 1; }
done
rf_split_user 'CONTOSO\jdoe'; [[ $RF_USER == jdoe && $RF_DOMAIN == CONTOSO ]] || exit 1
rf_split_user 'jdoe@contoso.com'; [[ $RF_USER == jdoe@contoso.com && -z $RF_DOMAIN ]] || exit 1
rf_split_user 'jdoe'; [[ $RF_USER == jdoe && $RF_DOMAIN == - ]] || exit 1
[[ $(rf_ws_id_for "https://Office.Contoso.COM/RDWeb/Feed/webfeed.aspx") == office-contoso-com ]] || exit 1
)
if url_helpers; then check "addresses, user names and ids" true; else check "addresses, user names and ids" false; fi
python3 "$root/tests/fake_rdweb.py" > "$tmp/url" &
server=$!
for _ in $(seq 50); do [[ -s $tmp/url ]] && break; sleep 0.1; done
url="$(< "$tmp/url")"
id=127-0-0-1
password="$(python3 -c 'import sys; sys.path.insert(0, sys.argv[1]); import fake_rdweb; print(fake_rdweb.PASSWORD)' "$root/tests")"
out="$(printf 'CONTOSO\\jdoe\nwrong\n' | rdfeed add "$url" 2>&1)"
rc=$?
check "a wrong password is refused" "[[ \$rc -ne 0 ]] && grep -q 'did not accept' <<< \"\$out\""
check "and leaves nothing behind" "[[ ! -e $XDG_CONFIG_HOME/rdfeed/workspaces/$id ]]"
out="$(printf 'jdoe\n%s\n' "$password" | rdfeed add "${url%/RDWeb/*}" 2>&1)"
check "add signs in and loads the apps" "grep -q '4 apps from Contoso Apps are now in your app menu' <<< \"\$out\""
ws="$XDG_CONFIG_HOME/rdfeed/workspaces/$id"
check "the workspace remembers the server's domain" "grep -qx 'Domain=CONTOSO' '$ws' && grep -qx 'User=jdoe' '$ws'"
check "the password is in the keyring, as typed" "[[ \$(< '$tmp/secrets/$id') == \"\$password\" ]]"
entry="$XDG_DATA_HOME/applications/rdfeed-$id--powerpnt.desktop"
check "each app has a menu entry" "[[ \$(ls '$XDG_DATA_HOME/applications' | wc -l) -eq 4 ]]"
check "which starts it" "grep -q 'Exec=$root/src/rdfeed run $id/powerpnt' '$entry'"
check "with its own icon" "grep -qx 'Icon=$XDG_DATA_HOME/rdfeed/$id/icons/powerpnt.png' '$entry'"
word="$XDG_DATA_HOME/applications/rdfeed-$id--winword.desktop"
if [[ -r /usr/share/mime/globs2 ]]; then
check "Word is offered for .docx files" "grep -q '^MimeType=.*application/vnd.openxmlformats-officedocument.wordprocessingml.document;' '$word' && grep -q 'run $id/winword %f\$' '$word'"
check "Excel for the types of the feed" "grep -qx 'MimeType=.*text/csv;.*' '$XDG_DATA_HOME/applications/rdfeed-$id--excel.desktop' || grep -q 'MimeType=.*csv' '$XDG_DATA_HOME/applications/rdfeed-$id--excel.desktop'"
fi
check "the desktop for none" "! grep -q MimeType '$XDG_DATA_HOME/applications/rdfeed-$id--desktop.desktop'"
if command -v desktop-file-validate > /dev/null; then
check "the entries are valid" "desktop-file-validate --no-hints '$XDG_DATA_HOME'/applications/*.desktop"
fi
check "the workspace has a folder" "grep -q '<Category>X-rdfeed-$id</Category>' '$XDG_CONFIG_HOME/menus/applications-merged/rdfeed-$id.menu' && grep -qx 'Name=Contoso Apps' '$XDG_DATA_HOME/desktop-directories/rdfeed-$id.directory'"
out="$(rdfeed list 2>&1)"
check "list shows the apps" "grep -q '$id/powerpnt *PowerPoint' <<< \"\$out\""
out="$(rdfeed refresh < /dev/null 2>&1)"
check "refresh takes the password from the keyring" "grep -q 'Contoso Apps: 4 apps' <<< \"\$out\""
rdfeed run powerpnt > /dev/null 2>&1
check "run hands FreeRDP the .rdp file first" "[[ \$(head -n1 '$tmp/freerdp-args') == '$XDG_DATA_HOME/rdfeed/$id/rdp/powerpnt.rdp' ]]"
check "with the user, domain and password" "grep -qx '/u:jdoe' '$tmp/freerdp-args' && grep -qx '/d:CONTOSO' '$tmp/freerdp-args' && grep -qxF \"/p:\$password\" '$tmp/freerdp-args'"
check "and the hook, with GFX left on" "grep -qx 'LD_PRELOAD=$RF_HOOK' '$tmp/freerdp-env' && ! grep -q 'RDFEED_NO_GFX' '$tmp/freerdp-env'"
check "the log has what FreeRDP said" "grep -q 'gateway-delay 12' '$XDG_CACHE_HOME/rdfeed/$id--powerpnt.log'"
check "a notification says a second prompt comes" "grep -q 'notify-send .*A second sign-in is coming' '$tmp/calls'"
mkdir -p "$HOME/Downloads" "$HOME/Documents/Reports"
: > "$HOME/Downloads/Report, final.docx"
rdfeed run "$id/winword" "$HOME/Downloads/Report, final.docx" > /dev/null 2>&1
check "a file opens through its shared folder" "grep -qxF 'remoteapplicationcmdline:s:\"\\\\tsclient\\Downloads\\Report, final.docx\"'\$'\\r' '$tmp/freerdp-rdp' && grep -qx '/drive:Downloads,$HOME/Downloads' '$tmp/freerdp-args'"
check "and its copy of the .rdp is gone after" "[[ -z \$(ls -A '$XDG_RUNTIME_DIR/rdfeed' 2>/dev/null) ]]"
check "and a notification names it" "grep -q 'notify-send .*Opening Report, final.docx in Word' '$tmp/calls'"
rdfeed run "$id/winword" "$HOME/nothing.docx" > /dev/null 2>&1
rc=$?
check "a missing file is refused" "[[ \$rc -ne 0 ]]"
# shellcheck disable=SC2016 # user-dirs.dirs wants $HOME as it is
printf 'XDG_DOCUMENTS_DIR="$HOME/Documents"\n' > "$XDG_CONFIG_HOME/user-dirs.dirs"
printf 'Share=documents\nGraphics=classic\nSound=no\nOpenWith=no\n' > "$XDG_CONFIG_HOME/rdfeed/config"
: > "$HOME/Documents/Reports/q3.xlsx"
rdfeed run "$id/excel" "$HOME/Documents/Reports/q3.xlsx" > /dev/null 2>&1
check "a file in the shared folder needs no drive of its own" "grep -qF 'tsclient\\Documents\\Reports\\q3.xlsx' '$tmp/freerdp-rdp' && [[ \$(grep -c '^/drive:' '$tmp/freerdp-args') -eq 1 ]]"
check "the settings reach FreeRDP" "grep -qx 'RDFEED_NO_GFX=1' '$tmp/freerdp-env' && ! grep -qx '/sound' '$tmp/freerdp-args'"
rdfeed refresh < /dev/null > /dev/null 2>&1
check "with Open with off the entries name no file types" "! grep -q MimeType '$word' && grep -q 'run $id/winword\$' '$word'"
FAKE_FREERDP_EXIT=131 rdfeed run "$id/powerpnt" > /dev/null 2>&1
rc=$?
check "a failed start says so" "[[ \$rc -ne 0 ]] && grep -q 'notify-send .*PowerPoint could not start' '$tmp/calls'"
rdfeed run nothing > /dev/null 2>&1
rc=$?
check "an unknown app is refused" "[[ \$rc -ne 0 ]]"
rdfeed remove "$id" > /dev/null 2>&1
check "remove takes everything away" "[[ ! -e '$ws' && ! -e '$tmp/secrets/$id' && ! -d '$XDG_DATA_HOME/rdfeed/$id' ]] && ! ls '$XDG_DATA_HOME'/applications/rdfeed-* > /dev/null 2>&1"
exit "$failed"
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
#
# rdfeed-fetch: NTLM against the test vectors of MS-NLMP, the feed, the .rdp
# files, the icons, and whole subscriptions against tests/fake_rdweb.py.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
import hmac
import os
import struct
import subprocess
import sys
import tempfile
import unittest
import zlib
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import fake_rdweb # noqa: E402
fetch = fake_rdweb.fetch
FETCH = os.path.join(HERE, "..", "src", "fetch", "rdfeed-fetch")
def png_info(png):
"""Width, height and the RGBA bytes of a PNG written by png_encode."""
assert png[:8] == fetch.PNG_SIGNATURE
width, height = struct.unpack(">II", png[16:24])
pos, idat = 8, b""
while pos < len(png):
length = struct.unpack(">I", png[pos:pos + 4])[0]
kind = png[pos + 4:pos + 8]
if kind == b"IDAT":
idat += png[pos + 8:pos + 8 + length]
pos += 12 + length
raw = zlib.decompress(idat)
return width, height, raw
class Ntlm(unittest.TestCase):
def test_md4(self):
self.assertEqual(fetch.md4(b"").hex(), "31d6cfe0d16ae931b73c59d7e0c089c0")
self.assertEqual(fetch.md4(b"abc").hex(), "a448017aaf21d8525fc10ae87aa6729d")
self.assertEqual(fetch.md4(b"1234567890" * 8).hex(), "e33b4ddc9c38f2199c3e7b164fcc0536")
# MS-NLMP 4.2.4: User, Domain, Password, server challenge 0123456789abcdef,
# client challenge aa..aa, time 0, target info Domain and Server.
def test_ntlmv2_vectors(self):
key = fetch.ntowfv2("User", "Domain", "Password")
self.assertEqual(key.hex(), "0c868a403bfd7a93a3001ef22ef02e3f")
server, client = bytes.fromhex("0123456789abcdef"), b"\xaa" * 8
lm = hmac.new(key, server + client, "md5").digest() + client
self.assertEqual(lm.hex(), "86c35097ac9cec102554764a57cccc19aaaaaaaaaaaaaaaa")
info = (struct.pack("<HH", 2, 12) + "Domain".encode("utf-16-le")
+ struct.pack("<HH", 1, 12) + "Server".encode("utf-16-le") + b"\x00" * 4)
nt = fetch.ntlmv2_response(key, server, client, b"\x00" * 8, info)
self.assertEqual(nt[:16].hex(), "68cd0ab851e51c96aabc927bebef6a1c")
def test_domain_from_server(self):
challenge, server_challenge = fake_rdweb.ntlm_challenge()
message, domain = fetch.ntlm_authenticate(challenge, "jdoe", None, fake_rdweb.PASSWORD)
self.assertEqual(domain, "CONTOSO")
self.assertTrue(fake_rdweb.ntlm_check(message, server_challenge))
message, _ = fetch.ntlm_authenticate(challenge, "jdoe", None, "wrong")
self.assertFalse(fake_rdweb.ntlm_check(message, server_challenge))
class Feed(unittest.TestCase):
def test_parse(self):
name, apps = fetch.parse_feed(fake_rdweb.feed_xml())
self.assertEqual(name, "Contoso Apps")
self.assertEqual([a["slug"] for a in apps], ["winword", "excel", "powerpnt", "desktop"])
self.assertEqual([a["type"] for a in apps], ["RemoteApp"] * 3 + ["Desktop"])
self.assertEqual(apps[0]["rdp"], "/RDWeb/Pages/rdp/cpub-winword-RemoteApps-CmsRdsh.rdp")
self.assertEqual(apps[1]["types"], ["xlsx", "csv"])
self.assertEqual(apps[0]["types"], [])
def test_known_types(self):
self.assertEqual(fetch.known_types("||winword", "Word", "winword-x")[:2], ["docx", "docm"])
self.assertEqual(fetch.known_types("||powerpoint", "PowerPoint", ""), "pptx pptm ppt potx potm ppsx pps odp".split())
self.assertEqual(fetch.known_types("||trueview", "DWG TrueView", "trueview-x"), ["dwg", "dxf"])
self.assertEqual(fetch.known_types("||pdf24", "PDF24", ""), ["pdf"])
self.assertEqual(fetch.known_types("||wordpad", "WordPad", "wordpad-x"), [])
self.assertEqual(fetch.known_types("||pwchange", "Passwort ändern", "pwchange-x"), [])
def test_not_a_feed(self):
for data in (b"<html><body>login</body></html>", b"", b"<RDWAPage/>"):
with self.assertRaises(fetch.FeedError):
fetch.parse_feed(data)
def test_slugs(self):
taken = set()
self.assertEqual(fetch.make_slug("Passwort ändern", taken), "passwort-andern")
self.assertEqual(fetch.make_slug("Passwort ändern", taken), "passwort-andern-2")
self.assertEqual(fetch.make_slug("计算器", taken), "app")
self.assertEqual(fetch.make_slug("!!!", taken), "app-2")
self.assertEqual(fetch.common_suffix(["a-X-Y", "bb-X-Y"]), "-X-Y")
self.assertEqual(fetch.common_suffix(["word", "sword"]), "")
self.assertEqual(fetch.common_suffix(["only-one"]), "")
class Rdp(unittest.TestCase):
def test_sanitize(self):
text = fetch.decode_rdp(fake_rdweb.rdp_file("winword-x", "Word", "RemoteApp"))
out = fetch.sanitize_rdp(text)
self.assertIn("promptcredentialonce:i:1\r\n", out)
self.assertIn("prompt for credentials on client:i:0\r\n", out)
for gone in ("drivestoredirect", "devicestoredirect", "camerastoredirect"):
self.assertNotIn(gone, out)
self.assertIn("remoteapplicationprogram:s:||winword\r\n", out)
def test_encodings(self):
line = "full address:s:host\r\n"
for data in (line.encode("utf-16"), line.encode("utf-16-le"), line.encode("utf-8-sig"), line.encode()):
self.assertEqual(fetch.decode_rdp(data), line)
def test_append(self):
self.assertEqual(fetch.rdp_set("a:s:b\r\n", "promptcredentialonce", "i", "1"),
"a:s:b\r\npromptcredentialonce:i:1\r\n")
class Icons(unittest.TestCase):
def test_32bpp(self):
width, height, raw = png_info(fetch.ico_to_png(fake_rdweb.ico_32bpp(48, (30, 110, 220, 128))))
self.assertEqual((width, height), (48, 48))
self.assertEqual(raw[1:5], bytes((30, 110, 220, 128)))
def test_24bpp_with_mask(self):
size = 16
header = struct.pack("<IiiHHIIiiII", 40, size, size * 2, 1, 24, 0, 0, 0, 0, 0, 0)
stride = ((size * 24 + 31) // 32) * 4
pixels = (bytes((0, 0, 255)) * size + b"\x00" * (stride - size * 3)) * size
# The top row of the image (the last one in the file) is transparent.
mask = b"\x00\x00\x00\x00" * (size - 1) + b"\xff\xff\x00\x00"
dib = header + pixels + mask
ico = (struct.pack("<HHH", 0, 1, 1)
+ struct.pack("<BBBBHHII", size, size, 0, 0, 1, 24, len(dib), 22) + dib)
width, height, raw = png_info(fetch.ico_to_png(ico))
self.assertEqual((width, height), (16, 16))
self.assertEqual(raw[1:5], bytes((255, 0, 0, 0)))
row = 1 + width * 4
self.assertEqual(raw[row + 1:row + 5], bytes((255, 0, 0, 255)))
def test_png_frame_and_biggest(self):
small = fake_rdweb.ico_32bpp(16)
png = fetch.png_encode(2, 1, [bytes((1, 2, 3, 4, 5, 6, 7, 8))])
# Two entries: a 16 px bitmap and a 256 px PNG (width 0 means 256).
dib = small[22:]
ico = (struct.pack("<HHH", 0, 1, 2)
+ struct.pack("<BBBBHHII", 16, 16, 0, 0, 1, 32, len(dib), 38)
+ struct.pack("<BBBBHHII", 0, 0, 0, 0, 1, 32, len(png), 38 + len(dib)) + dib + png)
self.assertEqual(fetch.ico_to_png(ico), png)
def test_garbage(self):
self.assertIsNone(fetch.ico_to_png(b"not an icon"))
self.assertIsNone(fetch.ico_to_png(b"\x00\x00\x01\x00\x01\x00" + b"\x00" * 16))
class Subscribe(unittest.TestCase):
"""rdfeed-fetch against the fake server, as rdfeed runs it."""
@classmethod
def setUpClass(cls):
cls.server = fake_rdweb.serve()
cls.url = cls.server.base + "/RDWeb/Feed/webfeed.aspx"
@classmethod
def tearDownClass(cls):
cls.server.shutdown()
def run_fetch(self, password, url=None, user="jdoe", domain=None):
out = tempfile.mkdtemp()
args = [sys.executable, FETCH, "--url", url or self.url, "--user", user, "--out", out, "--timeout", "5"]
if domain is not None:
args += ["--domain", domain]
proc = subprocess.run(args, input=password + "\n", capture_output=True, text=True, timeout=60)
return proc, out
def test_ok(self):
proc, out = self.run_fetch(fake_rdweb.PASSWORD)
self.assertEqual(proc.returncode, 0, proc.stderr)
lines = [line.split("\t") for line in proc.stdout.splitlines()]
self.assertIn(["workspace", "Contoso Apps"], lines)
self.assertIn(["domain", "CONTOSO"], lines)
self.assertIn(["app", "powerpnt", "RemoteApp", "PowerPoint"], lines)
self.assertIn(["app", "desktop", "Desktop", "Contoso Desktop"], lines)
with open(os.path.join(out, "apps.tsv")) as f:
rows = [line.split("\t") for line in f.read().splitlines()]
self.assertEqual(len(rows), 4)
types = {row[0]: row[3] for row in rows}
self.assertTrue(types["winword"].startswith("docx docm doc "))
self.assertEqual(types["excel"], "xlsx csv")
self.assertEqual(types["desktop"], "")
with open(os.path.join(out, "rdp", "winword.rdp")) as f:
rdp = f.read()
self.assertIn("promptcredentialonce:i:1", rdp)
self.assertNotIn("drivestoredirect", rdp)
with open(os.path.join(out, "icons", "excel.png"), "rb") as f:
self.assertEqual(png_info(f.read())[:2], (48, 48))
def test_domain_given(self):
proc, _ = self.run_fetch(fake_rdweb.PASSWORD, domain="contoso")
self.assertEqual(proc.returncode, 0, proc.stderr)
def test_wrong_password(self):
proc, _ = self.run_fetch("nope")
self.assertEqual(proc.returncode, fetch.EXIT_AUTH)
def test_no_feed(self):
proc, _ = self.run_fetch(fake_rdweb.PASSWORD, url=self.server.base + "/somewhere/else")
self.assertEqual(proc.returncode, fetch.EXIT_NOFEED)
def test_unreachable(self):
proc, _ = self.run_fetch(fake_rdweb.PASSWORD, url="http://127.0.0.1:9/RDWeb/Feed/webfeed.aspx")
self.assertEqual(proc.returncode, fetch.EXIT_NETWORK)
if __name__ == "__main__":
unittest.main()
+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
#
# rdfeed-hook.so: the wait before a new gateway tunnel, and GFX kept off.
# Connections go to port 443 of this computer, nothing has to answer there.
#
# Copyright (C) 2026 Felitendo
# SPDX-License-Identifier: GPL-3.0-or-later
set -uo pipefail
cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." || exit 1
hook="$(readlink -f "${RDFEED_HOOK:-build/rdfeed-hook.so}")"
[[ -f $hook ]] || { echo "no hook at $hook, run make first" >&2; exit 1; }
failed=0
check() {
if [[ $2 == "$3" ]]; then
printf 'ok %s\n' "$1"
else
printf 'FAIL %s: got "%s", want "%s"\n' "$1" "$2" "$3"
failed=1
fi
}
# timings <wait mode> <pause>
# One connection, the pause, then two right after each other. Prints how long
# each one waited, in whole seconds, and whether the hook said so.
timings() {
LD_PRELOAD="$hook" RDFEED_GW_DELAY=1 RDFEED_GW_WAIT="$1" python3 - "$2" 2>&1 <<- 'EOF'
import socket, sys, time
def attempt():
start = time.monotonic()
s = socket.socket()
try:
s.connect(("127.0.0.1", 443))
except OSError:
pass
s.close()
return round(time.monotonic() - start)
first = attempt()
time.sleep(float(sys.argv[1]))
second = attempt()
third = attempt()
print(first, second, third)
EOF
}
check "auto waits after a long first sign-in" "$(timings auto 3 | tr '\n' ' ')" "[rdfeed] gateway-delay 1 0 1 0 "
check "auto does not wait after a quick one" "$(timings auto 2.1)" "0 0 0"
check "always waits" "$(timings always 2.1 | tail -n1)" "0 1 0"
check "never waits not" "$(timings never 3)" "0 0 0"
check "the same tunnel does not wait" "$(timings always 0.5)" "0 0 0"
# GFX: what FreeRDP's own command line parser makes of it, with and without.
gfx() {
env "$@" python3 - <<- 'EOF'
import ctypes
try:
core = ctypes.CDLL("libfreerdp3.so.3", mode=ctypes.RTLD_GLOBAL)
client = ctypes.CDLL("libfreerdp-client3.so.3", mode=ctypes.RTLD_GLOBAL)
except OSError:
print("skip")
raise SystemExit
core.freerdp_settings_new.restype = ctypes.c_void_p
core.freerdp_settings_new.argtypes = [ctypes.c_uint32]
core.freerdp_settings_get_key_for_name.restype = ctypes.c_ssize_t
core.freerdp_settings_get_key_for_name.argtypes = [ctypes.c_char_p]
core.freerdp_settings_get_bool.argtypes = [ctypes.c_void_p, ctypes.c_size_t]
client.freerdp_client_settings_parse_command_line.argtypes = [
ctypes.c_void_p, ctypes.c_int, ctypes.POINTER(ctypes.c_char_p), ctypes.c_int]
settings = core.freerdp_settings_new(0)
argv = [b"xfreerdp3", b"/v:host.invalid", b"/gfx"]
client.freerdp_client_settings_parse_command_line(
settings, len(argv), (ctypes.c_char_p * len(argv))(*argv), 0)
key = core.freerdp_settings_get_key_for_name(b"FreeRDP_SupportGraphicsPipeline")
print("on" if core.freerdp_settings_get_bool(settings, key) else "off")
EOF
}
with="$(gfx LD_PRELOAD="$hook" RDFEED_NO_GFX=1 WLOG_LEVEL=OFF)"
if [[ $with == skip ]]; then
echo "skip GFX: FreeRDP 3 is not installed"
else
check "GFX stays off with RDFEED_NO_GFX" "$with" "off"
check "GFX is left alone without it" "$(gfx LD_PRELOAD="$hook" WLOG_LEVEL=OFF)" "on"
fi
exit "$failed"