Update 2026-07-23 11:13:07

This commit is contained in:
kassam 2026-07-23 11:13:09 +04:00
parent 1d354ad2a3
commit 28ef7a6893
10 changed files with 202 additions and 8 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@ __pycache__/
*.pyc
Logs/
*.log
.pudu_token
access.log

View File

@ -1 +1 @@
{"yaml": "/home/zedx/Robotics_workspace/yslootahtech/Project/G1/Nav2_Projects/sanad_nav3/maps/pudu_modified/map.yaml"}
{"yaml": "/home/zedx/Robotics_workspace/yslootahtech/Project/Other/pudu_map_gui/maps/pudu_modified/map.yaml"}

View File

@ -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: []

BIN
maps/pudu_modified/map.pgm Normal file

Binary file not shown.

Binary file not shown.

View File

@ -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

Binary file not shown.

View File

@ -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

View File

@ -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,10 +2257,120 @@ 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=<secret> 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 = """<!doctype html><meta charset=utf-8>
<title>Pudu Map GUI - sign in</title>
<meta name=viewport content="width=device-width,initial-scale=1">
<style>
body{background:#14161a;color:#e8e6e3;font:14px/1.5 system-ui,sans-serif;
display:grid;place-items:center;height:100vh;margin:0}
form{background:#1c1f24;border:1px solid #2a2f36;border-radius:10px;padding:26px 28px;
width:min(90vw,340px)}
h1{font-size:15px;margin:0 0 4px} p{color:#8b939e;font-size:12px;margin:0 0 16px}
input{width:100%;box-sizing:border-box;background:#0f1114;border:1px solid #2a2f36;
color:#e8e6e3;border-radius:6px;padding:9px 11px;font-size:14px}
button{width:100%;margin-top:11px;background:#e8a33d;color:#14161a;border:0;
border-radius:6px;padding:9px;font-weight:600;font-size:14px;cursor:pointer}
.err{color:#e05c5c;font-size:12px;margin-top:10px;display:none}
</style>
<form onsubmit="go(event)">
<h1>Pudu Map GUI</h1>
<p>This dashboard can drive the robots. Enter the access key.</p>
<input id=k type=password autofocus autocomplete=current-password placeholder="access key">
<button>Sign in</button>
<div class=err id=e>Wrong key.</div>
</form>
<script>
async function go(ev){
ev.preventDefault();
const k = document.getElementById('k').value.trim();
if(!k) return;
// Ask the server to validate + set the cookie, then land on the clean URL.
const r = await fetch('/login', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({key:k})});
if(r.ok){ location.replace('/'); }
else { document.getElementById('e').style.display='block'; }
}
</script>"""
class Handler(BaseHTTPRequestHandler):
def log_message(self, *a):
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://<host>: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()
self.send_response(code)
@ -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()

View File

@ -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"