diff --git a/README.md b/README.md index b390dd5..7f2dace 100644 --- a/README.md +++ b/README.md @@ -1 +1,266 @@ -# PrintShield \ No newline at end of file +# PrintShield + +**PrintShield** is a command-line framework for securing the 3D printing pipeline—from model creation through slicing to print execution. It provides cryptographic integrity verification, confidentiality through encryption, provenance tracking, secure transfer, real-time monitoring, and compliance mapping against industry standards. + +## What It Does + +PrintShield addresses security gaps in additive manufacturing by enabling: + +- **Integrity & Authenticity**: Hash files canonically and create detached cryptographic signatures +- **Confidentiality**: Encrypt files with hybrid X25519+AES-256-GCM envelopes; sign on encrypt +- **Provenance**: Embed or attach JSON manifests recording model metadata, slicer information, and upstream hashes +- **Secure Transfer**: Upload bundles to printers/servers via SFTP, HTTPS, or OctoPrint REST API with verification +- **Monitoring**: Watch directories for file drift; compare against baselines; detect unexpected changes +- **Audit Trails**: Append-only, tamper-evident logs of all operations +- **Compliance**: Map PrintShield features to NIST SP 800-171 and other policies; generate compliance reports + +## Supported Formats + +- **STL** (ASCII & Binary) +- **OBJ** (with MTL materials) +- **3MF** (ZIP-based container) +- **AMF** (XML-based) +- **G-code** (toolpath files) + +## Quick Start + +### Installation + +```bash +git clone https://github.com/alimezar/PrintShield.git PrintShield + +cd PrintShield + +# Create a virtual environment +python -m venv .venv +source .venv/bin/activate # or .venv\Scripts\Activate.ps1 on Windows + +# Install +pip install -e . + +# Or If you prefer the GUI +pip install -e.[gui] +``` + +### Basic Usage + +```bash +# Verify CLI is ready +printshield --help +printshield version + +# Create a minimal config +cat > ps.yaml < baseline.json + +# Later, detect changes +printshield monitor diff --baseline baseline.json --path jobs + +# Or continuous watch +printshield monitor watch --path jobs +``` + +### G-code Verification + +```bash +# Create provenance for G-code with source model hash +printshield provenance create output.gcode \ + --source-model-hash abc123... \ + --slicer-name PrusaSlicer \ + --slicer-version 2.7.1 + +# Sign the G-code +printshield gcode sign output.gcode --private-key my_key.pem + +# On printer: verify G-code against source model +printshield gcode gate output.gcode \ + --source-hash abc123... \ + --manifest output.gcode.provenance.json +``` + +### Compliance Reporting + +```bash +# Generate NIST 800-171 compliance report +printshield compliance run --policy nist-800-171 --format json > report.json + +# Or with a custom policy +printshield compliance run --policy-file my-policy.yml +``` + +## Core Concepts + +### Audit Log + +Every operation (encrypt, sign, transfer, etc.) is recorded in an append-only audit log. The log is cryptographically verified to detect tampering: + +```bash +printshield audit verify +``` + +### Configuration + +PrintShield reads a YAML config file specifying: +- Policy ID (for compliance mapping) +- Audit log path +- Transfer endpoints +- Monitoring rules + +See [Configuration Guide](docs/config.md) for details. + +### Bundles (`.pshieldpkg`) + +A bundle is a ZIP container holding: +- Encrypted payload (`.psenc`) +- Signed provenance manifest +- Policy snapshot +- Checksums + +Bundles provide a single self-contained unit for secure transfer. + +### Envelopes (`.psenc`) + +An envelope is a hybrid-encrypted file (X25519 + AES-256-GCM) with: +- Ephemeral key exchange data +- Payload SHA-256 +- Optional sender signature (for sign-on-encrypt) + +## Key Management + +PrintShield uses: +- **Ed25519** for signatures (sign/verify) +- **X25519** for encryption (encrypt/decrypt) + +Sample keys are provided in `examples/keys/` for testing: + +```text +examples/keys/ + ed25519/ + sample_private_ed25519.pem + sample_public_ed25519.pem + x25519/ + sample_private_x25519.pem + sample_public_x25519.pem +``` + +⚠️ **Never use these in production.** + +## Development + +### Run Tests + +```bash +pytest tests/ +``` + +### Linting & Type Checking + +```bash +ruff check src/ +mypy src/ +``` + +### Adding a New Command + +1. Define the command in `src/printshield/cli.py` +2. Implement the underlying logic in `src/printshield/core/` +3. Add unit tests in `tests/unit/` +4. Update `docs/cli.md` with the command reference + +## Roadmap + +PrintShield is actively developed. Current milestones include: + +- ✅ M0: Project skeleton, CLI, config, logging +- ✅ M1: Hash, sign, verify, audit chain +- ✅ M2: Encryption, bundling, packaging +- ✅ M3: Provenance manifests (STL/OBJ/3MF/AMF + G-code) +- ✅ M4: Transfer (SFTP/HTTPS/OctoPrint) & receive gate +- ✅ M5: Monitoring & drift detection +- M6: Hardening & sandboxing (in progress) +- ✅ M7: Compliance reporting (NIST 800-171 subset) +- ⏳ Future: GUI improvements, additional policies, extended format support + +See [ROADMAP.md](ROADMAP.md) for detailed deliverables. + +## Limitations & Non-Goals + +- **Not a slicer**: PrintShield does not generate G-code; it secures files that already exist +- **Not a PKI system**: Key distribution and revocation are out of scope; use your organization's PKI +- **Not real-time enforcement**: Monitoring is for drift detection, not real-time access control +- **Not a printer firmware**: PrintShield runs on workstations, servers, and OctoPrint—not the printer MCU + +## License + +See [LICENSE](LICENSE) file. + +## Contact & Support + +For questions, issues, or contributions: + +- Open an issue on the repository +- Review [docs/troubleshooting.md](docs/troubleshooting.md) for common problems +- Check existing tests in `tests/unit/` for usage examples + +## Acknowledgments + +PrintShield draws inspiration from: +- NIST guidelines on 3D printing cybersecurity +- Industry best practices for supply chain security +- Cryptographic standards (Ed25519, X25519, AES-256-GCM) \ No newline at end of file diff --git a/ROADMAP.md b/ROADMAP.md index c783827..bbf3ded 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -7,7 +7,7 @@ Core goals: **encrypt, verify, transfer, monitor, harden, audit**. # Milestones & Deliverables -## M0 — Project Skeleton & DX +## M0 — Project Skeleton & DX ✅ - **Repo scaffold**, Typer CLI shell, Rich help, `--format json|text`, `--config`. - **Config schema** (Pydantic), loader, defaults. @@ -18,7 +18,7 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. --- -## M1 — Verify (Hash & Signature Backbone) +## M1 — Verify (Hash & Signature Backbone) ✅ - **Canonical hashing:** - **STL (ascii+binary):** semantic-geometry and strict-file modes @@ -33,7 +33,7 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. --- -## M2 — Encrypt (Confidentiality & Packaging) +## M2 — Encrypt (Confidentiality & Packaging) ✅ - **Hybrid encryption:** X25519 + AES-GCM with recipient sets; optional GPG integration. - **`.pshieldpkg` bundle:** payload + signed provenance + policy snapshot + checksums. @@ -44,7 +44,7 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. --- -## M3 — Provenance (3D-Printing Aware) +## M3 — Provenance (3D-Printing Aware) ✅ - **Provenance manifest schema (JSON):** - `part_id`, `project`, `revision`, `owner`, `policy_id/version`, @@ -60,7 +60,7 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. --- -## M4 — Transfer (Move & Gate) +## M4 — Transfer (Move & Gate) ✅ - **SFTP/HTTPS upload** (Paramiko/requests), progress, retries, integrity check before/after. - **OctoPrint adapter:** upload, pre-print verify (hash/signature/footer). @@ -71,7 +71,7 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. --- -## M5 — Monitor (Observe & Notify) +## M5 — Monitor (Observe & Notify) ✅ - **Directory watchers:** new/changed files → hash drift detection → audit event. - **Printer event ingest:** OctoPrint polling or webhook. @@ -82,7 +82,7 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. --- -## M6 — Harden (Host/Printer Baselines) & Sandbox +## M6 — Harden (Host/Printer Baselines) & Sandbox ❗ - **Baseline checks:** workstation, server, printer (advisory + remediation guidance). - **Sandbox slicer wrapper (Docker):** read-only inputs, output volume, no network by default. @@ -92,7 +92,7 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. --- -## M7 — Compliance (NIST Mapping) & Reports +## M7 — Compliance (NIST Mapping) & Reports ✅ - **Rule engine:** mapping core features to NIST 800-171 controls. - **Reporter:** human summary + JSON (pass/fail + remediation text). @@ -102,7 +102,7 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. --- -## M8 — Init Wizard, Keys, Docs & E2E +## M8 — Init Wizard, Keys, Docs & E2E ❗ - **Init interactive bootstrap:** keys, policy, audit path, endpoints, baseline selection. - **Keys:** create/import/export/list; expiration/rotation warnings. @@ -110,3 +110,50 @@ Runs `printshield --help`; empty subcommands; `docs/getting-started`. **Deliverables:** Polished docs, fixtures, CI passing, versioned release. + +--- + +## M9 — Toolpath & G-code Security ✅ + +**Goal:** Extend the trust chain beyond model files into the actual instructions printers run. + +### Required + +#### Canonical Hashing for G-code +- Normalize line endings. +- Enforce whitespace normalization rules. +- Standardized handling of comments. +- Option to hash **with or without non‑executed comments**. + +#### Sign/Verify for G-code and Slicer Outputs +- Treat G-code as first‑class verifiable artifacts. +- Digital signature support (sign + verify). + +#### Provenance Linkage +G-code provenance fields must include: +- `source_model_hash` +- `slicer_id` +- `profile_hash` +- `machine_profile_hash` + +#### Print-Gate Policy Update +Policy rule: +> *“Printer may only execute signed G-code with matching upstream model hash.”* + +#### CLI Commands +``` +gcode hash|sign|verify +policy check --artifact gcode +``` + +### Deliverables +- Fully working E2E flow: + **STL/3MF → slicer → G-code → sign → verify → print-gate** +- Test set with intentional tampering cases. + +### Acceptance Criteria +- Tampered G-code must fail verification. +- Valid signed G-code must pass at least one real print workflow (simulated acceptable). + + +--- diff --git a/docs/config.md b/docs/config.md new file mode 100644 index 0000000..6726334 --- /dev/null +++ b/docs/config.md @@ -0,0 +1,548 @@ +# Configuration Guide + +This guide explains how to configure PrintShield via YAML, including schema, defaults, environment variables, and troubleshooting. + +## Overview + +PrintShield reads a single YAML configuration file (default: `ps.yaml` or path provided via `--config`) that specifies: + +- **policy_id** – Which policy controls compliance reporting (e.g., `nist-800-171`) +- **audit** – Append-only audit log configuration +- **transfer** – Endpoints for SFTP, HTTPS, OctoPrint uploads +- **monitor** – Directory watchers and monitoring rules +- **logging** – Log format and verbosity + +## Minimal Configuration + +```yaml +# ps.yaml +policy_id: nist-800-171 +audit: + path: ./audit.log +``` + +This is the bare minimum. Test it: + +```bash +printshield --config ps.yaml config +``` + +Output: +``` +Config source: ps.yaml +Policy: nist-800-171 +Audit path: ./audit.log +``` + +## Full Configuration + +Here's a complete example with all available options: + +```yaml +# ps.yaml + +# Policy ID for compliance mapping (e.g., nist-800-171) +# Used by: printshield compliance run +policy_id: nist-800-171 + +# Audit log configuration +audit: + # Path to the append-only audit log file + # If relative, resolved from the current working directory + # If missing, PrintShield will create it + path: ./audit.log + +# Transfer endpoints (optional) +transfer: + sftp: + # Default SFTP host for uploads + host: printer.example.com + user: uploads + port: 22 + key_file: ~/.ssh/id_rsa # Path to SSH private key + + https: + # Default HTTPS endpoint for uploads + url: https://staging.example.com/upload + token: bearer-token-here # Or use --token flag + ca_cert: /etc/ssl/certs/ca-bundle.crt # Custom CA (optional) + + octoprint: + # Default OctoPrint instance + base_url: https://octopi.local + api_key: ${OCTO_API_KEY} # Environment variable reference + location: local + # select/print defaults can be set per transfer + +# Monitoring rules (optional) +monitor: + # Directory watchers to run with 'printshield monitor watch' + paths: + - path: /jobs/incoming + include: "*.stl,*.psenc,*.pshieldpkg,*.gcode" + recursive: true + - path: /jobs/printing + include: "*.gcode" + recursive: false + + # Printer monitoring (OctoPrint) + printers: + - name: octopi-main + url: https://octopi.local + api_key: ${OCTO_API_KEY} + poll_interval: 1.0 + +# Logging configuration (optional; CLI --log-level overrides this) +logging: + level: info # debug | info | warning | error | critical + format: text # text | json + +# Storage paths (optional) +paths: + policies: ./policies + profiles: ./profiles + keys: ./examples/keys +``` + +## Configuration Schema + +### `policy_id` (string, required) + +The logical identifier for the policy used in compliance reporting. + +**Examples:** +- `nist-800-171` (built-in NIST SP 800-171 subset) +- `custom-policy` (user-defined policy) + +**Used by:** `printshield compliance run --policy` + +### `audit` (object, required) + +#### `audit.path` (string, required) + +Filesystem path to the append-only audit log. + +**Behavior:** +- If the file doesn't exist, PrintShield creates it +- If the file exists, events are appended +- Path is relative to the current working directory (unless absolute) +- Should be on a filesystem with integrity guarantees (e.g., not a network share without guarantees) + +**Examples:** +```yaml +audit: + path: ./audit.log +``` + +```yaml +audit: + path: /var/log/printshield/audit.log +``` + +```yaml +audit: + path: C:\PrintShield\audit.log # Windows +``` + +### `transfer` (object, optional) + +Configuration for secure transfer endpoints. + +#### `transfer.sftp` (object, optional) + +SFTP upload defaults. + +**Fields:** +- `host` (string) – SFTP server hostname +- `user` (string) – SSH username +- `port` (integer, default: 22) – SSH port +- `key_file` (string) – Path to SSH private key (PEM) + +**Example:** +```yaml +transfer: + sftp: + host: printer.example.com + user: gcode_uploader + port: 22 + key_file: ~/.ssh/id_rsa +``` + +**CLI Override:** `printshield transfer sftp --host ... --user ...` + +#### `transfer.https` (object, optional) + +HTTPS upload defaults. + +**Fields:** +- `url` (string) – HTTPS endpoint URL +- `token` (string) – Bearer token for Authorization header +- `ca_cert` (string) – Path to custom CA certificate bundle (PEM) +- `verify_tls` (boolean, default: true) – Whether to verify TLS + +**Example:** +```yaml +transfer: + https: + url: https://staging.example.com/api/upload + token: secret-bearer-token + ca_cert: /etc/ssl/certs/ca-bundle.crt +``` + +**CLI Override:** `printshield transfer https --url ... --token ...` + +#### `transfer.octoprint` (object, optional) + +OctoPrint upload defaults. + +**Fields:** +- `base_url` (string) – Base URL of OctoPrint (e.g., `https://octopi.local`) +- `api_key` (string) – OctoPrint API key (X-Api-Key header) +- `location` (string, default: `local`) – Target location: `local` or `sdcard` +- `verify_tls` (boolean, default: true) – Whether to verify TLS + +**Example:** +```yaml +transfer: + octoprint: + base_url: https://octopi.local + api_key: ${OCTO_API_KEY} # Environment variable + location: local +``` + +**CLI Override:** `printshield transfer octoprint --url ... --api-key ...` + +### `monitor` (object, optional) + +Configuration for directory and printer monitoring. + +#### `monitor.paths` (list of objects, optional) + +Directories to watch with `printshield monitor watch`. + +**Fields per path:** +- `path` (string, required) – Directory to watch +- `include` (string, default: `*.stl,*.psenc,*.pshieldpkg,*.gcode`) – Comma-separated glob patterns +- `recursive` (boolean, default: true) – Watch subdirectories + +**Example:** +```yaml +monitor: + paths: + - path: /jobs/incoming + include: "*.stl,*.gcode" + recursive: true + - path: /jobs/archive + include: "*.psenc,*.pshieldpkg" + recursive: false +``` + +**CLI Override:** `printshield monitor watch --path ... --include ...` + +#### `monitor.printers` (list of objects, optional) + +Printers to monitor with `printshield monitor printer octoprint`. + +**Fields per printer:** +- `name` (string, required) – Friendly name +- `url` (string, required) – OctoPrint base URL +- `api_key` (string, required) – OctoPrint API key +- `poll_interval` (float, default: 1.0) – Polling interval in seconds +- `verify_tls` (boolean, default: true) – Whether to verify TLS + +**Example:** +```yaml +monitor: + printers: + - name: octopi-main + url: https://octopi.local + api_key: ${OCTO_API_KEY} + poll_interval: 1.0 + - name: octopi-lab + url: https://octopi-lab.local + api_key: ${OCTO_API_KEY_LAB} + poll_interval: 2.0 +``` + +**CLI Override:** `printshield monitor printer octoprint --url ... --api-key ...` + +### `logging` (object, optional) + +Logging behavior. + +**Fields:** +- `level` (string, default: `info`) – Log level: `debug`, `info`, `warning`, `error`, `critical` +- `format` (string, default: `text`) – Log format: `text` or `json` + +**Example:** +```yaml +logging: + level: debug + format: json +``` + +**CLI Override:** `printshield --log-level debug --format json` + +### `paths` (object, optional) + +Filesystem paths used for looking up policies, profiles, and keys. + +**Fields:** +- `policies` (string) – Directory containing policy YAML files +- `profiles` (string) – Directory containing hardening profiles +- `keys` (string) – Directory containing key files + +**Example:** +```yaml +paths: + policies: ./policies + profiles: ./profiles + keys: ./examples/keys +``` + +**Default behavior:** If omitted, PrintShield uses built-in defaults and searches standard locations. + +## Environment Variables + +PrintShield supports environment variable substitution in YAML values using `${VARIABLE_NAME}` syntax: + +```yaml +transfer: + octoprint: + api_key: ${OCTO_API_KEY} # Will be replaced with $OCTO_API_KEY value + base_url: ${OCTO_URL} +``` + +**Example:** +```bash +export OCTO_API_KEY="abc123def456" +export OCTO_URL="https://octopi.local" + +printshield --config ps.yaml transfer octoprint myfile.gcode +``` + +This is useful for secrets management—store keys in environment variables rather than YAML files. + +## CLI Overrides + +Global CLI options override configuration file values: + +```bash +# Override config file path +printshield --config custom.yaml COMMAND + +# Override log level +printshield --log-level debug COMMAND + +# Override output format +printshield --format json COMMAND +``` + +Command-specific options also override config: + +```bash +# Config has default, CLI overrides it +printshield transfer sftp myfile.psenc --host other.example.com +``` + +## Default Configuration + +If no `--config` is provided, PrintShield looks for defaults: + +1. `./ps.yaml` (current directory) +2. `~/.printshield/config.yaml` (user home) +3. Built-in defaults (if available) + +If none exist, some commands will fail with an error like: +``` +Error: Could not load config. Provide --config or create ps.yaml +``` + +To check which config is loaded: + +```bash +printshield --config ps.yaml config + +# Output: +# Config source: ps.yaml +``` + +## Examples + +### Minimal Setup + +```yaml +# ps.yaml +policy_id: nist-800-171 +audit: + path: ./audit.log +``` + +### Development Setup + +```yaml +# ps.yaml (dev) +policy_id: nist-800-171 +audit: + path: ./audit.log + +logging: + level: debug + format: text + +monitor: + paths: + - path: ./test_jobs + include: "*.stl,*.gcode" + recursive: true +``` + +### Production Staging + +```yaml +# ps.yaml (staging) +policy_id: nist-800-171 +audit: + path: /var/log/printshield/audit.log + +transfer: + sftp: + host: staging.example.com + user: gcode_uploader + port: 22 + key_file: /home/operator/.ssh/staging_rsa + + https: + url: https://staging.example.com/api/gcode/upload + token: ${STAGING_UPLOAD_TOKEN} + ca_cert: /etc/ssl/certs/ca.pem + +monitor: + paths: + - path: /jobs/incoming + include: "*.gcode,*.psenc" + recursive: true + + printers: + - name: staging-octoprint + url: https://staging-octo.example.com + api_key: ${OCTO_STAGING_KEY} + poll_interval: 1.0 + +logging: + level: info + format: json +``` + +### Production with High Assurance + +```yaml +# ps.yaml (prod) +policy_id: nist-800-171 +audit: + path: /secure/audit/printshield.log + +transfer: + sftp: + host: prod-printer.example.com + user: gcode_uploader + port: 2222 # Non-standard port + key_file: /etc/printshield/prod_ssh_key.pem + + octoprint: + base_url: https://prod-octo.example.com + api_key: ${PROD_OCTO_API_KEY} + location: sdcard + verify_tls: true + +monitor: + printers: + - name: prod-octoprint-01 + url: https://prod-octo-01.example.com + api_key: ${PROD_OCTO_01_KEY} + poll_interval: 1.0 + +logging: + level: warning + format: json +``` + +## Validation & Errors + +PrintShield validates the configuration on load. Common errors: + +### Missing required fields + +``` +Error: Config validation failed: field 'policy_id' is required +``` + +**Fix:** Add `policy_id: nist-800-171` (or your desired policy) + +### Invalid policy + +``` +Error: Policy 'unknown-policy' not found +``` + +**Fix:** Use a known policy ID (e.g., `nist-800-171`) or provide `--policy-file path/to/policy.yml` + +### Audit path not writable + +``` +Error: Cannot write to audit path: /var/log/printshield/audit.log (permission denied) +``` + +**Fix:** Ensure the directory exists and is writable: +```bash +sudo mkdir -p /var/log/printshield +sudo chown $USER /var/log/printshield +``` + +### Environment variable not set + +``` +Error: Environment variable 'OCTO_API_KEY' not set +``` + +**Fix:** Export the variable before running: +```bash +export OCTO_API_KEY="your-api-key" +printshield --config ps.yaml transfer octoprint ... +``` + +## Best Practices + +1. **Keep secrets out of YAML files** + - Use environment variables for API keys and tokens + - Store YAML in version control; keep `.env` files local + +2. **Use absolute paths for production** + - Avoids confusion if the working directory changes + - Example: `/var/log/printshield/audit.log` instead of `./audit.log` + +3. **Enable JSON logging for production** + - Easier to parse and integrate with log aggregators + - Set `logging.format: json` + +4. **Test configuration before deployment** + - Run `printshield --config ps.yaml config` to validate + - Check that audit log path is writable: `touch /path/to/audit.log` + +5. **Document your policy choice** + - Add a comment explaining why you chose a specific policy: + ```yaml + # Using NIST 800-171 because we're in a regulated environment + policy_id: nist-800-171 + ``` + +6. **Version your configuration** + - Store `ps.yaml` in version control + - Use `.gitignore` for sensitive values (if any) + - Tag releases with config changes + +## See Also + +- [Getting Started](getting-started.md) – Basic workflow with minimal config +- [CLI Reference](cli.md#2-global-options) – Config and option details +- [ROADMAP.md](../ROADMAP.md) – Future configuration enhancements diff --git a/docs/gcode.md b/docs/gcode.md new file mode 100644 index 0000000..d7cc429 --- /dev/null +++ b/docs/gcode.md @@ -0,0 +1,616 @@ +# G-code Security + +This guide covers PrintShield's G-code (toolpath) security features: canonical hashing, signing, verification, and print-gate policy enforcement. + +## Overview + +G-code is the low-level instructions that control a 3D printer. PrintShield provides: + +- **Canonical hashing**: Deterministic SHA-256 of normalized G-code content +- **Signing**: Detached Ed25519 signatures over G-code files +- **Verification**: Cryptographic proof that G-code has not been modified +- **Print-gate policy**: Enforce that G-code originated from a specific upstream model +- **Provenance linking**: Connect G-code back to its source model via manifests + +## Why G-code Security Matters + +G-code can be: +- **Intercepted and modified** during transfer +- **Accidentally corrupted** by re-export or format conversion +- **Maliciously altered** to change print parameters, chamber temps, or layer heights + +PrintShield ensures that: +- G-code matches the expected upstream model (via `source_model_hash`) +- G-code has not been tampered with since it was signed +- The slicer profile and parameters are documented and verifiable + +## Canonical Hashing + +G-code files can vary in whitespace, comment formatting, and representation (mm vs. inches) while producing identical prints. PrintShield normalizes G-code before hashing to ensure deterministic, repeatable results. + +### Hash Command + +```bash +printshield gcode hash output.gcode [--include-comments] +``` + +#### Options + +- `--include-comments` (optional) + - Default: `false` + - When `false`: Only executed instructions are hashed; comments and formatting are ignored + - When `true`: Normalized comment text is included in the hash + +#### Example + +```bash +# Hash without comments (faster, more forgiving of comment changes) +printshield gcode hash part.gcode + +# Output: +# File: part.gcode +# Include comments: False +# SHA-256: abc123def456... +``` + +```bash +# Hash with comments (stricter; detects comment changes) +printshield gcode hash part.gcode --include-comments +``` + +#### JSON Output + +```bash +printshield --format json gcode hash part.gcode +``` + +Returns: + +```json +{ + "file": "part.gcode", + "include_comments": false, + "sha256": "abc123def456..." +} +``` + +#### Audit Trail + +Every hash operation is logged: + +``` +[gcode.hash] file=part.gcode include_comments=false sha256=abc123... +``` + +## Signing G-code + +Create a detached Ed25519 signature over canonicalized G-code. + +### Sign Command + +```bash +printshield gcode sign output.gcode \ + --private-key my_private_key.pem \ + [--signature output.gcode.sig] \ + [--include-comments] +``` + +#### Options + +- `--private-key, -k` **required** + - Path to Ed25519 private key (PEM format) +- `--signature, -s` (optional) + - Output signature file path (default: `.sig`) +- `--include-comments` (optional) + - Must match the mode used later at verification time + - Default: `false` + +#### Example + +```bash +# Sign G-code (comments excluded from hash) +printshield gcode sign output.gcode \ + --private-key slicer_private_key.pem + +# This creates: output.gcode.sig +``` + +```bash +# Sign with comments included (more strict) +printshield gcode sign output.gcode \ + --private-key slicer_private_key.pem \ + --include-comments +``` + +#### JSON Output + +```bash +printshield --format json gcode sign output.gcode \ + --private-key slicer_private_key.pem +``` + +Returns: + +```json +{ + "file": "output.gcode", + "signature": "output.gcode.sig", + "include_comments": false, + "sig_b64": "KL7S+eO4...base64-encoded-signature..." +} +``` + +#### Audit Trail + +``` +[gcode.sign] file=output.gcode signature=output.gcode.sig include_comments=false +``` + +## Verifying G-code Signatures + +Verify that a G-code file matches its detached signature. + +### Verify Command + +```bash +printshield gcode verify output.gcode \ + --public-key slicer_public_key.pem \ + [--signature output.gcode.sig] \ + [--include-comments] +``` + +#### Options + +- `--public-key, -K` **required** + - Path to Ed25519 public key (PEM format) +- `--signature, -s` (optional) + - Path to `.sig` file (default: `.sig`) +- `--include-comments` (optional) + - **Must match the signing mode**; if the signature was created with `--include-comments`, verification must also use it + +#### Example + +```bash +# Verify G-code signature +printshield gcode verify output.gcode \ + --public-key slicer_public_key.pem + +# Output (text mode): +# File: output.gcode +# Signature: output.gcode.sig +# Include comments:False +# Valid: True +``` + +#### Exit Code + +- `0` if signature is valid +- `1` if invalid or missing + +#### JSON Output + +```bash +printshield --format json gcode verify output.gcode \ + --public-key slicer_public_key.pem +``` + +Returns: + +```json +{ + "file": "output.gcode", + "signature": "output.gcode.sig", + "include_comments": false, + "ok": true +} +``` + +#### Audit Trail + +``` +[gcode.verify] file=output.gcode signature=output.gcode.sig include_comments=false ok=true +``` + +## Provenance: Linking G-code to Source Models + +PrintShield can create signed provenance manifests for G-code that record: +- **Source model hash** (upstream STL/OBJ/3MF hash) +- **Slicer identity** (PrusaSlicer, Cura, etc.) +- **Slicer profile hash** (configuration parameters) +- **Timestamps** and metadata +- **Signer fingerprint** (who created this G-code) + +### Create Provenance for G-code + +```bash +printshield provenance create output.gcode \ + --source-model-hash \ + --slicer-name PrusaSlicer \ + --slicer-version 2.7.1 \ + --slicer-profile-hash \ + [--machine-profile-hash ] \ + [--project MyProject] \ + [--part-id Part-001] \ + [--revision 1.0] \ + [--owner alice@company.com] \ + [--signer-key my_private_key.pem] \ + [--manifest output.gcode.provenance.json] +``` + +#### Required Arguments + +- `output.gcode` (file to document) +- `--source-model-hash` **required for G-code** + - SHA-256 of the upstream model (STL, OBJ, 3MF, etc.) + - Typically from `printshield hash model.stl` + +#### Slicer Metadata + +- `--slicer-name, --slicer-id` + - Name of the slicer software (e.g., `PrusaSlicer`, `Cura`, `IdeaMaker`) +- `--slicer-version` + - Version of the slicer (e.g., `2.7.1`) +- `--slicer-profile-hash` + - Hash of the slicer profile/configuration (ensures reproducibility) + +#### Machine Profile + +- `--machine-profile-hash` + - Hash of the printer's configuration (bed size, nozzle diameter, etc.) + - Optional but recommended for traceability + +#### Project & Part Metadata + +- `--project` + - Project name or identifier +- `--part-id` + - Part identifier within the project +- `--revision` + - Revision number or version string +- `--owner` + - Person or team responsible (email, username, etc.) + +#### Signing + +- `--signer-key` + - Ed25519 private key to sign the manifest (optional) + - If provided, the manifest includes a signature that can be verified later +- `--manifest` + - Output manifest file path (default: `.provenance.json`) + +#### Example + +```bash +# Assume we have: +# - model.stl (the source model) +# - output.gcode (generated from model.stl) +# - my_key.pem (Ed25519 private key) + +# Hash the source model +MODEL_HASH=$(printshield --format json hash model.stl | jq -r '.sha256') + +# Create signed provenance for the G-code +printshield provenance create output.gcode \ + --source-model-hash "$MODEL_HASH" \ + --slicer-name PrusaSlicer \ + --slicer-version 2.7.1 \ + --slicer-profile-hash profile123abc \ + --machine-profile-hash machine456def \ + --project CoolPart \ + --part-id COOL-001 \ + --revision 1.0 \ + --owner alice@company.com \ + --signer-key my_key.pem +``` + +This creates `output.gcode.provenance.json`: + +```json +{ + "artifact": { + "format": "gcode", + "path": "output.gcode", + "sha256": "gcode_file_hash..." + }, + "gcode": { + "source_model_hash": "model_hash_value...", + "slicer_id": "PrusaSlicer", + "slicer_version": "2.7.1", + "profile_hash": "profile123abc", + "machine_profile_hash": "machine456def" + }, + "project": "CoolPart", + "part_id": "COOL-001", + "revision": "1.0", + "owner": "alice@company.com", + "timestamp": "2025-12-21T14:30:45Z", + "policy_id": "nist-800-171", + "signature": { + "signer_fp": "3a4b5c6d7e8f9a0b", + "sig_b64": "KL7S+eO4...base64-signature..." + } +} +``` + +#### Audit Trail + +``` +[provenance.create] file=output.gcode manifest=output.gcode.provenance.json signed=true artifact_format=gcode source_model_hash=model_hash... +``` + +## Print-Gate Policy: Enforce Model-to-G-code Binding + +The **print-gate** ensures that: +1. A G-code file's `source_model_hash` (in its manifest) matches an **expected** model hash +2. The manifest itself is properly signed (if required) +3. Only approved source models can be printed + +### Gate Command + +```bash +printshield gcode gate output.gcode \ + --source-hash \ + [--manifest output.gcode.provenance.json] \ + [--expected-manifest-signer-pub manifest_signer_pubkey.pem] +``` + +#### Options + +- `output.gcode` **required** + - Path to the G-code file to check +- `--source-hash, -H` **required** + - Expected SHA-256 of the upstream model + - This is the "approved" model hash your organization has verified +- `--manifest, -m` (optional) + - Path to the provenance manifest (default: `.provenance.json`) + - If not found, gate will fail +- `--expected-manifest-signer-pub, -E` (optional) + - Ed25519 public key to verify manifest signature + - If provided, gate fails if manifest is not signed by this key + +#### Example + +```bash +# Verify that G-code came from a specific source model +printshield gcode gate output.gcode \ + --source-hash abc123def456... + +# Output (text): +# G-code print-gate OK +``` + +```bash +# Stricter: also verify manifest was signed by the slicer +printshield gcode gate output.gcode \ + --source-hash abc123def456... \ + --expected-manifest-signer-pub slicer_public_key.pem + +# Output on success: +# G-code print-gate OK + +# Output on failure: +# G-code print-gate FAIL — [reason] +``` + +#### Exit Code + +- `0` on success (gate passes) +- `1` on failure (gate denies) + +#### JSON Output + +```bash +printshield --format json gcode gate output.gcode \ + --source-hash abc123def456... +``` + +Returns: + +```json +{ + "ok": true, + "reason": "source_model_hash matches expected_hash", + "details": { + "manifest_path": "output.gcode.provenance.json", + "source_model_hash": "abc123def456...", + "expected_source_hash": "abc123def456..." + } +} +``` + +#### Failure Scenarios + +| Scenario | Reason | Exit Code | +|----------|--------|-----------| +| Manifest not found | "manifest_not_found" | 1 | +| Source model hash mismatch | "source_hash_mismatch" | 1 | +| Manifest unsigned but signer required | "manifest_not_signed" | 1 | +| Manifest signature invalid | "signature_verification_failed" | 1 | +| G-code file not found | "gcode_file_not_found" | 1 | +| Hashes match | (none, gate passes) | 0 | + +#### Audit Trail + +``` +[gcode.gate] file=output.gcode manifest=output.gcode.provenance.json ok=true reason=source_model_hash_matches +``` + +## Typical Workflows + +### Slicer → Server → Printer + +**Step 1: Generate and sign G-code on the slicer workstation** + +```bash +# Hash the source model +MODEL_HASH=$(printshield hash model.stl | grep SHA-256 | awk '{print $NF}') + +# Generate G-code with your slicer (e.g., PrusaSlicer) +# → output.gcode + +# Create provenance +printshield provenance create output.gcode \ + --source-model-hash "$MODEL_HASH" \ + --slicer-name PrusaSlicer \ + --slicer-version 2.7.1 \ + --slicer-profile-hash profile_hash \ + --signer-key slicer_private_key.pem + +# Sign the G-code +printshield gcode sign output.gcode \ + --private-key slicer_private_key.pem +``` + +**Step 2: Transfer to server** + +```bash +# Bundle (optional, for multiple files) +printshield bundle create output.gcode.psenc + +# Or transfer directly +printshield transfer sftp output.gcode \ + --host staging.example.com \ + --user uploader \ + --dest-dir /jobs +``` + +**Step 3: On the server, verify before sending to printer** + +```bash +# Verify signature +printshield gcode verify output.gcode \ + --public-key slicer_public_key.pem + +# Check print-gate against approved model +printshield gcode gate output.gcode \ + --source-hash abc123def456... \ + --expected-manifest-signer-pub slicer_public_key.pem +``` + +**Step 4: Transfer to printer (OctoPrint)** + +```bash +printshield transfer octoprint output.gcode \ + --url https://octopi.local \ + --api-key XXXXX \ + --location local \ + --select \ + --print +``` + +**Step 5: On OctoPrint, live verification (optional)** + +```bash +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key XXXXX \ + --verify file \ + --expected-gcode output.gcode +``` + +### Reproducibility: Re-slicing for Verification + +To verify that G-code was produced from a specific model without modification: + +```bash +# Original workflow: +MODEL_HASH_1=$(printshield hash original_model.stl | grep SHA-256 | awk '{print $NF}') +# ... slice with same settings ... +printshield provenance create gcode_v1.gcode \ + --source-model-hash "$MODEL_HASH_1" \ + --slicer-name PrusaSlicer \ + --slicer-version 2.7.1 \ + --slicer-profile-hash profile_abc + +# Later, re-slice the same model with the same settings: +MODEL_HASH_2=$(printshield hash original_model.stl | grep SHA-256 | awk '{print $NF}') +printshield gcode hash gcode_v1.gcode > hash_v1.txt +# ... re-slice to gcode_v2.gcode ... +printshield gcode hash gcode_v2.gcode > hash_v2.txt + +# If hashes match, the G-code was reproduced correctly +diff hash_v1.txt hash_v2.txt +``` + +## Integration with Monitoring + +Monitor G-code files for unexpected changes: + +```bash +# Create a baseline of trusted G-code files +printshield monitor scan --path /printer/jobs --format json > baseline.json + +# Later, detect changes +printshield monitor diff --baseline baseline.json --path /printer/jobs + +# If file is modified without a new signature, the diff will show it +``` + +## Canonical Form Details + +PrintShield normalizes G-code as follows: + +1. **Strip trailing whitespace** from each line +2. **Remove empty lines** (lines with only whitespace) +3. **Normalize coordinate precision** to prevent floating-point drift +4. **Sort metadata comments** (if `--include-comments`) +5. **Preserve command order** and case sensitivity +6. **Ignore formatting variations** (tabs vs. spaces, extra spaces around operators) + +This ensures that: +- Re-exporting from a slicer with different whitespace still produces the same hash +- Minor changes to comments don't invalidate signatures (unless `--include-comments`) +- Version control diffs stay meaningful + +## Troubleshooting + +### Signature verification fails after re-exporting G-code + +**Problem**: G-code was re-exported or reformatted, so the signature is now invalid. + +**Solution**: +1. If comments were added/removed, re-sign with `--include-comments` flag that matches +2. Ask the slicer to export with minimal changes (same version, same profile) +3. Or hash with `--include-comments false` to ignore comment changes + +### Manifest not found + +**Problem**: `gcode gate` fails because `.provenance.json` doesn't exist. + +**Solution**: +1. Ensure provenance was created: `printshield provenance create output.gcode --source-model-hash ...` +2. Specify manifest path explicitly: `printshield gcode gate output.gcode --source-hash ... --manifest path/to/manifest.json` + +### Source model hash mismatch + +**Problem**: G-code gate fails because `source_model_hash` in manifest doesn't match `--source-hash`. + +**Solution**: +1. Verify you're passing the correct model hash: `printshield hash original_model.stl` +2. Check that the manifest was created from the correct model +3. If you intentionally support multiple models, gate each one against its own hash + +### Slicer version mismatch + +**Problem**: G-code was generated with a different slicer version than expected. + +**Solution**: +1. Check the manifest: `cat output.gcode.provenance.json | grep slicer_version` +2. Re-slice with the approved version if reproducibility is critical +3. Or add version tolerance to your policy (future enhancement) + +## Command Reference + +| Command | Purpose | Example | +|---------|---------|---------| +| `gcode hash` | Canonical SHA-256 of G-code | `printshield gcode hash file.gcode` | +| `gcode sign` | Create detached signature | `printshield gcode sign file.gcode -k privkey.pem` | +| `gcode verify` | Verify detached signature | `printshield gcode verify file.gcode -K pubkey.pem` | +| `gcode gate` | Enforce print-gate policy | `printshield gcode gate file.gcode --source-hash ...` | +| `provenance create` | Create signed manifest | `printshield provenance create file.gcode --source-model-hash ...` | + +## See Also + +- [Printer Monitoring Guide](monitor-printer.md) – Live G-code verification during printing +- [CLI Reference](cli.md#8-gcode) – Detailed command options +- [Getting Started](getting-started.md) – Basic workflow examples diff --git a/docs/monitor-printer.md b/docs/monitor-printer.md new file mode 100644 index 0000000..2c9b850 --- /dev/null +++ b/docs/monitor-printer.md @@ -0,0 +1,708 @@ +# Printer Monitoring Guide + +This guide covers PrintShield's live printer monitoring features, focusing on OctoPrint integration, G-code verification, and real-time enforcement policies. + +## Overview + +The `printshield monitor printer octoprint` command enables: + +- **Live status polling** – Monitor print state, progress, and events in real-time +- **G-code verification** – Verify that the file being printed matches an expected source model +- **Mid-print detection** – Detect if G-code is swapped during printing +- **Automated control** – Pause, resume, or cancel printing based on verification failures +- **Audit logging** – Record all state changes, verification results, and control actions + +This is useful for: +- **Print farms** where unvetted G-code must be rejected +- **High-security environments** requiring end-to-end provenance verification +- **Quality assurance** ensuring correct configuration and source models +- **Regulatory compliance** proving that all printed parts came from approved designs + +## Quick Start + +### Prerequisites + +1. **OctoPrint instance** running with API access +2. **OctoPrint API key** (Settings → API → Current API key) +3. **G-code file** to verify (locally or on OctoPrint) +4. **Provenance manifest** for the G-code (optional but recommended) +5. **PrintShield configuration** with policy settings + +### Basic Usage + +```bash +# Print status once and exit +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key YOUR_API_KEY \ + --once + +# Live monitoring (continuous, press Ctrl+C to stop) +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key YOUR_API_KEY +``` + +### With G-code Verification + +```bash +# Verify that the file being printed matches a known G-code +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key YOUR_API_KEY \ + --verify file \ + --expected-gcode output.gcode \ + --on-violation pause +``` + +### With Print-Gate Policy + +```bash +# Enforce that G-code originated from a specific approved model +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key YOUR_API_KEY \ + --verify file \ + --expected-gcode output.gcode \ + --source-hash abc123def456... \ + --manifest output.gcode.provenance.json \ + --on-violation cancel +``` + +## Command Reference + +### `printshield monitor printer octoprint` + +```bash +printshield monitor printer octoprint \ + --url BASE_URL \ + --api-key API_KEY \ + [--poll INTERVAL] \ + [--timeout SECONDS] \ + [--verify-tls|--no-verify-tls] \ + [--once] \ + [--control|--no-control] \ + [--verify MODE] \ + [--expected-gcode PATH] \ + [--include-comments] \ + [--on-violation ACTION] \ + [--max-download-mb SIZE] \ + [--recheck-metadata-every-s INTERVAL] \ + [--source-hash HASH] \ + [--manifest PATH] \ + [--expected-manifest-signer-pub KEY] +``` + +#### Required Options + +**`--url, -U BASE_URL`** + +Base URL of the OctoPrint instance. + +**Examples:** +- `https://octopi.local` +- `http://192.168.1.100:5000` +- `https://octopi.company.com` + +Also supports environment variable: +```bash +export OCTO_URL="https://octopi.local" +printshield monitor printer octoprint --api-key ... +``` + +**`--api-key API_KEY`** + +OctoPrint API key for authentication (X-Api-Key header). + +Also supports environment variable: +```bash +export OCTO_KEY="abcdef0123456789..." +printshield monitor printer octoprint --url https://octopi.local +``` + +#### Polling Options + +**`--poll INTERVAL`** (default: 1.0 second) + +How often to poll OctoPrint for status updates (in seconds). + +- Minimum: 0.2 seconds +- Recommended: 1.0 for normal use, 0.5 for high-security environments + +**Example:** +```bash +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY \ + --poll 0.5 # Check every 500ms +``` + +**`--timeout SECONDS`** (default: 10.0 seconds) + +HTTP request timeout for OctoPrint API calls. + +**Example:** +```bash +--timeout 30.0 # Wait up to 30 seconds for API response +``` + +**`--verify-tls / --no-verify-tls`** (default: `--verify-tls`) + +Whether to verify OctoPrint's TLS certificate. + +- `--verify-tls` (recommended) – Verify certificate and host +- `--no-verify-tls` – Skip TLS verification (only for self-signed certs in testing) + +**Example:** +```bash +# For self-signed certificate on octopi.local +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY \ + --no-verify-tls +``` + +#### Execution Options + +**`--once`** (optional) + +Print status once and exit instead of continuous monitoring. + +**Example:** +```bash +# Check current status +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY \ + --once +``` + +Output: +``` +Printer Status: Operational +State: Idle +Job: None +``` + +**`--control / --no-control`** (default: `--control`) + +Enable interactive stdin commands (pause, resume, cancel, start). + +- `--control` – Accept commands interactively +- `--no-control` – Monitoring only, no manual control + +**Example (monitoring only, no interactive control):** +```bash +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY \ + --no-control +``` + +#### Verification Options + +**`--verify MODE`** (default: `none`) + +Verification strategy for G-code files being printed. + +**Options:** +- `none` – No verification (default) +- `file` – Verify that remote file matches `--expected-gcode` +- `prefix` – Verify first N bytes of remote file +- `file+prefix` – Verify both full file and prefix + +**Example:** +```bash +--verify file \ + --expected-gcode output.gcode +``` + +**`--expected-gcode PATH`** (required if `--verify != none`) + +Path to the local G-code file to compare against the remote file being printed. + +**Example:** +```bash +--expected-gcode ./job/output.gcode +``` + +**`--include-comments`** (optional) + +Include comments when verifying G-code hash. + +Must match the mode used when the G-code was signed. + +**Default:** `false` (comments ignored) + +**Example:** +```bash +--verify file \ + --expected-gcode output.gcode \ + --include-comments +``` + +**`--on-violation ACTION`** (default: `warn`) + +What to do if verification fails. + +**Options:** +- `warn` – Print warning to console; continue monitoring +- `pause` – Pause the print job +- `cancel` – Cancel the print job +- `exit` – Exit the monitoring tool + +**Example (strict enforcement):** +```bash +--verify file \ + --expected-gcode output.gcode \ + --on-violation cancel +``` + +#### Print-Gate Options + +**`--source-hash HASH`** (optional) + +Expected SHA-256 hash of the upstream model (e.g., from `printshield hash model.stl`). + +Used to enforce the print-gate policy (G-code must have originated from this model). + +**Example:** +```bash +--source-hash abc123def456... \ + --manifest output.gcode.provenance.json +``` + +**`--manifest PATH`** (optional) + +Path to the provenance manifest for the G-code being printed. + +**Default:** `.provenance.json` + +**Example:** +```bash +--manifest ./job/output.gcode.provenance.json \ + --expected-manifest-signer-pub slicer_public_key.pem +``` + +**`--expected-manifest-signer-pub KEY`** (optional) + +Ed25519 public key to verify the manifest signature. + +If provided, the manifest must be signed by this key. + +**Example:** +```bash +--expected-manifest-signer-pub ./keys/slicer_ed25519_pub.pem +``` + +#### Download & Recheck Options + +**`--max-download-mb SIZE`** (default: 250 MB) + +Maximum file size to download from OctoPrint for verification. + +Prevents downloading very large files unnecessarily. + +**Example:** +```bash +--verify file \ + --expected-gcode output.gcode \ + --max-download-mb 500 # Allow up to 500 MB files +``` + +**`--recheck-metadata-every-s INTERVAL`** (default: 10.0 seconds) + +How often to re-check OctoPrint file metadata to detect mid-print swaps. + +This detects if the file being printed was changed (swapped out) during printing. + +**Example:** +```bash +--verify file \ + --recheck-metadata-every-s 5.0 # Check every 5 seconds +``` + +## Output Modes + +### Text (Human-Readable) + +```bash +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY +``` + +**Output:** +``` +Monitoring https://octopi.local + +State: Printing +File: output.gcode +Progress: 45% +Time Elapsed: 00:15:32 +Time Remaining: 00:18:47 +Bed Temp: 60°C (target: 60°C) +Nozzle Temp: 205°C (target: 205°C) + +Verification: PASS (file matches expected) +Source Model: APPROVED (hash matches policy) + +[13:45:32] State change: Idle → Printing +[13:45:45] Control: Pause +[13:46:10] Control: Resume +[13:47:30] File swap detected (via metadata recheck) +[13:47:30] Violation: CANCEL +``` + +### JSON + +```bash +printshield --format json monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY \ + --once +``` + +**Output:** +```json +{ + "timestamp": "2025-12-21T13:45:32Z", + "printer_state": "Printing", + "file": "output.gcode", + "progress": { + "completion": 45.0, + "filepos": 1234567, + "filetotal": 2750000, + "time_elapsed": 935, + "time_remaining": 1127 + }, + "temps": { + "bed": {"current": 60.0, "target": 60.0}, + "nozzle": {"current": 205.0, "target": 205.0} + }, + "verification": { + "mode": "file", + "status": "PASS", + "reason": "file_hash_matches" + }, + "print_gate": { + "status": "APPROVED", + "source_hash_match": true + } +} +``` + +## Typical Workflows + +### Monitor and Auto-Pause on Verification Failure + +```bash +# Slicer workstation: create and sign G-code +printshield gcode sign output.gcode -k slicer_key.pem + +# Server/print farm: start monitoring +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key XXXXX \ + --verify file \ + --expected-gcode output.gcode \ + --on-violation pause +``` + +If the G-code is swapped or modified during printing, the print is automatically paused, giving operators time to investigate. + +### Strict Print-Gate with Manifest Verification + +```bash +# Operator: verify that G-code came from approved model +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key XXXXX \ + --verify file \ + --expected-gcode approved_part.gcode \ + --source-hash abc123def456... \ + --manifest approved_part.gcode.provenance.json \ + --expected-manifest-signer-pub slicer_public_key.pem \ + --on-violation cancel +``` + +This ensures: +1. The file being printed is the exact file we expect +2. The file originated from an approved source model +3. The manifest was signed by the approved slicer + +If any check fails, the print is immediately canceled. + +### Continuous Monitoring (Audit Trail Only) + +```bash +# Run in monitoring-only mode; record all state changes +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key XXXXX \ + --poll 1.0 \ + --no-control # Prevent accidental manual control +``` + +All state changes are logged to the audit log. Later, review the audit: + +```bash +printshield audit verify +``` + +### Pre-Print Verification Only + +```bash +# Just verify the file before starting the print (don't monitor) +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key XXXXX \ + --verify file \ + --expected-gcode output.gcode \ + --once +``` + +This checks the file once and exits. The operator manually starts the print if verification passes. + +## Integration Examples + +### With Bash Script + +```bash +#!/bin/bash +set -e + +GCODE="$1" +OCTO_URL="${2:-https://octopi.local}" +OCTO_KEY="${OCTO_API_KEY}" + +echo "Verifying G-code..." +printshield gcode verify "$GCODE" --public-key slicer_key.pem + +echo "Starting OctoPrint monitoring..." +printshield monitor printer octoprint \ + --url "$OCTO_URL" \ + --api-key "$OCTO_KEY" \ + --verify file \ + --expected-gcode "$GCODE" \ + --on-violation cancel +``` + +Run it: +```bash +chmod +x monitor_job.sh +./monitor_job.sh ./job/output.gcode +``` + +### With systemd Service + +Create `/etc/systemd/system/printshield-monitor.service`: + +```ini +[Unit] +Description=PrintShield OctoPrint Monitor +After=network.target + +[Service] +Type=simple +User=printshield +WorkingDirectory=/opt/printshield +Environment="OCTO_URL=https://octopi.local" +Environment="OCTO_KEY=your-api-key" +ExecStart=/usr/local/bin/printshield monitor printer octoprint \ + --url $OCTO_URL \ + --api-key $OCTO_KEY \ + --poll 1.0 \ + --verify file \ + --expected-gcode /var/spool/printshield/current_job.gcode +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Enable and start: +```bash +sudo systemctl enable printshield-monitor.service +sudo systemctl start printshield-monitor.service + +# View logs +sudo journalctl -u printshield-monitor.service -f +``` + +### With Docker Compose + +```yaml +version: '3.9' +services: + octoprint: + image: octoprint/octoprint:latest + ports: + - "5000:5000" + volumes: + - octoprint_data:/octoprint + + printshield-monitor: + image: printshield:latest + depends_on: + - octoprint + environment: + OCTO_URL: http://octoprint:5000 + OCTO_KEY: ${OCTO_API_KEY} + command: > + monitor printer octoprint + --url http://octoprint:5000 + --api-key $$OCTO_KEY + --verify-tls false + --poll 1.0 + volumes: + - ./audit.log:/app/audit.log + - ./jobs:/app/jobs:ro + +volumes: + octoprint_data: +``` + +Start it: +```bash +export OCTO_API_KEY="your-key" +docker-compose up -d +``` + +## Troubleshooting + +### Connection Error: "Connection refused" + +``` +Error: Failed to connect to OctoPrint: Connection refused +``` + +**Causes:** +- OctoPrint is not running or not accessible at the URL +- Firewall blocking the connection +- TLS certificate issue + +**Solutions:** +1. Check OctoPrint is running: `curl -v https://octopi.local` +2. Verify URL: `printshield monitor printer octoprint --url https://octopi.local --api-key KEY --once` +3. For self-signed certs: `--no-verify-tls` + +### Authentication Error: "Invalid API key" + +``` +Error: API key validation failed +``` + +**Solutions:** +1. Check API key: OctoPrint Settings → API → Current API key +2. Set correctly: `export OCTO_KEY="your-actual-key"` or `--api-key "your-key"` +3. Regenerate if needed: OctoPrint Settings → API → Revoke and generate new key + +### File Verification Fails: "File not found on printer" + +``` +Verification: FAIL (file_not_found_on_printer) +``` + +**Causes:** +- G-code file doesn't exist on OctoPrint +- File was uploaded under a different name +- File was deleted + +**Solutions:** +1. Upload file first: `printshield transfer octoprint output.gcode --url ... --api-key ...` +2. Verify file is on printer: `curl -H "X-Api-Key: KEY" https://octopi.local/api/files` +3. Use correct filename: Check OctoPrint UI for actual filename + +### File Mismatch: "File hash doesn't match" + +``` +Verification: FAIL (file_hash_mismatch) +``` + +**Causes:** +- Remote file was modified +- Remote file was swapped +- `--expected-gcode` path is wrong + +**Solutions:** +1. Re-upload the correct file: `printshield transfer octoprint expected_gcode.gcode ...` +2. Check `--expected-gcode` path: Should be the local copy +3. If file was intentionally changed, update `--expected-gcode` to point to new file + +### Manifest Not Found: "Provenance manifest missing" + +``` +Violation: FAIL (manifest_not_found) +``` + +**Causes:** +- Manifest file doesn't exist +- `--manifest` path is wrong + +**Solutions:** +1. Create manifest: `printshield provenance create output.gcode --source-model-hash ...` +2. Check path: `ls -la output.gcode.provenance.json` +3. Explicitly specify: `--manifest ./path/to/manifest.json` + +### Source Hash Mismatch: "Model hash doesn't match policy" + +``` +Print-gate: DENIED (source_hash_mismatch) +``` + +**Causes:** +- G-code was generated from a different source model +- `--source-hash` value is wrong +- Model was modified after G-code was generated + +**Solutions:** +1. Verify correct model: `printshield hash original_model.stl` +2. Update `--source-hash` to expected value +3. If model was intentionally updated, re-slice with new model + +## Audit Trail Examples + +### State Changes + +``` +[monitor.printer.state_change] type=octoprint url=https://octopi.local old=Idle new=Printing +[monitor.printer.state_change] type=octoprint url=https://octopi.local old=Printing new=Paused +``` + +### Control Actions + +``` +[monitor.printer.control] type=octoprint url=https://octopi.local action=pause +[monitor.printer.control] type=octoprint url=https://octopi.local action=resume +[monitor.printer.control] type=octoprint url=https://octopi.local action=cancel +``` + +### Verification Results + +``` +[gcode.verify] file=output.gcode ok=true +[gcode.gate] file=output.gcode ok=true reason=source_model_hash_matches +``` + +## Performance Considerations + +- **Poll interval**: Lower poll interval (0.2s) increases accuracy but uses more API bandwidth. Default 1.0s is a good balance. +- **File downloads**: First verification may be slow if file is large; subsequent checks are cached. +- **Metadata rechecks**: Default 10s rechecks; increase for high-latency networks. +- **TLS verification**: `--verify-tls` adds ~50-100ms per request; justified for security. + +## Security Notes + +- **API keys** are sensitive; use environment variables, not command-line arguments in scripts +- **Manifest verification** requires the slicer's public key; store it securely +- **On-violation actions** should default to `pause` or `cancel` for production +- **Audit logging** captures all actions; review regularly for suspicious activity + +## See Also + +- [G-code Security](gcode.md) – Signing, verifying, and gating G-code +- [CLI Reference](cli.md#monitor-printer-app) – Detailed command options +- [Configuration Guide](config.md) – Setting up OctoPrint defaults in config +- [Getting Started](getting-started.md) – Basic workflow examples diff --git a/docs/receive-gate.md b/docs/receive-gate.md new file mode 100644 index 0000000..a19d9dd --- /dev/null +++ b/docs/receive-gate.md @@ -0,0 +1,820 @@ +# Server-Side Receive & Gate Guide + +This guide covers PrintShield's server-side (`receive`) and gate (`gcode gate`) commands, which enforce policy and decrypt bundles on print servers or printers. + +## Overview + +The **receive** command is the server-side counterpart to client-side encryption and bundling. It: + +- **Receives** `.psenc` envelopes or `.pshieldpkg` bundles +- **Validates** the sender's identity (optional) +- **Enforces** policy requirements (optional) +- **Decrypts** the payload using the server's private key +- **Stages** plaintext files in a safe directory for printing + +The **gcode gate** command then: + +- **Enforces** the print-gate policy +- **Verifies** G-code came from an approved model +- **Checks** manifest signatures (optional) +- **Allows/denies** printing based on policy + +Together, these commands create a **secure handoff** from design→slicer→transfer→printer. + +## Architecture + +``` +Client Network Server/Printer +───────────────────────────────────────────────────────────────── + +Model.stl ────────┐ + ├─→ Encrypt + Sign ─────→ Envelope +Sender Key │ (.psenc) +Recipient Key ────┘ │ + ├─→ Bundle (.pshieldpkg) + │ + (Transfer: SFTP/HTTPS/OctoPrint) + │ + ▼ + Receive Command + │ +Recipient Key ─────────────────────────→ Decrypt & Validate + │ + ▼ + Plaintext File + (Staging Dir) + │ + ├─→ Verify Signature + │ + ├─→ Check Policy + │ + ▼ + Gate Command + (Print-Gate Policy) + │ + ├─→ Verify Manifest + │ + ├─→ Verify Source Hash + │ + ▼ + Approved for Printing +``` + +## The `receive` Command + +### Purpose + +Decrypt a `.psenc` envelope or extract a `.pshieldpkg` bundle on the server/printer side, verifying sender identity and policy compliance. + +### Basic Usage + +```bash +# Decrypt a .psenc envelope +printshield receive encrypted.psenc \ + --private-key server_x25519_private.pem + +# Extract a .pshieldpkg bundle +printshield receive bundle.pshieldpkg \ + --private-key server_x25519_private.pem +``` + +### Command Signature + +```bash +printshield receive INPUT_FILE \ + --private-key RECIPIENT_PRIVATE_KEY \ + [--expected-sender-pub SENDER_PUBLIC_KEY] \ + [--out-dir STAGING_DIR] \ + [--require-policy POLICY_ID] +``` + +### Options + +#### Required + +**`INPUT_FILE`** (positional argument) + +Path to the `.psenc` envelope or `.pshieldpkg` bundle to receive. + +**Examples:** +```bash +printshield receive job.psenc ... +printshield receive job.pshieldpkg ... +``` + +**`--private-key, -k`** (required) + +Path to the recipient's X25519 private key (PEM format). + +This is the server/printer's private key that was used in the `encrypt` command on the client side. + +**Example:** +```bash +--private-key /etc/printshield/server_x25519_private.pem +``` + +#### Optional + +**`--expected-sender-pub, -E`** (optional) + +Ed25519 public key of the expected sender. If provided, the received file must be signed by this key. + +Used to enforce **sender authenticity**: ensure only files signed by your trusted slicer/operator are accepted. + +**Example:** +```bash +--expected-sender-pub /etc/printshield/trusted_slicer_pub.pem +``` + +**Behavior:** +- If not provided: Sender verification is skipped +- If provided: Envelope signature must match this key; otherwise, receive fails + +**`--out-dir, -d`** (optional) + +Output directory for staged files. + +**Default:** `.stage` (created in current directory) + +**Examples:** +```bash +--out-dir /var/spool/printshield/jobs +--out-dir ./staging +``` + +**`--require-policy`** (optional) + +Policy ID that the bundle must declare. If provided, received bundle's `manifest.policy_id` must match. + +Used to enforce **policy consistency**: ensure only bundles from approved policies are accepted. + +**Example:** +```bash +--require-policy nist-800-171 +``` + +**Behavior:** +- If not provided: Policy check is skipped +- If provided: Bundle's `policy_id` must match; otherwise, receive fails + +### Output + +#### Text Mode (Default) + +```bash +printshield receive job.pshieldpkg --private-key key.pem +``` + +Output: +``` +Staged dir: /path/to/job.stage +Manifest: /path/to/job.stage/manifest.json +Envelope: /path/to/job.stage/envelope.psenc +Decrypted: /path/to/job.stage/model.stl +``` + +#### JSON Mode + +```bash +printshield --format json receive job.pshieldpkg --private-key key.pem +``` + +Output: +```json +{ + "staged_dir": "/path/to/job.stage", + "envelope": "/path/to/job.stage/envelope.psenc", + "decrypted": "/path/to/job.stage/model.stl", + "manifest": "/path/to/job.stage/manifest.json" +} +``` + +### Audit Trail + +Every receive operation is logged: + +``` +[receive] input=job.pshieldpkg staged_dir=job.stage envelope=job.stage/envelope.psenc decrypted=job.stage/model.stl manifest=job.stage/manifest.json +``` + +### Example Workflows + +#### Simple Decryption (No Policy Enforcement) + +```bash +# Client sends an encrypted file +printshield encrypt model.stl \ + -R server_pub.pem \ + -S client_key.pem + +# Server receives and decrypts +printshield receive model.stl.psenc \ + --private-key /etc/printshield/server_x25519_private.pem \ + --out-dir /var/spool/jobs +``` + +#### Verify Sender (Signature Binding) + +```bash +# Client sends and signs +printshield encrypt model.stl \ + -R server_pub.pem \ + -S trusted_slicer_key.pem + +# Server receives and verifies sender +printshield receive model.stl.psenc \ + --private-key /etc/printshield/server_x25519_private.pem \ + --expected-sender-pub /etc/printshield/trusted_slicer_pub.pem +``` + +If sender is not trusted, receive fails: +``` +Receive FAIL — signature verification failed or sender not expected +``` + +#### Policy Enforcement + +```bash +# Client bundles with policy metadata +printshield bundle create model.stl.psenc +# → model.stl.psenc.pshieldpkg (includes policy_id) + +# Server receives and enforces policy +printshield receive model.stl.psenc.pshieldpkg \ + --private-key /etc/printshield/server_x25519_private.pem \ + --require-policy nist-800-171 +``` + +If policy doesn't match, receive fails: +``` +Receive FAIL — policy_id mismatch (expected nist-800-171, got custom-policy) +``` + +#### Full Chain: Sender + Policy + +```bash +# Server: Maximum security +printshield receive job.pshieldpkg \ + --private-key /etc/printshield/server_x25519_private.pem \ + --expected-sender-pub /etc/printshield/trusted_slicer_pub.pem \ + --require-policy nist-800-171 \ + --out-dir /var/spool/printshield/approved_jobs +``` + +This ensures: +1. File was encrypted for this server +2. File was signed by the trusted slicer +3. File declares the correct policy +4. Decrypted files are staged in a safe location + +## The `gcode gate` Command + +### Purpose + +Enforce the **print-gate policy**: verify that a G-code file originated from an approved source model and is properly signed. + +### Basic Usage + +```bash +# Verify G-code against a known source model +printshield gcode gate output.gcode \ + --source-hash +``` + +### Command Signature + +```bash +printshield gcode gate GCODE_FILE \ + --source-hash EXPECTED_MODEL_SHA256 \ + [--manifest MANIFEST_PATH] \ + [--expected-manifest-signer-pub SIGNER_PUBLIC_KEY] +``` + +### Options + +#### Required + +**`GCODE_FILE`** (positional argument) + +Path to the G-code file to verify. + +**Example:** +```bash +printshield gcode gate output.gcode ... +``` + +**`--source-hash, -H`** (required) + +Expected SHA-256 hash of the upstream model. + +This is the "approved" model that this G-code must have come from. + +**Example:** +```bash +--source-hash abc123def456... +``` + +How to get the source hash: +```bash +# From the original model file +printshield hash model.stl | grep SHA-256 +# → SHA-256: abc123def456... + +# Or extract from manifest +cat output.gcode.provenance.json | jq .gcode.source_model_hash +# → "abc123def456..." +``` + +#### Optional + +**`--manifest, -m`** (optional) + +Path to the G-code's provenance manifest. + +**Default:** `.provenance.json` + +**Example:** +```bash +--manifest output.gcode.provenance.json +``` + +If manifest is not found at default location: +``` +Gate FAIL — manifest_not_found +``` + +**`--expected-manifest-signer-pub, -E`** (optional) + +Ed25519 public key to verify the manifest's signature. + +If provided, the manifest must be signed by this key. + +**Example:** +```bash +--expected-manifest-signer-pub /etc/printshield/slicer_pub.pem +``` + +**Behavior:** +- If not provided: Manifest signature is not checked +- If provided: Manifest must be signed by this key; gate fails otherwise + +### Output + +#### Text Mode (Default) + +**Success:** +```bash +printshield gcode gate output.gcode --source-hash abc123... +``` + +Output: +``` +G-code print-gate OK +``` + +Exit code: `0` + +**Failure:** +``` +G-code print-gate FAIL — source_hash_mismatch +``` + +Exit code: `1` + +#### JSON Mode + +```bash +printshield --format json gcode gate output.gcode --source-hash abc123... +``` + +**Success:** +```json +{ + "ok": true, + "reason": "source_model_hash_matches", + "details": { + "manifest_path": "output.gcode.provenance.json", + "source_model_hash": "abc123...", + "expected_source_hash": "abc123..." + } +} +``` + +**Failure:** +```json +{ + "ok": false, + "reason": "source_hash_mismatch", + "details": { + "manifest_path": "output.gcode.provenance.json", + "source_model_hash": "xyz789...", + "expected_source_hash": "abc123..." + } +} +``` + +### Audit Trail + +Every gate operation is logged: + +``` +[gcode.gate] file=output.gcode manifest=output.gcode.provenance.json ok=true reason=source_model_hash_matches +``` + +Failures are also logged for audit trail: + +``` +[gcode.gate] file=output.gcode manifest=output.gcode.provenance.json ok=false reason=source_hash_mismatch +``` + +### Exit Codes + +| Scenario | Exit Code | +|----------|-----------| +| Source hash matches | 0 (success) | +| Source hash mismatch | 1 (failure) | +| Manifest not found | 1 (failure) | +| Manifest signature invalid | 1 (failure) | +| G-code file not found | 1 (failure) | + +### Example Workflows + +#### Simple Source Verification + +```bash +# Get approved model hash +APPROVED_HASH=$(printshield hash approved_model.stl | grep SHA-256 | awk '{print $NF}') + +# Gate incoming G-code +printshield gcode gate incoming.gcode \ + --source-hash "$APPROVED_HASH" +``` + +If gate passes, approve for printing; if it fails, reject and investigate. + +#### Strict Manifest Verification + +```bash +# Only allow G-code from the trusted slicer +printshield gcode gate output.gcode \ + --source-hash abc123def456... \ + --manifest output.gcode.provenance.json \ + --expected-manifest-signer-pub /etc/printshield/trusted_slicer_pub.pem +``` + +This ensures: +1. G-code came from the approved model +2. Manifest is signed by the trusted slicer + +#### Automated Policy Enforcement + +Use in a script to auto-approve or auto-reject: + +```bash +#!/bin/bash +GCODE="$1" +APPROVED_MODEL_HASH="abc123def456..." + +if printshield gcode gate "$GCODE" --source-hash "$APPROVED_MODEL_HASH"; then + echo "✓ G-code approved for printing" + # Send to printer + printshield transfer octoprint "$GCODE" --url https://octopi.local --api-key KEY +else + echo "✗ G-code rejected by print-gate policy" + # Notify operator + echo "Unauthorized G-code rejected" | mail -s "PrintShield Alert" operator@example.com + exit 1 +fi +``` + +## Typical Server Workflows + +### Minimal Server (Decryption Only) + +**Setup:** +```bash +# Generate keys +openssl genpkey -algorithm X25519 -out server_x25519_private.pem +openssl pkey -in server_x25519_private.pem -pubout -out server_x25519_public.pem + +# Share public key with clients +# Clients use it with: printshield encrypt model.stl -R server_x25519_public.pem +``` + +**On server (one-time):** +```bash +printshield receive job.psenc --private-key server_x25519_private.pem +# → Decrypts to job.stage/model.stl +``` + +### Sender-Verified Server (Envelope + Signature Check) + +**Setup:** +```bash +# Server has recipient key pair (as above) +# Also collect trusted slicer's public key +cp /path/from/slicer/slicer_public_ed25519.pem /etc/printshield/trusted_slicer_pub.pem +``` + +**On server:** +```bash +printshield receive job.psenc \ + --private-key server_x25519_private.pem \ + --expected-sender-pub /etc/printshield/trusted_slicer_pub.pem +# → Decrypts and verifies sender is trusted slicer +``` + +### Policy-Enforcing Server (Bundle + Policy Check) + +**Setup:** +```bash +# Create config with policy +cat > ps.yaml < /etc/printshield/gateway.yaml <> /var/log/printshield/rejected_jobs.log +fi +``` + +## Integration Examples + +### With Systemd + +Create `/etc/systemd/system/printshield-gateway.service`: + +```ini +[Unit] +Description=PrintShield Gateway Server +After=network.target + +[Service] +Type=simple +User=printshield +WorkingDirectory=/opt/printshield-gateway +ExecStart=/usr/local/bin/printshield-gateway-daemon +Restart=always +RestartSec=5 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +``` + +Daemon script (`/usr/local/bin/printshield-gateway-daemon`): + +```bash +#!/bin/bash +set -e + +CONFIG="/etc/printshield/gateway.yaml" +INBOX="/var/spool/printshield/inbox" +APPROVED="/var/spool/printshield/approved" +REJECTED="/var/spool/printshield/rejected" + +mkdir -p "$INBOX" "$APPROVED" "$REJECTED" + +while true; do + for job in "$INBOX"/*.pshieldpkg; do + if [ -f "$job" ]; then + echo "Processing: $job" + + # Receive and decrypt + if printshield --config "$CONFIG" receive "$job" \ + --private-key /etc/printshield/key.pem \ + --expected-sender-pub /etc/printshield/slicer_pub.pem \ + --out-dir "${job%.pshieldpkg}.stage"; then + + # Gate the G-code + GCODE="${job%.pshieldpkg}.stage/output.gcode" + if printshield gcode gate "$GCODE" \ + --source-hash "abc123..." \ + --expected-manifest-signer-pub /etc/printshield/slicer_pub.pem; then + + mv "$job" "$APPROVED/" + echo "Job approved: $job" + else + mv "$job" "$REJECTED/" + echo "Job rejected: $job" + fi + else + mv "$job" "$REJECTED/" + echo "Job failed decryption: $job" + fi + fi + done + + sleep 5 +done +``` + +Start service: +```bash +sudo systemctl enable printshield-gateway.service +sudo systemctl start printshield-gateway.service +sudo journalctl -u printshield-gateway.service -f +``` + +### With REST API (Flask) + +```python +from flask import Flask, request, jsonify +import subprocess +import os + +app = Flask(__name__) + +@app.route('/api/receive', methods=['POST']) +def receive_job(): + """Accept encrypted job bundle and process it.""" + file = request.files['file'] + expected_policy = request.form.get('policy', 'nist-800-171') + + # Save uploaded file + temp_path = f'/tmp/{file.filename}' + file.save(temp_path) + + try: + # Run receive command + result = subprocess.run([ + 'printshield', 'receive', temp_path, + '--private-key', '/etc/printshield/key.pem', + '--require-policy', expected_policy, + '--format', 'json' + ], capture_output=True, text=True) + + if result.returncode == 0: + import json + output = json.loads(result.stdout) + return jsonify({'ok': True, 'staged_dir': output['staged_dir']}) + else: + return jsonify({'ok': False, 'error': result.stderr}), 400 + finally: + os.unlink(temp_path) + +@app.route('/api/gcode-gate', methods=['POST']) +def gate_gcode(): + """Check if G-code passes the print-gate policy.""" + gcode_path = request.json['gcode_path'] + source_hash = request.json['source_hash'] + + result = subprocess.run([ + 'printshield', 'gcode', 'gate', gcode_path, + '--source-hash', source_hash, + '--format', 'json' + ], capture_output=True, text=True) + + import json + output = json.loads(result.stdout) + return jsonify(output) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=5000, ssl_context='adhoc') +``` + +Usage: +```bash +# Upload encrypted job +curl -F file=@job.pshieldpkg http://gateway:5000/api/receive + +# Check G-code policy +curl -X POST http://gateway:5000/api/gcode-gate \ + -H "Content-Type: application/json" \ + -d '{"gcode_path": "/path/output.gcode", "source_hash": "abc123..."}' +``` + +## Troubleshooting + +### Receive Fails: "Decryption failed" + +**Causes:** +- Wrong private key +- File is not a valid `.psenc` or `.pshieldpkg` +- File was corrupted in transfer + +**Solutions:** +1. Verify private key: `openssl pkey -in key.pem -check` +2. Verify file format: `file job.psenc` +3. Re-send the file from client +4. Check SHA-256 matches between send and receive + +### Gate Fails: "Manifest not found" + +**Cause:** `.provenance.json` doesn't exist + +**Solution:** +```bash +# Create it on client before sending +printshield provenance create output.gcode --source-model-hash ... --signer-key ... +``` + +### Gate Fails: "Source hash mismatch" + +**Cause:** G-code came from a different model than expected + +**Solution:** +1. Verify correct model hash: `printshield hash original_model.stl` +2. Update `--source-hash` argument +3. Or ask client to re-slice with approved model + +### Sender Verification Fails + +**Cause:** Signature doesn't match expected signer public key + +**Solution:** +1. Verify you're using correct slicer public key +2. Ensure client used correct private key to sign +3. Share keys securely (use key management system) + +## Security Best Practices + +1. **Private keys** should be protected: + ```bash + chmod 600 /etc/printshield/server_x25519_private.pem + ``` + +2. **Audit logs** should be immutable: + ```bash + chmod 444 /var/log/printshield/audit.log + ``` + +3. **Staging directory** should be world-unreadable: + ```bash + chmod 700 /var/spool/printshield/jobs + ``` + +4. **Policy enforcement** should default to **reject**: + - Use `--require-policy` to ensure correct policy + - Use `--expected-sender-pub` to bind to trusted slicer + - Use `--expected-manifest-signer-pub` for gate + +5. **Audit regularly**: + ```bash + printshield audit verify + # Check for failed operations + grep -i "fail\|error" audit.log + ``` + +## See Also + +- [Getting Started](getting-started.md) – End-to-end workflow +- [G-code Security](gcode.md) – Print-gate policy details +- [Configuration Guide](config.md) – Server-side config +- [CLI Reference](cli.md#receive) – Detailed options diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..7ef3fed --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,880 @@ +# Troubleshooting & FAQ + +This guide covers common problems, debugging techniques, and frequently asked questions about PrintShield. + +## Troubleshooting Basics + +### Getting Help + +Before reaching out, gather this information: + +1. **PrintShield version:** + ```bash + printshield version + ``` + +2. **Python version:** + ```bash + python --version + ``` + +3. **Configuration:** + ```bash + printshield config + ``` + +4. **Error message** (full output with `--log-level debug`) + +5. **Audit log** (last 20 lines): + ```bash + tail -20 audit.log + ``` + +### Enable Debug Logging + +Most issues are easier to diagnose with verbose output: + +```bash +# Show debug-level logs +printshield --log-level debug COMMAND + +# Example: debug an encrypt operation +printshield --log-level debug encrypt model.stl -R pubkey.pem -S privkey.pem +``` + +### Check Audit Log + +The audit log records all operations: + +```bash +# View entire audit log +cat audit.log + +# Search for errors +grep -i "fail\|error" audit.log + +# See last 10 operations +tail -10 audit.log + +# Verify audit log integrity +printshield audit verify +``` + +--- + +## Common Issues & Solutions + +### Installation & Setup + +#### Issue: "printshield: command not found" + +**Problem:** The CLI is not in your PATH. + +**Solutions:** + +1. **Check if installed:** + ```bash + pip list | grep printshield + ``` + +2. **Reinstall:** + ```bash + pip install -e . + ``` + +3. **Use full path:** + ```bash + python -m printshield --help + ``` + +4. **Check virtual environment:** + ```bash + # Activate venv + source .venv/bin/activate # Linux/macOS + .venv\Scripts\Activate.ps1 # Windows + + # Then try + printshield --help + ``` + +#### Issue: "ModuleNotFoundError: No module named 'printshield'" + +**Problem:** PrintShield is not installed in the current Python environment. + +**Solutions:** + +```bash +# Reinstall in editable mode +cd PrintShield +pip install -e . + +# Or with dev extras +pip install -e ".[dev]" +``` + +--- + +### Configuration + +#### Issue: "Could not load config. Provide --config or create ps.yaml" + +**Problem:** No configuration file found or provided. + +**Solutions:** + +1. **Create minimal config:** + ```bash + cat > ps.yaml <> ps.yaml +echo " path: ./audit.log" >> ps.yaml +``` + +--- + +### Encryption & Decryption + +#### Issue: "BadParameter: Unsigned envelopes are disabled by default" + +**Problem:** Trying to encrypt without a sender key. + +**Solutions:** + +```bash +# Option 1: Provide sender key (recommended) +printshield encrypt file.stl \ + -R recipient_pubkey.pem \ + -S sender_privkey.pem + +# Option 2: Allow unsigned (not recommended) +printshield encrypt file.stl \ + -R recipient_pubkey.pem \ + --allow-unsigned +``` + +#### Issue: "Invalid key format" or "Could not deserialize key data" + +**Problem:** Key file is not PEM format or is corrupted. + +**Solutions:** + +```bash +# Check key format +head -1 your_key.pem +# Should be: -----BEGIN PRIVATE KEY----- or -----BEGIN PUBLIC KEY----- + +# If not PEM, convert it: +# (Depends on original format; see key-gen guide) + +# Verify key is valid +openssl pkey -in your_key.pem -check + +# For public keys +openssl pkey -pubin -in your_pubkey.pem -check +``` + +#### Issue: Decryption fails: "Decryption failed" or "GCM tag verification failed" + +**Problems:** +- Wrong private key +- File was corrupted in transfer +- File is not a valid `.psenc` + +**Solutions:** + +```bash +# Verify private key is correct +openssl pkey -in recipient_key.pem -check + +# Verify file is valid .psenc +file encrypted.psenc +# Should output: data (or binary) + +# Check SHA-256 integrity +sha256sum encrypted.psenc +# Compare with sender's checksum + +# Re-send from client if corrupted +``` + +#### Issue: "Sender signature verification failed" + +**Problem:** Envelope signature doesn't match expected sender key. + +**Solutions:** + +```bash +# Check you're using the correct sender public key +printshield decrypt encrypted.psenc \ + --private-key my_key.pem \ + --expected-sender-pub /path/to/correct/sender_pub.pem + +# If sender key changed, don't specify --expected-sender-pub +printshield decrypt encrypted.psenc \ + --private-key my_key.pem +``` + +--- + +### Signing & Verification + +#### Issue: "Invalid key type" (for signing) + +**Problem:** Using wrong key type for signature operation. + +**Solutions:** + +```bash +# For signing/verifying, use Ed25519 keys ONLY +# NOT X25519 keys (those are for encryption) + +# Check key type +openssl pkey -in your_key.pem -text | grep -i "algorithm\|type" + +# Expected for signing: +# - "ED25519" or "Ed25519" +# - NOT "X25519" or "X25519" + +# If wrong type, regenerate: +openssl genpkey -algorithm ED25519 -out ed25519_privkey.pem +openssl pkey -in ed25519_privkey.pem -pubout -out ed25519_pubkey.pem +``` + +#### Issue: "Signature verification failed" or "Signature is invalid" + +**Problem:** +- File was modified after signing +- Wrong public key +- Signature file is missing or corrupted + +**Solutions:** + +```bash +# Check signature file exists +ls -la file.stl.sig + +# Verify with correct public key +printshield verify file.stl \ + --public-key /path/to/CORRECT/signer_pub.pem \ + --signature file.stl.sig + +# Check file wasn't modified +sha256sum file.stl > current_hash.txt +# Compare with original hash + +# Check signature file integrity +file file.stl.sig +# Should be "data" +``` + +--- + +### G-code Operations + +#### Issue: "G-code hash mismatch" or "File not found" + +**Problem:** +- File doesn't exist +- Path is incorrect +- File was modified + +**Solutions:** + +```bash +# Check file exists and is readable +ls -la output.gcode +file output.gcode + +# Use absolute path if possible +printshield gcode hash /full/path/to/output.gcode + +# Verify file wasn't modified +sha256sum output.gcode > before.txt +# ... later ... +sha256sum output.gcode > after.txt +diff before.txt after.txt # Should be identical +``` + +#### Issue: "Manifest not found" (for gcode operations) + +**Problem:** `.provenance.json` file doesn't exist. + +**Solutions:** + +```bash +# Create manifest +printshield provenance create output.gcode \ + --source-model-hash \ + --slicer-name PrusaSlicer \ + --slicer-version 2.7.1 \ + --signer-key my_key.pem + +# Verify it was created +ls -la output.gcode.provenance.json + +# Specify explicit path if in different location +printshield gcode gate output.gcode \ + --source-hash ... \ + --manifest /path/to/manifest.json +``` + +#### Issue: "Source hash mismatch" (print-gate fails) + +**Problem:** G-code came from a different source model than expected. + +**Solutions:** + +```bash +# Get correct source hash +printshield hash original_model.stl +# Copy the SHA-256 value + +# Check what's in the manifest +cat output.gcode.provenance.json | grep source_model_hash + +# If intentionally different model, update --source-hash +printshield gcode gate output.gcode \ + --source-hash + +# Or re-slice with original model +``` + +--- + +### Transfer Operations + +#### Issue: "Connection refused" (SFTP/HTTPS/OctoPrint) + +**Problem:** Cannot connect to server/printer. + +**Solutions:** + +```bash +# Check server is reachable +ping printer.example.com +curl -v https://printer.example.com # for HTTPS + +# Verify hostname/IP is correct +printshield --log-level debug transfer sftp model.stl \ + --host printer.example.com \ + --user operator \ + --dest-dir /uploads + +# Check firewall +netstat -an | grep LISTEN # See open ports +``` + +#### Issue: "Authentication failed" (SFTP/OctoPrint) + +**Problem:** Wrong credentials or key. + +**Solutions:** + +**SFTP:** +```bash +# Test SSH connection manually +ssh -i my_key.pem operator@printer.example.com + +# If key-based auth fails, try password: +printshield transfer sftp model.stl \ + --host printer.example.com \ + --user operator \ + --dest-dir /uploads \ + --password my_password + +# Or check key permissions +chmod 600 my_key.pem +``` + +**OctoPrint:** +```bash +# Test API key manually +curl -H "X-Api-Key: YOUR_KEY" \ + https://octopi.local/api/version + +# If that fails, regenerate key: +# 1. Go to OctoPrint UI +# 2. Settings → API → Click "Revoke and generate new key" +# 3. Copy new key +# 4. Use in PrintShield +``` + +#### Issue: "File upload failed" or "HTTP 4xx/5xx" + +**Problem:** +- Insufficient space on remote +- File path doesn't exist +- Server returned error + +**Solutions:** + +```bash +# Check remote directory exists +printshield transfer sftp model.stl \ + --host printer.example.com \ + --user operator \ + --dest-dir /uploads # Make sure /uploads exists + +# Check available space (if you can SSH) +ssh operator@printer.example.com df -h + +# For HTTPS, check server logs +# (Ask server administrator) + +# Retry with verbose logging +printshield --log-level debug transfer https model.stl \ + --url https://staging.example.com/upload +``` + +#### Issue: "SHA-256 verification failed after upload" + +**Problem:** File was corrupted during transfer or modified on server. + +**Solutions:** + +```bash +# Re-send with verbose output +printshield --log-level debug transfer sftp model.stl \ + --host printer.example.com \ + --user operator \ + --dest-dir /uploads + +# Check file on remote (if accessible) +ssh operator@printer.example.com sha256sum /uploads/model.stl + +# Compare with local +sha256sum model.stl + +# If different, network corruption occurred: +# - Retry transfer +# - Check network stability +# - Use TLS for HTTPS (default) +``` + +--- + +### Monitoring & Audit + +#### Issue: "Directory not found" (monitor scan/watch) + +**Problem:** Path doesn't exist or is not readable. + +**Solutions:** + +```bash +# Check directory exists +ls -la /path/to/jobs + +# Create if missing +mkdir -p /path/to/jobs + +# Check permissions +ls -ld /path/to/jobs # Should have 'x' for user + +# Use absolute path +printshield monitor scan --path /full/path/to/jobs +``` + +#### Issue: "Baseline mismatch" (monitor diff) + +**Problem:** Baseline was created from a different directory. + +**Solutions:** + +```bash +# Verify baseline path matches +cat baseline.json | jq .root +# Should match your --path argument + +# Create new baseline if directory moved +printshield monitor scan --path /new/location --format json > new_baseline.json + +# Use matching path for diff +printshield monitor diff --baseline baseline.json --path /same/location/as/baseline +``` + +#### Issue: "Audit log is corrupted" + +**Problem:** Audit log failed verification. + +**Solutions:** + +```bash +printshield audit verify +# If fails, last good line is reported + +# Check what happened +tail -20 audit.log + +# If corrupted by accident, manual recovery: +# 1. Back up corrupted log +cp audit.log audit.log.backup + +# 2. Check if it's just truncated +# (Last entry incomplete due to crash) +# If so, remove last incomplete line + +# 3. Re-verify +printshield audit verify + +# Note: If log was maliciously altered, corruption will be detected +# This is working as designed! +``` + +--- + +### OctoPrint Monitoring + +#### Issue: "Cannot connect to OctoPrint" + +**Problem:** Connection to OctoPrint instance failed. + +**Solutions:** + +```bash +# Test OctoPrint is reachable +curl https://octopi.local + +# For self-signed cert, disable verification +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY \ + --no-verify-tls + +# Check URL is correct +# Should be: http://IP:PORT or https://hostname +# NOT: http://IP:PORT/admin or similar +``` + +#### Issue: "File verification failed: file not found on printer" + +**Problem:** Expected G-code file is not on OctoPrint. + +**Solutions:** + +```bash +# Upload file first +printshield transfer octoprint output.gcode \ + --url https://octopi.local \ + --api-key KEY + +# Verify it's there +curl -H "X-Api-Key: KEY" \ + https://octopi.local/api/files | jq .files + +# Use exact filename when monitoring +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY \ + --verify file \ + --expected-gcode output.gcode +``` + +#### Issue: "Violation: Cannot pause/cancel (printer offline)" + +**Problem:** Tried to control printer but it's offline. + +**Solutions:** + +```bash +# Use --no-control to disable auto-control +printshield monitor printer octoprint \ + --url https://octopi.local \ + --api-key KEY \ + --no-control # Monitoring only + +# Or wait for printer to come online before monitoring +``` + +--- + +## FAQ + +### Q: How do I generate keys? + +**A:** + +```bash +# Ed25519 (for signing) +openssl genpkey -algorithm ED25519 -out ed25519_private.pem +openssl pkey -in ed25519_private.pem -pubout -out ed25519_public.pem + +# X25519 (for encryption) +openssl genpkey -algorithm X25519 -out x25519_private.pem +openssl pkey -in x25519_private.pem -pubout -out x25519_public.pem + +# Verify they work +openssl pkey -in ed25519_private.pem -check +openssl pkey -in x25519_private.pem -check +``` + +### Q: Can I use the same key for signing and encryption? + +**A:** No. Use Ed25519 for signing, X25519 for encryption. They're different algorithms for different purposes. + +### Q: How do I share keys securely? + +**A:** + +- **Public keys**: Can be shared freely (email, git, etc.) +- **Private keys**: Store securely + - Never commit to git + - Use file permissions: `chmod 600 key.pem` + - Consider: key management system, secrets vault, HSM + - For production: use rotating keys + +### Q: Can I encrypt for multiple recipients? + +**A:** Currently, one recipient per envelope. Future versions may support recipient sets. + +For now, if you need multiple recipients: +1. Create separate envelopes for each +2. Or use a key hierarchy (encrypt to a master key that multiple recipients can access) + +### Q: What happens if I lose my private key? + +**A:** You cannot decrypt files encrypted with the corresponding public key. No recovery possible. + +**Prevention:** +- Back up private keys (encrypted) +- Use key management system +- Test recovery procedure regularly + +### Q: Can I revoke a key? + +**A:** PrintShield doesn't have built-in revocation. Instead: + +1. Stop using the old key +2. Rotate to a new key +3. Document the rotation in your audit log +4. Mark old signatures/keys as untrusted + +### Q: How often should I verify the audit log? + +**A:** Regularly (e.g., daily or after sensitive operations): + +```bash +printshield audit verify +``` + +Any corruption will be detected immediately. + +### Q: What if the audit log gets very large? + +**A:** + +```bash +# Archive old logs +mv audit.log audit.log.2025-12-01.backup + +# Create new empty log (next operation creates it) +printshield COMMAND # Will create new audit.log + +# Verify archived log +printshield --config temp-config.yaml audit verify +# (Set audit.path to archived log in temp config) +``` + +### Q: Can PrintShield run in a container? + +**A:** Yes. Example Dockerfile: + +```dockerfile +FROM python:3.12-slim +RUN pip install printshield +WORKDIR /app +VOLUME ["/app/keys", "/app/jobs", "/app/audit.log"] +ENTRYPOINT ["printshield"] +``` + +Usage: +```bash +docker build -t printshield . +docker run -v $(pwd)/keys:/app/keys -v $(pwd)/jobs:/app/jobs printshield hash jobs/model.stl +``` + +### Q: Can I use PrintShield with 3D model repositories (Thingiverse, Printables)? + +**A:** Not directly built-in, but you can: + +1. Download model +2. Hash it: `printshield hash model.stl` +3. Use that hash to track the source +4. Encrypt and bundle before sending to printer + +### Q: How do I integrate PrintShield with OctoPrint? + +**A:** Two approaches: + +1. **Pre-print verification**: Use `printshield monitor printer octoprint` with `--verify file` and `--on-violation pause` +2. **Server-side gateway**: Build a gateway that receives jobs, verifies them, then uploads to OctoPrint + +See [Server-Side Receive & Gate Guide](receive-gate.md) for details. + +### Q: What NIST controls does PrintShield support? + +**A:** Currently a subset (M7 deliverable). See: + +```bash +printshield compliance run --format json | jq .controls +``` + +More controls coming in future versions. + +### Q: Can I use PrintShield with other slicers? + +**A:** Yes. PrintShield doesn't generate G-code; it secures files from any slicer. Just: + +1. Slice in PrusaSlicer, Cura, etc. +2. Sign/encrypt with PrintShield: `printshield encrypt output.gcode ...` +3. Transfer and verify + +### Q: What's the performance impact of verification? + +**A:** + +| Operation | Time | +|-----------|------| +| Hash STL (1MB) | ~5ms | +| Sign (detached) | ~1ms | +| Verify signature | ~1ms | +| Encrypt | ~10ms | +| Decrypt | ~10ms | +| Monitor (per file) | ~5ms | + +For large files and high-frequency operations, consider: +- Caching hashes +- Batching operations +- Running on separate machine + +### Q: Can I automate PrintShield with cron/systemd? + +**A:** Yes. Examples: + +**Cron (hourly audit verification):** +```bash +0 * * * * /usr/local/bin/printshield audit verify >> /var/log/printshield-audit.log 2>&1 +``` + +**systemd timer (check every 10 minutes):** +```ini +[Unit] +Description=PrintShield Audit Verification Timer +[Timer] +OnBootSec=5min +OnUnitActiveSec=10min +[Install] +WantedBy=timers.target +``` + +See [Server-Side Receive & Gate Guide](receive-gate.md) for full integration examples. + +### Q: Is PrintShield suitable for production? + +**A:** PrintShield is feature-complete for the M1-M7 milestones (ROADMAP.md). Before production: + +- ✅ Review architecture and threat model +- ✅ Test backup/recovery procedures +- ✅ Verify audit log integrity +- ✅ Set up monitoring and alerting +- ✅ Train operators +- ✅ Document your security policy + +--- + +## Getting More Help + +If these docs don't answer your question: + +1. **Check the CLI help:** + ```bash + printshield COMMAND --help + ``` + +2. **Review existing issues/discussions** on the repository + +3. **Enable debug logging** and share relevant output: + ```bash + printshield --log-level debug COMMAND 2>&1 | head -50 + ``` + +4. **Provide context:** + - PrintShield version: `printshield version` + - Python version: `python --version` + - Your OS: `uname -a` (Linux) or `ver` (Windows) + - Steps to reproduce + +--- + +## See Also + +- [Getting Started](getting-started.md) – Basic workflow +- [CLI Reference](cli.md) – All command options +- [Configuration Guide](config.md) – Config file schema +- [G-code Security](gcode.md) – G-code-specific issues +- [Printer Monitoring](monitor-printer.md) – OctoPrint setup +- [Server-Side Receive & Gate](receive-gate.md) – Server deployment diff --git a/examples/bundle/Sample.stl.psenc b/examples/bundle/Sample.stl.psenc deleted file mode 100644 index b3ac8f7..0000000 Binary files a/examples/bundle/Sample.stl.psenc and /dev/null differ diff --git a/examples/bundle/manifest.json b/examples/bundle/manifest.json deleted file mode 100644 index cc65663..0000000 --- a/examples/bundle/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "created_at": "2025-10-19T23:41:53.109636+00:00", - "envelope": { - "alg": "AES-256-GCM", - "filename": "Sample.stl", - "kdf": "HKDF-SHA256", - "kx": "X25519", - "payload_sha256": null, - "sender_pub_ed25519": null - }, - "payload": { - "filename": "Sample.stl.psenc", - "sha256": "a813d059af69279e957313497940a71d4e4b5591ff81079536a044e6d2ff6ee8" - }, - "policy_id": "nist-800-171", - "v": 1 -} \ No newline at end of file diff --git a/examples/enc/Sample.stl b/examples/enc/Sample.stl deleted file mode 100644 index 8caa1d7..0000000 Binary files a/examples/enc/Sample.stl and /dev/null differ diff --git a/examples/enc/Sample.stl.provenance.json b/examples/enc/Sample.stl.provenance.json deleted file mode 100644 index 2f01c58..0000000 --- a/examples/enc/Sample.stl.provenance.json +++ /dev/null @@ -1,18 +0,0 @@ -{ - "v": 1, - "created_at": "2025-11-25T21:16:19.206895+00:00", - "policy_id": "nist-800-171", - "artifact": { - "filename": "Sample.stl", - "sha256": "a701095e7adf3a522fdc2259da2aa29a975fd852d73235521b41e987b3178d1f", - "size": 1899390, - "format": "stl" - }, - "project": null, - "part_id": null, - "revision": "1", - "owner": "ali", - "slicer": null, - "signer_pub_ed25519": "2NWHb34FFmSUkc77VKhwk2rq9Nj8yhp95xaI9Uvl6GM=", - "signature": "Lkp1ZG2MruVMtWagxYcO/R7g9PmLM4sdE0CDDNpjuFmxeDohYP8EV5cvHUuAMvfSaI6x6FBa6JHHKoMCzvq+DA==" -} \ No newline at end of file diff --git a/examples/enc/Sample.stl.psenc b/examples/enc/Sample.stl.psenc deleted file mode 100644 index b3ac8f7..0000000 Binary files a/examples/enc/Sample.stl.psenc and /dev/null differ diff --git a/examples/enc/incorrect_private_ed25519.pem b/examples/enc/incorrect_private_ed25519.pem deleted file mode 100644 index 79e6d89..0000000 --- a/examples/enc/incorrect_private_ed25519.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIA0Uz2TA66FhUY6XXFtUm1gitpACP7Fe30g1UbZGubAR ------END PRIVATE KEY----- diff --git a/examples/enc/incorrect_public_ed25519.pem b/examples/enc/incorrect_public_ed25519.pem deleted file mode 100644 index f8f9243..0000000 --- a/examples/enc/incorrect_public_ed25519.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEA5pIucZl12nyISlFbKdWVvG5w66MSfXbshpkh0ehQAIE= ------END PUBLIC KEY----- diff --git a/examples/files/stl/Sample.stl b/examples/files/stl/Sample.stl deleted file mode 100644 index e26fb45..0000000 Binary files a/examples/files/stl/Sample.stl and /dev/null differ diff --git a/examples/files/stl/Sample.stl.psenc b/examples/files/stl/Sample.stl.psenc deleted file mode 100644 index a3d027c..0000000 Binary files a/examples/files/stl/Sample.stl.psenc and /dev/null differ diff --git a/examples/files/stl/Sample.stl.sig b/examples/files/stl/Sample.stl.sig deleted file mode 100644 index fed9bd3..0000000 Binary files a/examples/files/stl/Sample.stl.sig and /dev/null differ diff --git a/examples/mvp/extracted/manifest.json b/examples/mvp/extracted/manifest.json deleted file mode 100644 index 95902ac..0000000 --- a/examples/mvp/extracted/manifest.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "created_at": "2025-10-23T13:09:51.866481+00:00", - "envelope": { - "alg": "AES-256-GCM", - "filename": "mvpEX.stl", - "kdf": "HKDF-SHA256", - "kx": "X25519", - "payload_sha256": "409d11741321d4940018f304a2df598b4c175cb2f2933985ea99f91ff69f2078", - "sender_pub_ed25519": "2NWHb34FFmSUkc77VKhwk2rq9Nj8yhp95xaI9Uvl6GM=" - }, - "payload": { - "filename": "mvpEX.stl.psenc", - "sha256": "25f2eedf62aeebd95c525de870932e38c76e4c0909f636bebfd4f7338042949e" - }, - "policy_id": "nist-800-171", - "v": 1 -} \ No newline at end of file diff --git a/examples/mvp/extracted/mvpEX.stl.psenc b/examples/mvp/extracted/mvpEX.stl.psenc deleted file mode 100644 index d9f4316..0000000 Binary files a/examples/mvp/extracted/mvpEX.stl.psenc and /dev/null differ diff --git a/examples/mvp/incorrect_private_ed25519.pem b/examples/mvp/incorrect_private_ed25519.pem deleted file mode 100644 index 79e6d89..0000000 --- a/examples/mvp/incorrect_private_ed25519.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEIA0Uz2TA66FhUY6XXFtUm1gitpACP7Fe30g1UbZGubAR ------END PRIVATE KEY----- diff --git a/examples/mvp/incorrect_public_ed25519.pem b/examples/mvp/incorrect_public_ed25519.pem deleted file mode 100644 index f8f9243..0000000 --- a/examples/mvp/incorrect_public_ed25519.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEA5pIucZl12nyISlFbKdWVvG5w66MSfXbshpkh0ehQAIE= ------END PUBLIC KEY----- diff --git a/examples/mvp/mvpEX.stl b/examples/mvp/mvpEX.stl deleted file mode 100644 index ee25e4b..0000000 Binary files a/examples/mvp/mvpEX.stl and /dev/null differ diff --git a/examples/mvp/mvpEX.stl.psenc b/examples/mvp/mvpEX.stl.psenc deleted file mode 100644 index d9f4316..0000000 Binary files a/examples/mvp/mvpEX.stl.psenc and /dev/null differ diff --git a/examples/mvp/mvpEX.stl.psenc.pshieldpkg b/examples/mvp/mvpEX.stl.psenc.pshieldpkg deleted file mode 100644 index 6fcd571..0000000 Binary files a/examples/mvp/mvpEX.stl.psenc.pshieldpkg and /dev/null differ diff --git a/examples/mvp/sample_private_ed25519.pem b/examples/mvp/sample_private_ed25519.pem deleted file mode 100644 index 49ff226..0000000 --- a/examples/mvp/sample_private_ed25519.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VwBCIEICat24dZ+8D7aInN2TpwHIOKYGJUVNVTdBA6zGB6Nfzz ------END PRIVATE KEY----- diff --git a/examples/mvp/sample_private_x25519.pem b/examples/mvp/sample_private_x25519.pem deleted file mode 100644 index a829e74..0000000 --- a/examples/mvp/sample_private_x25519.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PRIVATE KEY----- -MC4CAQAwBQYDK2VuBCIEIGBTwyL4k3HiuW9P6PrHpAYqvVZ+lpfXmPs/k1ZzUCRD ------END PRIVATE KEY----- diff --git a/examples/mvp/sample_public_ed25519.pem b/examples/mvp/sample_public_ed25519.pem deleted file mode 100644 index 47a6dcc..0000000 --- a/examples/mvp/sample_public_ed25519.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PUBLIC KEY----- -MCowBQYDK2VwAyEA2NWHb34FFmSUkc77VKhwk2rq9Nj8yhp95xaI9Uvl6GM= ------END PUBLIC KEY----- diff --git a/examples/mvp/sample_public_x25519.pem b/examples/mvp/sample_public_x25519.pem deleted file mode 100644 index 786136d..0000000 --- a/examples/mvp/sample_public_x25519.pem +++ /dev/null @@ -1,3 +0,0 @@ ------BEGIN PUBLIC KEY----- -MCowBQYDK2VuAyEAGJ2TDSnFELT8DVy+WKn8rLefA6Z0X0mS/SBl7TYaXjs= ------END PUBLIC KEY----- diff --git a/examples/sample-reports.yml/complicance-sample.json b/examples/sample-reports.yml/complicance-sample.json deleted file mode 100644 index e69de29..0000000 diff --git a/examples/stage/mvpEX.stl b/examples/stage/mvpEX.stl deleted file mode 100644 index ee25e4b..0000000 Binary files a/examples/stage/mvpEX.stl and /dev/null differ diff --git a/examples/stage/mvpEX.stl.psenc b/examples/stage/mvpEX.stl.psenc deleted file mode 100644 index d9f4316..0000000 Binary files a/examples/stage/mvpEX.stl.psenc and /dev/null differ diff --git a/examples/workflows/minimal-secure-transfer.yml b/examples/workflows/minimal-secure-transfer.yml deleted file mode 100644 index e69de29..0000000 diff --git a/policies/nist-800-171.yml b/policies/nist-800-171.yml index 8524a44..c6e0b49 100644 --- a/policies/nist-800-171.yml +++ b/policies/nist-800-171.yml @@ -1,37 +1,108 @@ id: nist-800-171 -name: NIST SP 800-171 (PrintShield subset) -version: "Rev2-subset" +name: NIST SP 800-171 (PrintShield operational subset) +version: "Rev2-operational-subset" description: > - File-centric subset of NIST SP 800-171 controls that PrintShield can help - address for 3D printing data security (artifacts, jobs, and audit logs). + Operationally-verifiable subset of NIST SP 800-171 controls mapped to + PrintShield capabilities that exist today (audit chain, crypto ops, + secure transfer, provenance, monitoring, and print-gate). + + Notes: + - Key management (M8) and host hardening/sandboxing (M6) are out of scope + for automated evaluation in this policy. controls: - - id: "3.13.16" - family: SC - title: Protect the confidentiality of data at rest + - id: "3.3.1" + family: AU + title: Create and retain system audit logs and records description: > - Protect sensitive 3D printing data (e.g. STL files, encrypted bundles) - at rest using strong cryptography and key management. + Audit logging must be configured and the tamper-evident audit chain must verify. mappings: - - feature: "crypto.encryption.envelope" + - feature: config.present + params: { path: "audit.path" } + - feature: audit.chain_valid + params: { require_exists: true } - - id: "3.3.1" + - id: "3.3.2" family: AU - title: Create and retain system audit logs and records + title: Ensure auditing is enabled for defined events + description: > + The workflow should generate audit evidence for core security actions. + mappings: + - feature: audit.event.seen + params: + actions: ["encrypt","decrypt","sign","verify","transfer.sftp","transfer.https","transfer.octoprint","receive","gcode.gate"] + within_days: 120 + min_count: 1 + + - id: "3.3.8" + family: AU + title: Protect audit information and audit tools from unauthorized access + description: > + Best-effort check: integrity verification plus absence of insecure transfer flags. + mappings: + - feature: audit.chain_valid + params: { require_exists: true } + - feature: audit.event.none + params: { action: "transfer.https", within_days: 120, where: { insecure_no_verify_tls: true } } + - feature: audit.event.none + params: { action: "transfer.octoprint", within_days: 120, where: { insecure_no_verify_tls: true } } + - feature: audit.event.none + params: { action: "transfer.sftp", within_days: 120, where: { insecure_no_hostkey_check: true } } + + - id: "3.13.8" + family: SC + title: Implement cryptographic mechanisms to prevent unauthorized disclosure description: > - Ensure that audit logging is configured and that logs are retained in - a tamper-evident format suitable for monitoring and investigation. + Cryptographic runtime must be available and show evidence of encryption use. mappings: - - feature: "audit.configured" - - feature: "audit.chain_valid" + - feature: python.module + params: { module: "cryptography" } + - feature: audit.event.seen + params: { actions: ["encrypt","decrypt"], within_days: 120, min_count: 1 } + + - id: "3.13.16" + family: SC + title: Protect the confidentiality of data at rest + description: > + Evidence-based: encryption operations should occur in the audited workflow. + mappings: + - feature: audit.event.seen + params: { action: "encrypt", within_days: 120, min_count: 1 } - id: "3.14.6" family: SI title: Monitor systems to detect attacks and indicators of potential attacks description: > - Use file-integrity snapshots and real-time monitoring of 3D printing - directories to detect suspicious or unauthorized changes to artifacts - and job bundles. + Evidence-based: scans/watches and drift summaries should appear in the audit log. + mappings: + - feature: audit.event.seen + params: { actions: ["monitor.scan","monitor.watch"], within_days: 120, min_count: 1 } + - feature: audit.event.seen + params: { action: "monitor.diff.summary", within_days: 120, min_count: 1 } + + - id: "3.14.2" + family: SI + title: Provide protection from malicious code at appropriate locations + description: > + For AM, tampered toolpaths are a key risk. Evidence-based: print-gate should be executed. + mappings: + - feature: audit.event.seen + params: { action: "gcode.gate", within_days: 120, min_count: 1 } + + - id: "3.4.1" + family: CM + title: Establish and maintain baseline configurations and inventories + description: > + PrintShield baselines are represented by monitor scan events. + mappings: + - feature: audit.event.seen + params: { action: "monitor.scan", within_days: 365, min_count: 1 } + + - id: "3.1.2" + family: AC + title: Limit system access to authorized users/processes + description: > + PrintShield's receive + print-gate are enforcement points for only accepting verified artifacts. mappings: - - feature: "monitor.snapshot" - - feature: "monitor.watch" \ No newline at end of file + - feature: audit.event.seen + params: { actions: ["receive","gcode.gate"], within_days: 120, min_count: 1 } \ No newline at end of file diff --git a/src/printshield/__main__.py b/src/printshield/__main__.py new file mode 100644 index 0000000..27d14a7 --- /dev/null +++ b/src/printshield/__main__.py @@ -0,0 +1,4 @@ +from .cli import main + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/src/printshield/cli.py b/src/printshield/cli.py index f9431f3..4134a32 100644 --- a/src/printshield/cli.py +++ b/src/printshield/cli.py @@ -1,10 +1,14 @@ from __future__ import annotations import json +import signal import structlog import typer import time +import sys +import threading from hashlib import sha256 +from rich.console import Console from typing import cast, Optional from pathlib import Path from base64 import b64decode, b64encode @@ -34,6 +38,9 @@ from .core.monitor.fs import snapshot_directory from .core.monitor.diff import diff_against_current from .core.monitor.watch import start_observer +from .core.monitor.octoprint_client import OctoPrintClient +from .core.monitor.octoprint_live import run_octoprint_live_monitor +from .core.monitor.octoprint_verify import VerifyConfig from .core.policy.rules import load_policy_file, resolve_policy_path from .core.policy.engine import evaluate_policy from .core.policy.gate import check_gcode_policy @@ -84,6 +91,9 @@ def main_callback( monitor_app = typer.Typer(help="Monitor paths for file integrity and drift.") app.add_typer(monitor_app, name="monitor") +monitor_printer_app = typer.Typer(help="Live printer monitoring & control.") +monitor_app.add_typer(monitor_printer_app, name="printer") + compliance_app = typer.Typer(help="Map PrintShield features to policies (e.g. NIST 800-171) and generate reports.") app.add_typer(compliance_app, name="compliance") @@ -142,7 +152,7 @@ def encrypt( fields["sender_fpr"] = sender_fpr record_event(loaded.config.audit.path, "encrypt", fields) - typer.echo(f"Encrypted → {out_p}") + typer.echo(f"Encrypted to {out_p}") @app.command(help="Decrypt a .psenc envelope with recipient's X25519 private key; verifies sender signature.") def decrypt( @@ -157,7 +167,7 @@ def decrypt( out_p = decrypt_file(path, private_key, out_path=output, expected_sender_pub_pem=expected_sender_pub) # For audit, we don’t re-parse header here (already verified); keep event simple record_event(loaded.config.audit.path, "decrypt", {"in": str(path), "out": str(out_p)}) - typer.echo(f"Decrypted → {out_p}") + typer.echo(f"Decrypted to {out_p}") # ============================ HASH ================================ @@ -396,7 +406,7 @@ def provenance_create( else: mode = "signed" if signer_key is not None else "unsigned" typer.echo( - f"Provenance ({mode}, format={prov.artifact.format}) → {out_path}" + f"Provenance ({mode}, format={prov.artifact.format}) to {out_path}" ) @prov_app.command("verify", help="Verify a provenance sidecar against an STL file.") @@ -861,6 +871,149 @@ def monitor_watch( finally: observer.join() +@monitor_printer_app.command("octoprint", help="Live monitor an OctoPrint server; control pause/resume/cancel/start; optional PrintShield verification.") +def monitor_printer_octoprint( + ctx: typer.Context, + url: str = typer.Option( + ..., + "--url", + envvar="OCTO_URL", + help="OctoPrint base URL (e.g. http://localhost:5000)", + ), + api_key: str = typer.Option( + ..., + "--api-key", + envvar="OCTO_KEY", + help="OctoPrint API key (X-Api-Key)", + ), + poll: float = typer.Option(1.0, "--poll", min=0.2, help="Poll interval in seconds."), + timeout: float = typer.Option(10.0, "--timeout", min=1.0, help="HTTP timeout in seconds."), + verify_tls: bool = typer.Option(True, "--verify-tls/--no-verify-tls", help="Verify TLS certificates."), + once: bool = typer.Option(False, "--once", help="Print status once and exit."), + control: bool = typer.Option(True, "--control/--no-control", help="Enable stdin commands."), + verify: str = typer.Option( + "none", + "--verify", + help="Verification mode: none|file|prefix|file+prefix", + ), + expected_gcode: Optional[Path] = typer.Option( + None, + "--expected-gcode", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Approved local G-code file to compare against (required if --verify != none).", + ), + include_comments: bool = typer.Option( + False, + "--include-comments", + help="Use same canonicalization mode as signing (include comments in canonical hash).", + ), + on_violation: str = typer.Option( + "warn", + "--on-violation", + help="Action when verification fails: warn|pause|cancel|exit", + ), + max_download_mb: int = typer.Option( + 250, + "--max-download-mb", + min=1, + help="Maximum remote file size to download for verification.", + ), + recheck_metadata_every_s: float = typer.Option( + 10.0, + "--recheck-metadata-every-s", + min=0.5, + help="How often to re-check OctoPrint file metadata to detect mid-print swaps.", + ), + # ---- Optional PrintShield gate enforcement ---- + source_hash: Optional[str] = typer.Option( + None, + "--source-hash", + help="If set, enforce PrintShield print-gate policy against this expected source model hash.", + ), + manifest_path: Optional[Path] = typer.Option( + None, + "--manifest", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Provenance manifest path (defaults to .provenance.json in verifier).", + ), + expected_manifest_signer_pub: Optional[Path] = typer.Option( + None, + "--expected-manifest-signer-pub", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + help="Require this Ed25519 public key signed the manifest.", + ), +): + c = cast(Context, ctx.obj) + loaded = load_config(c.config_path) + console = Console() + + vmode = (verify or "none").strip().lower() + if vmode not in ("none", "file", "prefix", "file+prefix"): + raise typer.BadParameter("Invalid --verify. Use: none|file|prefix|file+prefix") + + action = (on_violation or "warn").strip().lower() + if action not in ("warn", "pause", "cancel", "exit"): + raise typer.BadParameter("Invalid --on-violation. Use: warn|pause|cancel|exit") + + verify_cfg: VerifyConfig | None = None + if vmode != "none": + if expected_gcode is None: + raise typer.BadParameter("--expected-gcode is required when --verify != none") + + verify_cfg = VerifyConfig( + mode=vmode, + expected_gcode=expected_gcode, + include_comments=include_comments, + on_violation=action, # type: ignore[arg-type] + source_hash=source_hash, + manifest_path=manifest_path, + expected_manifest_signer_pub=expected_manifest_signer_pub, + max_download_mb=max_download_mb, + recheck_metadata_every_s=recheck_metadata_every_s, + ) + + def on_state_change(old: str | None, new: str | None) -> None: + record_event( + loaded.config.audit.path, + "monitor.printer.state_change", + {"type": "octoprint", "url": url, "old": old, "new": new}, + ) + + def on_control_action(action_name: str) -> None: + record_event( + loaded.config.audit.path, + "monitor.printer.control", + {"type": "octoprint", "url": url, "action": action_name}, + ) + + with OctoPrintClient( + url, + api_key=api_key, + timeout=timeout, + verify_tls=verify_tls, + ) as client: + code = run_octoprint_live_monitor( + client=client, + poll_interval=poll, + console=console, + output_format=c.output_format, + verify_cfg=verify_cfg, + once=once, + enable_control=control, + on_state_change=on_state_change, + on_control_action=on_control_action, + ) + raise typer.Exit(code=code) + # ============================ AUDIT-VERIFY ================================ @audit_app.command("verify", help="Verify the tamper-evident audit log chain.") def audit_verify(ctx: typer.Context) -> None: @@ -888,7 +1041,7 @@ def bundle_create( loaded = load_config(c.config_path) out_p = create_bundle(envelope, out_path=output, policy_id=loaded.config.policy_id) record_event(loaded.config.audit.path, "bundle.create", {"envelope": str(envelope), "bundle": str(out_p)}) - typer.echo(f"Bundle → {out_p}") + typer.echo(f"Bundle to {out_p}") @bundle_app.command("extract", help="Verify and extract a .pshieldpkg to a directory.") def bundle_extract( @@ -901,7 +1054,7 @@ def bundle_extract( loaded = load_config(c.config_path) out_payload = extract_bundle(bundle, out_dir, require_policy_id=require_policy) record_event(loaded.config.audit.path, "bundle.extract", {"bundle": str(bundle), "payload": str(out_payload)}) - typer.echo(f"Extracted payload → {out_payload}") + typer.echo(f"Extracted payload to {out_payload}") # ============================ COMPLIANCE ================================ @compliance_app.command("run", help="Evaluate current setup against a policy and emit a compliance report.") diff --git a/src/printshield/core/monitor/octoprint_client.py b/src/printshield/core/monitor/octoprint_client.py new file mode 100644 index 0000000..5ea759a --- /dev/null +++ b/src/printshield/core/monitor/octoprint_client.py @@ -0,0 +1,352 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Optional, Iterable +from urllib.parse import quote + +import requests + + +# ========================= +# Exceptions +# ========================= + +class OctoPrintError(RuntimeError): + """Base error for OctoPrint client failures.""" + + +class OctoPrintAuthError(OctoPrintError): + """Raised when authentication/authorization fails (401/403).""" + + +class OctoPrintConflictError(OctoPrintError): + """Raised when a request conflicts with current printer/job state (409).""" + + +class OctoPrintHttpError(OctoPrintError): + """Raised for other non-success HTTP responses.""" + + def __init__(self, status_code: int, message: str, *, body: str | None = None) -> None: + super().__init__(f"OctoPrint HTTP {status_code}: {message}") + self.status_code = status_code + self.body = body + + +# ========================= +# Data models (minimal) +# ========================= + +@dataclass(frozen=True) +class OctoPrintJobFile: + name: Optional[str] + origin: Optional[str] + path: Optional[str] + display: Optional[str] + size: Optional[int] + date: Optional[int] + + +@dataclass(frozen=True) +class OctoPrintJobProgress: + completion: Optional[float] + filepos: Optional[int] + print_time: Optional[int] + print_time_left: Optional[int] + + +@dataclass(frozen=True) +class OctoPrintJobStatus: + state: Optional[str] + error: Optional[str] + file: OctoPrintJobFile + progress: OctoPrintJobProgress + raw: dict[str, Any] + + +@dataclass(frozen=True) +class OctoPrintFileRefs: + resource: Optional[str] + download: Optional[str] + + +@dataclass(frozen=True) +class OctoPrintFileInfo: + name: Optional[str] + path: Optional[str] + origin: Optional[str] + size: Optional[int] + date: Optional[int] + hash: Optional[str] + refs: OctoPrintFileRefs + raw: dict[str, Any] + + +# ========================= +# Client +# ========================= + +class OctoPrintClient: + """ + Thin, monitoring-friendly OctoPrint REST API client. + + Auth: + - X-Api-Key header (recommended), or + - Authorization: Bearer + """ + + def __init__( + self, + base_url: str, + *, + api_key: str | None = None, + bearer_token: str | None = None, + timeout: float = 30.0, + verify_tls: bool = True, + session: requests.Session | None = None, + user_agent: str = "PrintShield-OctoPrintClient/0.1", + ) -> None: + self.base_url = base_url.rstrip("/") + self.api_key = api_key + self.bearer_token = bearer_token + self.timeout = timeout + self.verify_tls = verify_tls + self._session = session or requests.Session() + self._owns_session = session is None + self._user_agent = user_agent + + if not (self.api_key or self.bearer_token): + raise ValueError("Provide api_key or bearer_token for OctoPrintClient") + + def close(self) -> None: + if self._owns_session: + self._session.close() + + def __enter__(self) -> "OctoPrintClient": + return self + + def __exit__(self, exc_type, exc, tb) -> None: + self.close() + + # ------------------------- + # Low-level request helpers + # ------------------------- + + def _headers(self) -> dict[str, str]: + h = {"User-Agent": self._user_agent} + if self.api_key: + h["X-Api-Key"] = self.api_key + if self.bearer_token: + h["Authorization"] = f"Bearer {self.bearer_token}" + return h + + def _url(self, path: str) -> str: + if not path.startswith("/"): + path = "/" + path + return f"{self.base_url}{path}" + + def _raise_for_status(self, r: requests.Response) -> None: + if 200 <= r.status_code < 300: + return + + body = None + try: + body = r.text + except Exception: + body = None + + if r.status_code in (401, 403): + raise OctoPrintAuthError( + f"Auth failed ({r.status_code}). Check API key/permissions." + ) + if r.status_code == 409: + raise OctoPrintConflictError( + "Conflict (409): printer not operational or job state precondition failed." + ) + + raise OctoPrintHttpError(r.status_code, r.reason or "error", body=body) + + def _request_json( + self, + method: str, + path: str, + *, + expected_status: Iterable[int] = (200,), + params: dict[str, Any] | None = None, + json_body: dict[str, Any] | None = None, + ) -> dict[str, Any]: + r = self._session.request( + method=method, + url=self._url(path), + headers=self._headers(), + params=params, + json=json_body, + timeout=self.timeout, + verify=self.verify_tls, + ) + if r.status_code not in expected_status: + self._raise_for_status(r) + + if not r.content: + return {} + return r.json() + + def _request_no_content( + self, + method: str, + path: str, + *, + expected_status: Iterable[int] = (204,), + json_body: dict[str, Any] | None = None, + ) -> None: + r = self._session.request( + method=method, + url=self._url(path), + headers=self._headers(), + json=json_body, + timeout=self.timeout, + verify=self.verify_tls, + ) + if r.status_code not in expected_status: + self._raise_for_status(r) + + # ------------------------- + # Job operations + # ------------------------- + + def get_job(self) -> OctoPrintJobStatus: + data = self._request_json("GET", "/api/job", expected_status=(200,)) + job = (data.get("job") or {}) if isinstance(data, dict) else {} + prog = (data.get("progress") or {}) if isinstance(data, dict) else {} + + file_obj = job.get("file") or {} + file_info = OctoPrintJobFile( + name=file_obj.get("name"), + origin=file_obj.get("origin"), + path=file_obj.get("path"), + display=file_obj.get("display"), + size=file_obj.get("size"), + date=file_obj.get("date"), + ) + + progress = OctoPrintJobProgress( + completion=prog.get("completion"), + filepos=prog.get("filepos"), + print_time=prog.get("printTime"), + print_time_left=prog.get("printTimeLeft"), + ) + + return OctoPrintJobStatus( + state=data.get("state"), + error=data.get("error"), + file=file_info, + progress=progress, + raw=data, + ) + + def start(self) -> None: + self._request_no_content("POST", "/api/job", json_body={"command": "start"}) + + def cancel(self) -> None: + self._request_no_content("POST", "/api/job", json_body={"command": "cancel"}) + + def restart(self) -> None: + self._request_no_content("POST", "/api/job", json_body={"command": "restart"}) + + def pause(self) -> None: + self._request_no_content( + "POST", "/api/job", json_body={"command": "pause", "action": "pause"} + ) + + def resume(self) -> None: + self._request_no_content( + "POST", "/api/job", json_body={"command": "pause", "action": "resume"} + ) + + def toggle_pause(self) -> None: + self._request_no_content( + "POST", "/api/job", json_body={"command": "pause", "action": "toggle"} + ) + + # ------------------------- + # File operations + # ------------------------- + + def list_files( + self, + *, + location: str | None = None, + recursive: bool = False, + force: bool = False, + ) -> dict[str, Any]: + # GET /api/files or /api/files/ + path = "/api/files" if location is None else f"/api/files/{quote(location)}" + params: dict[str, Any] = {} + if recursive: + params["recursive"] = "true" + if force: + params["force"] = "true" + return self._request_json("GET", path, expected_status=(200,), params=params) + + def get_file_info( + self, + *, + location: str, + path: str, + recursive: bool = False, + ) -> OctoPrintFileInfo: + # GET /api/files// (path may include slashes) + enc_loc = quote(location) + enc_path = quote(path, safe="/") + params = {"recursive": "true"} if recursive else None + + data = self._request_json( + "GET", + f"/api/files/{enc_loc}/{enc_path}", + expected_status=(200,), + params=params, + ) + + refs = data.get("refs") or {} + return OctoPrintFileInfo( + name=data.get("name"), + path=data.get("path"), + origin=data.get("origin"), + size=data.get("size"), + date=data.get("date"), + hash=data.get("hash"), + refs=OctoPrintFileRefs(resource=refs.get("resource"), download=refs.get("download")), + raw=data, + ) + + def download_file(self, *, download_url: str) -> bytes: + # Uses refs.download; may or may not require auth depending on server config. + r = self._session.get( + download_url, + headers=self._headers(), + timeout=self.timeout, + verify=self.verify_tls, + ) + if r.status_code != 200: + self._raise_for_status(r) + return r.content + + # ------------------------- + # Printer state (optional, but useful for monitoring UI) + # ------------------------- + + def get_printer_state( + self, + *, + history: bool = False, + limit: int | None = None, + exclude: list[str] | None = None, + ) -> dict[str, Any]: + # GET /api/printer + params: dict[str, Any] = {} + if history: + params["history"] = "true" + if limit is not None: + params["limit"] = str(limit) + if exclude: + params["exclude"] = ",".join(exclude) + return self._request_json("GET", "/api/printer", expected_status=(200,), params=params or None) \ No newline at end of file diff --git a/src/printshield/core/monitor/octoprint_live.py b/src/printshield/core/monitor/octoprint_live.py new file mode 100644 index 0000000..943c727 --- /dev/null +++ b/src/printshield/core/monitor/octoprint_live.py @@ -0,0 +1,500 @@ +from __future__ import annotations + +import json +import queue +import threading +import time +from dataclasses import dataclass +from datetime import datetime, timezone +from typing import Any, Optional, Callable + +from rich import box +from rich.console import Console +from rich.live import Live +from rich.panel import Panel +from rich.table import Table +from rich.text import Text + +from .octoprint_client import ( + OctoPrintClient, + OctoPrintError, + OctoPrintAuthError, + OctoPrintConflictError, +) +from .octoprint_verify import OctoPrintGcodeVerifier, VerifyConfig, VerifyResult + + +def _utc_iso(ts: float) -> str: + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() + + +def _fmt_seconds(s: Optional[int]) -> str: + if s is None: + return "-" + if s < 0: + return str(s) + h = s // 3600 + m = (s % 3600) // 60 + sec = s % 60 + if h: + return f"{h:d}h {m:02d}m {sec:02d}s" + if m: + return f"{m:d}m {sec:02d}s" + return f"{sec:d}s" + + +@dataclass(frozen=True) +class OctoStatusSnapshot: + ts: float + state: Optional[str] + error: Optional[str] + file_name: Optional[str] + file_origin: Optional[str] + file_size: Optional[int] + completion: Optional[float] + filepos: Optional[int] + print_time: Optional[int] + print_time_left: Optional[int] + temperatures: dict[str, dict[str, Optional[float]]] + connection_ok: bool + connection_error: Optional[str] + verify_status: str | None + verify_reason: str | None + + def to_event(self) -> dict[str, Any]: + return { + "ts": _utc_iso(self.ts), + "kind": "monitor.printer.status", + "printer": {"type": "octoprint"}, + "job": { + "state": self.state, + "error": self.error, + "file": { + "name": self.file_name, + "origin": self.file_origin, + "size": self.file_size, + }, + "progress": { + "completion": self.completion, + "filepos": self.filepos, + "print_time": self.print_time, + "print_time_left": self.print_time_left, + }, + }, + "temperature": self.temperatures, + "connection": {"ok": self.connection_ok, "error": self.connection_error}, + "verify": {"status": self.verify_status, "reason": self.verify_reason}, + } + + +class _StdinCommandThread(threading.Thread): + def __init__(self, q: "queue.Queue[str]") -> None: + super().__init__(daemon=True) + self._q = q + + def run(self) -> None: + while True: + try: + line = input() + except EOFError: + return + line = (line or "").strip() + if line: + self._q.put(line) + + +def _poll_snapshot( + client: OctoPrintClient, + *, + verifier: OctoPrintGcodeVerifier | None = None, +) -> tuple[OctoStatusSnapshot, VerifyResult | None]: + ts = time.time() + try: + job = client.get_job() + printer = client.get_printer_state(exclude=["sd"]) + temps_raw = printer.get("temperature") or {} + + # Normalize temps to: {"tool0": {"actual":..,"target":..}, "bed": {...}, ...} + temps: dict[str, dict[str, Optional[float]]] = {} + if isinstance(temps_raw, dict): + for k, v in temps_raw.items(): + if isinstance(v, dict): + temps[k] = { + "actual": v.get("actual"), + "target": v.get("target"), + "offset": v.get("offset"), + } + + vr: VerifyResult | None = None + if verifier is not None: + try: + vr = verifier.verify_step(client, job) + except Exception as e: + # Never kill monitoring because verifier threw; surface as warn + vr = VerifyResult(ok=False, status="warn", reason=f"Verifier error: {type(e).__name__}: {e}") + + snap = OctoStatusSnapshot( + ts=ts, + state=job.state, + error=job.error, + file_name=job.file.name, + file_origin=job.file.origin, + file_size=job.file.size, + completion=job.progress.completion, + filepos=job.progress.filepos, + print_time=job.progress.print_time, + print_time_left=job.progress.print_time_left, + temperatures=temps, + connection_ok=True, + connection_error=None, + verify_status=(vr.status if vr is not None else None), + verify_reason=(vr.reason if vr is not None else None), + ) + return snap, vr + + except Exception as e: + # Keep monitoring alive on transient failures + snap = OctoStatusSnapshot( + ts=ts, + state=None, + error=None, + file_name=None, + file_origin=None, + file_size=None, + completion=None, + filepos=None, + print_time=None, + print_time_left=None, + temperatures={}, + connection_ok=False, + connection_error=f"{type(e).__name__}: {e}", + verify_status=None, + verify_reason=None, + ) + return snap, None + + +def _render(snapshot: OctoStatusSnapshot, *, last_msg: str | None) -> Panel: + t = Table.grid(padding=(0, 1)) + t.add_column(justify="right", style="bold") + t.add_column() + + state = snapshot.state or ("[red]DISCONNECTED[/red]" if not snapshot.connection_ok else "-") + t.add_row("State", str(state)) + t.add_row("File", snapshot.file_name or "-") + t.add_row("Origin", snapshot.file_origin or "-") + + pct = "-" if snapshot.completion is None else f"{snapshot.completion:.1f}%" + pos = "-" if snapshot.filepos is None else f"{snapshot.filepos}" + size = "-" if snapshot.file_size is None else f"{snapshot.file_size}" + t.add_row("Progress", f"{pct} (filepos {pos}/{size} bytes)") + t.add_row("Print Time", _fmt_seconds(snapshot.print_time)) + t.add_row("Time Left", _fmt_seconds(snapshot.print_time_left)) + + # Temps + temps_tbl = Table(box=box.SIMPLE, show_header=True, header_style="bold") + temps_tbl.add_column("Heater") + temps_tbl.add_column("Actual", justify="right") + temps_tbl.add_column("Target", justify="right") + temps_tbl.add_column("Offset", justify="right") + if snapshot.temperatures: + for k, v in snapshot.temperatures.items(): + temps_tbl.add_row( + k, + "-" if v.get("actual") is None else f"{v.get('actual'):.1f}°C", + "-" if v.get("target") is None else f"{v.get('target'):.1f}°C", + "-" if v.get("offset") is None else f"{v.get('offset'):.1f}°C", + ) + else: + temps_tbl.add_row("-", "-", "-", "-") + + notes = Text() + + # Verification status + if snapshot.verify_status: + if snapshot.verify_status == "ok": + notes.append("Verification: OK\n", style="green") + elif snapshot.verify_status == "disabled": + notes.append("Verification: DISABLED\n") + elif snapshot.verify_status == "warn": + notes.append("Verification: WARN", style="yellow") + if snapshot.verify_reason: + notes.append(f" - {snapshot.verify_reason}\n", style="yellow") + else: + notes.append("\n", style="yellow") + elif snapshot.verify_status == "fail": + notes.append("Verification: FAIL", style="bold red") + if snapshot.verify_reason: + notes.append(f" - {snapshot.verify_reason}\n", style="bold red") + else: + notes.append("\n", style="bold red") + else: + notes.append(f"Verification: {snapshot.verify_status}\n") + + if snapshot.error: + notes.append(f"OctoPrint error: {snapshot.error}\n") + if not snapshot.connection_ok: + notes.append(f"Connection: {snapshot.connection_error}\n", style="red") + if last_msg: + notes.append(f"Last: {last_msg}\n", style="yellow") + + controls = ( + "Commands (type then Enter): " + "p|pause, r|resume, t|toggle, c|cancel, s|start, rr|restart, help, q|quit" + ) + + body = Table.grid() + body.add_row(t) + body.add_row(temps_tbl) + body.add_row(Panel(notes if notes.plain.strip() else Text("OK"), title="Status / Warnings", box=box.SIMPLE)) + body.add_row(Panel(Text(controls), title="Controls", box=box.SIMPLE)) + + title = f"OctoPrint Live Monitor • {_utc_iso(snapshot.ts)}" + return Panel(body, title=title, box=box.ROUNDED) + + +def run_octoprint_live_monitor( + *, + client: OctoPrintClient, + poll_interval: float, + console: Console, + output_format: str, + verify_cfg: VerifyConfig | None = None, # NEW + once: bool = False, + enable_control: bool = True, + on_state_change: Optional[Callable[[str | None, str | None], None]] = None, + on_control_action: Optional[Callable[[str], None]] = None, +) -> int: + """ + Runs a live monitor loop. + + output_format: + - "text": Rich live dashboard + - "json": one JSON event per poll + + verify_cfg: + - If provided, enables Phase C verification + optional enforcement actions. + """ + q: "queue.Queue[str]" = queue.Queue() + last_msg: str | None = None + last_state: str | None = None + last_verify_status: str | None = None + + verifier = OctoPrintGcodeVerifier(verify_cfg) if verify_cfg is not None else None + + if enable_control: + _StdinCommandThread(q).start() + + def _handle_cmd(cmd: str) -> bool: + nonlocal last_msg + c = cmd.strip().lower() + + mapping = { + "p": "pause", + "pause": "pause", + "r": "resume", + "resume": "resume", + "t": "toggle", + "toggle": "toggle", + "c": "cancel", + "cancel": "cancel", + "s": "start", + "start": "start", + "rr": "restart", + "restart": "restart", + "q": "quit", + "quit": "quit", + "help": "help", + "?": "help", + } + action = mapping.get(c) + if not action: + last_msg = f"Unknown command: {cmd} (try: help)" + return False + + if action == "help": + last_msg = "Commands: p/pause, r/resume, t/toggle, c/cancel, s/start, rr/restart, q/quit" + return False + + if action == "quit": + last_msg = "Quitting monitor." + return True + + try: + if action == "pause": + client.pause() + elif action == "resume": + client.resume() + elif action == "toggle": + client.toggle_pause() + elif action == "cancel": + client.cancel() + elif action == "start": + client.start() + elif action == "restart": + client.restart() + else: + last_msg = f"Unhandled action: {action}" + return False + + last_msg = f"Sent command: {action}" + if on_control_action: + on_control_action(action) + + except OctoPrintConflictError as e: + last_msg = f"Rejected by OctoPrint (state conflict): {e}" + except OctoPrintAuthError as e: + last_msg = f"Auth error: {e}" + except OctoPrintError as e: + last_msg = f"OctoPrint error: {e}" + except Exception as e: + last_msg = f"{type(e).__name__}: {e}" + + return False + + def _apply_verify_enforcement(vr: VerifyResult) -> int | None: + """ + Returns an exit code if enforcement requires exit, else None. + Applies only on transition into 'fail' to avoid repeated pause/cancel spam. + """ + nonlocal last_msg, last_verify_status + + if verify_cfg is None: + return None + + # Only enforce once per transition into FAIL + if vr.status == "fail" and last_verify_status != "fail": + action = getattr(verify_cfg, "on_violation", "warn") + + if action == "warn": + last_msg = f"Verification failed: {vr.reason}" + return None + + if action == "pause": + try: + client.pause() + last_msg = f"Verification failed -> PAUSED. {vr.reason}" + except Exception as e: + last_msg = f"Verification failed; pause attempt error: {type(e).__name__}: {e}" + return None + + if action == "cancel": + try: + client.cancel() + last_msg = f"Verification failed -> CANCELED. {vr.reason}" + except Exception as e: + last_msg = f"Verification failed; cancel attempt error: {type(e).__name__}: {e}" + return None + + if action == "exit": + last_msg = f"Verification failed -> EXIT. {vr.reason}" + return 2 + + last_msg = f"Verification failed; unknown on_violation={action!r}. {vr.reason}" + return None + + return None + + # ONE-SHOT mode + if once: + snap, vr = _poll_snapshot(client, verifier=verifier) + + # apply enforcement once if needed + if vr is not None and verify_cfg is not None: + code = _apply_verify_enforcement(vr) + if code is not None: + if output_format == "json": + console.print(json.dumps(snap.to_event(), ensure_ascii=False)) + else: + console.print(_render(snap, last_msg=last_msg)) + return code + + if output_format == "json": + console.print(json.dumps(snap.to_event(), ensure_ascii=False)) + else: + console.print(_render(snap, last_msg=last_msg)) + + if not snap.connection_ok: + return 1 + if snap.verify_status == "fail": + return 2 + return 0 + + # LOOP mode + if output_format == "json": + try: + while True: + snap, vr = _poll_snapshot(client, verifier=verifier) + + # state change signal + if snap.state is not None and snap.state != last_state: + if on_state_change: + on_state_change(last_state, snap.state) + last_state = snap.state + + # verification enforcement (if enabled) + if vr is not None and verify_cfg is not None: + code = _apply_verify_enforcement(vr) + if code is not None: + console.print(json.dumps(snap.to_event(), ensure_ascii=False)) + return code + + last_verify_status = snap.verify_status + + console.print(json.dumps(snap.to_event(), ensure_ascii=False)) + + # handle queued commands + if enable_control: + while True: + try: + cmd = q.get_nowait() + except queue.Empty: + break + should_quit = _handle_cmd(cmd) + if should_quit: + return 0 + + time.sleep(poll_interval) + except KeyboardInterrupt: + return 0 + + # Rich live dashboard + try: + snap0, vr0 = _poll_snapshot(client, verifier=verifier) + if vr0 is not None and verify_cfg is not None: + _ = _apply_verify_enforcement(vr0) + last_verify_status = snap0.verify_status + + with Live(_render(snap0, last_msg=last_msg), console=console, refresh_per_second=4) as live: + while True: + snap, vr = _poll_snapshot(client, verifier=verifier) + + if snap.state is not None and snap.state != last_state: + if on_state_change: + on_state_change(last_state, snap.state) + last_state = snap.state + + if vr is not None and verify_cfg is not None: + code = _apply_verify_enforcement(vr) + if code is not None: + live.update(_render(snap, last_msg=last_msg)) + return code + + last_verify_status = snap.verify_status + + # handle queued commands + if enable_control: + while True: + try: + cmd = q.get_nowait() + except queue.Empty: + break + should_quit = _handle_cmd(cmd) + if should_quit: + live.update(_render(snap, last_msg=last_msg)) + return 0 + + live.update(_render(snap, last_msg=last_msg)) + time.sleep(poll_interval) + except KeyboardInterrupt: + return 0 \ No newline at end of file diff --git a/src/printshield/core/monitor/octoprint_verify.py b/src/printshield/core/monitor/octoprint_verify.py new file mode 100644 index 0000000..d874ce9 --- /dev/null +++ b/src/printshield/core/monitor/octoprint_verify.py @@ -0,0 +1,285 @@ +from __future__ import annotations + +import hashlib +import tempfile +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Optional, Literal + +from ..hash.gcode import hash_gcode_file +from ..policy.gate import check_gcode_policy +from ..provenance.manifest import default_manifest_path +from .octoprint_client import OctoPrintClient, OctoPrintJobStatus + + +OnViolation = Literal["warn", "pause", "cancel", "exit"] +VerifyMode = Literal["none", "file", "prefix", "file+prefix"] + + +@dataclass(frozen=True) +class VerifyConfig: + mode: VerifyMode = "none" + expected_gcode: Optional[Path] = None + include_comments: bool = False + on_violation: OnViolation = "warn" + + # optional print-gate policy enforcement + source_hash: Optional[str] = None + manifest_path: Optional[Path] = None + expected_manifest_signer_pub: Optional[Path] = None + + # performance / stability + max_download_mb: int = 250 + recheck_metadata_every_s: float = 10.0 + + +@dataclass(frozen=True) +class VerifyResult: + ok: bool + status: str # "ok" | "warn" | "fail" | "disabled" + reason: Optional[str] = None + details: dict | None = None + + +class _RollingSHA256: + """ + Incremental sha256 over a file prefix [0..pos). + Keeps internal state so we only hash new bytes as filepos increases. + """ + def __init__(self) -> None: + self._h = hashlib.sha256() + self._pos = 0 + + @property + def pos(self) -> int: + return self._pos + + def reset(self) -> None: + self._h = hashlib.sha256() + self._pos = 0 + + def update_to(self, path: Path, target_pos: int) -> None: + if target_pos < 0: + target_pos = 0 + if target_pos < self._pos: + # job restarted / filepos reset + self.reset() + + if target_pos == self._pos: + return + + with path.open("rb") as f: + f.seek(self._pos) + remaining = target_pos - self._pos + chunk_size = 1024 * 1024 + while remaining > 0: + buf = f.read(min(chunk_size, remaining)) + if not buf: + break + self._h.update(buf) + remaining -= len(buf) + + self._pos = target_pos + + def hexdigest(self) -> str: + return self._h.hexdigest() + + +class OctoPrintGcodeVerifier: + """ + Maintains verification state for the currently selected/printing job file. + """ + def __init__(self, cfg: VerifyConfig) -> None: + self.cfg = cfg + + self._job_key: Optional[tuple] = None + self._remote_tmp: Optional[Path] = None + + self._local_canon: Optional[str] = None + self._remote_canon: Optional[str] = None + + self._local_prefix = _RollingSHA256() + self._remote_prefix = _RollingSHA256() + + self._last_meta_check: float = 0.0 + self._meta_snapshot: dict | None = None + + self._violation_latched: bool = False + self._latched_reason: Optional[str] = None + + def _current_job_key(self, job: OctoPrintJobStatus) -> Optional[tuple]: + origin = job.file.origin + path = job.file.path + if not origin or not path: + return None + # date/size help detect swaps with same name + return (origin, path, job.file.size, job.file.date) + + def _download_remote(self, client: OctoPrintClient, job: OctoPrintJobStatus) -> Path: + assert job.file.origin and job.file.path + + info = client.get_file_info(location=job.file.origin, path=job.file.path) + if not info.refs.download: + raise RuntimeError("OctoPrint file has no download reference (SD prints not supported).") + + blob = client.download_file(download_url=info.refs.download) + + max_bytes = self.cfg.max_download_mb * 1024 * 1024 + if len(blob) > max_bytes: + raise RuntimeError( + f"Remote file is {len(blob)/1024/1024:.1f}MB > max_download_mb={self.cfg.max_download_mb}MB." + ) + + # write to temp for consistent hashing & prefix reads + tmpdir = Path(tempfile.gettempdir()) / "printshield-octoprint" + tmpdir.mkdir(parents=True, exist_ok=True) + + # unique-ish name: origin_path_date + safe_name = job.file.path.replace("/", "_").replace("\\", "_") + tmp = tmpdir / f"remote_{job.file.origin}_{safe_name}_{job.file.date or 'na'}.gcode" + tmp.write_bytes(blob) + return tmp + + def _ensure_local_canon(self) -> None: + if self._local_canon is not None: + return + if not self.cfg.expected_gcode: + raise RuntimeError("expected_gcode is required for verification.") + self._local_canon = hash_gcode_file(self.cfg.expected_gcode, include_comments=self.cfg.include_comments) + + def _ensure_remote_canon(self) -> None: + if self._remote_canon is not None: + return + if not self._remote_tmp: + raise RuntimeError("remote file not downloaded yet.") + self._remote_canon = hash_gcode_file(self._remote_tmp, include_comments=self.cfg.include_comments) + + def _recheck_metadata(self, client: OctoPrintClient, job: OctoPrintJobStatus) -> Optional[str]: + if not job.file.origin or not job.file.path: + return None + + now = time.time() + if now - self._last_meta_check < self.cfg.recheck_metadata_every_s: + return None + + self._last_meta_check = now + + info = client.get_file_info(location=job.file.origin, path=job.file.path) + meta = { + "size": info.size, + "date": info.date, + "hash": info.hash, + } + if self._meta_snapshot is None: + self._meta_snapshot = meta + return None + + if meta != self._meta_snapshot: + return f"Remote file metadata changed during monitoring: {self._meta_snapshot} -> {meta}" + return None + + def _gate_check(self) -> Optional[str]: + # Only if user supplied a source hash (enables policy gate) + if not self.cfg.source_hash: + return None + if not self.cfg.expected_gcode: + return "Gate check requires --expected-gcode." + + manifest = self.cfg.manifest_path or default_manifest_path(self.cfg.expected_gcode) + + res = check_gcode_policy( + gcode_path=self.cfg.expected_gcode, + manifest_path=manifest, + expected_source_hash=self.cfg.source_hash, + expected_manifest_signer_pub=self.cfg.expected_manifest_signer_pub, + ) + if not res.ok: + return f"Print-gate policy failed: {res.reason}" + return None + + def _latch_violation(self, reason: str) -> VerifyResult: + self._violation_latched = True + self._latched_reason = reason + return VerifyResult(ok=False, status="fail", reason=reason) + + def reset_for_new_job(self) -> None: + self._job_key = None + self._remote_tmp = None + self._remote_canon = None + self._local_canon = None + self._local_prefix.reset() + self._remote_prefix.reset() + self._meta_snapshot = None + self._last_meta_check = 0.0 + self._violation_latched = False + self._latched_reason = None + + def verify_step(self, client: OctoPrintClient, job: OctoPrintJobStatus) -> VerifyResult: + if self.cfg.mode == "none": + return VerifyResult(ok=True, status="disabled") + + if not self.cfg.expected_gcode: + return VerifyResult(ok=False, status="fail", reason="--expected-gcode is required for verification modes.") + + # If already violated, keep reporting it (prevents flapping) + if self._violation_latched: + return VerifyResult(ok=False, status="fail", reason=self._latched_reason) + + key = self._current_job_key(job) + if key is None: + return VerifyResult(ok=False, status="warn", reason="No printable job file detected yet.") + + # New job file? Re-init verification context. + if key != self._job_key: + self.reset_for_new_job() + self._job_key = key + + try: + self._remote_tmp = self._download_remote(client, job) + except Exception as e: + return VerifyResult(ok=False, status="warn", reason=f"Could not download remote job file: {e}") + + # whole-file canonical match if requested + if self.cfg.mode in ("file", "file+prefix"): + try: + self._ensure_local_canon() + self._ensure_remote_canon() + if self._local_canon != self._remote_canon: + return self._latch_violation( + f"Whole-file canonical hash mismatch (local != remote). " + f"local={self._local_canon} remote={self._remote_canon}" + ) + except Exception as e: + return VerifyResult(ok=False, status="warn", reason=f"Whole-file verify error: {e}") + + # optional policy gate + gate_reason = self._gate_check() + if gate_reason: + return self._latch_violation(gate_reason) + + # periodic metadata recheck to detect mid-print file replacement + meta_reason = self._recheck_metadata(client, job) + if meta_reason: + return self._latch_violation(meta_reason) + + # prefix verification during printing/paused + if self.cfg.mode in ("prefix", "file+prefix"): + filepos = job.progress.filepos + if filepos is None: + return VerifyResult(ok=True, status="ok", reason="prefix: no filepos yet") + + # Only meaningful once filepos advances + if filepos > 0 and self._remote_tmp: + try: + self._local_prefix.update_to(self.cfg.expected_gcode, filepos) + self._remote_prefix.update_to(self._remote_tmp, filepos) + + if self._local_prefix.hexdigest() != self._remote_prefix.hexdigest(): + return self._latch_violation( + f"Prefix mismatch at filepos={filepos} bytes (local prefix != remote prefix)." + ) + except Exception as e: + return VerifyResult(ok=False, status="warn", reason=f"Prefix verify error: {e}") + + return VerifyResult(ok=True, status="ok") \ No newline at end of file diff --git a/src/printshield/core/monitor/printer_events.py b/src/printshield/core/monitor/printer_events.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/printshield/core/policy/checks.py b/src/printshield/core/policy/checks.py new file mode 100644 index 0000000..488edb8 --- /dev/null +++ b/src/printshield/core/policy/checks.py @@ -0,0 +1,409 @@ +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, Optional, Tuple +import importlib +import json + +from ..audit.chain import verify_log # existing PrintShield audit verifier + + +Status = str # "unknown" | "pass" | "fail" | "partial" + + +@dataclass +class CheckResult: + status: Status + rationale: str + remediation: Optional[str] = None + evidence: Optional[dict[str, Any]] = None + confidence: float = 0.7 # 0..1 (used only for reporting) + + +def _get_config_value(config: Any, dotpath: str) -> Any: + """ + Resolve dotpath like "audit.path" from a Pydantic model or plain objects. + Returns None if any segment is missing. + """ + cur: Any = config + for seg in dotpath.split("."): + if cur is None: + return None + # Pydantic models expose attrs; dicts use get + if isinstance(cur, dict): + cur = cur.get(seg) + else: + cur = getattr(cur, seg, None) + return cur + + +def _iter_audit_json_events(audit_path: Path) -> Iterable[dict[str, Any]]: + """ + Best-effort parser for PrintShield audit log lines. + + We don't assume a strict format beyond: each line contains a JSON object. + If the line contains prefix/suffix, we extract the outermost {...} substring. + """ + if not audit_path.exists(): + return + for raw in audit_path.read_text(encoding="utf-8", errors="replace").splitlines(): + s = raw.strip() + if not s: + continue + # Extract first {...} block if there is noise + if not s.startswith("{"): + i = s.find("{") + j = s.rfind("}") + if i != -1 and j != -1 and j > i: + s = s[i:j+1] + try: + obj = json.loads(s) + if isinstance(obj, dict): + yield obj + except Exception: + # ignore unparseable lines; chain verification still handled elsewhere + continue + + +def _extract_action(evt: dict[str, Any]) -> Optional[str]: + # record_event(...) usually stores something like "action" or "event" + for k in ("action", "event", "type", "name"): + v = evt.get(k) + if isinstance(v, str) and v: + return v + return None + + +def _extract_fields(evt: dict[str, Any]) -> dict[str, Any]: + # record_event stores a "fields" dict in some implementations; fall back to whole event. + f = evt.get("fields") + return f if isinstance(f, dict) else evt + + +def _extract_ts(evt: dict[str, Any]) -> Optional[datetime]: + # Try common timestamp keys (ISO8601 or epoch seconds). + for k in ("ts", "timestamp", "time", "created_at"): + v = evt.get(k) + if v is None: + continue + if isinstance(v, (int, float)): + try: + return datetime.utcfromtimestamp(float(v)) + except Exception: + pass + if isinstance(v, str): + # Try a couple of safe parses without external deps + try: + # ISO-like + return datetime.fromisoformat(v.replace("Z", "+00:00")).astimezone(tz=None).replace(tzinfo=None) + except Exception: + pass + return None + + +def check_config_present(config: Any, *, path: str) -> CheckResult: + v = _get_config_value(config, path) + if v is None or (isinstance(v, str) and not v.strip()): + return CheckResult( + status="fail", + rationale=f"Config value '{path}' is missing or empty.", + remediation=f"Set '{path}' in your PrintShield config YAML.", + evidence={"path": path, "value": v}, + confidence=0.9, + ) + return CheckResult( + status="pass", + rationale=f"Config value '{path}' is set.", + evidence={"path": path, "value": str(v)}, + confidence=0.9, + ) + + +def check_python_module(_config: Any, *, module: str) -> CheckResult: + try: + importlib.import_module(module) + return CheckResult( + status="pass", + rationale=f"Python module '{module}' is importable.", + evidence={"module": module}, + confidence=0.85, + ) + except Exception as e: + return CheckResult( + status="fail", + rationale=f"Python module '{module}' is not importable: {e}", + remediation=f"Install/ensure '{module}' is available in the active environment.", + evidence={"module": module, "error": str(e)}, + confidence=0.85, + ) + + +def check_audit_chain_valid(config: Any, *, require_exists: bool = False) -> CheckResult: + audit_val = _get_config_value(config, "audit.path") + if not audit_val: + return CheckResult( + status="fail", + rationale="audit.path is not configured, cannot verify audit integrity.", + remediation="Set audit.path in config and re-run operations to generate audit events.", + confidence=0.9, + ) + audit_path = Path(str(audit_val)) + + if not audit_path.exists(): + if require_exists: + return CheckResult( + status="fail", + rationale=f"Audit log does not exist at {audit_path}.", + remediation="Run at least one PrintShield command that records an audit event (e.g., encrypt, transfer, provenance) or create the log location.", + evidence={"audit_path": str(audit_path)}, + confidence=0.85, + ) + return CheckResult( + status="unknown", + rationale=f"Audit log does not yet exist at {audit_path}.", + remediation="Generate audit events and re-run compliance.", + evidence={"audit_path": str(audit_path)}, + confidence=0.7, + ) + + try: + ok, count, bad = verify_log(audit_path) + except Exception as e: + return CheckResult( + status="fail", + rationale=f"Audit verification raised an error: {e}", + remediation="Inspect the audit file for corruption and ensure record_event() is used consistently.", + evidence={"audit_path": str(audit_path), "error": str(e)}, + confidence=0.8, + ) + + if ok: + return CheckResult( + status="pass", + rationale=f"Audit chain verified ({count} event(s)).", + evidence={"audit_path": str(audit_path), "count": count}, + confidence=0.9, + ) + return CheckResult( + status="fail", + rationale=f"Audit chain verification failed at line {bad}.", + remediation="Treat the audit log as compromised; rotate the audit file and investigate the tampering source.", + evidence={"audit_path": str(audit_path), "count": count, "first_bad_line": bad}, + confidence=0.9, + ) + + +def check_audit_event_seen( + config: Any, + *, + actions: list[str] | None = None, + action: str | None = None, + min_count: int = 1, + within_days: int | None = None, + where: dict[str, Any] | None = None, + require_all_actions: bool = False, +) -> CheckResult: + audit_val = _get_config_value(config, "audit.path") + if not audit_val: + return CheckResult( + status="fail", + rationale="audit.path is not configured, cannot use audit evidence.", + remediation="Set audit.path in config and re-run operations to generate audit evidence.", + confidence=0.9, + ) + audit_path = Path(str(audit_val)) + if not audit_path.exists(): + return CheckResult( + status="unknown", + rationale=f"Audit log does not exist at {audit_path}.", + remediation="Generate audit events and re-run compliance.", + evidence={"audit_path": str(audit_path)}, + confidence=0.7, + ) + + acts: list[str] = [] + if actions: + acts.extend([a for a in actions if a]) + if action: + acts.append(action) + acts = list(dict.fromkeys(acts)) # uniq keep order + if not acts: + return CheckResult(status="unknown", rationale="No 'action(s)' provided to audit_event_seen check.", confidence=0.6) + + cutoff: Optional[datetime] = None + if within_days is not None: + cutoff = datetime.utcnow() - timedelta(days=int(within_days)) + + # Matchers + hits_by_action: dict[str, int] = {a: 0 for a in acts} + examples: list[dict[str, Any]] = [] + + for evt in _iter_audit_json_events(audit_path): + a = _extract_action(evt) + if a not in hits_by_action: + continue + + if cutoff is not None: + ts = _extract_ts(evt) + if ts is not None and ts < cutoff: + continue + + fields = _extract_fields(evt) + if where: + ok = True + for k, expected in where.items(): + if fields.get(k) != expected: + ok = False + break + if not ok: + continue + + hits_by_action[a] += 1 + if len(examples) < 3: + ex = {"action": a} + for k in ("insecure_no_verify_tls", "insecure_no_hostkey_check", "hostkey_pinned", "has_token", "signed", "ok"): + if k in fields: + ex[k] = fields.get(k) + examples.append(ex) + + if require_all_actions: + missing = [a for a, c in hits_by_action.items() if c < min_count] + if missing: + return CheckResult( + status="partial", + rationale=f"Missing audit evidence for: {', '.join(missing)} (need >= {min_count} each).", + remediation="Run the required operations (or wire automation) so the audit log captures evidence of use.", + evidence={"audit_path": str(audit_path), "hits": hits_by_action, "examples": examples}, + confidence=0.75, + ) + return CheckResult( + status="pass", + rationale=f"Audit evidence present for all actions (>= {min_count} each).", + evidence={"audit_path": str(audit_path), "hits": hits_by_action, "examples": examples}, + confidence=0.8, + ) + + total = sum(hits_by_action.values()) + if total >= min_count: + return CheckResult( + status="pass", + rationale=f"Found {total} matching audit event(s) for {', '.join(acts)}.", + evidence={"audit_path": str(audit_path), "hits": hits_by_action, "examples": examples}, + confidence=0.8, + ) + + return CheckResult( + status="partial", + rationale=f"No recent audit evidence for {', '.join(acts)} (need >= {min_count}).", + remediation="This looks like the feature exists but is not being used in the audited workflow.", + evidence={"audit_path": str(audit_path), "hits": hits_by_action}, + confidence=0.7, + ) + + +def check_audit_event_none( + config: Any, + *, + action: str, + within_days: int | None = None, + where: dict[str, Any] | None = None, +) -> CheckResult: + # Reuse event_seen but invert + res = check_audit_event_seen( + config, + action=action, + min_count=1, + within_days=within_days, + where=where, + require_all_actions=False, + ) + if res.status in ("unknown",): + return res + if res.status == "fail": + # missing audit path etc. + return res + # If we saw any hits, event_seen returns pass; that's a fail for "none" + if res.status == "pass": + return CheckResult( + status="fail", + rationale=f"Found prohibited audit event(s) matching action={action} where={where}.", + remediation="Stop using insecure options; re-run transfers with verification enabled.", + evidence=res.evidence, + confidence=res.confidence, + ) + # partial = no evidence -> pass + return CheckResult( + status="pass", + rationale=f"No prohibited audit events found for action={action} where={where}.", + evidence=res.evidence, + confidence=res.confidence, + ) + + +def check_capability_only(_config: Any, *, name: str, hint: str) -> CheckResult: + return CheckResult( + status="partial", + rationale=f"Capability '{name}' appears implemented, but this control is not automatically verifiable yet.", + remediation=hint, + evidence={"capability": name}, + confidence=0.4, + ) + + +CHECKS: dict[str, Callable[[Any], CheckResult]] = {} + + +def register_check(name: str, fn: Callable[..., CheckResult]) -> None: + # Small wrapper to adapt keyword-only checks to the engine calling convention. + def _wrapped(config: Any, params: dict[str, Any]) -> CheckResult: + return fn(config, **(params or {})) + CHECKS[name] = _wrapped + + +# Core checks +register_check("config.present", check_config_present) +register_check("python.module", check_python_module) + +# Backwards compatible checks +register_check("audit.configured", lambda cfg: check_config_present(cfg, path="audit.path")) +register_check("audit.chain_valid", check_audit_chain_valid) + +# Audit evidence checks +register_check("audit.event.seen", check_audit_event_seen) +register_check("audit.event.none", check_audit_event_none) + +# Legacy feature flags (avoid false PASS) +register_check( + "crypto.encryption.envelope", + lambda cfg: check_capability_only( + cfg, + name="crypto.encryption.envelope", + hint="Map this control to 'audit.event.seen' for action 'encrypt' (and optionally enforce encryption via workflow).", + ), +) +register_check( + "crypto.signing.detached", + lambda cfg: check_capability_only( + cfg, + name="crypto.signing.detached", + hint="Map this control to 'audit.event.seen' for actions ['sign','verify'] or 'gcode.sign/gcode.verify'.", + ), +) +register_check( + "monitor.snapshot", + lambda cfg: check_capability_only( + cfg, + name="monitor.snapshot", + hint="Map this control to 'audit.event.seen' for action 'monitor.scan' and/or 'monitor.diff.summary'.", + ), +) +register_check( + "monitor.watch", + lambda cfg: check_capability_only( + cfg, + name="monitor.watch", + hint="Map this control to 'audit.event.seen' for action 'monitor.watch'.", + ), +) \ No newline at end of file diff --git a/src/printshield/core/policy/engine.py b/src/printshield/core/policy/engine.py index be3b02b..8f9f770 100644 --- a/src/printshield/core/policy/engine.py +++ b/src/printshield/core/policy/engine.py @@ -1,165 +1,95 @@ from __future__ import annotations -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Dict, List +from typing import Any, Dict, List, Tuple from .rules import Policy, Control from .report import ComplianceReport, ControlResult, Status -from ..audit.chain import verify_log +from .checks import CHECKS, CheckResult -@dataclass -class FeatureStatus: - name: str - status: Status - rationale: str +def _combine_status(statuses: List[Status]) -> Status: + if any(s == "fail" for s in statuses): + return "fail" + if any(s == "partial" for s in statuses): + return "partial" + if all(s == "pass" for s in statuses) and statuses: + return "pass" + if any(s == "pass" for s in statuses) and any(s == "unknown" for s in statuses): + return "partial" + return "unknown" -def _compute_features(config: Any) -> Dict[str, FeatureStatus]: - """ - Compute a set of high-level feature statuses from the current configuration - and environment. - """ - features: Dict[str, FeatureStatus] = {} - - # Audit part - audit_path = Path(config.audit.path) - - # 1) audit.configured - if config.audit.path: - features["audit.configured"] = FeatureStatus( - name="audit.configured", - status="pass", - rationale=f"audit.path is configured as {audit_path}", - ) - else: - features["audit.configured"] = FeatureStatus( - name="audit.configured", - status="fail", - rationale="audit.path is not configured", - ) - - # 2) audit.chain_valid - if audit_path.exists(): - try: - ok, count, bad = verify_log(audit_path) - if ok: - features["audit.chain_valid"] = FeatureStatus( - name="audit.chain_valid", - status="pass", - rationale=f"audit log at {audit_path} verified ({count} event(s))", - ) - else: - features["audit.chain_valid"] = FeatureStatus( - name="audit.chain_valid", - status="fail", - rationale=f"audit log corruption at line {bad}", - ) - except Exception as e: - features["audit.chain_valid"] = FeatureStatus( - name="audit.chain_valid", - status="fail", - rationale=f"error verifying audit log: {e}", - ) - else: - features["audit.chain_valid"] = FeatureStatus( - name="audit.chain_valid", - status="unknown", - rationale=f"audit log file {audit_path} does not yet exist", - ) - - # --- Crypto features --------------------------------------------------- - # These are capability-based for now, could change in the future not sure - # - core.crypto.encrypt / decrypt (X25519 + AES-256-GCM envelopes) - # - core.crypto.sign / verify (Ed25519 detached signatures) - - features["crypto.encryption.envelope"] = FeatureStatus( - name="crypto.encryption.envelope", - status="pass", - rationale="Envelope encryption (X25519→HKDF→AES-256-GCM) is implemented and exposed via `encrypt`/`decrypt`.", - ) - - features["crypto.signing.detached"] = FeatureStatus( - name="crypto.signing.detached", - status="pass", - rationale="Detached Ed25519 signatures are implemented and exposed via `sign`/`verify`.", - ) - - # Monitoring part - features["monitor.snapshot"] = FeatureStatus( - name="monitor.snapshot", - status="pass", - rationale="One-shot filesystem snapshot is available via `monitor scan` with audit logging.", - ) - - features["monitor.watch"] = FeatureStatus( - name="monitor.watch", - status="pass", - rationale="Real-time filesystem monitoring is available via `monitor watch` using watchdog.", - ) - - return features - - -def _evaluate_control(ctrl: Control, features: Dict[str, FeatureStatus]) -> ControlResult: +def _evaluate_control(ctrl: Control, config: Any) -> ControlResult: if not ctrl.mappings: return ControlResult( id=ctrl.id, title=ctrl.title, status="unknown", - rationale="No feature mappings defined for this control; cannot evaluate automatically.", + rationale="No mappings defined; cannot evaluate automatically.", + confidence=0.5, ) statuses: List[Status] = [] rationale_parts: List[str] = [] + remediation_parts: List[str] = [] + evidence: Dict[str, Any] = {"mappings": []} + confidences: List[float] = [] - for mapping in ctrl.mappings: - fs = features.get(mapping.feature) - if fs is None: + for m in ctrl.mappings: + impl = CHECKS.get(m.feature) + if impl is None: statuses.append("unknown") - rationale_parts.append(f"{mapping.feature}: feature not known to this engine.") - else: - statuses.append(fs.status) - rationale_parts.append(f"{mapping.feature}: {fs.rationale}") - - # Combine statuses: - # - Any fail → fail - # - Else any partial → partial - # - Else all pass → pass - # - Else if mix of pass + unknown → partial - # - Else → unknown - if "fail" in statuses: - status: Status = "fail" - elif "partial" in statuses: - status = "partial" - elif statuses and all(s == "pass" for s in statuses): - status = "pass" - elif ("pass" in statuses) and ("unknown" in statuses): - status = "partial" - else: - status = "unknown" - - rationale = " | ".join(rationale_parts) + rationale_parts.append(f"{m.feature}: unknown check id") + evidence["mappings"].append({"feature": m.feature, "status": "unknown", "params": m.params}) + confidences.append(0.4) + continue + + try: + res: CheckResult = impl(config, m.params or {}) + except Exception as e: + statuses.append("fail") + rationale_parts.append(f"{m.feature}: error during evaluation ({e})") + remediation_parts.append("Fix compliance check implementation or input parameters.") + evidence["mappings"].append({"feature": m.feature, "status": "fail", "error": str(e), "params": m.params}) + confidences.append(0.5) + continue + + statuses.append(res.status) # type: ignore + rationale_parts.append(f"{m.feature}: {res.rationale}") + if res.remediation: + remediation_parts.append(f"{m.feature}: {res.remediation}") + evidence["mappings"].append({ + "feature": m.feature, + "status": res.status, + "rationale": res.rationale, + "remediation": res.remediation, + "evidence": res.evidence, + "params": m.params, + "confidence": res.confidence, + }) + confidences.append(res.confidence) + + status = _combine_status(statuses) + # Conservative confidence: min of mapping confidences + confidence = min(confidences) if confidences else 0.6 + + remediation = "\n".join(dict.fromkeys(remediation_parts)) if remediation_parts else None return ControlResult( id=ctrl.id, title=ctrl.title, status=status, - rationale=rationale or None, + rationale=" | ".join(rationale_parts), + remediation=remediation, + evidence=evidence, + confidence=confidence, ) def evaluate_policy(policy: Policy, config: Any) -> ComplianceReport: - """ - Real evaluation: map controls → features → statuses using the current config - and audit state. - """ - features = _compute_features(config) results: List[ControlResult] = [] - for ctrl in policy.controls: - results.append(_evaluate_control(ctrl, features)) + results.append(_evaluate_control(ctrl, config)) return ComplianceReport( policy_id=policy.id, diff --git a/src/printshield/core/policy/report.py b/src/printshield/core/policy/report.py index 4fc6a6d..13133ab 100644 --- a/src/printshield/core/policy/report.py +++ b/src/printshield/core/policy/report.py @@ -1,58 +1,35 @@ from __future__ import annotations -from typing import List, Literal, Dict - -from pydantic import BaseModel - -from .rules import Policy +from dataclasses import dataclass, field +from typing import Any, Dict, List, Literal, Optional Status = Literal["unknown", "pass", "fail", "partial"] -class ControlResult(BaseModel): +@dataclass +class ControlResult: id: str title: str status: Status - rationale: str | None = None + rationale: str = "" + remediation: Optional[str] = None + evidence: Optional[Dict[str, Any]] = None + # Useful in enterprise settings: overall confidence (0..1) + confidence: float = 0.7 -class ComplianceReport(BaseModel): +@dataclass +class ComplianceReport: policy_id: str policy_name: str - policy_version: str | None = None - controls: List[ControlResult] + policy_version: Optional[str] + controls: List[ControlResult] = field(default_factory=list) def stats(self) -> Dict[str, int]: total = len(self.controls) - counts = {"unknown": 0, "pass": 0, "fail": 0, "partial": 0} - for c in self.controls: - counts[c.status] += 1 - counts["total"] = total - return counts - - -def evaluate_policy_stub(policy: Policy) -> ComplianceReport: - """ - Minimal stub for now (yes everything is unknown) - - Later micro-steps will map PrintShield features/config/audit events - into real per-control statuses and rationales. - """ - results: List[ControlResult] = [] - for ctrl in policy.controls: - results.append( - ControlResult( - id=ctrl.id, - title=ctrl.title, - status="unknown", - rationale="Not yet evaluated (rule engine stub).", - ) - ) - - return ComplianceReport( - policy_id=policy.id, - policy_name=policy.name, - policy_version=policy.version, - controls=results, - ) \ No newline at end of file + pass_ = sum(1 for c in self.controls if c.status == "pass") + fail = sum(1 for c in self.controls if c.status == "fail") + partial = sum(1 for c in self.controls if c.status == "partial") + unknown = sum(1 for c in self.controls if c.status == "unknown") + return {"total": total, "pass": pass_, "fail": fail, "partial": partial, "unknown": unknown} \ No newline at end of file diff --git a/src/printshield/core/policy/rules.py b/src/printshield/core/policy/rules.py index 43ce533..fd9540a 100644 --- a/src/printshield/core/policy/rules.py +++ b/src/printshield/core/policy/rules.py @@ -1,21 +1,23 @@ from __future__ import annotations from pathlib import Path -from typing import List, Optional +from typing import Any, Dict, List, Optional from pydantic import BaseModel, Field, ValidationError -import yaml +import yaml class ControlMapping(BaseModel): feature: str + # Optional params passed to the check implementation (enterprise-grade mapping) + params: Dict[str, Any] = Field(default_factory=dict) detail: Optional[str] = None class Control(BaseModel): id: str title: str - description: str + description: Optional[str] = None family: Optional[str] = None mappings: List[ControlMapping] = Field(default_factory=list) @@ -25,38 +27,29 @@ class Policy(BaseModel): name: str version: Optional[str] = None description: Optional[str] = None - controls: List[Control] + controls: List[Control] = Field(default_factory=list) -def load_policy_file(path: str | Path) -> Policy: - """ - Load a policy YAML and validate it against the Policy schema. - """ - p = Path(path) - if not p.is_file(): - raise FileNotFoundError(f"Policy file not found: {p}") +def load_policy_file(path: Path) -> Policy: + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception as e: + raise RuntimeError(f"Could not read policy file: {e}") - raw = p.read_text(encoding="utf-8") - data = yaml.safe_load(raw) or {} try: return Policy.model_validate(data) except ValidationError as e: - raise ValueError(f"Invalid policy file {p}: {e}") from e + raise RuntimeError(f"Policy validation failed: {e}") -def resolve_policy_path( - policy_id: str, - policy_file: str | Path | None = None, - base_dir: Optional[Path] = None, -) -> Path: - """ - Determine which file to load for a given policy ID. - - - If policy_file is provided, use that. - - Otherwise, look in /policies/.yml - """ +def resolve_policy_path(policy_id: str, policy_file: Optional[Path] = None) -> Path: if policy_file is not None: - return Path(policy_file) - - root = base_dir or Path.cwd() - return root / "policies" / f"{policy_id}.yml" \ No newline at end of file + if not policy_file.exists(): + raise FileNotFoundError(str(policy_file)) + return policy_file + + # Keep the existing behavior: ./policies/.yml relative to CWD. + p = Path.cwd() / "policies" / f"{policy_id}.yml" + if not p.exists(): + raise FileNotFoundError(f"Policy '{policy_id}' not found at {p}") + return p \ No newline at end of file diff --git a/src/printshield/gui/app.py b/src/printshield/gui/app.py index 654184b..7b04dbf 100644 --- a/src/printshield/gui/app.py +++ b/src/printshield/gui/app.py @@ -3,6 +3,8 @@ import sys import structlog +from PySide6.QtCore import Qt +from PySide6.QtGui import QPalette, QColor from PySide6.QtWidgets import QApplication, QMessageBox from ..core.config.loader import load_config @@ -13,6 +15,108 @@ logger = structlog.get_logger("printshield.gui") +def _apply_global_theme(app: QApplication) -> None: + """Apply a consistent dark Fusion theme + subtle styling. + """ + + # consistent across Windows versions + app.setStyle("Fusion") + + # Dark Palette + palette = QPalette() + palette.setColor(QPalette.ColorRole.Window, QColor(45, 45, 48)) + palette.setColor(QPalette.ColorRole.WindowText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Base, QColor(30, 30, 30)) + palette.setColor(QPalette.ColorRole.AlternateBase, QColor(45, 45, 48)) + palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(30, 30, 30)) + palette.setColor(QPalette.ColorRole.ToolTipText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Text, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.Button, QColor(60, 60, 63)) + palette.setColor(QPalette.ColorRole.ButtonText, Qt.GlobalColor.white) + palette.setColor(QPalette.ColorRole.BrightText, Qt.GlobalColor.red) + palette.setColor(QPalette.ColorRole.Link, QColor(56, 140, 230)) + palette.setColor(QPalette.ColorRole.Highlight, QColor(56, 140, 230)) + palette.setColor(QPalette.ColorRole.HighlightedText, QColor(30, 30, 30)) + + app.setPalette(palette) + + # QSS + app.setStyleSheet( + """ + QWidget { + font-size: 11pt; + } + + QMainWindow { + background-color: #2b2b2b; + } + + QTabBar::tab { + padding: 6px 16px; + margin-right: 2px; + } + + QTabBar::tab:selected { + background: #3c3c3c; + } + + QTabBar::tab:!selected { + background: #2b2b2b; + } + + QTabWidget::pane { + border-top: 1px solid #444; + padding-top: 4px; + } + + QPushButton { + padding: 6px 18px; + border-radius: 4px; + border: 1px solid #666; + background-color: #3b3b3b; + } + + QPushButton:hover { + border-color: #999; + background-color: #444; + } + + QPushButton:pressed { + background-color: #2f2f2f; + } + + QLineEdit, QPlainTextEdit, QTextEdit { + border: 1px solid #555; + border-radius: 4px; + padding: 4px 6px; + background-color: #262626; + selection-background-color: #3874d9; + } + + QLabel { + color: #f0f0f0; + } + + QGroupBox { + border: 1px solid #444; + border-radius: 6px; + margin-top: 8px; + } + + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 4px; + } + + QToolTip { + background-color: #333; + color: #fff; + border: 1px solid #555; + } + """ + ) + def main() -> None: """App bootstrap. @@ -26,6 +130,7 @@ def main() -> None: # Qt application app = QApplication(sys.argv) + _apply_global_theme(app) # Config bootstrap try: diff --git a/src/printshield/gui/main_window.py b/src/printshield/gui/main_window.py index 982b48f..509babb 100644 --- a/src/printshield/gui/main_window.py +++ b/src/printshield/gui/main_window.py @@ -13,6 +13,7 @@ from .tabs.bundle_tab import BundleTab from .tabs.provenance_tab import ProvenanceTab from .tabs.monitor_tab import MonitorTab +from .tabs.transfer_tab import TransferTab class MainWindow(QMainWindow): """Top-level window that hosts the main navigation and tabs. @@ -46,6 +47,7 @@ def _setup_ui(self) -> None: self.bundle_tab = BundleTab(self._loaded_config, parent=self) self.monitor_tab = MonitorTab(self._loaded_config, parent=self) self.provenance_tab = ProvenanceTab(self._loaded_config, parent=self) + self.transfer_tab = TransferTab(self._loaded_config, self) tabs.addTab(self.hash_tab, "Hash") tabs.addTab(self.sign_tab, "Sign") @@ -55,6 +57,7 @@ def _setup_ui(self) -> None: tabs.addTab(self.bundle_tab, "Bundle") tabs.addTab(self.provenance_tab, "Provenance") tabs.addTab(self.monitor_tab, "Monitor") + tabs.addTab(self.transfer_tab, "Transfer") tabs.addTab(self.audit_tab, "Audit") layout.addWidget(tabs) diff --git a/src/printshield/gui/tabs/hash_tab.py b/src/printshield/gui/tabs/hash_tab.py index 3a49b92..73cc29a 100644 --- a/src/printshield/gui/tabs/hash_tab.py +++ b/src/printshield/gui/tabs/hash_tab.py @@ -4,40 +4,45 @@ from typing import Any, Optional import structlog -from PySide6.QtCore import Slot +from PySide6.QtCore import Slot, Qt +from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QWidget, QVBoxLayout, + QHBoxLayout, QLabel, QPushButton, QFileDialog, QMessageBox, + QLineEdit, ) from ...core.formats.stl.handler import detect_stl_type from ...core.hash.stl import sha256_file from ...core.audit.chain import record_event + logger = structlog.get_logger("printshield.gui.hash_tab") -class HashTab(QWidget): - """UI for the Hash feature. - Provides: - - A button to select an STL file - - A button to compute the hash - - Labels showing selected path, STL type, and hash - """ +class HashTab(QWidget): + """Simple UI for deterministic STL hashing.""" def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None: super().__init__(parent) self._loaded_config = loaded_config - self._selected_path: Optional[Path] = None + self._file_path: Optional[Path] = None layout = QVBoxLayout(self) + # ---- Header ---- title = QLabel("Hash") + title_font = title.font() + title_font.setPointSize(title_font.pointSize() + 2) + title_font.setBold(True) + title.setFont(title_font) + desc = QLabel( "Compute deterministic hashes for STL files.\n" "1. Select an STL file.\n" @@ -45,46 +50,69 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None ) desc.setWordWrap(True) + layout.addWidget(title) + layout.addWidget(desc) + + # ---- Select file button ---- + self._select_button = QPushButton("Select STL file…") + self._select_button.clicked.connect(self._on_select_file_clicked) + layout.addWidget(self._select_button) + + # ---- Selected file label ---- self._file_label = QLabel("No file selected.") self._file_label.setWordWrap(True) + layout.addWidget(self._file_label) - self._select_button = QPushButton("Select STL file…") - self._select_button.clicked.connect(self._on_select_clicked) + # ---- Compute button ---- + self._compute_button = QPushButton("Compute hash") + self._compute_button.clicked.connect(self._on_compute_clicked) + layout.addWidget(self._compute_button) - self._hash_button = QPushButton("Compute hash") - self._hash_button.clicked.connect(self._on_hash_clicked) + # ---- Results ---- + # STL type: keep as a simple label self._stl_type_label = QLabel("STL type: (unknown)") - self._hash_value_label = QLabel("SHA-256: (not computed)") - self._status_label = QLabel("Ready.") - - layout.addWidget(title) - layout.addWidget(desc) - layout.addWidget(self._select_button) - layout.addWidget(self._file_label) - layout.addWidget(self._hash_button) layout.addWidget(self._stl_type_label) - layout.addWidget(self._hash_value_label) + + # SHA-256: label + *copyable* line edit + hash_row = QHBoxLayout() + self._hash_label = QLabel("SHA-256:") + self._hash_edit = QLineEdit() + self._hash_edit.setReadOnly(True) + self._hash_edit.setPlaceholderText("(not computed)") + # monospace font for the digest + mono = QFont() + mono.setStyleHint(QFont.Monospace) + mono.setFamily("Consolas") + self._hash_edit.setFont(mono) + + hash_row.addWidget(self._hash_label) + hash_row.addWidget(self._hash_edit) + layout.addLayout(hash_row) + + # Status line + self._status_label = QLabel("Ready.") layout.addWidget(self._status_label) + layout.addStretch(1) + # ------------------------------------------------------------------# + # Slots + # ------------------------------------------------------------------# + @Slot() - def _on_select_clicked(self) -> None: - """Open a file dialog and update the selected path label. - """ + def _on_select_file_clicked(self) -> None: + """Select an STL file for hashing.""" file_name, _ = QFileDialog.getOpenFileName( self, "Select STL file", "", "STL files (*.stl);;All files (*.*)", ) - if not file_name: return path = Path(file_name) - - # Basic validation at selection time if not path.exists() or not path.is_file(): QMessageBox.warning( self, @@ -93,23 +121,24 @@ def _on_select_clicked(self) -> None: ) return - # Enforce .stl extension for now, matching CLI behavior. if path.suffix.lower() != ".stl": QMessageBox.warning( self, "Unsupported file type", - "Only .stl files are supported for hashing in this version.", + "Hashing currently expects an .stl file.", ) - return - self._selected_path = path - self._file_label.setText(str(self._selected_path)) + self._file_path = path + self._file_label.setText(str(path)) + self._stl_type_label.setText("STL type: (unknown)") + self._hash_edit.clear() + self._hash_edit.setPlaceholderText("(not computed)") self._status_label.setText("File selected. Ready to compute hash.") @Slot() - def _on_hash_clicked(self) -> None: - """Compute the hash for the selected file""" - if self._selected_path is None: + def _on_compute_clicked(self) -> None: + """Run detect_stl_type + sha256_file and emit an audit event.""" + if self._file_path is None: QMessageBox.warning( self, "No file selected", @@ -117,51 +146,52 @@ def _on_hash_clicked(self) -> None: ) return - path = self._selected_path + path = self._file_path - # Re-check on hash (TOCTOU safety) + # TOCTOU-safe re-check if not path.exists() or not path.is_file(): QMessageBox.warning( self, "File not found", "The selected file no longer exists or is not a regular file.", ) + self._file_path = None + self._file_label.setText("No file selected.") + self._hash_edit.clear() + self._hash_edit.setPlaceholderText("(not computed)") self._status_label.setText("File missing; please select again.") return - if path.suffix.lower() != ".stl": - QMessageBox.warning( - self, - "Unsupported file type", - "Only .stl files are supported for hashing in this version.", - ) - self._status_label.setText("Unsupported file type.") - return - try: stl_type = detect_stl_type(path) digest = sha256_file(path) except Exception as exc: - logger.error("hash_failed", file=str(path), error=str(exc)) + logger.error( + "hash_failed", + file=str(path), + error=str(exc), + ) QMessageBox.critical( self, - "Hash error", - f"Failed to hash file:\n\n{exc}", + "Hashing error", + f"Failed to hash STL file:\n\n{exc}", ) - self._status_label.setText("Hash failed.") + self._status_label.setText("Hashing failed.") return + # Update UI self._stl_type_label.setText(f"STL type: {stl_type}") - self._hash_value_label.setText(f"SHA-256: {digest}") - self._status_label.setText("Hash computed successfully.") + self._hash_edit.setText(digest) + self._hash_edit.setCursorPosition(0) + self._status_label.setText("Hash computed successfully (you can select & copy it).") - + # Audit event equivalent to CLI `hash` command try: - audit_path = self._loaded_config.config.audit.path + audit_path = self._loaded_config.config.audit.path # type: ignore[attr-defined] record_event( audit_path, - action="hash", - fields={ + "hash", + { "file": str(path), "stl_type": stl_type, "mode": "strict-file", @@ -169,11 +199,14 @@ def _on_hash_clicked(self) -> None: }, ) except Exception as exc: - logger.error("audit_record_failed", file=str(path), error=str(exc)) - # Hash succeeded; audit failure shouldn't kill the UX. + logger.error( + "hash_audit_failed", + file=str(path), + error=str(exc), + ) QMessageBox.warning( self, "Audit warning", - "Hash succeeded, but failed to record the audit event.\n\n" + "Hash computed successfully, but failed to record the audit event.\n\n" f"Details: {exc}", ) \ No newline at end of file diff --git a/src/printshield/gui/tabs/monitor_tab.py b/src/printshield/gui/tabs/monitor_tab.py index aa08843..b92796f 100644 --- a/src/printshield/gui/tabs/monitor_tab.py +++ b/src/printshield/gui/tabs/monitor_tab.py @@ -1,63 +1,373 @@ from __future__ import annotations import json +import time +from dataclasses import asdict from datetime import datetime, timezone from pathlib import Path from typing import Any, Optional import structlog -from PySide6.QtCore import Slot, Qt +from PySide6.QtCore import QObject, QThread, QTimer, Signal, Slot, Qt from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QWidget, QVBoxLayout, QHBoxLayout, + QGridLayout, QLabel, QPushButton, QFileDialog, QMessageBox, QLineEdit, QSpinBox, + QDoubleSpinBox, QTableWidget, QTableWidgetItem, QAbstractItemView, QHeaderView, QGroupBox, QCheckBox, + QComboBox, + QStackedWidget, + QProgressBar, + QPlainTextEdit, + QToolButton, ) +from PySide6.QtWidgets import QScrollArea, QFrame, QLayout + from ...core.monitor.fs import snapshot_directory from ...core.monitor.diff import diff_against_current from ...core.monitor.watch import start_observer from ...core.audit.chain import record_event +# Printer live monitor (OctoPrint) +from ...core.monitor.octoprint_client import ( + OctoPrintClient, + OctoPrintError, + OctoPrintAuthError, + OctoPrintConflictError, +) +from ...core.monitor.octoprint_verify import OctoPrintGcodeVerifier, VerifyConfig, VerifyResult + logger = structlog.get_logger("printshield.gui.monitor_tab") -class MonitorTab(QWidget): - """Monitor tab: scan, diff, and watch directories for integrity drift. +def _utc_iso(ts: float) -> str: + return datetime.fromtimestamp(ts, tz=timezone.utc).isoformat() - Features: - - Scan directory (one-shot): - * Uses snapshot_directory(root, patterns). - * Shows results in a table. - * Emits monitor.file + monitor.scan audit events. - * Can save a baseline JSON compatible with CLI monitor diff. +def _fmt_seconds(s: Optional[int]) -> str: + if s is None: + return "-" + if s < 0: + return str(s) + h = s // 3600 + m = (s % 3600) // 60 + sec = s % 60 + if h: + return f"{h:d}h {m:02d}m {sec:02d}s" + if m: + return f"{m:d}m {sec:02d}s" + return f"{sec:d}s" - - Diff against baseline: - * Uses diff_against_current(baseline, current_root, patterns_override). - * Shows added/removed/modified in a table. - * Emits monitor.diff.add/remove/modify + monitor.diff.summary events. - - Watch directory: - * Uses start_observer(root, patterns, audit_path, recursive). - * Runs continuously; events go into the audit log. +class _OctoPrintWorker(QObject): + """ + Runs in a QThread. Uses a QTimer inside that thread to poll OctoPrint without freezing the GUI. + """ + + snapshot = Signal(object) # dict payload + connected = Signal(bool, str) # ok, message + message = Signal(str, bool) # msg, is_error + command_result = Signal(str, bool, str) # action, ok, msg + finished = Signal(int) # exit code + + def __init__(self) -> None: + super().__init__() + self._timer: QTimer | None = None + self._client: OctoPrintClient | None = None + self._verifier: OctoPrintGcodeVerifier | None = None + self._verify_cfg: VerifyConfig | None = None + + self._poll_s: float = 1.0 + self._last_verify_status: str | None = None + self._last_state: str | None = None + self._running: bool = False + + def configure( + self, + *, + url: str, + api_key: str, + poll_s: float, + timeout_s: float, + verify_tls: bool, + verify_cfg: VerifyConfig | None, + ) -> None: + self._url = url + self._api_key = api_key + self._poll_s = max(0.2, float(poll_s)) + self._timeout_s = max(1.0, float(timeout_s)) + self._verify_tls = bool(verify_tls) + self._verify_cfg = verify_cfg + + @Slot() + def start(self) -> None: + if self._running: + return + self._running = True + + try: + self._client = OctoPrintClient( + self._url, + api_key=self._api_key, + timeout=self._timeout_s, + verify_tls=self._verify_tls, + ) + # quick auth/health check + _ = self._client.get_job() + except OctoPrintAuthError as e: + self.connected.emit(False, f"Auth failed: {e}") + self._running = False + self.finished.emit(1) + return + except Exception as e: + self.connected.emit(False, f"Connection failed: {type(e).__name__}: {e}") + self._running = False + self.finished.emit(1) + return + + self._verifier = OctoPrintGcodeVerifier(self._verify_cfg) if self._verify_cfg else None + self._last_verify_status = None + self._last_state = None + + self._timer = QTimer(self) + self._timer.setInterval(int(self._poll_s * 1000)) + self._timer.timeout.connect(self._poll_once) + self._timer.start() + + self.connected.emit(True, "Connected.") + # poll immediately so UI fills right away + self._poll_once() + + @Slot() + def stop(self) -> None: + if not self._running: + self.finished.emit(0) + return + self._running = False + + try: + if self._timer is not None: + self._timer.stop() + finally: + self._timer = None + + try: + if self._client is not None: + self._client.close() + finally: + self._client = None + + self._verifier = None + self._last_verify_status = None + self._last_state = None + + self.connected.emit(False, "Disconnected.") + self.finished.emit(0) + + @Slot(object) + def update_verify_cfg(self, verify_cfg_obj: object) -> None: + cfg = verify_cfg_obj if isinstance(verify_cfg_obj, VerifyConfig) else None + self._verify_cfg = cfg + self._verifier = OctoPrintGcodeVerifier(cfg) if cfg else None + self._last_verify_status = None + self.message.emit("Verification settings updated.", False) + + @Slot(str) + def do_command(self, action: str) -> None: + if not self._client: + self.command_result.emit(action, False, "Not connected.") + return + + try: + if action == "pause": + self._client.pause() + elif action == "resume": + self._client.resume() + elif action == "toggle": + self._client.toggle_pause() + elif action == "cancel": + self._client.cancel() + elif action == "start": + self._client.start() + elif action == "restart": + self._client.restart() + else: + self.command_result.emit(action, False, f"Unknown action: {action}") + return + + self.command_result.emit(action, True, "OK") + except OctoPrintConflictError as e: + self.command_result.emit(action, False, f"Rejected (state conflict): {e}") + except OctoPrintAuthError as e: + self.command_result.emit(action, False, f"Auth error: {e}") + except OctoPrintError as e: + self.command_result.emit(action, False, f"OctoPrint error: {e}") + except Exception as e: + self.command_result.emit(action, False, f"{type(e).__name__}: {e}") + + def _apply_verify_enforcement(self, vr: VerifyResult) -> int | None: + """ + Returns exit code if enforcement requires stop/exit, else None. + Applies only on transition into FAIL to avoid spam. + """ + if self._verify_cfg is None: + return None + if vr.status == "fail" and self._last_verify_status != "fail": + action = getattr(self._verify_cfg, "on_violation", "warn") + + if action == "warn": + self.message.emit(f"Verification failed: {vr.reason}", True) + return None + + if action == "pause": + try: + self._client.pause() # type: ignore[union-attr] + self.message.emit(f"Verification failed -> PAUSED. {vr.reason}", True) + except Exception as e: + self.message.emit(f"Verification failed; pause error: {type(e).__name__}: {e}", True) + return None + + if action == "cancel": + try: + self._client.cancel() # type: ignore[union-attr] + self.message.emit(f"Verification failed -> CANCELED. {vr.reason}", True) + except Exception as e: + self.message.emit(f"Verification failed; cancel error: {type(e).__name__}: {e}", True) + return None + + if action == "exit": + self.message.emit(f"Verification failed -> EXIT. {vr.reason}", True) + return 2 + + return None + + @Slot() + def _poll_once(self) -> None: + ts = time.time() + + if not self._client: + self.snapshot.emit( + { + "ts": ts, + "connection_ok": False, + "connection_error": "Not connected.", + } + ) + return + + try: + job = self._client.get_job() + printer = self._client.get_printer_state(exclude=["sd"]) + temps_raw = printer.get("temperature") or {} + + temps: dict[str, dict[str, Optional[float]]] = {} + if isinstance(temps_raw, dict): + for k, v in temps_raw.items(): + if isinstance(v, dict): + temps[k] = { + "actual": v.get("actual"), + "target": v.get("target"), + "offset": v.get("offset"), + } + + vr: VerifyResult | None = None + if self._verifier is not None: + try: + vr = self._verifier.verify_step(self._client, job) + except Exception as e: + vr = VerifyResult(ok=False, status="warn", reason=f"Verifier error: {type(e).__name__}: {e}") + + payload = { + "ts": ts, + "connection_ok": True, + "connection_error": None, + "state": job.state, + "error": job.error, + "file": { + "name": job.file.name, + "origin": job.file.origin, + "path": job.file.path, + "size": job.file.size, + "date": job.file.date, + }, + "progress": { + "completion": job.progress.completion, + "filepos": job.progress.filepos, + "print_time": job.progress.print_time, + "print_time_left": job.progress.print_time_left, + }, + "temperatures": temps, + "verify": { + "status": (vr.status if vr else None), + "reason": (vr.reason if vr else None), + "ok": (vr.ok if vr else None), + }, + } + + # enforcement (optional) + if vr is not None and self._verify_cfg is not None: + code = self._apply_verify_enforcement(vr) + if code is not None: + self._last_verify_status = vr.status + self.snapshot.emit(payload) + # stop after emitting snapshot + self.stop() + self.finished.emit(code) + return + + self._last_verify_status = vr.status if vr else None + self._last_state = job.state + + self.snapshot.emit(payload) + + except Exception as e: + # keep monitoring alive on transient failures + self.snapshot.emit( + { + "ts": ts, + "connection_ok": False, + "connection_error": f"{type(e).__name__}: {e}", + } + ) + + +class MonitorTab(QWidget): + """ + Monitor tab: + + - File system: + * Scan directory (snapshot) + * Diff against baseline + * Watch directory (audit events) + + - Printer (OctoPrint): + * Live status polling (job + temperatures) + * Pause/Resume/Toggle/Cancel/Start/Restart + * Optional G-code verification (file/prefix) + enforcement actions """ _DEFAULT_PATTERNS = "*.stl,*.psenc,*.pshieldpkg,*.gcode" + # thread-safe controls -> worker + _printer_stop = Signal() + _printer_cmd = Signal(str) + _printer_update_verify = Signal(object) + def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None: super().__init__(parent) @@ -65,32 +375,131 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None # Resolve audit log path from configuration try: - self._audit_path = Path(self._loaded_config.config.audit.path) + self._audit_path = Path(self._loaded_config.config.audit.path) except Exception as exc: logger.error("audit_path_lookup_failed", error=str(exc)) self._audit_path = Path("audit.log") - # Last scan data for "Save baseline" + # File-system state self._last_scan_root: Optional[Path] = None self._last_scan_patterns: list[str] = [] self._last_scan_snapshots: list[Any] = [] - - # Watch observer handle self._watch_observer: Optional[Any] = None - # ---------- Outer layout ---------- + # Printer state + self._printer_thread: QThread | None = None + self._printer_worker: _OctoPrintWorker | None = None + self._printer_connected: bool = False + self._printer_last_state: str | None = None + + self._build_ui() + + def closeEvent(self, event) -> None: + self._cleanup() + super().closeEvent(event) + + def _cleanup(self) -> None: + # Stop FS watcher if running + try: + if self._watch_observer is not None: + self._watch_observer.stop() + self._watch_observer.join() + except Exception: + pass + finally: + self._watch_observer = None + + # Stop printer monitor thread if running + self._stop_printer_monitor() + + # --------------------------- + # UI build + # --------------------------- + + def _build_ui(self) -> None: outer = QVBoxLayout(self) - title = QLabel("Monitor (integrity & drift)") + title = QLabel("Monitor") + title_font = title.font() + title_font.setPointSize(max(12, title_font.pointSize() + 2)) + title_font.setBold(True) + title.setFont(title_font) + + desc = QLabel( + "Switch between filesystem integrity monitoring and OctoPrint live printer monitoring." + ) + desc.setWordWrap(True) + + outer.addWidget(title) + outer.addWidget(desc) + + # Mode selector + stacked pages + mode_row = QHBoxLayout() + mode_label = QLabel("Monitor target:") + self._mode_combo = QComboBox() + self._mode_combo.addItems(["File system", "Printer (OctoPrint)"]) + self._mode_combo.currentIndexChanged.connect(self._on_mode_changed) + + mode_row.addWidget(mode_label) + mode_row.addWidget(self._mode_combo) + mode_row.addStretch(1) + outer.addLayout(mode_row) + + self._stack = QStackedWidget(self) + + # Pages + self._fs_page = QWidget(self) + self._printer_page = QWidget(self) + + self._build_filesystem_page(self._fs_page) + self._build_printer_page(self._printer_page) + + self._stack.addWidget(self._fs_page) + self._stack.addWidget(self._printer_page) + + # Put the stacked pages inside a scroll area + self._scroll = QScrollArea(self) + self._scroll.setWidgetResizable(True) + self._scroll.setFrameShape(QFrame.NoFrame) + self._scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) + self._scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) + + self._scroll_host = QWidget(self._scroll) + host_layout = QVBoxLayout(self._scroll_host) + host_layout.setContentsMargins(0, 0, 0, 0) + + # makes the scroll area respect the layout's minimum size + host_layout.setSizeConstraint(QLayout.SetMinAndMaxSize) + + host_layout.addWidget(self._stack) + self._scroll.setWidget(self._scroll_host) + + outer.addWidget(self._scroll, stretch=1) + + + # default + self._stack.setCurrentIndex(0) + + @Slot(int) + def _on_mode_changed(self, idx: int) -> None: + self._stack.setCurrentIndex(idx) + if hasattr(self, "_scroll_host"): + self._scroll_host.adjustSize() + if hasattr(self, "_scroll"): + self._scroll.verticalScrollBar().setValue(0) + + # File system page part + + def _build_filesystem_page(self, page: QWidget) -> None: + outer = QVBoxLayout(page) + + title = QLabel("File system (integrity & drift)") desc = QLabel( - "Observe directories for new/changed artifacts and detect drift against " - "a known baseline.\n\n" "• Scan: one-shot snapshot of a directory.\n" "• Diff: compare current state against a saved baseline.\n" "• Watch: continuously monitor for changes (events go to the Audit log)." ) desc.setWordWrap(True) - outer.addWidget(title) outer.addWidget(desc) @@ -98,7 +507,6 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None scan_group = QGroupBox("Scan directory (one-shot snapshot)") scan_layout = QVBoxLayout(scan_group) - # Scan: root directory scan_root_row = QHBoxLayout() scan_root_label = QLabel("Directory:") self._scan_root_edit = QLineEdit() @@ -109,14 +517,12 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None scan_root_row.addWidget(self._scan_root_edit) scan_root_row.addWidget(self._scan_root_browse) - # Scan: patterns scan_patterns_row = QHBoxLayout() scan_patterns_label = QLabel("Include patterns:") self._scan_patterns_edit = QLineEdit(self._DEFAULT_PATTERNS) scan_patterns_row.addWidget(scan_patterns_label) scan_patterns_row.addWidget(self._scan_patterns_edit) - # Scan: controls scan_controls_row = QHBoxLayout() self._scan_button = QPushButton("Run scan") self._scan_button.clicked.connect(self._on_scan_run) @@ -129,12 +535,9 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None scan_controls_row.addWidget(self._scan_save_baseline_button) scan_controls_row.addStretch(1) - # Scan: results table self._scan_table = QTableWidget() self._scan_table.setColumnCount(4) - self._scan_table.setHorizontalHeaderLabels( - ["File", "Size (bytes)", "Modified", "SHA-256"] - ) + self._scan_table.setHorizontalHeaderLabels(["File", "Size (bytes)", "Modified", "SHA-256"]) self._scan_table.setSelectionBehavior(QAbstractItemView.SelectRows) self._scan_table.setSelectionMode(QAbstractItemView.SingleSelection) self._scan_table.setEditTriggers(QAbstractItemView.NoEditTriggers) @@ -158,7 +561,6 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None diff_group = QGroupBox("Diff against baseline (drift detection)") diff_layout = QVBoxLayout(diff_group) - # Baseline JSON diff_baseline_row = QHBoxLayout() diff_baseline_label = QLabel("Baseline JSON:") self._diff_baseline_edit = QLineEdit() @@ -169,7 +571,6 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None diff_baseline_row.addWidget(self._diff_baseline_edit) diff_baseline_row.addWidget(self._diff_baseline_browse) - # Current root directory (optional) diff_root_row = QHBoxLayout() diff_root_label = QLabel("Current directory (optional):") self._diff_root_edit = QLineEdit() @@ -180,21 +581,18 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None diff_root_row.addWidget(self._diff_root_edit) diff_root_row.addWidget(self._diff_root_browse) - # Patterns override diff_patterns_row = QHBoxLayout() diff_patterns_label = QLabel("Include patterns override (optional):") self._diff_patterns_edit = QLineEdit() diff_patterns_row.addWidget(diff_patterns_label) diff_patterns_row.addWidget(self._diff_patterns_edit) - # Diff controls diff_controls_row = QHBoxLayout() self._diff_button = QPushButton("Run diff") self._diff_button.clicked.connect(self._on_diff_run) diff_controls_row.addWidget(self._diff_button) diff_controls_row.addStretch(1) - # Diff results table self._diff_table = QTableWidget() self._diff_table.setColumnCount(6) self._diff_table.setHorizontalHeaderLabels( @@ -212,7 +610,6 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None diff_header.setSectionResizeMode(4, QHeaderView.ResizeToContents) diff_header.setSectionResizeMode(5, QHeaderView.ResizeToContents) - # monospace-ish for hashes mono_font = QFont() mono_font.setStyleHint(QFont.Monospace) mono_font.setFamily("Consolas") @@ -232,7 +629,6 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None watch_group = QGroupBox("Watch directory for changes (continuous)") watch_layout = QVBoxLayout(watch_group) - # Watch: root watch_root_row = QHBoxLayout() watch_root_label = QLabel("Directory:") self._watch_root_edit = QLineEdit() @@ -243,7 +639,6 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None watch_root_row.addWidget(self._watch_root_edit) watch_root_row.addWidget(self._watch_root_browse) - # Watch: patterns + recursive watch_patterns_row = QHBoxLayout() watch_patterns_label = QLabel("Include patterns:") self._watch_patterns_edit = QLineEdit(self._DEFAULT_PATTERNS) @@ -254,7 +649,6 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None watch_patterns_row.addWidget(self._watch_patterns_edit) watch_patterns_row.addWidget(self._watch_recursive_checkbox) - # Watch: controls watch_controls_row = QHBoxLayout() self._watch_start_button = QPushButton("Start watching") self._watch_start_button.clicked.connect(self._on_watch_start) @@ -278,13 +672,735 @@ def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None watch_layout.addLayout(watch_controls_row) watch_layout.addWidget(self._watch_status_label) - # ---------- Add groups to outer ---------- outer.addWidget(scan_group) outer.addWidget(diff_group) outer.addWidget(watch_group) outer.addStretch(1) - # Scan part + + + def _build_printer_page(self, page: QWidget) -> None: + outer = QVBoxLayout(page) + + title = QLabel("Printer (OctoPrint live)") + desc = QLabel( + "Connect to OctoPrint, view near real-time job status & temperatures, " + "and optionally verify the active G-code during printing." + ) + desc.setWordWrap(True) + + outer.addWidget(title) + outer.addWidget(desc) + + # Top row: connection + live status + top_row = QHBoxLayout() + + # --- Connection group + conn_group = QGroupBox("Connection") + conn_layout = QGridLayout(conn_group) + + self._octo_url = QLineEdit() + self._octo_url.setPlaceholderText("http://octopi.local or https://printer.example") + + self._octo_key = QLineEdit() + self._octo_key.setPlaceholderText("OctoPrint API key") + self._octo_key.setEchoMode(QLineEdit.Password) + + self._octo_key_eye = QToolButton() + self._octo_key_eye.setText("👁") + self._octo_key_eye.setCheckable(True) + self._octo_key_eye.setToolTip("Show/Hide API key") + self._octo_key_eye.toggled.connect(self._on_toggle_api_key_visibility) + + self._octo_verify_tls = QCheckBox("Verify TLS certificates") + self._octo_verify_tls.setChecked(True) + + self._octo_timeout = QDoubleSpinBox() + self._octo_timeout.setRange(1.0, 120.0) + self._octo_timeout.setSingleStep(1.0) + self._octo_timeout.setValue(10.0) + self._octo_timeout.setSuffix(" s") + + self._octo_poll = QDoubleSpinBox() + self._octo_poll.setRange(0.2, 30.0) + self._octo_poll.setSingleStep(0.1) + self._octo_poll.setValue(1.0) + self._octo_poll.setSuffix(" s") + + self._octo_connect_btn = QPushButton("Connect") + self._octo_disconnect_btn = QPushButton("Disconnect") + self._octo_disconnect_btn.setEnabled(False) + + self._octo_connect_btn.clicked.connect(self._on_printer_connect) + self._octo_disconnect_btn.clicked.connect(self._on_printer_disconnect) + + self._octo_conn_status = QLabel("Disconnected.") + self._octo_conn_status.setWordWrap(True) + + row = 0 + conn_layout.addWidget(QLabel("URL:"), row, 0) + conn_layout.addWidget(self._octo_url, row, 1, 1, 2) + row += 1 + + conn_layout.addWidget(QLabel("API key:"), row, 0) + conn_layout.addWidget(self._octo_key, row, 1) + conn_layout.addWidget(self._octo_key_eye, row, 2) + row += 1 + + conn_layout.addWidget(self._octo_verify_tls, row, 0, 1, 3) + row += 1 + + conn_layout.addWidget(QLabel("Timeout:"), row, 0) + conn_layout.addWidget(self._octo_timeout, row, 1, 1, 2) + row += 1 + + conn_layout.addWidget(QLabel("Poll interval:"), row, 0) + conn_layout.addWidget(self._octo_poll, row, 1, 1, 2) + row += 1 + + btn_row = QHBoxLayout() + btn_row.addWidget(self._octo_connect_btn) + btn_row.addWidget(self._octo_disconnect_btn) + btn_row.addStretch(1) + + conn_layout.addLayout(btn_row, row, 0, 1, 3) + row += 1 + + conn_layout.addWidget(self._octo_conn_status, row, 0, 1, 3) + + # --- Live status group + status_group = QGroupBox("Live status") + status_layout = QVBoxLayout(status_group) + + self._state_pill = QLabel("DISCONNECTED") + pill_font = self._state_pill.font() + pill_font.setBold(True) + pill_font.setPointSize(max(11, pill_font.pointSize() + 2)) + self._state_pill.setFont(pill_font) + self._state_pill.setAlignment(Qt.AlignCenter) + self._state_pill.setStyleSheet( + "QLabel { padding: 6px 10px; border-radius: 10px; background: #444; color: white; }" + ) + + grid = QGridLayout() + self._job_file = QLabel("-") + self._job_file.setTextInteractionFlags(Qt.TextSelectableByMouse) + self._job_origin = QLabel("-") + self._job_origin.setTextInteractionFlags(Qt.TextSelectableByMouse) + self._job_progress = QLabel("-") + self._job_time = QLabel("-") + self._job_left = QLabel("-") + + grid.addWidget(QLabel("File:"), 0, 0) + grid.addWidget(self._job_file, 0, 1) + grid.addWidget(QLabel("Origin:"), 1, 0) + grid.addWidget(self._job_origin, 1, 1) + grid.addWidget(QLabel("Progress:"), 2, 0) + grid.addWidget(self._job_progress, 2, 1) + grid.addWidget(QLabel("Print time:"), 3, 0) + grid.addWidget(self._job_time, 3, 1) + grid.addWidget(QLabel("Time left:"), 4, 0) + grid.addWidget(self._job_left, 4, 1) + + self._progress_bar = QProgressBar() + self._progress_bar.setRange(0, 100) + self._progress_bar.setValue(0) + self._progress_bar.setTextVisible(True) + + status_layout.addWidget(self._state_pill) + status_layout.addLayout(grid) + status_layout.addWidget(self._progress_bar) + + top_row.addWidget(conn_group, stretch=1) + top_row.addWidget(status_group, stretch=1) + + outer.addLayout(top_row) + + # Temps + Controls row + mid_row = QHBoxLayout() + + # --- Temperatures + temps_group = QGroupBox("Temperatures") + temps_layout = QVBoxLayout(temps_group) + + self._temps_table = QTableWidget() + self._temps_table.setColumnCount(4) + self._temps_table.setHorizontalHeaderLabels(["Heater", "Actual", "Target", "Offset"]) + self._temps_table.setSelectionBehavior(QAbstractItemView.SelectRows) + self._temps_table.setSelectionMode(QAbstractItemView.SingleSelection) + self._temps_table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self._temps_table.setAlternatingRowColors(True) + th = self._temps_table.horizontalHeader() + th.setSectionResizeMode(0, QHeaderView.Stretch) + th.setSectionResizeMode(1, QHeaderView.ResizeToContents) + th.setSectionResizeMode(2, QHeaderView.ResizeToContents) + th.setSectionResizeMode(3, QHeaderView.ResizeToContents) + + temps_layout.addWidget(self._temps_table) + + # --- Controls + ctrl_group = QGroupBox("Controls") + ctrl_layout = QVBoxLayout(ctrl_group) + + ctrl_btns = QHBoxLayout() + self._btn_pause = QPushButton("Pause") + self._btn_resume = QPushButton("Resume") + self._btn_toggle = QPushButton("Toggle") + self._btn_cancel = QPushButton("Cancel") + self._btn_start = QPushButton("Start") + self._btn_restart = QPushButton("Restart") + + for b in (self._btn_pause, self._btn_resume, self._btn_toggle, self._btn_cancel, self._btn_start, self._btn_restart): + b.setEnabled(False) + + self._btn_pause.clicked.connect(lambda: self._send_printer_cmd("pause")) + self._btn_resume.clicked.connect(lambda: self._send_printer_cmd("resume")) + self._btn_toggle.clicked.connect(lambda: self._send_printer_cmd("toggle")) + self._btn_cancel.clicked.connect(lambda: self._send_printer_cmd("cancel")) + self._btn_start.clicked.connect(lambda: self._send_printer_cmd("start")) + self._btn_restart.clicked.connect(lambda: self._send_printer_cmd("restart")) + + ctrl_btns.addWidget(self._btn_pause) + ctrl_btns.addWidget(self._btn_resume) + ctrl_btns.addWidget(self._btn_toggle) + ctrl_btns.addWidget(self._btn_cancel) + ctrl_btns.addWidget(self._btn_start) + ctrl_btns.addWidget(self._btn_restart) + + self._printer_last_msg = QLabel("No actions yet.") + self._printer_last_msg.setWordWrap(True) + self._printer_last_msg.setTextInteractionFlags(Qt.TextSelectableByMouse) + + ctrl_layout.addLayout(ctrl_btns) + ctrl_layout.addWidget(self._printer_last_msg) + + mid_row.addWidget(temps_group, stretch=1) + mid_row.addWidget(ctrl_group, stretch=1) + outer.addLayout(mid_row) + + # Verification group + ver_group = QGroupBox("Verification (optional)") + ver_layout = QGridLayout(ver_group) + + self._ver_enabled = QCheckBox("Enable verification") + self._ver_enabled.setChecked(False) + self._ver_enabled.toggled.connect(self._on_verify_enabled_toggled) + + self._ver_mode = QComboBox() + self._ver_mode.addItems(["none", "file", "prefix", "file+prefix"]) + self._ver_mode.setEnabled(False) + + self._ver_expected_gcode = QLineEdit() + self._ver_expected_gcode.setEnabled(False) + self._ver_expected_gcode.setPlaceholderText("Approved local G-code file (required for file/prefix modes)") + self._ver_browse_gcode = QPushButton("Browse…") + self._ver_browse_gcode.setEnabled(False) + self._ver_browse_gcode.clicked.connect(self._on_browse_expected_gcode) + + self._ver_include_comments = QCheckBox("Include comments in canonical hash") + self._ver_include_comments.setEnabled(False) + + self._ver_on_violation = QComboBox() + self._ver_on_violation.addItems(["warn", "pause", "cancel", "exit"]) + self._ver_on_violation.setEnabled(False) + + self._ver_max_download = QSpinBox() + self._ver_max_download.setRange(1, 5000) + self._ver_max_download.setValue(250) + self._ver_max_download.setSuffix(" MB") + self._ver_max_download.setEnabled(False) + + self._ver_recheck_meta = QDoubleSpinBox() + self._ver_recheck_meta.setRange(0.5, 300.0) + self._ver_recheck_meta.setValue(10.0) + self._ver_recheck_meta.setSuffix(" s") + self._ver_recheck_meta.setEnabled(False) + + # Gate / provenance options (advanced) + self._gate_source_hash = QLineEdit() + self._gate_source_hash.setPlaceholderText("Expected source model hash (enables PrintShield gate enforcement)") + self._gate_source_hash.setEnabled(False) + + self._gate_manifest = QLineEdit() + self._gate_manifest.setPlaceholderText("Optional manifest path (defaults to .provenance.json)") + self._gate_manifest.setEnabled(False) + self._gate_manifest_browse = QPushButton("Browse…") + self._gate_manifest_browse.setEnabled(False) + self._gate_manifest_browse.clicked.connect(self._on_browse_manifest) + + self._gate_signer_pub = QLineEdit() + self._gate_signer_pub.setPlaceholderText("Optional expected manifest signer public key (Ed25519 PEM)") + self._gate_signer_pub.setEnabled(False) + self._gate_signer_pub_browse = QPushButton("Browse…") + self._gate_signer_pub_browse.setEnabled(False) + self._gate_signer_pub_browse.clicked.connect(self._on_browse_signer_pub) + + self._ver_apply_btn = QPushButton("Apply verification settings") + self._ver_apply_btn.setEnabled(False) + self._ver_apply_btn.clicked.connect(self._on_apply_verify_settings) + + self._ver_status = QLabel("Verification: DISABLED") + self._ver_status.setWordWrap(True) + self._ver_status.setTextInteractionFlags(Qt.TextSelectableByMouse) + + # layout + r = 0 + ver_layout.addWidget(self._ver_enabled, r, 0, 1, 3) + r += 1 + + ver_layout.addWidget(QLabel("Mode:"), r, 0) + ver_layout.addWidget(self._ver_mode, r, 1, 1, 2) + r += 1 + + ver_layout.addWidget(QLabel("Expected G-code:"), r, 0) + ver_layout.addWidget(self._ver_expected_gcode, r, 1) + ver_layout.addWidget(self._ver_browse_gcode, r, 2) + r += 1 + + ver_layout.addWidget(self._ver_include_comments, r, 0, 1, 3) + r += 1 + + ver_layout.addWidget(QLabel("On violation:"), r, 0) + ver_layout.addWidget(self._ver_on_violation, r, 1, 1, 2) + r += 1 + + ver_layout.addWidget(QLabel("Max download:"), r, 0) + ver_layout.addWidget(self._ver_max_download, r, 1, 1, 2) + r += 1 + + ver_layout.addWidget(QLabel("Recheck metadata every:"), r, 0) + ver_layout.addWidget(self._ver_recheck_meta, r, 1, 1, 2) + r += 1 + + ver_layout.addWidget(QLabel("Gate source hash:"), r, 0) + ver_layout.addWidget(self._gate_source_hash, r, 1, 1, 2) + r += 1 + + ver_layout.addWidget(QLabel("Manifest path:"), r, 0) + ver_layout.addWidget(self._gate_manifest, r, 1) + ver_layout.addWidget(self._gate_manifest_browse, r, 2) + r += 1 + + ver_layout.addWidget(QLabel("Expected signer pub:"), r, 0) + ver_layout.addWidget(self._gate_signer_pub, r, 1) + ver_layout.addWidget(self._gate_signer_pub_browse, r, 2) + r += 1 + + ver_layout.addWidget(self._ver_apply_btn, r, 0, 1, 3) + r += 1 + + ver_layout.addWidget(self._ver_status, r, 0, 1, 3) + r += 1 + + outer.addWidget(ver_group) + + # Log output (copyable) + log_group = QGroupBox("Messages") + log_layout = QVBoxLayout(log_group) + self._printer_log = QPlainTextEdit() + self._printer_log.setReadOnly(True) + self._printer_log.setMaximumBlockCount(300) + log_layout.addWidget(self._printer_log) + outer.addWidget(log_group, stretch=1) + + outer.addStretch(1) + + # small initial UI state + self._set_printer_connected_ui(False) + + @Slot(bool) + def _on_toggle_api_key_visibility(self, show: bool) -> None: + self._octo_key.setEchoMode(QLineEdit.Normal if show else QLineEdit.Password) + + # --------------------------- + # Printer: connect/disconnect + thread wiring + # --------------------------- + + def _start_printer_monitor(self, *, verify_cfg: VerifyConfig | None) -> None: + self._stop_printer_monitor() + + worker = _OctoPrintWorker() + thread = QThread(self) + + # configure before moving + worker.configure( + url=self._octo_url.text().strip(), + api_key=self._octo_key.text().strip(), + poll_s=float(self._octo_poll.value()), + timeout_s=float(self._octo_timeout.value()), + verify_tls=bool(self._octo_verify_tls.isChecked()), + verify_cfg=verify_cfg, + ) + + worker.moveToThread(thread) + + # connect signals + thread.started.connect(worker.start) + self._printer_stop.connect(worker.stop) + self._printer_cmd.connect(worker.do_command) + self._printer_update_verify.connect(worker.update_verify_cfg) + + worker.snapshot.connect(self._on_printer_snapshot) + worker.connected.connect(self._on_printer_connected) + worker.message.connect(self._on_printer_message) + worker.command_result.connect(self._on_printer_command_result) + worker.finished.connect(self._on_printer_finished) + + # cleanup objects when thread stops + worker.finished.connect(thread.quit) + thread.finished.connect(worker.deleteLater) + thread.finished.connect(thread.deleteLater) + + self._printer_worker = worker + self._printer_thread = thread + thread.start() + + def _stop_printer_monitor(self) -> None: + if self._printer_thread is None or self._printer_worker is None: + return + try: + self._printer_stop.emit() + except Exception: + pass + self._printer_thread = None + self._printer_worker = None + + @Slot() + def _on_printer_connect(self) -> None: + url = self._octo_url.text().strip() + api_key = self._octo_key.text().strip() + if not url: + QMessageBox.warning(self, "URL required", "Please enter the OctoPrint URL.") + return + if not (url.startswith("http://") or url.startswith("https://")): + QMessageBox.warning(self, "Invalid URL", "URL must start with http:// or https://") + return + if not api_key: + QMessageBox.warning(self, "API key required", "Please enter the OctoPrint API key.") + return + + verify_cfg = self._collect_verify_config() + if verify_cfg is False: + return # validation dialog already shown + + self._append_log("Starting OctoPrint monitor…") + self._start_printer_monitor(verify_cfg=verify_cfg if isinstance(verify_cfg, VerifyConfig) else None) + + @Slot() + def _on_printer_disconnect(self) -> None: + self._append_log("Disconnect requested.") + self._stop_printer_monitor() + self._set_printer_connected_ui(False) + + @Slot(bool, str) + def _on_printer_connected(self, ok: bool, msg: str) -> None: + self._printer_connected = bool(ok) + self._octo_conn_status.setText(msg) + self._append_log(msg, is_error=not ok) + self._set_printer_connected_ui(ok) + + @Slot(object) + def _on_printer_snapshot(self, payload: object) -> None: + if not isinstance(payload, dict): + return + + ts = float(payload.get("ts", time.time())) + ok = bool(payload.get("connection_ok", False)) + conn_err = payload.get("connection_error") + + if not ok: + self._set_state_pill("DISCONNECTED", kind="err") + if conn_err: + self._octo_conn_status.setText(str(conn_err)) + self._append_log(str(conn_err), is_error=True) + return + + state = payload.get("state") or "-" + error = payload.get("error") + file_info = payload.get("file") or {} + prog = payload.get("progress") or {} + temps = payload.get("temperatures") or {} + verify = payload.get("verify") or {} + + # Audit: state change + try: + if isinstance(state, str) and state != self._printer_last_state and state != "-": + record_event( + self._audit_path, + "monitor.printer.state_change", + {"type": "octoprint", "url": self._octo_url.text().strip(), "old": self._printer_last_state, "new": state}, + ) + self._printer_last_state = state + except Exception: + pass + + # State pill coloring + s_lower = str(state).lower() + if "printing" in s_lower: + self._set_state_pill(str(state), kind="ok") + elif "paused" in s_lower: + self._set_state_pill(str(state), kind="warn") + elif "error" in s_lower: + self._set_state_pill(str(state), kind="err") + else: + self._set_state_pill(str(state), kind="neutral") + + # Job labels + fname = file_info.get("name") or "-" + origin = file_info.get("origin") or "-" + self._job_file.setText(str(fname)) + self._job_origin.setText(str(origin)) + + completion = prog.get("completion") + filepos = prog.get("filepos") + size = file_info.get("size") + + if completion is None: + self._job_progress.setText("-") + self._progress_bar.setValue(0) + else: + self._job_progress.setText( + f"{float(completion):.1f}% (filepos {filepos or 0}/{size or '-'} bytes)" + ) + self._progress_bar.setValue(max(0, min(100, int(float(completion))))) + + self._job_time.setText(_fmt_seconds(prog.get("print_time"))) + self._job_left.setText(_fmt_seconds(prog.get("print_time_left"))) + + # Temps table + self._temps_table.setRowCount(0) + if isinstance(temps, dict) and temps: + self._temps_table.setRowCount(len(temps)) + for i, (heater, tv) in enumerate(temps.items()): + tv = tv if isinstance(tv, dict) else {} + a = tv.get("actual") + t = tv.get("target") + o = tv.get("offset") + self._temps_table.setItem(i, 0, QTableWidgetItem(str(heater))) + self._temps_table.setItem(i, 1, QTableWidgetItem("-" if a is None else f"{float(a):.1f} °C")) + self._temps_table.setItem(i, 2, QTableWidgetItem("-" if t is None else f"{float(t):.1f} °C")) + self._temps_table.setItem(i, 3, QTableWidgetItem("-" if o is None else f"{float(o):.1f} °C")) + else: + self._temps_table.setRowCount(1) + self._temps_table.setItem(0, 0, QTableWidgetItem("-")) + self._temps_table.setItem(0, 1, QTableWidgetItem("-")) + self._temps_table.setItem(0, 2, QTableWidgetItem("-")) + self._temps_table.setItem(0, 3, QTableWidgetItem("-")) + self._temps_table.resizeRowsToContents() + + # Verification label + vstatus = verify.get("status") + vreason = verify.get("reason") + if not vstatus: + self._ver_status.setText("Verification: DISABLED") + self._ver_status.setStyleSheet("") + else: + if vstatus == "ok": + self._ver_status.setText("Verification: OK") + self._ver_status.setStyleSheet("QLabel { color: #1b7f3a; font-weight: 600; }") + elif vstatus == "disabled": + self._ver_status.setText("Verification: DISABLED") + self._ver_status.setStyleSheet("") + elif vstatus == "warn": + self._ver_status.setText(f"Verification: WARN — {vreason or ''}".strip()) + self._ver_status.setStyleSheet("QLabel { color: #a36a00; font-weight: 600; }") + elif vstatus == "fail": + self._ver_status.setText(f"Verification: FAIL — {vreason or ''}".strip()) + self._ver_status.setStyleSheet("QLabel { color: #b00020; font-weight: 700; }") + else: + self._ver_status.setText(f"Verification: {vstatus} — {vreason or ''}".strip()) + self._ver_status.setStyleSheet("") + + if error: + self._append_log(f"OctoPrint error: {error}", is_error=True) + + @Slot(str, bool) + def _on_printer_message(self, msg: str, is_error: bool) -> None: + self._append_log(msg, is_error=is_error) + self._printer_last_msg.setText(msg) + + @Slot(str, bool, str) + def _on_printer_command_result(self, action: str, ok: bool, msg: str) -> None: + if ok: + self._append_log(f"Sent command: {action}") + self._printer_last_msg.setText(f"Sent: {action}") + try: + record_event( + self._audit_path, + "monitor.printer.control", + {"type": "octoprint", "url": self._octo_url.text().strip(), "action": action}, + ) + except Exception: + pass + else: + self._append_log(f"{action}: {msg}", is_error=True) + self._printer_last_msg.setText(f"{action}: {msg}") + + @Slot(int) + def _on_printer_finished(self, code: int) -> None: + if code != 0: + self._append_log(f"Printer monitor stopped (code={code}).", is_error=True) + else: + self._append_log("Printer monitor stopped.") + self._set_printer_connected_ui(False) + + def _send_printer_cmd(self, action: str) -> None: + if not self._printer_connected: + QMessageBox.information(self, "Not connected", "Connect to OctoPrint first.") + return + self._printer_cmd.emit(action) + + def _set_state_pill(self, text: str, *, kind: str) -> None: + self._state_pill.setText(text) + if kind == "ok": + self._state_pill.setStyleSheet( + "QLabel { padding: 6px 10px; border-radius: 10px; background: #1b7f3a; color: white; }" + ) + elif kind == "warn": + self._state_pill.setStyleSheet( + "QLabel { padding: 6px 10px; border-radius: 10px; background: #a36a00; color: white; }" + ) + elif kind == "err": + self._state_pill.setStyleSheet( + "QLabel { padding: 6px 10px; border-radius: 10px; background: #b00020; color: white; }" + ) + else: + self._state_pill.setStyleSheet( + "QLabel { padding: 6px 10px; border-radius: 10px; background: #444; color: white; }" + ) + + def _set_printer_connected_ui(self, connected: bool) -> None: + self._octo_connect_btn.setEnabled(not connected) + self._octo_disconnect_btn.setEnabled(connected) + + self._octo_url.setEnabled(not connected) + self._octo_key.setEnabled(not connected) + self._octo_key_eye.setEnabled(not connected) + self._octo_verify_tls.setEnabled(not connected) + self._octo_timeout.setEnabled(not connected) + self._octo_poll.setEnabled(not connected) + + for b in (self._btn_pause, self._btn_resume, self._btn_toggle, self._btn_cancel, self._btn_start, self._btn_restart): + b.setEnabled(connected) + + self._printer_connected = connected + + def _append_log(self, msg: str, *, is_error: bool = False) -> None: + ts = _utc_iso(time.time()) + prefix = "ERROR" if is_error else "INFO" + self._printer_log.appendPlainText(f"{ts} [{prefix}] {msg}") + + # --------------------------- + # Verification UI helpers + # --------------------------- + + @Slot(bool) + def _on_verify_enabled_toggled(self, enabled: bool) -> None: + for w in ( + self._ver_mode, + self._ver_expected_gcode, + self._ver_browse_gcode, + self._ver_include_comments, + self._ver_on_violation, + self._ver_max_download, + self._ver_recheck_meta, + self._gate_source_hash, + self._gate_manifest, + self._gate_manifest_browse, + self._gate_signer_pub, + self._gate_signer_pub_browse, + self._ver_apply_btn, + ): + w.setEnabled(enabled) + + @Slot() + def _on_browse_expected_gcode(self) -> None: + filename, _ = QFileDialog.getOpenFileName( + self, + "Select expected/approved G-code", + "", + "G-code (*.gcode *.gc *.gco);;All files (*.*)", + ) + if filename: + self._ver_expected_gcode.setText(filename) + + @Slot() + def _on_browse_manifest(self) -> None: + filename, _ = QFileDialog.getOpenFileName( + self, + "Select provenance manifest (optional)", + "", + "JSON files (*.json);;All files (*.*)", + ) + if filename: + self._gate_manifest.setText(filename) + + @Slot() + def _on_browse_signer_pub(self) -> None: + filename, _ = QFileDialog.getOpenFileName( + self, + "Select expected manifest signer public key (optional)", + "", + "PEM files (*.pem);;All files (*.*)", + ) + if filename: + self._gate_signer_pub.setText(filename) + + def _collect_verify_config(self) -> VerifyConfig | None | bool: + if not self._ver_enabled.isChecked(): + return None + + mode = self._ver_mode.currentText().strip().lower() + expected_gcode_txt = self._ver_expected_gcode.text().strip() + expected_gcode = Path(expected_gcode_txt) if expected_gcode_txt else None + + if mode != "none": + if expected_gcode is None or not expected_gcode.exists() or not expected_gcode.is_file(): + QMessageBox.warning( + self, + "Expected G-code required", + "Select a valid local expected/approved G-code file for the chosen verification mode.", + ) + return False + + source_hash = self._gate_source_hash.text().strip() or None + + manifest_txt = self._gate_manifest.text().strip() + manifest_path = Path(manifest_txt) if manifest_txt else None + if manifest_path is not None and (not manifest_path.exists() or not manifest_path.is_file()): + QMessageBox.warning(self, "Invalid manifest", "Manifest path is not a valid file.") + return False + + signer_txt = self._gate_signer_pub.text().strip() + signer_pub = Path(signer_txt) if signer_txt else None + if signer_pub is not None and (not signer_pub.exists() or not signer_pub.is_file()): + QMessageBox.warning(self, "Invalid signer pub", "Signer public key path is not a valid file.") + return False + + cfg = VerifyConfig( + mode=mode, + expected_gcode=expected_gcode, + include_comments=bool(self._ver_include_comments.isChecked()), + on_violation=self._ver_on_violation.currentText().strip().lower(), + source_hash=source_hash, + manifest_path=manifest_path, + expected_manifest_signer_pub=signer_pub, + max_download_mb=int(self._ver_max_download.value()), + recheck_metadata_every_s=float(self._ver_recheck_meta.value()), + ) + return cfg + + @Slot() + def _on_apply_verify_settings(self) -> None: + cfg = self._collect_verify_config() + if cfg is False: + return + # if connected, hot-update worker; else just store + if self._printer_connected and self._printer_worker is not None: + self._printer_update_verify.emit(cfg if isinstance(cfg, VerifyConfig) else None) + + # File system handlers (these havent been changed) + def _parse_patterns(self, text: str) -> list[str]: return [p.strip() for p in text.split(",") if p.strip()] @@ -297,11 +1413,7 @@ def _format_mtime(self, mtime_ns: int) -> str: @Slot() def _on_scan_browse_root(self) -> None: - directory = QFileDialog.getExistingDirectory( - self, - "Select directory to scan", - "", - ) + directory = QFileDialog.getExistingDirectory(self, "Select directory to scan", "") if directory: self._scan_root_edit.setText(directory) @@ -309,20 +1421,12 @@ def _on_scan_browse_root(self) -> None: def _on_scan_run(self) -> None: root_text = self._scan_root_edit.text().strip() if not root_text: - QMessageBox.warning( - self, - "Directory required", - "Please select a directory to scan.", - ) + QMessageBox.warning(self, "Directory required", "Please select a directory to scan.") return root = Path(root_text) if not root.exists() or not root.is_dir(): - QMessageBox.warning( - self, - "Invalid directory", - "The selected directory does not exist or is not a directory.", - ) + QMessageBox.warning(self, "Invalid directory", "The selected directory does not exist or is not a directory.") return patterns_text = self._scan_patterns_edit.text().strip() @@ -331,21 +1435,14 @@ def _on_scan_run(self) -> None: try: snapshots = snapshot_directory(root, patterns) except Exception as exc: - logger.error( - "monitor_scan_failed", root=str(root), patterns=patterns, error=str(exc) - ) - QMessageBox.critical( - self, - "Scan error", - f"Failed to scan directory:\n\n{exc}", - ) + logger.error("monitor_scan_failed", root=str(root), patterns=patterns, error=str(exc)) + QMessageBox.critical(self, "Scan error", f"Failed to scan directory:\n\n{exc}") return - # Update table self._scan_table.setRowCount(0) self._scan_table.setRowCount(len(snapshots)) - for row, snap in enumerate(snapshots): + for row, snap in enumerate(snapshots): try: rel = snap.path.relative_to(root) except Exception: @@ -355,8 +1452,6 @@ def _on_scan_run(self) -> None: item_size = QTableWidgetItem(str(snap.size)) item_mtime = QTableWidgetItem(self._format_mtime(snap.mtime_ns)) item_sha = QTableWidgetItem(snap.sha256) - - # Slight alignment item_size.setTextAlignment(Qt.AlignRight | Qt.AlignVCenter) self._scan_table.setItem(row, 0, item_file) @@ -367,16 +1462,14 @@ def _on_scan_run(self) -> None: self._scan_table.resizeRowsToContents() self._scan_summary_label.setText( - f"Scanned {root} — {len(snapshots)} file(s) matched " - f"({', '.join(patterns)})." + f"Scanned {root} — {len(snapshots)} file(s) matched ({', '.join(patterns)})." ) - # Remember for saving baseline self._last_scan_root = root self._last_scan_patterns = patterns self._last_scan_snapshots = list(snapshots) self._scan_save_baseline_button.setEnabled(len(snapshots) > 0) - + try: for snap in snapshots: record_event( @@ -394,33 +1487,20 @@ def _on_scan_run(self) -> None: record_event( self._audit_path, "monitor.scan", - { - "root": str(root), - "count": len(snapshots), - "patterns": patterns, - }, + {"root": str(root), "count": len(snapshots), "patterns": patterns}, ) except Exception as exc: - logger.error( - "monitor_scan_audit_failed", - root=str(root), - error=str(exc), - ) + logger.error("monitor_scan_audit_failed", root=str(root), error=str(exc)) QMessageBox.warning( self, "Audit warning", - "Scan completed, but recording audit events failed.\n\n" - f"Details: {exc}", + "Scan completed, but recording audit events failed.\n\n" f"Details: {exc}", ) @Slot() def _on_scan_save_baseline(self) -> None: if self._last_scan_root is None or not self._last_scan_snapshots: - QMessageBox.information( - self, - "No scan data", - "Run a scan first before saving a baseline.", - ) + QMessageBox.information(self, "No scan data", "Run a scan first before saving a baseline.") return filename, _ = QFileDialog.getSaveFileName( @@ -438,12 +1518,7 @@ def _on_scan_save_baseline(self) -> None: "count": len(self._last_scan_snapshots), "patterns": self._last_scan_patterns, "files": [ - { - "file": str(s.path), - "size": s.size, - "mtime_ns": s.mtime_ns, - "sha256": s.sha256, - } + {"file": str(s.path), "size": s.size, "mtime_ns": s.mtime_ns, "sha256": s.sha256} for s in self._last_scan_snapshots ], } @@ -452,43 +1527,23 @@ def _on_scan_save_baseline(self) -> None: with out_path.open("w", encoding="utf-8") as f: json.dump(payload, f, indent=2) except Exception as exc: - logger.error( - "baseline_write_failed", - path=str(out_path), - error=str(exc), - ) - QMessageBox.critical( - self, - "Write error", - f"Failed to write baseline JSON:\n\n{exc}", - ) + logger.error("baseline_write_failed", path=str(out_path), error=str(exc)) + QMessageBox.critical(self, "Write error", f"Failed to write baseline JSON:\n\n{exc}") return - QMessageBox.information( - self, - "Baseline saved", - f"Baseline JSON saved to:\n{out_path}", - ) + QMessageBox.information(self, "Baseline saved", f"Baseline JSON saved to:\n{out_path}") - #DIFF @Slot() def _on_diff_browse_baseline(self) -> None: filename, _ = QFileDialog.getOpenFileName( - self, - "Select baseline JSON", - "", - "JSON files (*.json);;All files (*.*)", + self, "Select baseline JSON", "", "JSON files (*.json);;All files (*.*)" ) if filename: self._diff_baseline_edit.setText(filename) @Slot() def _on_diff_browse_root(self) -> None: - directory = QFileDialog.getExistingDirectory( - self, - "Select current directory (optional)", - "", - ) + directory = QFileDialog.getExistingDirectory(self, "Select current directory (optional)", "") if directory: self._diff_root_edit.setText(directory) @@ -496,20 +1551,12 @@ def _on_diff_browse_root(self) -> None: def _on_diff_run(self) -> None: baseline_text = self._diff_baseline_edit.text().strip() if not baseline_text: - QMessageBox.warning( - self, - "Baseline required", - "Please select a baseline JSON file.", - ) + QMessageBox.warning(self, "Baseline required", "Please select a baseline JSON file.") return baseline = Path(baseline_text) if not baseline.exists() or not baseline.is_file(): - QMessageBox.warning( - self, - "Baseline not found", - "The baseline JSON does not exist or is not a regular file.", - ) + QMessageBox.warning(self, "Baseline not found", "The baseline JSON does not exist or is not a regular file.") return root_text = self._diff_root_edit.text().strip() @@ -517,11 +1564,7 @@ def _on_diff_run(self) -> None: if root_text: current_root = Path(root_text) if not current_root.exists() or not current_root.is_dir(): - QMessageBox.warning( - self, - "Invalid directory", - "The current directory does not exist or is not a directory.", - ) + QMessageBox.warning(self, "Invalid directory", "The current directory does not exist or is not a directory.") return patterns_override: Optional[list[str]] = None @@ -529,13 +1572,8 @@ def _on_diff_run(self) -> None: if include_text: patterns_override = self._parse_patterns(include_text) - # Run diff try: - result = diff_against_current( - baseline, - current_root=current_root, - patterns_override=patterns_override, - ) + result = diff_against_current(baseline, current_root=current_root, patterns_override=patterns_override) except Exception as exc: logger.error( "monitor_diff_failed", @@ -543,32 +1581,16 @@ def _on_diff_run(self) -> None: current_root=str(current_root) if current_root else None, error=str(exc), ) - QMessageBox.critical( - self, - "Diff error", - f"Failed to compute diff:\n\n{exc}", - ) + QMessageBox.critical(self, "Diff error", f"Failed to compute diff:\n\n{exc}") return - # Populate table - rows = ( - len(result.added) - + len(result.removed) - + len(result.modified) - ) + rows = len(result.added) + len(result.removed) + len(result.modified) self._diff_table.setRowCount(0) self._diff_table.setRowCount(rows) row = 0 - def set_row( - change: str, - file_display: str, - old_size: str, - new_size: str, - old_sha: str, - new_sha: str, - ) -> None: + def set_row(change: str, file_display: str, old_size: str, new_size: str, old_sha: str, new_sha: str) -> None: nonlocal row self._diff_table.setItem(row, 0, QTableWidgetItem(change)) self._diff_table.setItem(row, 1, QTableWidgetItem(file_display)) @@ -578,34 +1600,17 @@ def set_row( self._diff_table.setItem(row, 5, QTableWidgetItem(new_sha)) row += 1 - # Added for snap in result.added: try: rel = snap.path.relative_to(result.root) except Exception: rel = snap.path.name - set_row( - "ADDED", - str(rel), - "", - str(snap.size), - "", - snap.sha256, - ) + set_row("ADDED", str(rel), "", str(snap.size), "", snap.sha256) - # Removed for old in result.removed: rel = old.get("rel", old.get("file", "")) - set_row( - "REMOVED", - str(rel), - str(old.get("size", "")), - "", - str(old.get("sha256", "")), - "", - ) + set_row("REMOVED", str(rel), str(old.get("size", "")), "", str(old.get("sha256", "")), "") - # Modified for old, new in result.modified: try: rel = new.path.relative_to(result.root) @@ -633,10 +1638,9 @@ def set_row( self._diff_summary_label.setText( f"Diff root: {summary['root']} | Baseline: {summary['baseline_root']} | " - f"Added: {summary['added']} Removed: {summary['removed']} " - f"Modified: {summary['modified']}" + f"Added: {summary['added']} Removed: {summary['removed']} Modified: {summary['modified']}" ) - + try: for snap in result.added: try: @@ -693,55 +1697,33 @@ def set_row( record_event(self._audit_path, "monitor.diff.summary", summary) except Exception as exc: - logger.error( - "monitor_diff_audit_failed", - baseline=str(baseline), - error=str(exc), - ) + logger.error("monitor_diff_audit_failed", baseline=str(baseline), error=str(exc)) QMessageBox.warning( self, "Audit warning", - "Diff completed, but recording audit events failed.\n\n" - f"Details: {exc}", + "Diff completed, but recording audit events failed.\n\n" f"Details: {exc}", ) - # Watch part @Slot() def _on_watch_browse_root(self) -> None: - directory = QFileDialog.getExistingDirectory( - self, - "Select directory to watch", - "", - ) + directory = QFileDialog.getExistingDirectory(self, "Select directory to watch", "") if directory: self._watch_root_edit.setText(directory) @Slot() def _on_watch_start(self) -> None: if self._watch_observer is not None: - QMessageBox.information( - self, - "Already watching", - "A watcher is already running. Stop it before starting a new one.", - ) + QMessageBox.information(self, "Already watching", "A watcher is already running. Stop it before starting a new one.") return root_text = self._watch_root_edit.text().strip() if not root_text: - QMessageBox.warning( - self, - "Directory required", - "Please select a directory to watch.", - ) + QMessageBox.warning(self, "Directory required", "Please select a directory to watch.") return root = Path(root_text) if not root.exists() or not root.is_dir(): - QMessageBox.warning( - self, - "Invalid directory", - "The selected directory does not exist or is not a directory.", - ) + QMessageBox.warning(self, "Invalid directory", "The selected directory does not exist or is not a directory.") return patterns_text = self._watch_patterns_edit.text().strip() @@ -749,24 +1731,10 @@ def _on_watch_start(self) -> None: recursive = self._watch_recursive_checkbox.isChecked() try: - observer = start_observer( - root=root, - patterns=patterns, - audit_path=self._audit_path, - recursive=recursive, - ) + observer = start_observer(root=root, patterns=patterns, audit_path=self._audit_path, recursive=recursive) except Exception as exc: - logger.error( - "monitor_watch_start_failed", - root=str(root), - patterns=patterns, - error=str(exc), - ) - QMessageBox.critical( - self, - "Watch error", - f"Failed to start watcher:\n\n{exc}", - ) + logger.error("monitor_watch_start_failed", root=str(root), patterns=patterns, error=str(exc)) + QMessageBox.critical(self, "Watch error", f"Failed to start watcher:\n\n{exc}") return self._watch_observer = observer @@ -778,8 +1746,7 @@ def _on_watch_start(self) -> None: self._watch_recursive_checkbox.setEnabled(False) self._watch_status_label.setText( - f"Watching {root} (patterns: {', '.join(patterns)}) — " - "events will be recorded in the audit log." + f"Watching {root} (patterns: {', '.join(patterns)}) — events will be recorded in the audit log." ) @Slot() @@ -793,11 +1760,7 @@ def _on_watch_stop(self) -> None: self._watch_observer.join() except Exception as exc: logger.error("monitor_watch_stop_failed", error=str(exc)) - QMessageBox.warning( - self, - "Watcher stop warning", - f"Stopping watcher encountered an error:\n\n{exc}", - ) + QMessageBox.warning(self, "Watcher stop warning", f"Stopping watcher encountered an error:\n\n{exc}") self._watch_observer = None diff --git a/src/printshield/gui/tabs/transfer_tab.py b/src/printshield/gui/tabs/transfer_tab.py new file mode 100644 index 0000000..09b2283 --- /dev/null +++ b/src/printshield/gui/tabs/transfer_tab.py @@ -0,0 +1,699 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Any, Optional + +import structlog +from PySide6.QtCore import Slot +from PySide6.QtGui import QFont +from PySide6.QtWidgets import ( + QWidget, + QVBoxLayout, + QHBoxLayout, + QGridLayout, + QLabel, + QPushButton, + QFileDialog, + QMessageBox, + QLineEdit, + QComboBox, + QStackedWidget, + QGroupBox, + QCheckBox, + QSpinBox, + QDoubleSpinBox, + QPlainTextEdit, +) + +from ...core.audit.chain import record_event +from ...core.transfer.sftp import sftp_upload_verify +from ...core.transfer.https import https_upload_verify +from ...core.transfer.octoprint import octoprint_upload + + +logger = structlog.get_logger("printshield.gui.transfer_tab") + + +class TransferTab(QWidget): + """GUI adapter for secure transfer (SFTP / HTTPS / OctoPrint).""" + + METHOD_SFTP = "SFTP" + METHOD_HTTPS = "HTTPS" + METHOD_OCTOPRINT = "OctoPrint" + + def __init__(self, loaded_config: Any, parent: Optional[QWidget] = None) -> None: + super().__init__(parent) + + self._loaded_config = loaded_config + self._input_path: Optional[Path] = None + + self._build_ui() + + # ------------------------------------------------------------------# + # UI construction + # ------------------------------------------------------------------# + + def _build_ui(self) -> None: + outer = QVBoxLayout(self) + + # ---- Header ---- + title = QLabel("Transfer") + title_font = title.font() + title_font.setPointSize(title_font.pointSize() + 2) + title_font.setBold(True) + title.setFont(title_font) + + desc = QLabel( + "Securely upload PrintShield bundles/envelopes via SFTP, HTTPS, or OctoPrint.\n" + "1. Choose the file to transfer.\n" + "2. Select a method and fill in connection details.\n" + "3. Click 'Run transfer'." + ) + desc.setWordWrap(True) + + outer.addWidget(title) + outer.addWidget(desc) + + # ---- Artifact selection ---- + artifact_group = QGroupBox("Artifact") + artifact_layout = QHBoxLayout(artifact_group) + + artifact_label = QLabel("File:") + self._file_edit = QLineEdit() + self._file_edit.setReadOnly(True) + self._file_edit.setPlaceholderText("No file selected.") + browse_button = QPushButton("Browse…") + browse_button.clicked.connect(self._on_browse_clicked) + + artifact_layout.addWidget(artifact_label) + artifact_layout.addWidget(self._file_edit) + artifact_layout.addWidget(browse_button) + + outer.addWidget(artifact_group) + + # ---- Method selection ---- + method_row = QHBoxLayout() + method_label = QLabel("Transfer method:") + self._method_combo = QComboBox() + self._method_combo.addItems( + [self.METHOD_SFTP, self.METHOD_HTTPS, self.METHOD_OCTOPRINT] + ) + self._method_combo.currentIndexChanged.connect(self._on_method_changed) + + method_row.addWidget(method_label) + method_row.addWidget(self._method_combo) + method_row.addStretch(1) + + outer.addLayout(method_row) + + # ---- Method-specific stacked widget ---- + self._stack = QStackedWidget() + + self._sftp_page = self._build_sftp_page() + self._https_page = self._build_https_page() + self._octoprint_page = self._build_octoprint_page() + + self._stack.addWidget(self._sftp_page) # index 0 + self._stack.addWidget(self._https_page) # index 1 + self._stack.addWidget(self._octoprint_page) # index 2 + + outer.addWidget(self._stack) + + # ---- Run button row ---- + button_row = QHBoxLayout() + button_row.addStretch(1) + self._run_button = QPushButton("Run transfer") + self._run_button.clicked.connect(self._on_run_transfer_clicked) + button_row.addWidget(self._run_button) + + outer.addLayout(button_row) + + # ---- Result / status ---- + result_group = QGroupBox("Result") + result_layout = QVBoxLayout(result_group) + + self._status_label = QLabel("Ready.") + result_layout.addWidget(self._status_label) + + self._result_plain = QPlainTextEdit() + self._result_plain.setReadOnly(True) + mono = QFont() + mono.setStyleHint(QFont.Monospace) + mono.setFamily("Consolas") + self._result_plain.setFont(mono) + self._result_plain.setPlaceholderText("Transfer output and details will appear here.") + result_layout.addWidget(self._result_plain) + + outer.addWidget(result_group) + outer.addStretch(1) + + # ---- SFTP page ----------------------------------------------------# + + def _build_sftp_page(self) -> QWidget: + page = QGroupBox("SFTP settings") + grid = QGridLayout(page) + + row = 0 + + self._sftp_host = QLineEdit() + self._sftp_user = QLineEdit() + self._sftp_dest_dir = QLineEdit() + self._sftp_dest_name = QLineEdit() + + self._sftp_port = QSpinBox() + self._sftp_port.setRange(1, 65535) + self._sftp_port.setValue(22) + + self._sftp_key_file = QLineEdit() + self._sftp_known_hosts = QLineEdit() + self._sftp_password = QLineEdit() + self._sftp_password.setEchoMode(QLineEdit.EchoMode.Password) + + self._sftp_hostkey_fp = QLineEdit() + self._sftp_allow_unknown = QCheckBox( + "Allow unknown host key (NOT recommended)" + ) + + # host / port + grid.addWidget(QLabel("Host:"), row, 0) + grid.addWidget(self._sftp_host, row, 1) + grid.addWidget(QLabel("Port:"), row, 2) + grid.addWidget(self._sftp_port, row, 3) + row += 1 + + # user / password + grid.addWidget(QLabel("User:"), row, 0) + grid.addWidget(self._sftp_user, row, 1) + grid.addWidget(QLabel("Password:"), row, 2) + grid.addWidget(self._sftp_password, row, 3) + row += 1 + + # dest dir / name + grid.addWidget(QLabel("Remote directory:"), row, 0) + grid.addWidget(self._sftp_dest_dir, row, 1, 1, 3) + row += 1 + + grid.addWidget(QLabel("Remote filename (optional):"), row, 0) + grid.addWidget(self._sftp_dest_name, row, 1, 1, 3) + row += 1 + + # key file + key_row = QHBoxLayout() + key_row.addWidget(self._sftp_key_file) + key_browse = QPushButton("Key file…") + key_browse.clicked.connect(self._on_sftp_key_browse) + key_row.addWidget(key_browse) + + grid.addWidget(QLabel("Private key (optional):"), row, 0) + grid.addLayout(key_row, row, 1, 1, 3) + row += 1 + + # known_hosts + kh_row = QHBoxLayout() + kh_row.addWidget(self._sftp_known_hosts) + kh_browse = QPushButton("known_hosts…") + kh_browse.clicked.connect(self._on_sftp_known_hosts_browse) + kh_row.addWidget(kh_browse) + + grid.addWidget(QLabel("known_hosts (optional):"), row, 0) + grid.addLayout(kh_row, row, 1, 1, 3) + row += 1 + + # fingerprint + allow unknown + grid.addWidget(QLabel("Pinned host key (SHA256:… optional):"), row, 0) + grid.addWidget(self._sftp_hostkey_fp, row, 1, 1, 3) + row += 1 + + grid.addWidget(self._sftp_allow_unknown, row, 0, 1, 4) + row += 1 + + grid.setColumnStretch(1, 1) + grid.setColumnStretch(3, 1) + + return page + + # ---- HTTPS page ---------------------------------------------------# + + def _build_https_page(self) -> QWidget: + page = QGroupBox("HTTPS settings") + grid = QGridLayout(page) + + row = 0 + + self._https_url = QLineEdit() + self._https_token = QLineEdit() + self._https_ca_cert = QLineEdit() + self._https_insecure = QCheckBox( + "Disable TLS verification (NOT recommended)" + ) + self._https_timeout = QDoubleSpinBox() + self._https_timeout.setRange(1.0, 600.0) + self._https_timeout.setDecimals(1) + self._https_timeout.setSingleStep(1.0) + self._https_timeout.setValue(30.0) + + # URL + grid.addWidget(QLabel("Endpoint URL (HTTPS):"), row, 0) + grid.addWidget(self._https_url, row, 1, 1, 3) + row += 1 + + # token + grid.addWidget(QLabel("Bearer token (optional):"), row, 0) + grid.addWidget(self._https_token, row, 1, 1, 3) + row += 1 + + # CA cert + ca_row = QHBoxLayout() + ca_row.addWidget(self._https_ca_cert) + ca_browse = QPushButton("CA cert…") + ca_browse.clicked.connect(self._on_https_ca_browse) + ca_row.addWidget(ca_browse) + + grid.addWidget(QLabel("Custom CA bundle (optional):"), row, 0) + grid.addLayout(ca_row, row, 1, 1, 3) + row += 1 + + # TLS flags + grid.addWidget(self._https_insecure, row, 0, 1, 4) + row += 1 + + # timeout + grid.addWidget(QLabel("Timeout (seconds):"), row, 0) + grid.addWidget(self._https_timeout, row, 1) + row += 1 + + grid.setColumnStretch(1, 1) + grid.setColumnStretch(3, 1) + + return page + + # ---- OctoPrint page -----------------------------------------------# + + def _build_octoprint_page(self) -> QWidget: + page = QGroupBox("OctoPrint settings") + grid = QGridLayout(page) + + row = 0 + + self._octo_base_url = QLineEdit() + self._octo_api_key = QLineEdit() + self._octo_location = QComboBox() + self._octo_location.addItems(["local", "sdcard"]) + self._octo_subpath = QLineEdit() + self._octo_select = QCheckBox("Select file after upload") + self._octo_print = QCheckBox("Start printing after upload") + self._octo_ca_cert = QLineEdit() + self._octo_insecure = QCheckBox( + "Disable TLS verification (NOT recommended)" + ) + self._octo_timeout = QDoubleSpinBox() + self._octo_timeout.setRange(1.0, 600.0) + self._octo_timeout.setDecimals(1) + self._octo_timeout.setSingleStep(1.0) + self._octo_timeout.setValue(30.0) + + # URL + grid.addWidget(QLabel("Base URL (e.g. https://octopi.local):"), row, 0) + grid.addWidget(self._octo_base_url, row, 1, 1, 3) + row += 1 + + # API key + grid.addWidget(QLabel("API key:"), row, 0) + grid.addWidget(self._octo_api_key, row, 1, 1, 3) + row += 1 + + # location / subpath + grid.addWidget(QLabel("Location:"), row, 0) + grid.addWidget(self._octo_location, row, 1) + grid.addWidget(QLabel("Subfolder (optional):"), row, 2) + grid.addWidget(self._octo_subpath, row, 3) + row += 1 + + # select / print + grid.addWidget(self._octo_select, row, 0, 1, 2) + grid.addWidget(self._octo_print, row, 2, 1, 2) + row += 1 + + # CA cert + ca_row = QHBoxLayout() + ca_row.addWidget(self._octo_ca_cert) + ca_browse = QPushButton("CA cert…") + ca_browse.clicked.connect(self._on_octo_ca_browse) + ca_row.addWidget(ca_browse) + + grid.addWidget(QLabel("Custom CA bundle (optional):"), row, 0) + grid.addLayout(ca_row, row, 1, 1, 3) + row += 1 + + # TLS + timeout + grid.addWidget(self._octo_insecure, row, 0, 1, 4) + row += 1 + + grid.addWidget(QLabel("Timeout (seconds):"), row, 0) + grid.addWidget(self._octo_timeout, row, 1) + row += 1 + + grid.setColumnStretch(1, 1) + grid.setColumnStretch(3, 1) + + return page + + # ------------------------------------------------------------------# + # Slots / interactions + # ------------------------------------------------------------------# + + @Slot() + def _on_browse_clicked(self) -> None: + file_name, _ = QFileDialog.getOpenFileName( + self, + "Select bundle or envelope", + "", + "PrintShield artifacts (*.psenc *.pshieldpkg);;All files (*.*)", + ) + if not file_name: + return + + path = Path(file_name) + if not path.exists() or not path.is_file(): + QMessageBox.warning( + self, + "Invalid selection", + "The selected path is not a regular file or no longer exists.", + ) + return + + self._input_path = path + self._file_edit.setText(str(path)) + self._status_label.setText("File selected. Ready to transfer.") + self._result_plain.clear() + + @Slot(int) + def _on_method_changed(self, index: int) -> None: + self._stack.setCurrentIndex(index) + method = self._method_combo.currentText() + self._status_label.setText(f"Transfer method: {method} (not yet run).") + self._result_plain.clear() + + @Slot() + def _on_run_transfer_clicked(self) -> None: + if self._input_path is None: + QMessageBox.warning( + self, + "No file selected", + "Please select an artifact before running the transfer.", + ) + return + + method = self._method_combo.currentText() + self._run_button.setEnabled(False) + self._status_label.setText(f"Running transfer via {method}…") + self._result_plain.clear() + + try: + if method == self.METHOD_SFTP: + self._run_sftp() + elif method == self.METHOD_HTTPS: + self._run_https() + elif method == self.METHOD_OCTOPRINT: + self._run_octoprint() + else: + QMessageBox.warning( + self, + "Unknown method", + f"Unknown transfer method: {method}", + ) + finally: + self._run_button.setEnabled(True) + + # ---- SFTP behaviour -----------------------------------------------# + + @Slot() + def _on_sftp_key_browse(self) -> None: + fname, _ = QFileDialog.getOpenFileName( + self, + "Select private key (PEM)", + "", + "PEM files (*.pem *.key);;All files (*.*)", + ) + if fname: + self._sftp_key_file.setText(fname) + + @Slot() + def _on_sftp_known_hosts_browse(self) -> None: + fname, _ = QFileDialog.getOpenFileName( + self, + "Select known_hosts file", + "", + "All files (*.*)", + ) + if fname: + self._sftp_known_hosts.setText(fname) + + def _run_sftp(self) -> None: + assert self._input_path is not None + lp = self._input_path + + host = self._sftp_host.text().strip() + user = self._sftp_user.text().strip() + dest_dir = self._sftp_dest_dir.text().strip() + + if not host or not user or not dest_dir: + QMessageBox.warning( + self, + "Missing fields", + "Host, user, and remote directory are required for SFTP.", + ) + self._status_label.setText("SFTP: missing required fields.") + return + + dest_name = self._sftp_dest_name.text().strip() or None + port = int(self._sftp_port.value()) + key_file = self._sftp_key_file.text().strip() or None + password = self._sftp_password.text() or None + known_hosts = self._sftp_known_hosts.text().strip() or None + hostkey_fp = self._sftp_hostkey_fp.text().strip() or None + allow_unknown = self._sftp_allow_unknown.isChecked() + + try: + res = sftp_upload_verify( + lp, + host=host, + username=user, + dest_dir=dest_dir, + dest_name=dest_name, + port=port, + key_filename=key_file, + password=password, + known_hosts=known_hosts, + hostkey_fingerprint=hostkey_fp, + allow_unknown_hostkey=allow_unknown, + ) + except Exception as exc: + logger.error("sftp_failed", file=str(lp), error=str(exc)) + QMessageBox.critical( + self, + "SFTP error", + f"Failed to upload via SFTP:\n\n{exc}", + ) + self._status_label.setText("SFTP transfer failed.") + return + + # Audit event, mirroring CLI + try: + audit_path = self._loaded_config.config.audit.path # type: ignore[attr-defined] + record_event( + audit_path, + "transfer.sftp", + { + "input": str(lp), + "host": host, + "remote_path": res.remote_path, + "size": res.size, + "sha256": res.local_sha256, + "hostkey_pinned": hostkey_fp is not None, + "insecure_no_hostkey_check": bool(allow_unknown), + }, + ) + except Exception as exc: + logger.error("sftp_audit_failed", error=str(exc)) + + self._status_label.setText("SFTP transfer completed successfully.") + self._result_plain.setPlainText( + "SFTP transfer OK\n" + f"Input: {lp}\n" + f"Host: {host}\n" + f"Remote path: {res.remote_path}\n" + f"Size: {res.size} bytes\n" + f"SHA-256: {res.local_sha256}" + ) + + # ---- HTTPS behaviour ----------------------------------------------# + + @Slot() + def _on_https_ca_browse(self) -> None: + fname, _ = QFileDialog.getOpenFileName( + self, + "Select CA bundle (PEM)", + "", + "PEM files (*.pem *.crt *.cer);;All files (*.*)", + ) + if fname: + self._https_ca_cert.setText(fname) + + def _run_https(self) -> None: + assert self._input_path is not None + lp = self._input_path + + url = self._https_url.text().strip() + if not url: + QMessageBox.warning( + self, + "Missing URL", + "Please enter the HTTPS endpoint URL.", + ) + self._status_label.setText("HTTPS: missing URL.") + return + + token = self._https_token.text().strip() or None + ca_cert = self._https_ca_cert.text().strip() or None + insecure = self._https_insecure.isChecked() + timeout = float(self._https_timeout.value()) + + try: + res = https_upload_verify( + lp, + url=url, + token=token, + ca_cert=ca_cert, + insecure_no_verify_tls=insecure, + timeout=timeout, + ) + except Exception as exc: + logger.error("https_failed", file=str(lp), error=str(exc)) + QMessageBox.critical( + self, + "HTTPS error", + f"Failed to upload via HTTPS:\n\n{exc}", + ) + self._status_label.setText("HTTPS transfer failed.") + return + + # Audit event, mirroring CLI + try: + audit_path = self._loaded_config.config.audit.path # type: ignore[attr-defined] + record_event( + audit_path, + "transfer.https", + { + "input": str(lp), + "url": res.url, + "sha256": res.sha256, + "stored_path": res.stored_path, + "insecure_no_verify_tls": bool(insecure), + "has_token": token is not None, + }, + ) + except Exception as exc: + logger.error("https_audit_failed", error=str(exc)) + + self._status_label.setText("HTTPS transfer completed successfully.") + stored = res.stored_path or "(server did not report a stored path)" + self._result_plain.setPlainText( + "HTTPS transfer OK\n" + f"Input: {lp}\n" + f"URL: {res.url}\n" + f"SHA-256: {res.sha256}\n" + f"Stored path: {stored}" + ) + + # ---- OctoPrint behaviour -----------------------------------------# + + @Slot() + def _on_octo_ca_browse(self) -> None: + fname, _ = QFileDialog.getOpenFileName( + self, + "Select CA bundle (PEM)", + "", + "PEM files (*.pem *.crt *.cer);;All files (*.*)", + ) + if fname: + self._octo_ca_cert.setText(fname) + + def _run_octoprint(self) -> None: + assert self._input_path is not None + lp = self._input_path + + base_url = self._octo_base_url.text().strip() + api_key = self._octo_api_key.text().strip() + + if not base_url or not api_key: + QMessageBox.warning( + self, + "Missing fields", + "Base URL and API key are required for OctoPrint transfer.", + ) + self._status_label.setText("OctoPrint: missing required fields.") + return + + location = self._octo_location.currentText() + subpath = self._octo_subpath.text().strip() or None + select = self._octo_select.isChecked() + print_after = self._octo_print.isChecked() + ca_cert = self._octo_ca_cert.text().strip() or None + insecure = self._octo_insecure.isChecked() + timeout = float(self._octo_timeout.value()) + + try: + res = octoprint_upload( + lp, + base_url=base_url, + api_key=api_key, + location=location, + select=select or print_after, + print_after_select=print_after, + subpath=subpath, + ca_cert=ca_cert, + insecure_no_verify_tls=insecure, + timeout=timeout, + ) + except Exception as exc: + logger.error("octoprint_failed", file=str(lp), error=str(exc)) + QMessageBox.critical( + self, + "OctoPrint error", + f"Failed to upload to OctoPrint:\n\n{exc}", + ) + self._status_label.setText("OctoPrint transfer failed.") + return + + # Audit event, mirroring CLI + try: + audit_path = self._loaded_config.config.audit.path # type: ignore[attr-defined] + record_event( + audit_path, + "transfer.octoprint", + { + "input": str(lp), + "url": res.url, + "location": res.location, + "path": res.path, + "origin": res.origin, + "sha256": res.sha256, + "select": select or print_after, + "print": print_after, + "insecure_no_verify_tls": bool(insecure), + }, + ) + except Exception as exc: + logger.error("octoprint_audit_failed", error=str(exc)) + + self._status_label.setText("OctoPrint transfer completed successfully.") + self._result_plain.setPlainText( + "OctoPrint transfer OK\n" + f"Input: {lp}\n" + f"API URL: {res.url}\n" + f"Location: {res.location}\n" + f"Origin: {res.origin}\n" + f"Remote path: {res.path}\n" + f"SHA-256: {res.sha256}" + ) \ No newline at end of file