Investigates Docker scaffold, Peewee schema patterns, and .intunewin format for the foundation phase. Flags a critical IV size correction (16 bytes, not 32 as stated in STACK.md) verified against svrooij.io. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
647 lines
32 KiB
Markdown
647 lines
32 KiB
Markdown
# Phase 1: Foundation - Research
|
|
|
|
**Researched:** 2026-04-10
|
|
**Domain:** Docker container scaffold, SQLite schema with Peewee ORM, .intunewin format spike (Python-native AES-256-CBC)
|
|
**Confidence:** HIGH (Docker/Peewee patterns), MEDIUM (.intunewin byte-level format — must be validated against real Intune tenant)
|
|
|
|
---
|
|
|
|
<user_constraints>
|
|
## User Constraints (from CONTEXT.md)
|
|
|
|
### Locked Decisions
|
|
|
|
- **App shell & navigation:** Persistent left sidebar with flat, equal-weight sections: Dashboard, Drivers, Printers, Clients, Packages. Dashboard is the landing page: quick action buttons at top ("New Printer", "Upload Driver", "Export Package") plus recent printers/packages list below. System/auto theme — follow OS dark/light preference.
|
|
- **CSS & offline access:** Air-gapped deployment — no CDN access from the server; all assets must be bundled in the Docker image. Use a lightweight pre-built CSS framework (e.g., Pico CSS) instead of Tailwind — no build step, just a static CSS file. HTMX and Alpine.js downloaded during Docker image build (ADD/curl), baked into the image as static files. All JS/CSS served from the container's static files directory — zero external requests at runtime.
|
|
- **Database schema:** Full schema created upfront in Phase 1 — all tables for phases 2-5 (drivers, printers, clients, icons). Peewee ORM for all database operations. Schema auto-created on first run via Peewee's `create_tables()`.
|
|
- **Driver storage:** SHA256 content-addressed storage for driver files on the Docker volume. Deduplication: same file uploaded twice results in one copy on disk. SQLite stores hash reference + original filename + metadata; filesystem stores actual files.
|
|
|
|
### Claude's Discretion
|
|
|
|
- Specific lightweight CSS framework selection (Pico CSS, Simple.css, or similar)
|
|
- Dashboard layout details and empty state design
|
|
- Exact color scheme for light and dark themes
|
|
- Project directory structure (guided by ARCHITECTURE.md research)
|
|
- .intunewin spike implementation details
|
|
|
|
### Deferred Ideas (OUT OF SCOPE)
|
|
|
|
None — discussion stayed within phase scope.
|
|
</user_constraints>
|
|
|
|
---
|
|
|
|
<phase_requirements>
|
|
## Phase Requirements
|
|
|
|
| ID | Description | Research Support |
|
|
|----|-------------|-----------------|
|
|
| INFRA-01 | Application runs as a single Docker container | Docker scaffold plan (Dockerfile + docker-compose.yml); python:3.12-slim-bookworm base; no sidecar services |
|
|
| INFRA-02 | Application has minimal runtime dependencies (no Node.js, no external DB) | Pico CSS + HTMX + Alpine.js baked into image at build time; SQLite via Peewee (stdlib + one pip package); no Node build pipeline |
|
|
</phase_requirements>
|
|
|
|
---
|
|
|
|
## Summary
|
|
|
|
Phase 1 delivers three things: a running Docker container with the app scaffold, the complete SQLite schema initialized via Peewee, and a validated Python-native .intunewin generator. These are independent workstreams that can be built in parallel but must converge before Phase 2 starts.
|
|
|
|
The Docker scaffold is low-risk and well-understood. The base image is `python:3.12-slim-bookworm` (never Alpine — C-extension wheels fail on musl libc). All frontend assets (Pico CSS, HTMX, Alpine.js) are downloaded with `curl` during the Docker build and served as static files. There are zero external HTTP requests at container runtime — a hard requirement for air-gapped MSP networks.
|
|
|
|
The SQLite schema via Peewee is also straightforward, but the Phase 1 decision to create the full schema upfront (all tables for phases 2-5) means the models file must define every table now. The `.intunewin` format spike is the highest-risk item: the format is reverse-engineered (MEDIUM confidence), AES-256-CBC with HMAC-SHA256, and the Python implementation must be validated against a real Intune tenant before Phase 5 export work begins. A known documentation error exists: STACK.md states "32-byte IV" but the actual AES-CBC standard IV is 16 bytes — use 16 bytes in the implementation.
|
|
|
|
**Primary recommendation:** Build the Docker scaffold and schema in parallel. Treat the .intunewin spike as a time-boxed investigation (max 2 days) that ends in a real Intune upload test — not just local file creation.
|
|
|
|
---
|
|
|
|
## Standard Stack
|
|
|
|
### Core
|
|
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| python:3.12-slim-bookworm | 3.12 (Debian 12) | Docker base image | LTS Python, Debian glibc (not musl), slim keeps image under 200 MB, pre-built C-extension wheels always work |
|
|
| FastAPI | 0.115.x | HTTP framework | Async-capable, Pydantic v2 validation, `TemplateResponse`, `FileResponse`, `StreamingResponse` built in |
|
|
| Uvicorn | 0.30.x | ASGI server | FastAPI's recommended server; `uvicorn[standard]` pulls in uvloop + httptools |
|
|
| Jinja2 | 3.1.x | HTML templating | Ships with FastAPI's template support; used for both HTML pages and PS script generation |
|
|
| Peewee | 3.17.x | ORM for SQLite | Sync-only ORM perfectly matched to SQLite single-writer model; `create_tables()` for schema auto-init |
|
|
| pycryptodome | 3.20.x | AES-256-CBC + HMAC-SHA256 | Required for .intunewin inner package encryption; import as `from Crypto.Cipher import AES` |
|
|
| python-dotenv | 1.0.x | Env-var config | Docker-level overrides without rebuilding (data dir, port, base URL) |
|
|
| python-multipart | 0.0.9 | Multipart file uploads | Required by FastAPI's `UploadFile`; always install alongside FastAPI for file upload routes |
|
|
|
|
### Supporting (Phase 1 specific)
|
|
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| Pico CSS | 2.x | Lightweight CSS framework | Downloaded at image build time via curl; ~14 KB minified; supports OS dark/light via `data-theme="auto"` |
|
|
| HTMX | 2.0.x | Dynamic UI without SPA | Downloaded at image build time; served as static file; handles partial page updates |
|
|
| Alpine.js | 3.x | Client-side UI state | Downloaded at image build time; dropdowns, toggles, modals; no build step |
|
|
|
|
### Installation
|
|
|
|
```bash
|
|
# In Dockerfile (not requirements.txt — these are baked in at image build time)
|
|
# Frontend assets downloaded via curl during build:
|
|
# RUN curl -sLo /app/static/pico.min.css https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css
|
|
# RUN curl -sLo /app/static/htmx.min.js https://unpkg.com/htmx.org@2/dist/htmx.min.js
|
|
# RUN curl -sLo /app/static/alpine.min.js https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js
|
|
|
|
# requirements.txt (installed via pip in Dockerfile)
|
|
fastapi==0.115.*
|
|
uvicorn[standard]==0.30.*
|
|
jinja2==3.1.*
|
|
python-multipart==0.0.9
|
|
pycryptodome==3.20.*
|
|
python-dotenv==1.0.*
|
|
peewee==3.17.*
|
|
```
|
|
|
|
---
|
|
|
|
## Architecture Patterns
|
|
|
|
### Recommended Project Structure
|
|
|
|
```
|
|
imptune/
|
|
├── api/ # HTTP route handlers (thin — delegate to services)
|
|
│ ├── __init__.py
|
|
│ ├── pages.py # HTML page routes (SSR with Jinja2)
|
|
│ └── health.py # GET /health — Docker healthcheck endpoint
|
|
├── services/ # Domain logic (testable without HTTP context)
|
|
│ └── __init__.py
|
|
├── generators/ # Format-specific builders
|
|
│ ├── __init__.py
|
|
│ └── intunewin_builder.py # Phase 1 spike: Python .intunewin assembler
|
|
├── templates/ # Jinja2 HTML templates
|
|
│ ├── base.html # Layout with sidebar, static asset includes
|
|
│ └── dashboard.html # Landing page (quick actions + recent activity)
|
|
├── db/
|
|
│ ├── __init__.py
|
|
│ ├── database.py # Peewee database init, create_tables()
|
|
│ └── models.py # ALL tables for phases 1-5 (full schema upfront)
|
|
├── storage/
|
|
│ └── driver_store.py # Abstraction over /data/drivers volume path
|
|
├── static/ # Served as /static/ — contains baked-in assets
|
|
│ ├── pico.min.css # Downloaded at Docker build time
|
|
│ ├── htmx.min.js # Downloaded at Docker build time
|
|
│ └── alpine.min.js # Downloaded at Docker build time
|
|
├── config.py # Env-var driven configuration (DATA_DIR, PORT)
|
|
├── main.py # App entrypoint: create FastAPI, mount routes, StaticFiles
|
|
├── Dockerfile
|
|
├── docker-compose.yml
|
|
└── requirements.txt
|
|
```
|
|
|
|
### Pattern 1: Docker Offline Asset Baking
|
|
|
|
**What:** Download CSS/JS assets with `curl` during `docker build` so they are baked into the image. No CDN access at container runtime.
|
|
|
|
**When to use:** Always — this is a hard requirement for air-gapped MSP networks.
|
|
|
|
**Example Dockerfile snippet:**
|
|
```dockerfile
|
|
FROM python:3.12-slim-bookworm
|
|
|
|
WORKDIR /app
|
|
|
|
# Install system deps and download frontend assets in one layer
|
|
RUN apt-get update && apt-get install -y --no-install-recommends curl \
|
|
&& mkdir -p /app/static \
|
|
&& curl -sLo /app/static/pico.min.css \
|
|
"https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css" \
|
|
&& curl -sLo /app/static/htmx.min.js \
|
|
"https://unpkg.com/htmx.org@2/dist/htmx.min.js" \
|
|
&& curl -sLo /app/static/alpine.min.js \
|
|
"https://cdn.jsdelivr.net/npm/alpinejs@3/dist/cdn.min.js" \
|
|
&& apt-get purge -y curl && apt-get autoremove -y \
|
|
&& rm -rf /var/lib/apt/lists/*
|
|
|
|
COPY requirements.txt .
|
|
RUN pip install --no-cache-dir -r requirements.txt
|
|
|
|
COPY . .
|
|
|
|
VOLUME ["/data"]
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
|
|
|
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
```
|
|
|
|
**docker-compose.yml:**
|
|
```yaml
|
|
services:
|
|
imptune:
|
|
build: .
|
|
ports:
|
|
- "8000:8000"
|
|
volumes:
|
|
- imptune_data:/data
|
|
restart: unless-stopped
|
|
environment:
|
|
- DATA_DIR=/data
|
|
|
|
volumes:
|
|
imptune_data:
|
|
```
|
|
|
|
### Pattern 2: Peewee Schema Auto-Init
|
|
|
|
**What:** Define all tables (all phases) in `models.py`, auto-create on startup via `create_tables(safe=True)`.
|
|
|
|
**When to use:** On every container start — `safe=True` is idempotent (no-op if tables already exist).
|
|
|
|
**Example:**
|
|
```python
|
|
# db/database.py
|
|
from peewee import SqliteDatabase
|
|
import os
|
|
|
|
DB_PATH = os.environ.get("DATA_DIR", "/data") + "/imptune.db"
|
|
db = SqliteDatabase(DB_PATH, pragmas={"journal_mode": "wal", "foreign_keys": 1})
|
|
|
|
def init_db():
|
|
from db.models import Driver, Printer, Client, Icon
|
|
db.connect(reuse_if_open=True)
|
|
db.create_tables([Driver, Printer, Client, Icon], safe=True)
|
|
```
|
|
|
|
```python
|
|
# db/models.py
|
|
from peewee import *
|
|
from db.database import db
|
|
import datetime
|
|
|
|
class BaseModel(Model):
|
|
class Meta:
|
|
database = db
|
|
|
|
class Client(BaseModel):
|
|
name = CharField(unique=True)
|
|
created_at = DateTimeField(default=datetime.datetime.utcnow)
|
|
|
|
class Driver(BaseModel):
|
|
sha256 = CharField(unique=True, index=True) # content-addressed key
|
|
original_filename = CharField()
|
|
size_bytes = IntegerField()
|
|
uploaded_at = DateTimeField(default=datetime.datetime.utcnow)
|
|
# Phase 2 fields (populated during INF parsing):
|
|
driver_desc = CharField(null=True) # parsed DriverDesc from INF
|
|
inf_filename = CharField(null=True) # which INF file inside the ZIP
|
|
architecture = CharField(null=True) # x64, x86, arm64
|
|
has_cat_file = BooleanField(default=False)
|
|
|
|
class Printer(BaseModel):
|
|
name = CharField()
|
|
ip_address = CharField()
|
|
port_name = CharField()
|
|
client = ForeignKeyField(Client, backref="printers", null=True)
|
|
driver = ForeignKeyField(Driver, backref="printers", null=True)
|
|
duplex_mode = CharField(default="OneSided") # OneSided|TwoSidedLongEdge|TwoSidedShortEdge
|
|
color_mode = BooleanField(default=True)
|
|
paper_size = CharField(default="A4")
|
|
collate = BooleanField(default=True)
|
|
created_at = DateTimeField(default=datetime.datetime.utcnow)
|
|
updated_at = DateTimeField(default=datetime.datetime.utcnow)
|
|
|
|
class Icon(BaseModel):
|
|
printer = ForeignKeyField(Printer, backref="icons", unique=True)
|
|
sha256 = CharField()
|
|
original_filename = CharField()
|
|
size_bytes = IntegerField()
|
|
uploaded_at = DateTimeField(default=datetime.datetime.utcnow)
|
|
```
|
|
|
|
### Pattern 3: .intunewin File Assembly (Python-Native)
|
|
|
|
**What:** Assemble a valid .intunewin file in Python without IntuneWinAppUtil.exe.
|
|
|
|
**Verified byte layout (from svrooij.io decryption article):**
|
|
```
|
|
Encrypted blob layout:
|
|
[0:32] — HMAC-SHA256 of the ciphertext (32 bytes)
|
|
[32:48] — AES-256-CBC Initialization Vector (16 bytes — standard AES block size)
|
|
[48:] — AES-256-CBC ciphertext (padded to 16-byte boundary)
|
|
|
|
IMPORTANT: The IV is 16 bytes, not 32. STACK.md has a documentation error on this point.
|
|
```
|
|
|
|
**Detection.xml schema:**
|
|
```xml
|
|
<ApplicationInfo xmlns="http://schemas.microsoft.com/IntuneWin">
|
|
<Name>install.ps1</Name>
|
|
<UnencryptedContentSize>12345</UnencryptedContentSize>
|
|
<FileName>IntunePackage.intunewin</FileName>
|
|
<SetupFile>install.ps1</SetupFile>
|
|
<EncryptionInfo>
|
|
<EncryptionKey>base64(32-byte AES key)</EncryptionKey>
|
|
<MacKey>base64(32-byte HMAC key)</MacKey>
|
|
<InitializationVector>base64(16-byte IV)</InitializationVector>
|
|
<Mac>base64(32-byte HMAC-SHA256)</Mac>
|
|
<MacAlgorithm>SHA256</MacAlgorithm>
|
|
<ProfileIdentifier>ProfileVersion1</ProfileIdentifier>
|
|
<FileDigest>base64(SHA256 of plaintext ZIP)</FileDigest>
|
|
<FileDigestAlgorithm>SHA256</FileDigestAlgorithm>
|
|
</EncryptionInfo>
|
|
</ApplicationInfo>
|
|
```
|
|
|
|
**Outer ZIP structure:**
|
|
```
|
|
IntuneWinPackage/
|
|
├── Contents/
|
|
│ └── IntunePackage.intunewin ← the encrypted blob
|
|
└── Metadata/
|
|
└── Detection.xml ← encryption metadata
|
|
```
|
|
|
|
**Python assembly skeleton:**
|
|
```python
|
|
# generators/intunewin_builder.py
|
|
import os, io, base64, hashlib, hmac, zipfile
|
|
from Crypto.Cipher import AES
|
|
from Crypto.Util.Padding import pad
|
|
from xml.etree.ElementTree import Element, SubElement, tostring
|
|
import xml.dom.minidom
|
|
|
|
def build_intunewin(source_dir: str, setup_file: str, output_path: str) -> None:
|
|
"""Build a .intunewin file from source_dir, with setup_file as entry point."""
|
|
|
|
# Step 1: Create inner ZIP (DEFLATE-compressed content)
|
|
inner_zip_buf = io.BytesIO()
|
|
with zipfile.ZipFile(inner_zip_buf, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
|
for root, dirs, files in os.walk(source_dir):
|
|
for file in files:
|
|
abs_path = os.path.join(root, file)
|
|
arc_name = os.path.relpath(abs_path, source_dir)
|
|
zf.write(abs_path, arc_name)
|
|
plaintext = inner_zip_buf.getvalue()
|
|
|
|
# Step 2: Encrypt with AES-256-CBC
|
|
aes_key = os.urandom(32) # 32-byte AES key
|
|
mac_key = os.urandom(32) # 32-byte HMAC key
|
|
iv = os.urandom(16) # 16-byte IV (standard AES block size)
|
|
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
|
ciphertext = cipher.encrypt(pad(plaintext, AES.block_size))
|
|
|
|
# Step 3: Compute HMAC-SHA256 over ciphertext
|
|
mac = hmac.new(mac_key, ciphertext, hashlib.sha256).digest()
|
|
|
|
# Step 4: Assemble encrypted blob: [HMAC(32)] + [IV(16)] + [ciphertext]
|
|
encrypted_blob = mac + iv + ciphertext
|
|
|
|
# Step 5: Compute plaintext digest for Detection.xml
|
|
file_digest = hashlib.sha256(plaintext).digest()
|
|
|
|
# Step 6: Build Detection.xml
|
|
app_info = Element("ApplicationInfo",
|
|
xmlns="http://schemas.microsoft.com/IntuneWin")
|
|
SubElement(app_info, "Name").text = setup_file
|
|
SubElement(app_info, "UnencryptedContentSize").text = str(len(plaintext))
|
|
SubElement(app_info, "FileName").text = "IntunePackage.intunewin"
|
|
SubElement(app_info, "SetupFile").text = setup_file
|
|
enc = SubElement(app_info, "EncryptionInfo")
|
|
SubElement(enc, "EncryptionKey").text = base64.b64encode(aes_key).decode()
|
|
SubElement(enc, "MacKey").text = base64.b64encode(mac_key).decode()
|
|
SubElement(enc, "InitializationVector").text = base64.b64encode(iv).decode()
|
|
SubElement(enc, "Mac").text = base64.b64encode(mac).decode()
|
|
SubElement(enc, "MacAlgorithm").text = "SHA256"
|
|
SubElement(enc, "ProfileIdentifier").text = "ProfileVersion1"
|
|
SubElement(enc, "FileDigest").text = base64.b64encode(file_digest).decode()
|
|
SubElement(enc, "FileDigestAlgorithm").text = "SHA256"
|
|
detection_xml = xml.dom.minidom.parseString(tostring(app_info)).toprettyxml()
|
|
|
|
# Step 7: Build outer ZIP (STORED — no extra compression on encrypted content)
|
|
with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_STORED) as outer:
|
|
outer.writestr("IntuneWinPackage/Contents/IntunePackage.intunewin",
|
|
encrypted_blob)
|
|
outer.writestr("IntuneWinPackage/Metadata/Detection.xml",
|
|
detection_xml)
|
|
```
|
|
|
|
**Source:** svrooij.io decryption article (verified format), volodymyrsmirnov/IntuneWin C# reference (structure verified)
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **Alpine Linux base image:** musl libc breaks pycryptodome and other C-extension wheels; use `python:3.12-slim-bookworm` only.
|
|
- **Downloading assets at container runtime:** Never use CDN links in HTML templates; all assets must be served from `/app/static/` which is baked into the image.
|
|
- **Tailwind CDN Play script in templates:** Per Tailwind docs, Play CDN is development-only. The locked decision already chooses Pico CSS — a pre-built static file that needs no CDN at runtime.
|
|
- **Storing the SQLite file inside the container filesystem:** Always mount `/data` as a named volume; SQLite must persist across container restarts.
|
|
- **`peewee.database.connect()` without WAL mode:** SQLite default journal mode is DELETE; enable WAL (`"journal_mode": "wal"`) so reads don't block writes during generation.
|
|
- **32-byte IV in .intunewin:** Standard AES-CBC IV is 16 bytes (AES block size). Using 32 bytes will produce a non-compliant file that Intune will reject. The STACK.md documentation has this wrong — use 16 bytes.
|
|
|
|
---
|
|
|
|
## Don't Hand-Roll
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| AES-256-CBC encryption | Custom AES implementation | `pycryptodome` (`from Crypto.Cipher import AES`) | Padding edge cases, IV handling, block alignment — stdlib `hashlib` does not provide AES |
|
|
| HMAC-SHA256 | Custom HMAC | Python stdlib `hmac.new(key, data, hashlib.sha256)` | Already in stdlib, correct constant-time comparison built in |
|
|
| SQLite schema management | Raw `CREATE TABLE IF NOT EXISTS` strings | Peewee `create_tables(safe=True)` | Migration safety, model-to-SQL mapping, foreign key management |
|
|
| Serving static files in FastAPI | Custom file-serving route | `app.mount("/static", StaticFiles(directory="static"))` | FastAPI's built-in `StaticFiles` handles ETags, range requests, content-type detection |
|
|
| Docker healthcheck HTTP request | curl (which may not be in final image) | Python one-liner: `python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"` | Uses stdlib; no curl dependency in the slim image |
|
|
|
|
**Key insight:** pycryptodome handles all the crypto complexity. The hard part of the .intunewin spike is not the encryption itself — it's assembling the exact byte layout Intune expects and validating the output against a real tenant.
|
|
|
|
---
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: Wrong IV Size in .intunewin (CRITICAL)
|
|
|
|
**What goes wrong:** Using a 32-byte IV instead of the correct 16-byte AES block size. The encrypted blob format is `[HMAC-SHA256 (32 bytes)] + [IV (16 bytes)] + [ciphertext]`. The total overhead is 48 bytes, not 64. Files built with a 32-byte IV will fail to decrypt on the Intune side.
|
|
|
|
**Why it happens:** STACK.md states "32-byte IV" — this is a documentation error. The decryption article confirms 16 bytes via `.NET's aes.IV.Length` (which is always 16 for AES).
|
|
|
|
**How to avoid:** Always use `iv = os.urandom(16)` and `AES.new(key, AES.MODE_CBC, iv)` where `len(iv) == 16`.
|
|
|
|
**Warning signs:** `ValueError: IV must be 16 bytes long` from pycryptodome if you use 32.
|
|
|
|
### Pitfall 2: Storing State in Container Filesystem
|
|
|
|
**What goes wrong:** Writing the SQLite file or driver ZIPs to `/app/` or `/tmp/`. Data disappears on container restart.
|
|
|
|
**Why it happens:** Default working directory in Docker is the app folder; developers forget to configure the volume.
|
|
|
|
**How to avoid:** Set `DATA_DIR=/data` env var. `docker-compose.yml` mounts `imptune_data:/data`. SQLite path must derive from `DATA_DIR`. Driver files go to `DATA_DIR/drivers/`. Never write persistent data outside the volume mount.
|
|
|
|
**Warning signs:** Fresh database on every `docker compose restart`.
|
|
|
|
### Pitfall 3: Alpine Base Image Breaking pycryptodome
|
|
|
|
**What goes wrong:** Using `python:3.12-alpine` as the Docker base. pycryptodome requires C extensions; the pre-built wheels target glibc, not Alpine's musl libc. pip will try to compile from source (requiring gcc/musl-dev) and often fails silently or produces a broken install.
|
|
|
|
**Why it happens:** Alpine is smaller, so it seems attractive for Docker images.
|
|
|
|
**How to avoid:** Use `python:3.12-slim-bookworm` (Debian 12). The final image will be slightly larger (~150-200 MB vs ~80 MB for Alpine) but will reliably install all C-extension packages.
|
|
|
|
### Pitfall 4: CDN Assets Requested at Runtime
|
|
|
|
**What goes wrong:** HTML templates reference `<link rel="stylesheet" href="https://cdn.jsdelivr.net/...">`. The container starts but the browser gets no CSS/JS when running on an air-gapped network.
|
|
|
|
**Why it happens:** Developers test on internet-connected machines where CDN works; the failure only manifests on offline deployments.
|
|
|
|
**How to avoid:** All `<link>` and `<script>` tags in templates must reference `/static/...` paths. The `curl` downloads in the Dockerfile must complete successfully — add `--fail` flag to `curl` so the build fails if a download fails rather than producing an empty file.
|
|
|
|
**Warning signs:** `curl` in Dockerfile without `--fail`; template contains `jsdelivr.net`, `unpkg.com`, or `cdnjs.com` URLs.
|
|
|
|
### Pitfall 5: .intunewin Spike Validated Only Locally
|
|
|
|
**What goes wrong:** The spike "works" because the developer verifies the file structure locally (zip contents, XML fields look right) but never uploads to a real Intune tenant. The actual validation — Intune accepting the file and successfully deploying it — is skipped. Phase 5 export is then built on an unvalidated format assumption.
|
|
|
|
**Why it happens:** Intune tenant access may not be immediately available; local file inspection seems sufficient.
|
|
|
|
**How to avoid:** The spike's only valid success criterion is: "file uploaded to a real Intune tenant, application shows as successfully uploaded (no format error)." Create a simple test package (one small file) and upload it. This validates the format; full driver packaging validation comes in Phase 5.
|
|
|
|
**Warning signs:** Spike task marked done without a real Intune upload test result.
|
|
|
|
### Pitfall 6: Peewee Called from FastAPI Async Context Without Executor
|
|
|
|
**What goes wrong:** Calling Peewee's synchronous ORM methods directly from a FastAPI `async def` route causes blocking of the event loop.
|
|
|
|
**Why it happens:** FastAPI encourages `async def` routes; Peewee is sync-only.
|
|
|
|
**How to avoid for Phase 1:** Use regular `def` (not `async def`) for FastAPI route handlers that touch the database. FastAPI runs sync handlers in a thread pool automatically. This is the correct pattern for Peewee + FastAPI — do not fight it with `run_in_executor`.
|
|
|
|
```python
|
|
# Correct: sync handler — FastAPI runs this in a thread pool
|
|
@router.get("/")
|
|
def dashboard():
|
|
recent = list(Printer.select().order_by(Printer.updated_at.desc()).limit(5))
|
|
return templates.TemplateResponse("dashboard.html", {"request": request, "printers": recent})
|
|
|
|
# Wrong for Phase 1: async handler calling sync Peewee
|
|
@router.get("/")
|
|
async def dashboard():
|
|
recent = list(Printer.select()...) # blocks the event loop
|
|
```
|
|
|
|
---
|
|
|
|
## Code Examples
|
|
|
|
### FastAPI App Entrypoint with Static Files and Templates
|
|
|
|
```python
|
|
# main.py
|
|
from fastapi import FastAPI
|
|
from fastapi.staticfiles import StaticFiles
|
|
from fastapi.templating import Jinja2Templates
|
|
from db.database import init_db
|
|
from api import pages
|
|
|
|
app = FastAPI(title="ImpTune")
|
|
|
|
# Serve baked-in static assets (pico.min.css, htmx.min.js, alpine.min.js)
|
|
app.mount("/static", StaticFiles(directory="static"), name="static")
|
|
|
|
# Register page routes
|
|
app.include_router(pages.router)
|
|
|
|
@app.on_event("startup")
|
|
def on_startup():
|
|
init_db() # creates all tables on first run, no-op if they exist
|
|
```
|
|
|
|
### Pico CSS Dark/Light Theme (OS preference)
|
|
|
|
```html
|
|
<!-- templates/base.html -->
|
|
<!DOCTYPE html>
|
|
<html lang="en" data-theme="auto">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>ImpTune</title>
|
|
<link rel="stylesheet" href="/static/pico.min.css">
|
|
<script defer src="/static/alpine.min.js"></script>
|
|
<script src="/static/htmx.min.js"></script>
|
|
</head>
|
|
<body>
|
|
<div style="display:flex">
|
|
<nav><!-- sidebar --></nav>
|
|
<main class="container">{% block content %}{% endblock %}</main>
|
|
</div>
|
|
</body>
|
|
</html>
|
|
```
|
|
|
|
`data-theme="auto"` instructs Pico CSS to follow the OS `prefers-color-scheme` media query automatically. No JavaScript needed.
|
|
|
|
### Docker healthcheck without curl
|
|
|
|
```dockerfile
|
|
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
|
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" || exit 1
|
|
```
|
|
|
|
### FastAPI health endpoint
|
|
|
|
```python
|
|
# api/health.py
|
|
from fastapi import APIRouter
|
|
router = APIRouter()
|
|
|
|
@router.get("/health")
|
|
def health():
|
|
return {"status": "ok"}
|
|
```
|
|
|
|
---
|
|
|
|
## State of the Art
|
|
|
|
| Old Approach | Current Approach | When Changed | Impact |
|
|
|--------------|------------------|--------------|--------|
|
|
| IntuneWinAppUtil.exe (Windows-only binary) | Python-native .intunewin (zipfile + pycryptodome) | 2023 — svrooij reverse-engineered format | Linux containers can now generate .intunewin without Wine or Windows base |
|
|
| Tailwind CDN Play in templates | Pre-built CSS framework (Pico CSS) served as static file | Phase 1 decision (2026-04-10) | Zero CDN dependency; air-gapped compatible |
|
|
| Gunicorn + Flask | Uvicorn + FastAPI | 2022-2024 ecosystem shift | Async-capable, Pydantic validation built in, less boilerplate |
|
|
| SQLAlchemy async | Peewee sync | Phase 1 decision | No async overhead for SQLite single-writer; simpler code |
|
|
|
|
**Deprecated/outdated:**
|
|
- `pycrypto`: Unmaintained since 2012, known CVEs. Use `pycryptodome` (maintained drop-in, `from Crypto.Cipher import AES`).
|
|
- `Tailwind Play CDN`: Explicitly marked as development-only by Tailwind docs. Not suitable for production or air-gapped environments.
|
|
- `Alpine Linux base image`: Avoid for any Python project using C-extension packages (pycryptodome, lxml, etc.).
|
|
|
|
---
|
|
|
|
## Open Questions
|
|
|
|
1. **Inner ZIP compression method (DEFLATE vs STORED)**
|
|
- What we know: The outer ZIP uses ZIP_STORED; the inner ZIP content appears to use DEFLATE (C# default, `volodymyrsmirnov/IntuneWin` uses default `.NET ZipArchive`). STACK.md says DEFLATE.
|
|
- What's unclear: Whether Intune requires DEFLATE specifically or accepts ZIP_STORED for the inner package. One search result stated "no compression used" which conflicts with STACK.md.
|
|
- Recommendation: Implement with `ZIP_DEFLATED` first (matches the reference implementation behavior). If Intune rejects it, try `ZIP_STORED`. The real Intune upload test in the spike will resolve this definitively.
|
|
|
|
2. **MacKey vs EncryptionKey sizes**
|
|
- What we know: EncryptionKey is 32 bytes (256-bit AES). MacKey is also described as a separate key for HMAC-SHA256. Standard HMAC-SHA256 can use any key size (SHA-256 block size is 64 bytes, but 32 bytes is common).
|
|
- What's unclear: Whether MacKey must be exactly 32 bytes or can differ. The svrooij articles don't state the MacKey size explicitly.
|
|
- Recommendation: Use `mac_key = os.urandom(32)` (32 bytes) — same size as the AES key, consistent with svrooij ContentPrep behavior.
|
|
|
|
3. **Pico CSS v2 sidebar layout**
|
|
- What we know: Pico CSS v2 is a classless/minimal framework with a `container` component and grid support. It does not have a built-in sidebar layout.
|
|
- What's unclear: Whether additional CSS will be needed for the persistent sidebar, or if Pico's grid/flex utilities suffice.
|
|
- Recommendation: Add a small `app.css` static file alongside `pico.min.css` for layout overrides (sidebar width, flex container). Keep it under 50 lines. This is Claude's discretion per CONTEXT.md.
|
|
|
|
---
|
|
|
|
## Validation Architecture
|
|
|
|
### Test Framework
|
|
|
|
| Property | Value |
|
|
|----------|-------|
|
|
| Framework | pytest (to be installed in Wave 0) |
|
|
| Config file | None — see Wave 0 |
|
|
| Quick run command | `pytest tests/ -x -q` |
|
|
| Full suite command | `pytest tests/ -v` |
|
|
|
|
### Phase Requirements → Test Map
|
|
|
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
|
|--------|----------|-----------|-------------------|-------------|
|
|
| INFRA-01 | Container starts and returns HTTP 200 on GET /health | smoke | `pytest tests/test_health.py -x` | Wave 0 |
|
|
| INFRA-01 | SQLite database initializes with correct tables on first run | unit | `pytest tests/test_db.py::test_create_tables -x` | Wave 0 |
|
|
| INFRA-02 | No Node.js process or external DB in running container | manual | `docker inspect imptune \| grep node` (manual check) | manual-only |
|
|
| INFRA-02 | All static assets served from /static/ (no CDN URLs in HTML) | unit | `pytest tests/test_static.py::test_no_cdn_urls -x` | Wave 0 |
|
|
| (spike) | .intunewin file has correct byte layout (HMAC+IV+ciphertext) | unit | `pytest tests/test_intunewin.py::test_byte_layout -x` | Wave 0 |
|
|
| (spike) | .intunewin uploads successfully to real Intune tenant | manual | Upload test — manual, requires Intune access | manual-only |
|
|
|
|
### Sampling Rate
|
|
|
|
- **Per task commit:** `pytest tests/ -x -q`
|
|
- **Per wave merge:** `pytest tests/ -v`
|
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
|
|
|
### Wave 0 Gaps
|
|
|
|
- [ ] `tests/__init__.py` — package marker
|
|
- [ ] `tests/conftest.py` — shared fixtures (temp dir, test DB path)
|
|
- [ ] `tests/test_health.py` — covers INFRA-01 HTTP health check
|
|
- [ ] `tests/test_db.py` — covers INFRA-01 schema init (all tables created, WAL mode enabled)
|
|
- [ ] `tests/test_static.py` — covers INFRA-02 no-CDN-URLs assertion (scan templates)
|
|
- [ ] `tests/test_intunewin.py` — covers spike byte layout validation
|
|
- [ ] Framework install: `pip install pytest` — add to `requirements-dev.txt`
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
|
|
- svrooij.io — Decrypting intunewin files (2023-10-09) — confirmed IV=16 bytes, HMAC-SHA256 layout
|
|
- svrooij.io — Creating IntuneWin files with C# (2023-10-24) — Detection.xml schema, outer ZIP structure
|
|
- svrooij.io — Analysing Win32 Content Prep Tool (2023-10-04) — encryption key sizes, overhead byte count
|
|
- [FastAPI deployment with Docker — Official Docs](https://fastapi.tiangolo.com/deployment/docker/) — Dockerfile patterns, CMD, volume
|
|
- [FastAPI StaticFiles — Official Docs](https://fastapi.tiangolo.com/tutorial/static-files/) — static asset serving
|
|
- [Pico CSS v2 — Official Docs](https://picocss.com/docs) — `data-theme="auto"`, classless usage
|
|
- [Peewee ORM docs](https://docs.peewee-orm.com/en/latest/) — `create_tables`, WAL mode pragma, sync patterns with FastAPI
|
|
- [pycryptodome docs — AES CBC examples](https://pycryptodome.readthedocs.io/en/latest/src/examples.html) — AES-CBC usage, padding
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
|
|
- volodymyrsmirnov/IntuneWin (GitHub) — C# reference implementation; confirmed DEFLATE for inner ZIP (default .NET behavior)
|
|
- SvRooij.ContentPrep NuGet 0.4.2 (2025-10-03) — cross-platform validation that the format is stable and reimplementable
|
|
- STACK.md (project research, 2026-04-10) — stack decisions; NOTE: IV size stated as 32 bytes is incorrect, should be 16
|
|
|
|
### Tertiary (LOW confidence)
|
|
|
|
- WebSearch result claiming "no compression used" for inner ZIP — conflicts with STACK.md and .NET default behavior; needs spike to resolve
|
|
|
|
---
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH — all libraries verified against official docs; versions confirmed compatible
|
|
- Docker scaffold pattern: HIGH — standard FastAPI Docker deployment, well-documented
|
|
- Peewee schema pattern: HIGH — official Peewee docs, straightforward sync ORM usage
|
|
- .intunewin format: MEDIUM — format confirmed by reverse-engineering; IV size corrected (16 bytes); inner ZIP compression TBD; must validate against real Intune tenant
|
|
- Pitfalls: HIGH — all pitfalls derived from verified sources or official documentation
|
|
|
|
**Research date:** 2026-04-10
|
|
**Valid until:** 2026-05-10 (stable ecosystem; .intunewin format validity: confirm during spike)
|
|
|
|
**Critical correction flagged:** STACK.md states "32-byte IV" for .intunewin encryption. Multiple sources (AES standard, svrooij.io decryption article citing `.NET aes.IV.Length = 16`) confirm the IV is 16 bytes. The planner must use 16 bytes in the spike implementation.
|