Last active
February 28, 2026 03:26
-
-
Save paoloo/275ed580fd7668628ab556aa5bcfb81b to your computer and use it in GitHub Desktop.
NGET: a wget-like file retrieval for Reticulum NomadNetwork
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/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