This commit is contained in:
commit
3e98dd1e7e
48 files changed
+13025
No files matched your search
Executable
+212
@@ -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
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user