Update 2026-07-23 11:13:07
This commit is contained in:
parent
1d354ad2a3
commit
28ef7a6893
2
.gitignore
vendored
2
.gitignore
vendored
@ -2,3 +2,5 @@ __pycache__/
|
||||
*.pyc
|
||||
Logs/
|
||||
*.log
|
||||
.pudu_token
|
||||
access.log
|
||||
|
||||
@ -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"}
|
||||
11
maps/pudu_modified/annotations.yaml
Normal file
11
maps/pudu_modified/annotations.yaml
Normal 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
BIN
maps/pudu_modified/map.pgm
Normal file
Binary file not shown.
BIN
maps/pudu_modified/map.pgm.orig
Normal file
BIN
maps/pudu_modified/map.pgm.orig
Normal file
Binary file not shown.
7
maps/pudu_modified/map.yaml
Normal file
7
maps/pudu_modified/map.yaml
Normal 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
|
||||
BIN
maps/pudu_modified/map_keepout_baked.pgm
Normal file
BIN
maps/pudu_modified/map_keepout_baked.pgm
Normal file
Binary file not shown.
7
maps/pudu_modified/map_keepout_baked.yaml
Normal file
7
maps/pudu_modified/map_keepout_baked.yaml
Normal 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
|
||||
163
pudu_gui.py
163
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,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()
|
||||
|
||||
16
start.sh
16
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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user