diff --git a/.gitignore b/.gitignore index c02264b..ef96b82 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc Logs/ *.log +.pudu_token +access.log diff --git a/maps/.current.json b/maps/.current.json index 685cacd..c250317 100644 --- a/maps/.current.json +++ b/maps/.current.json @@ -1 +1 @@ -{"yaml": "/home/zedx/Robotics_workspace/yslootahtech/Project/G1/Nav2_Projects/sanad_nav3/maps/pudu_modified/map.yaml"} \ No newline at end of file +{"yaml": "/home/zedx/Robotics_workspace/yslootahtech/Project/Other/pudu_map_gui/maps/pudu_modified/map.yaml"} \ No newline at end of file diff --git a/maps/pudu_modified/annotations.yaml b/maps/pudu_modified/annotations.yaml new file mode 100644 index 0000000..fd2b31a --- /dev/null +++ b/maps/pudu_modified/annotations.yaml @@ -0,0 +1,11 @@ +home_points: +- name: W + x: 0 + y: 0.01 + yaw: 0.00999633 +- name: N + x: 1.27 + y: -4.19 + yaw: -0.38 +charge_stations: [] +areas: [] diff --git a/maps/pudu_modified/map.pgm b/maps/pudu_modified/map.pgm new file mode 100644 index 0000000..5088cfc Binary files /dev/null and b/maps/pudu_modified/map.pgm differ diff --git a/maps/pudu_modified/map.pgm.orig b/maps/pudu_modified/map.pgm.orig new file mode 100644 index 0000000..2fa2d04 Binary files /dev/null and b/maps/pudu_modified/map.pgm.orig differ diff --git a/maps/pudu_modified/map.yaml b/maps/pudu_modified/map.yaml new file mode 100644 index 0000000..b93387c --- /dev/null +++ b/maps/pudu_modified/map.yaml @@ -0,0 +1,7 @@ +image: map.pgm +mode: trinary +resolution: 0.05 +origin: [-5.85, -11.8, 0.0] +negate: 0 +occupied_thresh: 0.65 +free_thresh: 0.196 diff --git a/maps/pudu_modified/map_keepout_baked.pgm b/maps/pudu_modified/map_keepout_baked.pgm new file mode 100644 index 0000000..0b97628 Binary files /dev/null and b/maps/pudu_modified/map_keepout_baked.pgm differ diff --git a/maps/pudu_modified/map_keepout_baked.yaml b/maps/pudu_modified/map_keepout_baked.yaml new file mode 100644 index 0000000..4cfe823 --- /dev/null +++ b/maps/pudu_modified/map_keepout_baked.yaml @@ -0,0 +1,7 @@ +image: map_keepout_baked.pgm +mode: trinary +resolution: 0.05 +origin: [-5.85, -11.8, 0.0] +negate: 0 +occupied_thresh: 0.65 +free_thresh: 0.196 diff --git a/pudu_gui.py b/pudu_gui.py index b3e74f5..ebfe8cc 100644 --- a/pudu_gui.py +++ b/pudu_gui.py @@ -17,12 +17,14 @@ Pipeline (all local, per user click): exactly like start_r1_nav.sh; G1/Go2 = plain RViz on the local ROS). Requires: pillow numpy pyyaml (same as the original converter). Server binds -127.0.0.1 only. Conversion logic is vendored from pudu_to_ros2_map.py (verified +127.0.0.1 by default; set PUDU_BIND (an address, or 0.0.0.0) to expose it on the +LAN — it has NO auth and can drive the robots, so only on a trusted network. Conversion logic is vendored from pudu_to_ros2_map.py (verified against real exports: origin = bottom-left meters, PNG row 0 = top, 0/128/255). """ import base64 import io import json +import hmac import os import shutil import subprocess @@ -2255,9 +2257,119 @@ def open_rviz(robot, ip): # ───────────────────────── http plumbing ───────────────────────── +# ── AUTH ───────────────────────────────────────────────────────────────────── +# This dashboard ssh's into the robots, starts/stops their nav stacks, deletes +# maps, opens RViz on THIS machine's X display and (G1) arms the gait. Exposed on +# a LAN with no auth, anyone on the wifi can do all of that — which is exactly +# what happened: a stranger drove this dashboard and it looked like the laptop +# being remote-controlled. So: no token, no LAN. +# PUDU_TOKEN= required whenever PUDU_BIND is not loopback. +# Loopback stays open with no token: a local browser is already the trust boundary. +AUTH_TOKEN = os.environ.get("PUDU_TOKEN", "").strip() +BIND_ADDR = os.environ.get("PUDU_BIND", "127.0.0.1").strip() or "127.0.0.1" +# PUDU_OPEN=1 = deliberately serve the LAN with NO key (operator's explicit choice). +# Without it, exposing the port with no token FAILS CLOSED rather than silently +# handing the robots to the network — an accident should never open the door. +AUTH_OPEN = os.environ.get("PUDU_OPEN", "").strip() in ("1", "true", "yes") +AUTH_REQUIRED = (BIND_ADDR not in ("127.0.0.1", "localhost", "::1")) and not AUTH_OPEN + + +def _client_ok(headers, path, cookie_hdr): + """Token from the Authorization header, ?t=, or the pudu_t cookie.""" + if not AUTH_REQUIRED: + return True + if not AUTH_TOKEN: + return False # exposed without a token: refuse everything + got = "" + auth = headers.get("Authorization", "") + if auth.startswith("Bearer "): + got = auth[7:].strip() + if not got and "t=" in (path or ""): + from urllib.parse import urlparse, parse_qs + got = (parse_qs(urlparse(path).query).get("t") or [""])[0] + if not got and cookie_hdr: + for part in cookie_hdr.split(";"): + k, _, v = part.strip().partition("=") + if k == "pudu_t": + got = v + break + return bool(got) and hmac.compare_digest(got, AUTH_TOKEN) + + +LOGIN_PAGE = """ +Pudu Map GUI - sign in + + +
+

Pudu Map GUI

+

This dashboard can drive the robots. Enter the access key.

+ + +
Wrong key.
+
+""" + + class Handler(BaseHTTPRequestHandler): - def log_message(self, *a): - pass + def log_message(self, fmt, *a): + # ACCESS LOG. The container runs --rm, so an in-memory-only log dies with + # it — that is why "who was pressing my buttons?" was unanswerable. Write + # to the project dir so it survives. + try: + line = "%s %s %s\n" % (time.strftime("%Y-%m-%d %H:%M:%S"), + self.client_address[0], fmt % a) + with open(os.path.join(HERE, "access.log"), "a") as f: + f.write(line) + except Exception: + pass + + def _deny(self): + # A BROWSER asking for a PAGE gets the login form, so the plain + # http://:8777/ URL works — type the key once, the cookie carries it + # from then on. Anything else (fetch/XHR/curl) gets a clean 401. + wants_page = ("text/html" in (self.headers.get("Accept") or "") + and self.command == "GET") + if wants_page: + body = LOGIN_PAGE.encode() + self.send_response(401) + self.send_header("Content-Type", "text/html; charset=utf-8") + self.send_header("Cache-Control", "no-store") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + else: + body = b'{"error":"unauthorized - open the dashboard and sign in"}' + self.send_response(401) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + self.log_message("DENIED %s", self.path) + + def _authed(self): + return _client_ok(self.headers, self.path, self.headers.get("Cookie", "")) def _json(self, obj, code=200): body = json.dumps(obj).encode() @@ -2270,6 +2382,11 @@ class Handler(BaseHTTPRequestHandler): def _html(self, body): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") + # Opening the page as ...:8777/?t=TOKEN stores it, so the app's own + # fetch() calls (which cannot know the token) stay authorised. + if AUTH_REQUIRED and AUTH_TOKEN and "t=" in self.path: + self.send_header("Set-Cookie", + f"pudu_t={AUTH_TOKEN}; Path=/; SameSite=Strict; Max-Age=86400") # no-store: a stale cached dashboard once ran OLD JS against a NEW # backend — "button does nothing" with zero errors. Never again. self.send_header("Cache-Control", "no-store") @@ -2278,6 +2395,8 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) def do_GET(self): + if not self._authed(): + return self._deny() path = self.path.split("?")[0] # ?v=... cache-busters must still match if path in ("/", "/index.html"): with open(os.path.join(HERE, "index.html"), "rb") as f: @@ -2291,6 +2410,28 @@ class Handler(BaseHTTPRequestHandler): self._json({"error": "not found"}, 404) def do_POST(self): + if self.path.split("?")[0] == "/login": + # the ONE unauthenticated endpoint: it grants the cookie, nothing else + try: + n = int(self.headers.get("Content-Length", 0)) + key = (json.loads(self.rfile.read(n) or b"{}").get("key") or "").strip() + except Exception: + key = "" + if AUTH_TOKEN and hmac.compare_digest(key, AUTH_TOKEN): + self.send_response(204) + self.send_header("Set-Cookie", + f"pudu_t={AUTH_TOKEN}; Path=/; SameSite=Strict; Max-Age=604800") + self.send_header("Content-Length", "0") + self.end_headers() + self.log_message("LOGIN ok") + else: + self.log_message("LOGIN FAILED") + self.send_response(403) + self.send_header("Content-Length", "0") + self.end_headers() + return + if not self._authed(): + return self._deny() n = int(self.headers.get("Content-Length", 0)) try: req = json.loads(self.rfile.read(n) or b"{}") @@ -2435,9 +2576,21 @@ def main(): except Exception as e: print(f"[pudu_gui] current-map restore skipped: {e}") threading.Thread(target=_reap_children, daemon=True).start() - srv = ThreadingHTTPServer(("127.0.0.1", PORT), Handler) - url = f"http://127.0.0.1:{PORT}" - print(f"[pudu_gui] serving {url} (Ctrl+C to stop)") + # BIND: localhost by default. Set PUDU_BIND to expose it on the LAN, e.g. + # PUDU_BIND=10.255.254.83 (this workstation's wifi address) + # PUDU_BIND=0.0.0.0 (every interface) + # ⚠ THIS DASHBOARD HAS NO AUTHENTICATION and it can ssh into the robots, start + # and stop their nav stacks, delete maps and — on the G1 — arm the gait. Anyone + # who can reach this port can do all of that. Only expose it on a network you + # trust, and prefer naming ONE interface over 0.0.0.0. + bind = os.environ.get("PUDU_BIND", "127.0.0.1").strip() or "127.0.0.1" + srv = ThreadingHTTPServer((bind, PORT), Handler) + shown = "127.0.0.1" if bind in ("0.0.0.0", "::") else bind + url = f"http://{shown}:{PORT}" + print(f"[pudu_gui] serving {url} (bound {bind}:{PORT}; Ctrl+C to stop)") + if bind not in ("127.0.0.1", "localhost"): + print("[pudu_gui] WARNING: reachable from the network and NOT authenticated — " + "it can drive the robots. Keep it on a trusted LAN.") threading.Timer(0.8, lambda: webbrowser.open(url)).start() try: srv.serve_forever() diff --git a/start.sh b/start.sh index 116f5e3..1b24adf 100755 --- a/start.sh +++ b/start.sh @@ -32,7 +32,18 @@ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" IMG=pudu-map-gui NAME=pudu-map-gui PORT=8777 -URL="http://127.0.0.1:$PORT" +# PUDU_BIND: which address the dashboard listens on. Default localhost-only. +# PUDU_BIND=10.255.254.83 ./start.sh restart -> reachable from the LAN +# PUDU_BIND=0.0.0.0 ./start.sh restart -> every interface +# The container already runs --net=host, so this is the ONLY thing that decides +# reachability. NO AUTH: anyone who can reach the port can drive the robots. +PUDU_BIND="${PUDU_BIND:-127.0.0.1}" +# health-check a REACHABLE address: 0.0.0.0 is a bind wildcard, not a destination +case "$PUDU_BIND" in + 0.0.0.0|::) HOSTADDR=127.0.0.1 ;; + *) HOSTADDR="$PUDU_BIND" ;; +esac +URL="http://$HOSTADDR:$PORT" _stop() { # the GUI's own viewer processes live INSIDE the container, so removing it @@ -62,6 +73,9 @@ RUN=(docker run --rm --net=host --name "$NAME" -e DISPLAY="${DISPLAY:-:0}" -e HOME="$HOME" -e PUDU_GUI_DIR="$HERE" + -e PUDU_BIND="$PUDU_BIND" + -e PUDU_TOKEN="${PUDU_TOKEN:-}" + -e PUDU_OPEN="${PUDU_OPEN:-}" -v /tmp/.X11-unix:/tmp/.X11-unix -v "$HOME/Robotics_workspace:$HOME/Robotics_workspace" -v "$HOME/Downloads:$HOME/Downloads"