21 KiB
Architecture Research
Domain: Single-container deployment package generator webapp (Intune + NinjaRMM) Researched: 2026-04-10 Confidence: HIGH (core patterns well-established; .intunewin internals MEDIUM — encryption layer may require IntuneWinAppUtil.exe rather than reimplementation)
Standard Architecture
System Overview
┌──────────────────────────────────────────────────────────────────┐
│ Browser (SPA or SSR) │
│ ┌──────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ Printer │ │ Driver Library │ │ Package Export │ │
│ │ Config Form │ │ Browser │ │ (Download Trigger)│ │
│ └──────┬───────┘ └────────┬────────┘ └──────────┬─────────┘ │
└──────────┼───────────────────┼──────────────────────┼────────────┘
│ HTTP/REST │ HTTP/REST │ HTTP/REST
┌──────────┼───────────────────┼──────────────────────┼────────────┐
│ ▼ ▼ ▼ │
│ API Layer (HTTP server) │
│ ┌──────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ /printers │ │ /drivers │ │ /packages │ │
│ │ CRUD routes │ │ upload routes │ │ generate routes │ │
│ └──────┬───────┘ └────────┬────────┘ └──────────┬─────────┘ │
│ │ │ │ │
├──────────┼───────────────────┼──────────────────────┼────────────┤
│ ▼ ▼ ▼ │
│ Service / Domain Layer │
│ ┌──────────────┐ ┌─────────────────┐ ┌────────────────────┐ │
│ │ Printer │ │ Driver │ │ Package Builder │ │
│ │ Service │ │ Service │ │ Service │ │
│ └──────┬───────┘ └────────┬────────┘ └──────────┬─────────┘ │
│ │ │ │ │
│ │ ┌────────┘ ┌────────┘ │
│ │ ▼ ▼ │
│ │ ┌─────────────────┐ ┌─────────────────────────────┐ │
│ │ │ Driver Store │ │ Package Generators │ │
│ │ │ (volume mount) │ │ ┌─────────┐ ┌───────────┐ │ │
│ │ └─────────────────┘ │ │ PS Tmpl │ │ intunewin │ │ │
│ │ │ │ Engine │ │ Builder │ │ │
│ │ │ └─────────┘ └───────────┘ │ │
│ │ │ ┌─────────┐ │ │
│ │ │ │ ZIP │ │ │
│ │ │ │ Builder │ │ │
│ │ │ └─────────┘ │ │
│ │ └─────────────────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ Data Layer │ │
│ │ ┌────────────────┐ ┌─────────────────┐ │ │
│ │ │ SQLite DB │ │ Filesystem │ │ │
│ │ │ (printers, │ │ (drivers/ vol, │ │ │
│ │ │ clients, │ │ tmp/ for │ │ │
│ │ │ driver refs) │ │ in-progress │ │ │
│ │ │ │ │ packages) │ │ │
│ │ └────────────────┘ └─────────────────┘ │ │
│ └──────────────────────────────────────────────────────────┘ │
└───────────────────────────────────────────────────────────────────┘
Component Responsibilities
| Component | Responsibility | Typical Implementation |
|---|---|---|
| API Layer | HTTP routing, request validation, response formatting | FastAPI (Python) or Hono/Express (Node) |
| Printer Service | CRUD for printer configs, parameter validation | Plain service class over SQLite |
| Driver Service | File upload handling, driver metadata storage, deduplication | Service + volume-mounted filesystem |
| Package Builder Service | Orchestrates generation: invokes PS template engine, intunewin builder, ZIP builder | Coordinator service, calls sub-generators |
| PowerShell Template Engine | Renders printer-specific PS install script from a template | Jinja2 or string template with parameter substitution |
| IntuneWin Builder | Produces a valid .intunewin file from a source folder | Wraps bundled IntuneWinAppUtil.exe via subprocess call |
| ZIP Builder | Produces NinjaRMM-ready ZIP: PS script + driver files | Python zipfile / Node archiver in-process |
| SQLite DB | Stores printer configs, client groups, driver metadata references | SQLite via a lightweight ORM or raw queries |
| Driver Store (volume) | Holds uploaded driver ZIP/INF blobs, persists across container restarts | Docker volume, addressed by content-hash filenames |
| Temp workspace | Staging area for a build: assembled files before zipping | tmpfs or host-path temp dir, cleaned after download |
Recommended Project Structure
imptune/
├── api/ # HTTP route handlers
│ ├── printers.py # /printers CRUD
│ ├── drivers.py # /drivers upload + list
│ ├── packages.py # /packages/generate endpoints
│ └── clients.py # /clients group management
├── services/
│ ├── printer_service.py # Printer domain logic
│ ├── driver_service.py # Driver file management
│ └── package_builder.py # Orchestrates package generation
├── generators/
│ ├── ps_template.py # PowerShell script renderer
│ ├── intunewin_builder.py # .intunewin packaging (calls IntuneWinAppUtil)
│ └── zip_builder.py # NinjaRMM ZIP assembly
├── templates/
│ └── printer_install.ps1.j2 # Jinja2 PS script template
├── db/
│ ├── database.py # SQLite connection, migrations
│ └── models.py # Printer, Client, Driver models
├── storage/
│ └── driver_store.py # Read/write to volume-mounted driver path
├── config.py # Env-var driven configuration
├── main.py # App entrypoint, mounts routes
├── Dockerfile
└── docker-compose.yml
Structure Rationale
- api/: Thin handlers only — validation and delegation to services. No business logic here.
- services/: All domain logic lives here, testable without HTTP context.
- generators/: Each output format is isolated. Adding a new format (SCCM, PDQ) means adding one file here.
- templates/: PS script is a template file, not a string in code. Easier to edit, diff, and review.
- db/: All persistence in one place. SQLite means no daemon dependency.
- storage/: Abstracts the filesystem from the rest of the app; makes future swap to S3 straightforward.
Architectural Patterns
Pattern 1: Request-Generate-Stream (synchronous generation)
What: The browser POSTs printer config → server assembles files → server streams the archive back as a file download, all in one HTTP call. When to use: Package generation completes in under ~10 seconds. For this domain (small driver bundles, scripted packaging), synchronous is correct. Trade-offs: Simple — no job queue, no polling, no WebSocket. The downside is the HTTP connection stays open during generation; acceptable for internal tooling on a LAN.
Example flow:
POST /api/packages/generate
body: { printerId, format: "intunewin" | "ninja" }
→ Package Builder assembles temp workspace
→ PS Template Engine renders install.ps1
→ IntuneWin Builder or ZIP Builder runs
→ Response: file download (Content-Disposition: attachment)
→ Temp workspace deleted after send
Pattern 2: Content-Addressed Driver Storage
What: When a driver archive is uploaded, compute a SHA256 of the content and store it under that hash as the filename. Record the hash + original name in SQLite. Multiple printers referencing the same driver point to one file. When to use: Always — avoids duplicate large files on the volume and makes driver references stable across renames. Trade-offs: Slight upload cost for hashing. Cleanup requires reference counting (a row delete check before GC).
Pattern 3: Template-Driven Script Generation
What: PowerShell install script is a Jinja2 (Python) or Handlebars/nunjucks (Node) template. Printer parameters are injected as variables. The template handles the elevation logic, port setup, and driver install commands in one canonical place. When to use: Always — never build the PS script by string concatenation in code. The template is testable, auditable, and editable without touching Python/Node. Trade-offs: Adds a template engine dependency (Jinja2 is stdlib-adjacent in Python; nunjucks is tiny in Node).
Template sketch:
# printer_install.ps1.j2
$PrinterName = "{{ printer.name }}"
$PrinterIP = "{{ printer.ip }}"
$DriverName = "{{ printer.driver_name }}"
$PortName = "IP_{{ printer.ip }}"
# Elevation guard
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
Start-Process powershell -ArgumentList "-ExecutionPolicy Bypass -File `"$PSCommandPath`"" -Verb RunAs
exit
}
# ... driver install, port add, printer add ...
Pattern 4: Subprocess Isolation for IntuneWinAppUtil
What: IntuneWinAppUtil.exe is a Windows binary. In a Linux container it must run under Wine, or the container must be Windows-based. Isolate this call behind an interface so the builder can be swapped (e.g., native reimplementation, different tool) without touching the orchestrator. When to use: Whenever calling an external binary for a critical generation step. Trade-offs: Wine on Linux adds image size (~500 MB). A Windows-based container image avoids Wine but is heavier by default. A pure Python reimplementation of the .intunewin format (outer ZIP + encrypted inner ZIP + detection.xml) is feasible and removes the tool dependency entirely — recommended if the format can be locked down to what Intune actually requires.
Data Flow
Printer Package Generation Flow
[Technician fills form]
↓
POST /api/packages/generate { printerId, format }
↓
API Layer validates request, loads printer config from SQLite
↓
Package Builder Service
├── Fetches driver files from Driver Store (volume path)
├── Renders install.ps1 via PS Template Engine (printer params injected)
├── Copies icon file (if Intune format)
└── Calls appropriate generator:
├── [Intune] IntuneWin Builder
│ └── subprocess: IntuneWinAppUtil.exe -c <tmpdir> -s install.ps1 -o <out>
│ OR: native Python .intunewin assembler
└── [Ninja] ZIP Builder
└── zipfile: { install.ps1, drivers/, readme.txt }
↓
Generated file written to temp workspace
↓
HTTP response streams file to browser (Content-Disposition: attachment)
↓
Temp workspace cleaned up (finally block / background task)
Driver Upload Flow
[Technician uploads ZIP/INF]
↓
POST /api/drivers (multipart/form-data)
↓
Driver Service
├── Streams upload to temp file
├── Computes SHA256
├── Checks SQLite: already exists? → return existing record
├── Moves file to drivers volume at hash-named path
└── Inserts driver record (id, hash, original_name, size, uploaded_at)
↓
Response: driver record (id, name) for use in printer config
Printer Config Flow
[Technician submits printer form]
↓
POST /api/printers
body: { name, ip, clientId, driverId, color, duplex, paperSize, tray, iconId }
↓
Printer Service validates params, resolves driverId FK
↓
SQLite INSERT → printer record with all settings
↓
Response: printer record
Build Order (Component Dependencies)
Build these layers in order — each depends on the previous being stable:
- Data layer — SQLite schema, models, connection management. Everything else reads/writes here.
- Storage layer — Driver Store abstraction over the volume filesystem.
- Services (Printer + Driver) — CRUD operations, driver upload with deduplication.
- PS Template Engine — Standalone, can be developed and unit-tested in isolation.
- ZIP Builder (NinjaRMM) — Depends on Driver Store + PS Template Engine. Simple to implement, good validation target.
- IntuneWin Builder — Depends on PS Template Engine + icon handling. Most complex generator; validate ZIP Builder first.
- API Layer — Thin wrappers once services are solid.
- UI — Depends on all API routes being defined.
Anti-Patterns
Anti-Pattern 1: Generating Scripts via String Concatenation
What people do: Build the PowerShell script by concatenating strings in Python/Node code. Why it's wrong: Injection risk (printer names with quotes, special chars), hard to read, impossible to review, breaks easily. Do this instead: Use a template file (Jinja2 / nunjucks). Escape all user-provided values explicitly in the template context. Test the rendered output for known edge cases.
Anti-Pattern 2: Storing Driver Blobs in SQLite
What people do: Base64-encode driver ZIPs and store them in a BLOB column. Why it's wrong: SQLite performs poorly with large BLOBs; the database file balloons; backups become unwieldy; streaming is impossible. Do this instead: Store files on the filesystem (Docker volume). Store only the path/hash reference in SQLite.
Anti-Pattern 3: Blocking the Server During Package Generation
What people do: Call IntuneWinAppUtil.exe synchronously in the request handler with no timeout. Why it's wrong: If generation hangs (tool crash, permission issue), the HTTP worker is permanently blocked. On a single-worker server this freezes the entire app. Do this instead: Run the subprocess with a timeout (e.g., 60s). Use a thread pool or async subprocess so other requests can proceed. Return a 500 with a clear error if timeout is exceeded.
Anti-Pattern 4: Leaving Temp Workspaces on Disk
What people do: Write package files to a temp directory and never clean up. Why it's wrong: Disk fills up. Sensitive driver files and generated scripts accumulate indefinitely. Do this instead: Use a try/finally pattern: generate in a uniquely-named temp dir, stream the response, then unconditionally delete the temp dir. For async flows, attach cleanup to the download-complete event.
Anti-Pattern 5: Hardcoding the IntuneWinAppUtil Path
What people do: Hardcode /app/tools/IntuneWinAppUtil.exe in the generator code.
Why it's wrong: Breaks if the tool is moved, the container is Windows vs Linux with Wine, or the tool is replaced.
Do this instead: Configure via an environment variable (INTUNEWIN_TOOL_PATH). The builder service reads from config. Switching to a native Python reimplementation means changing only config, not code.
Integration Points
External Tooling
| Tool | Integration Pattern | Notes |
|---|---|---|
| IntuneWinAppUtil.exe | subprocess call with timeout | Must be bundled in container image. On Linux, requires Windows container base image OR Wine. Wine image ~500 MB extra. Consider native reimplementation instead. |
| PowerShell (target endpoints) | Template output only — no runtime PS in container | Container never executes PS; it only generates .ps1 files. |
Internal Boundaries
| Boundary | Communication | Notes |
|---|---|---|
| API Layer ↔ Services | Direct function call (same process) | No IPC needed; single container, single process. |
| Services ↔ DB | SQLite via ORM or raw queries | Use WAL mode for concurrent reads during long-running generates. |
| Services ↔ Driver Store | Filesystem reads via Driver Store abstraction | Abstraction allows future swap to S3 without service changes. |
| Package Builder ↔ Generators | Direct function call, returns file path | Each generator writes output to a caller-provided temp directory. |
| API Layer ↔ Temp Files | Generator returns path → API streams file → cleanup | Use streaming response to avoid loading entire archive into memory. |
Scaling Considerations
This is an internal MSP tool. Scaling expectations are low; design for correctness and maintainability, not throughput.
| Scale | Architecture Adjustments |
|---|---|
| 1-5 concurrent technicians | Default single-worker setup is fine. Use thread pool for subprocess calls. SQLite WAL mode handles concurrent reads. |
| 10+ concurrent technicians | Add Gunicorn/Uvicorn worker count (still single container). Background job queue (in-process, e.g. Python's concurrent.futures) if generation is slow. |
| Multi-tenant or SaaS | Out of scope. Would require auth, per-tenant isolation, external storage — full redesign. |
Scaling Priorities
- First bottleneck: IntuneWinAppUtil.exe process duration (CPU/disk). Mitigation: timeout + thread pool.
- Second bottleneck: Disk I/O on the Docker volume for large driver packages. Mitigation: content-addressing avoids redundant copies.
Sources
- Microsoft: .intunewin format internals — How to decode Intune Win32 App Packages
- Microsoft Learn: Prepare a Win32 App for Intune (.intunewin packaging)
- Microsoft Win32 Content Prep Tool (IntuneWinAppUtil) — GitHub
- FastAPI deployment in Docker containers
- FastAPI file upload handling
- Web-Queue-Worker Architecture Style — Azure Architecture Center
- PowerShell as Win32 installer in Intune (2026)
- NinjaOne Remote Script Deployment
Architecture research for: ImpTune — single-container printer deployment package generator Researched: 2026-04-10