Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
160 changes: 156 additions & 4 deletions 03_implementation/src/hermes3d/api/routes/printers.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
REMOTE_SUBDIR_RE = re.compile(r"^[A-Za-z0-9._/-]+$")
ONBOARD_ID_RE = re.compile(r"^[A-Za-z0-9._-]{2,64}$")

# S1 is camera/read-only ONLY — it must never be added as a printable target.
# Any attempt to add a printer whose IP is in this set returns 403.
CAMERA_ONLY_IPS: frozenset[str] = frozenset({"192.168.0.12"})


class UrlUpdate(BaseModel):
url: str
Expand Down Expand Up @@ -63,6 +67,14 @@ class PrinterOnboardRequest(BaseModel):
write_enabled: bool = False


class PrinterProbeRequest(BaseModel):
ip: str


class CameraValidateRequest(BaseModel):
camera_url: str


def _printer(printer_id: str) -> dict[str, Any]:
printer = local_printer(printer_id)
if not printer:
Expand All @@ -75,6 +87,146 @@ def list_printers() -> list[dict[str, Any]]:
return local_printers()


@router.get("/api/printers/probe")
def probe_printer_by_ip(ip: str) -> dict[str, Any]:
"""Read-only Moonraker probe for the onboarding wizard.

Returns Moonraker server info (version, firmware, bed size) for the given
IP. NEVER sends GCode or any printer command — only GET /server/info and
GET /printer/objects/query (read-only). S1 (CAMERA_ONLY_IPS) is blocked.
"""
try:
address = ipaddress.ip_address(ip.strip())
except ValueError as exc:
raise HTTPException(status_code=400, detail="ip must be a valid IP address.") from exc

if str(address) in CAMERA_ONLY_IPS or is_s1_target(str(address)):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This check is redundant. is_s1_target(str(address)) already checks if the IP is in S1_ALT_IDS (which includes 192.168.0.12). Additionally, having CAMERA_ONLY_IPS defined here while S1_ALT_IDS exists in safety.py creates a split source of truth for the S1 policy. Consider consolidating these into safety.py.

Suggested change
if str(address) in CAMERA_ONLY_IPS or is_s1_target(str(address)):
if is_s1_target(str(address)):

raise HTTPException(
status_code=403,
detail={
"error": "CAMERA_ONLY_IP",
"ip": str(address),
"reason": "This IP is camera-only and cannot be added as a print target.",
},
)

if address.is_loopback or address.is_multicast or address.is_unspecified or address.is_reserved:
raise HTTPException(status_code=400, detail="IP address is not allowed.")

moonraker_url = f"http://{address}:7125"
client = MoonrakerClient(moonraker_url, timeout_s=4.0)

# Read-only: GET /server/info only — no GCode, no commands.
try:
info = client.server_info()
except (MoonrakerError, OSError, ValueError) as exc:
raise HTTPException(
status_code=502,
detail={
"error": "MOONRAKER_PROBE_FAILED",
"ip": str(address),
"moonraker_url": moonraker_url,
"reason": str(exc),
},
) from exc

# Optionally read printer objects (read-only).
bed_info: dict[str, Any] = {}
try:
state = client.printer_state(("configfile", "toolhead"))
bed_info = {
"print_state": state.state,
"filename": state.filename or None,
"progress": state.progress,
}
except (MoonrakerError, OSError, ValueError):
pass # optional — don't fail the probe if objects query fails

# Look up static fleet profile for bed size (read-only, no network call).
profile_info: dict[str, Any] = {}
try:
profile = get_profile(f"moonraker_{str(address).replace('.', '_')}")
profile_info = {
"bed_kind": profile.bed.kind,
"bed_diameter_mm": getattr(profile.bed, "diameter_mm", None),
"bed_x_mm": getattr(profile.bed, "x_mm", None),
"bed_y_mm": getattr(profile.bed, "y_mm", None),
"z_height_mm": profile.z_height_mm,
}
except KeyError:
pass # unknown profile — not an error

return {
"ok": True,
"ip": str(address),
"moonraker_url": moonraker_url,
"klippy_connected": info.klippy_connected,
"klippy_state": info.klippy_state,
"moonraker_version": info.moonraker_version,
"api_version": info.api_version,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

There is a type mismatch for api_version. The MoonrakerClient.server_info() method returns api_version as a string (explicitly cast in moonraker_client.py), but the frontend PrinterProbeResult interface expects a string[]. This will cause the frontend to display null for the API version because of the Array.isArray check in the adapter.

**bed_info,
**profile_info,
}


@router.post("/api/printers/validate-camera")
def validate_camera_url(body: CameraValidateRequest) -> dict[str, Any]:
"""Read-only camera URL validation.

Sends a HEAD request to the camera URL and checks that the Content-Type
looks like an MJPEG stream. Never sends print commands or writes.
"""
raw_url = body.camera_url.strip()
if not raw_url:
raise HTTPException(status_code=400, detail="camera_url is required.")

parsed = urlparse(raw_url)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise HTTPException(status_code=400, detail="Camera URL must be http(s) with a host.")
if parsed.username or parsed.password:
raise HTTPException(status_code=400, detail="Camera URL must not include credentials.")

try:
address = ipaddress.ip_address(parsed.hostname)
if address.is_loopback or address.is_multicast or address.is_unspecified or address.is_reserved:
raise HTTPException(status_code=400, detail="Camera URL host is not allowed.")
except ValueError:
pass # hostname — allowed for camera URLs
Comment on lines +189 to +194

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The SSRF protection in validate_camera_url is insufficient. It only checks if the hostname is an IP address and blocks restricted ranges. If a hostname is provided (e.g., localhost or a domain resolving to an internal IP), the check is bypassed because ipaddress.ip_address() raises a ValueError. You should resolve the hostname to an IP address and validate that IP against restricted ranges.


# Read-only HEAD request only — no GET body, no commands.
try:
request = urllib.request.Request(raw_url, method="HEAD", headers={"Accept": "*/*"})
with urllib.request.urlopen(request, timeout=3.0) as response:
content_type: str = response.headers.get("Content-Type", "")
http_status = int(response.status)
except urllib.error.HTTPError as exc:
return {
"ok": False,
"camera_url": raw_url,
"http_status": int(exc.code),
"content_type": None,
"reason": f"Camera returned HTTP {exc.code}.",
}
except OSError as exc:
return {
"ok": False,
"camera_url": raw_url,
"http_status": None,
"content_type": None,
"reason": str(exc),
}

is_mjpeg = "multipart/x-mixed-replace" in content_type or "image/jpeg" in content_type
return {
"ok": is_mjpeg or (200 <= http_status < 400),
"camera_url": raw_url,
"http_status": http_status,
"content_type": content_type or None,
"is_mjpeg": is_mjpeg,
"reason": None if (is_mjpeg or (200 <= http_status < 400)) else f"Unexpected content-type: {content_type!r}",
}


@router.post("/api/printers/onboard", status_code=201)
def onboard_printer(body: PrinterOnboardRequest) -> dict[str, Any]:
moonraker_url, host = _validate_onboard_moonraker_url(body.moonraker_url)
Expand Down Expand Up @@ -360,14 +512,14 @@ def _validate_onboard_moonraker_url(raw_url: str) -> tuple[str, str]:
address = ipaddress.ip_address(host)
except ValueError as exc:
raise HTTPException(status_code=400, detail="Moonraker URL host must be a printer IP address.") from exc
if str(address) == "192.168.0.12" or is_s1_target(str(address)):
if str(address) in CAMERA_ONLY_IPS or is_s1_target(str(address)):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Redundant check, similar to the one in probe_printer_by_ip. is_s1_target already covers the camera-only IP check.

Suggested change
if str(address) in CAMERA_ONLY_IPS or is_s1_target(str(address)):
if is_s1_target(str(address)):

raise HTTPException(
status_code=423,
status_code=403,
detail={
"error": "S1_POLICY_LOCKED",
"error": "CAMERA_ONLY_IP",
"printer_id": "flsun_s1",
"ip": str(address),
"reason": "FLSUN S1 remains offline/locked/no-test by operator policy; onboarding cannot convert it into a write-enabled printer.",
"reason": "This IP is camera-only and cannot be added as a print target.",
},
)
if address.is_loopback or address.is_multicast or address.is_unspecified or address.is_reserved:
Expand Down
98 changes: 98 additions & 0 deletions 03_implementation/ui/src/api/adapters.live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1087,6 +1087,104 @@ export function emitProofEventLive(type: string, payload: Record<string, unknown
return postVoid("/api/proof/events", { type, payload });
}

export interface PrinterProbeResult {
ok: boolean;
ip: string;
moonraker_url: string;
klippy_connected: boolean;
klippy_state: string;
moonraker_version: string | null;
api_version: string[] | null;
print_state?: string;
filename?: string | null;
progress?: number;
bed_kind?: string;
bed_diameter_mm?: number | null;
bed_x_mm?: number | null;
bed_y_mm?: number | null;
z_height_mm?: number;
error?: string;
reason?: string;
}

export interface CameraValidateResult {
ok: boolean;
camera_url: string;
http_status: number | null;
content_type: string | null;
is_mjpeg?: boolean;
reason?: string | null;
}

/** Read-only probe of a Moonraker printer by IP — GET /server/info only, never sends GCode. */
export async function probePrinterLive(ip: string): Promise<PrinterProbeResult> {
try {
const response = await fetch(`${LIVE_BASE_URL}/api/printers/probe?ip=${encodeURIComponent(ip)}`, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok) {
const detail = isRecord(payload) ? payload.detail : null;
const reason = isRecord(detail) && isString(detail.reason)
? detail.reason
: isRecord(payload) && isString(payload.detail)
? payload.detail
: `Probe failed with HTTP ${response.status}.`;
return { ok: false, ip, moonraker_url: `http://${ip}:7125`, klippy_connected: false, klippy_state: "unknown", moonraker_version: null, api_version: null, error: `HTTP ${response.status}`, reason };
}
if (!isRecord(payload)) {
return { ok: false, ip, moonraker_url: `http://${ip}:7125`, klippy_connected: false, klippy_state: "unknown", moonraker_version: null, api_version: null, reason: "Invalid probe response." };
}
return {
ok: payload.ok === true,
ip: isString(payload.ip) ? payload.ip : ip,
moonraker_url: isString(payload.moonraker_url) ? payload.moonraker_url : `http://${ip}:7125`,
klippy_connected: payload.klippy_connected === true,
klippy_state: isString(payload.klippy_state) ? payload.klippy_state : "unknown",
moonraker_version: isNullableString(payload.moonraker_version) ? payload.moonraker_version : null,
api_version: Array.isArray(payload.api_version) ? payload.api_version as string[] : null,
print_state: isString(payload.print_state) ? payload.print_state : undefined,
filename: isNullableString(payload.filename) ? payload.filename : undefined,
progress: isNumber(payload.progress) ? payload.progress : undefined,
bed_kind: isString(payload.bed_kind) ? payload.bed_kind : undefined,
bed_diameter_mm: isNullableNumber(payload.bed_diameter_mm) ? payload.bed_diameter_mm : undefined,
bed_x_mm: isNullableNumber(payload.bed_x_mm) ? payload.bed_x_mm : undefined,
bed_y_mm: isNullableNumber(payload.bed_y_mm) ? payload.bed_y_mm : undefined,
z_height_mm: isNumber(payload.z_height_mm) ? payload.z_height_mm : undefined,
};
} catch (error) {
return { ok: false, ip, moonraker_url: `http://${ip}:7125`, klippy_connected: false, klippy_state: "unknown", moonraker_version: null, api_version: null, reason: errorMessage(error) };
}
}

/** Read-only camera URL validation — HEAD request only, never sends print commands. */
export async function validateCameraUrlLive(cameraUrl: string): Promise<CameraValidateResult> {
try {
const response = await fetch(`${LIVE_BASE_URL}/api/printers/validate-camera`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/json" },
body: JSON.stringify({ camera_url: cameraUrl }),
cache: "no-store",
});
const payload: unknown = await response.json().catch(() => null);
if (!response.ok || !isRecord(payload)) {
return { ok: false, camera_url: cameraUrl, http_status: null, content_type: null, reason: `Camera validation failed with HTTP ${response.status}.` };
}
Comment on lines +1172 to +1174

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

validateCameraUrlLive should extract error details from the response payload, similar to probePrinterLive, to provide more specific feedback in the UI when validation fails.

    if (!response.ok || !isRecord(payload)) {
      const detail = isRecord(payload) ? payload.detail : null;
      const reason = isRecord(detail) && isString(detail.reason)
        ? detail.reason
        : isRecord(payload) && isString(payload.detail)
          ? payload.detail
          : `Camera validation failed with HTTP ${response.status}.`;
      return { ok: false, camera_url: cameraUrl, http_status: null, content_type: null, reason };
    }

return {
ok: payload.ok === true,
camera_url: isString(payload.camera_url) ? payload.camera_url : cameraUrl,
http_status: isNullableNumber(payload.http_status) ? payload.http_status : null,
content_type: isNullableString(payload.content_type) ? payload.content_type : null,
is_mjpeg: payload.is_mjpeg === true,
reason: isNullableString(payload.reason) ? payload.reason : null,
};
} catch (error) {
return { ok: false, camera_url: cameraUrl, http_status: null, content_type: null, reason: errorMessage(error) };
}
}

export function getCameraObserverStatusLive(): Promise<{ status: string; reason: string }> {
return fetchJson<{ state?: string; status?: string; reason?: string | null }>("/api/plugins/camera-observer/status").then((status) => {
if (!status) {
Expand Down
10 changes: 10 additions & 0 deletions 03_implementation/ui/src/api/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ import {
planPreviewLive,
planModuleRuntimeSetupQueueLive,
onboardPrinterLive,
probePrinterLive,
validateCameraUrlLive,
previewVoiceLive,
proposeJobRepairLive,
rollbackHermesAgentLive,
Expand Down Expand Up @@ -110,6 +112,8 @@ export type {
HermesDesktopDownload,
HermesDesktopUpdateStatus,
LearningReportContent,
PrinterProbeResult,
CameraValidateResult,
} from "./adapters.live";
import type {
AgentConfigPayload,
Expand All @@ -123,6 +127,8 @@ import type {
HermesDesktopDownload,
HermesDesktopUpdateStatus,
LearningReportContent,
PrinterProbeResult,
CameraValidateResult,
} from "./adapters.live";

export interface AdapterAPI {
Expand Down Expand Up @@ -155,6 +161,8 @@ export interface AdapterAPI {
getPrinterLockState(id: string): Promise<PrinterLock>;
testPrinter(id: string): Promise<TestResult>;
onboardPrinter(request: PrinterOnboardRequest): Promise<PrinterOnboardResult>;
probePrinter(ip: string): Promise<PrinterProbeResult>;
validateCameraUrl(cameraUrl: string): Promise<CameraValidateResult>;
uploadGcode(id: string, gcodePath: string, start: boolean, remoteSubdir?: string, actor?: string, jobId?: string): Promise<GcodeUploadResult>;
updatePrinterStatus(id: string, status: Printer["status"], actor?: string): Promise<void>;
getVoiceAgents(): Promise<VoiceAgent[]>;
Expand Down Expand Up @@ -231,6 +239,8 @@ export const adapters: AdapterAPI = {
getPrinterLockState: getPrinterLockStateLive,
testPrinter: testPrinterLive,
onboardPrinter: onboardPrinterLive,
probePrinter: probePrinterLive,
validateCameraUrl: validateCameraUrlLive,
uploadGcode: uploadGcodeLive,
updatePrinterStatus: updatePrinterStatusLive,
getVoiceAgents: getVoiceAgentsLive,
Expand Down
Loading