Skip to content

Instantly share code, notes, and snippets.

@paoloo
Last active February 28, 2026 03:26
Show Gist options
  • Select an option

  • Save paoloo/275ed580fd7668628ab556aa5bcfb81b to your computer and use it in GitHub Desktop.

Select an option

Save paoloo/275ed580fd7668628ab556aa5bcfb81b to your computer and use it in GitHub Desktop.
NGET: a wget-like file retrieval for Reticulum NomadNetwork
#!/usr/bin/env python3
# Paolo Oliveira <paolocmo@gmail.com>
import argparse
import os
import sys
import time
import threading
from urllib.parse import urlparse
import RNS
DEFAULT_PATH = "/page/index.mu"
def clean_hash(s: str) -> bytes:
# Accept "<..:..>" prettyhexrep style too
h = s.replace("<", "").replace(">", "").replace(":", "").strip().lower()
if h.startswith("0x"):
h = h[2:]
if len(h) != 32:
raise ValueError(f"Destination hash must be 32 hex chars (got {len(h)}): {h}")
return bytes.fromhex(h)
def parse_nomad_url(u: str):
"""
Returns (dest_hash_bytes, path_str)
Supports: nomadnet://HASH:/page/index.mu, nomadnetwork://HASH/page/index.mu, HASH:/page/index.mu, HASH
"""
u = u.strip()
if "://" in u:
pu = urlparse(u)
# scheme can be nomadnet / nomadnetwork; we don't really care
netloc = pu.netloc
path = pu.path or ""
# Your format: nomadnet://HASH:/page/index.mu
# urlparse() sets netloc="HASH:" and path="/page/index.mu"
if netloc.endswith(":") and path.startswith("/"):
dest = netloc[:-1]
return clean_hash(dest), path
# Common format: nomadnetwork://HASH/page/index.mu
# urlparse sets netloc="HASH" and path="/page/index.mu"
if netloc and path.startswith("/"):
return clean_hash(netloc), path
# Fallback
raise ValueError(f"[-] Could not parse URL: {u}")
# No scheme: either "HASH:/path" or "HASH"
if ":/" in u:
dest, path = u.split(":/", 1)
return clean_hash(dest), "/" + path
else:
return clean_hash(u), DEFAULT_PATH
def wait_for_path(dest_hash: bytes, timeout: float) -> bool:
if RNS.Transport.has_path(dest_hash):
return True
RNS.Transport.request_path(dest_hash)
start = time.time()
while time.time() - start < timeout:
if RNS.Transport.has_path(dest_hash):
return True
time.sleep(0.1)
return False
def main():
ap = argparse.ArgumentParser(description="Fetch a NomadNet resource over Reticulum and save it locally.")
ap.add_argument("resource", help="nomadnet/nomadnetwork URL or HASH[:/path]")
ap.add_argument("-o", "--output", help="Output filename (default: basename of path)")
ap.add_argument("-t", "--timeout", type=float, default=45.0, help="Timeout seconds (default: 45)")
ap.add_argument("-v", "--verbose", action="store_true", help="Verbose logging")
args = ap.parse_args()
# If you run rnsd as a shared instance, this will attach to it.
# else, it will start an embedded instance using your ~/.reticulum/config
RNS.loglevel = RNS.LOG_INFO if args.verbose else RNS.LOG_ERROR
reticulum = RNS.Reticulum()
dest_hash, path = parse_nomad_url(args.resource)
outname = args.output
if not outname:
base = os.path.basename(path.rstrip("/"))
outname = base if base else "index.mu"
print(f"[+] Requesting resource from {dest_hash.hex()}")
print(f"[+] Path: {path}")
print(f"[+] Output: {outname}")
# Make sure that we have a path (otherwise link will never go ACTIVE)
if not wait_for_path(dest_hash, timeout=args.timeout):
print("[-] No path to destination (no route/announce heard).")
print(" Tip: keep a NomadNet client/rBrowser running to hear announces,")
print(" and check hops with: rnpath <hash> (or watch announces).")
return 2
# Recall identity for the destination hash (from previously heard announces)
ident = RNS.Identity.recall(dest_hash)
if not ident:
print("[-] Could not recall identity for destination hash.")
print(" This usually means you have NOT heard an announce for that node yet.")
print(" Leave a client listening for announces, or wait longer, then retry.")
return 3
# Use the correct NomadNet node destination name
destination = RNS.Destination(
ident,
RNS.Destination.OUT,
RNS.Destination.SINGLE,
"nomadnetwork",
"node",
)
link = RNS.Link(destination)
done = threading.Event()
result = {"ok": False, "data": b"", "err": ""}
def on_response(receipt):
try:
data = receipt.response
if data is None:
result["err"] = "Empty response"
elif isinstance(data, bytes):
result["ok"] = True
result["data"] = data
else:
# Some handlers return strings/objects; store as UTF-8
result["ok"] = True
result["data"] = str(data).encode("utf-8")
except Exception as e:
result["err"] = f"Response handling error: {e}"
finally:
done.set()
def on_failed(_receipt):
result["err"] = "Request failed"
done.set()
def on_link_established(_l):
link.request(
path,
data=None,
response_callback=on_response,
failed_callback=on_failed,
)
link.set_link_established_callback(on_link_established)
# Wait for either response/fail
if not done.wait(timeout=args.timeout):
print("[-] Timeout waiting for response (link or request).")
return 4
if not result["ok"]:
print(f"[-] {result['err'] or 'Unknown error'}")
return 5
with open(outname, "wb") as f:
f.write(result["data"])
print(f"[+] Saved {len(result['data'])} bytes to ./{outname}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment