06 January 2026

Defense in depth means engineering not policy

Engineering Security Without Breaking Safety:
Why Critical Embedded Systems Need More Than Slogans

Modern cybersecurity is full of confident phrases: Zero Trust, assume breach, continuous verification, shift left. These ideas work well in cloud environments, IT enterprises, and SaaS ecosystems, where the worst consequences of a failure are usually downtime, data loss, or damaged reputation.

But there is a whole class of systems where failure is measured in lives lost, not tickets opened: aircraft avionics, medical devices, industrial control systems, defense platforms, and the emerging Internet of Military Things (IoMT). These systems have one job: keep people alive and missions intact, even when everything else is going wrong.

The white paper behind my DEF CON 33 Policy Village talk began as a critique of applying Zero Trust Architecture (ZTA) directly to avionics. As the work evolved, it became something broader and more important: a call for disciplined, systems-engineering-driven cybersecurity for all safety-critical embedded systems. Not just “more controls,” but better placement, better reasoning, and better respect for physics, timing, and human factors.


Frameworks vs. Physics: When Good Ideas Wander into the Wrong Domain

Most cybersecurity frameworks assume a very particular world:

  • Systems can be patched frequently and rebooted when needed.
  • Users can be prompted for reauthentication or blocked temporarily.
  • Latency and jitter are tolerable as long as the app “eventually” responds.
  • New security components (agents, scanners, policy engines) can be added with minimal risk.

Safety-critical embedded systems inhabit a very different reality:

  • Deterministic timing: Control loops run on tight deadlines; a few milliseconds of added delay can matter.
  • Certification baselines: Changing a line of code may trigger months of regression testing and safety analysis.
  • Human factors: Operators (pilots, clinicians, plant operators) are already at or near cognitive saturation in emergencies.
  • Consequences: Failure isn’t “downtime”—it’s physical harm, mission loss, or catastrophic damage.

This is the core friction: IT-centric frameworks were never designed for real-time, safety-critical environments. When we treat them as universal truths and apply them without adaptation, we risk creating systems that are formally “more secure” on paper but less survivable in the real world.

The white paper doesn’t argue that Zero Trust—or any modern cyber paradigm—is useless. It argues that: without systems engineering discipline, these frameworks become dangerous when pushed into places they were never meant to live.


Why Simplicity Still Wins in Safety-Critical Systems

Ask any seasoned safety engineer about system design, and you’ll hear the same principles:

  • Keep the core functions simple and deterministic.
  • Avoid unnecessary dependencies and “clever” runtime behavior.
  • Design for graceful degradation, not brittle perfection.
  • Ensure humans can still diagnose and act when the system is misbehaving.

Now compare this with how we often talk about cybersecurity:

  • “Just install another agent.”
  • “We’ll add a cloud-based risk engine.”
  • “We’ll continuously scan, monitor, and microsegment everything.”
  • “We’ll let the policy engine decide at runtime whether access is allowed.”

In enterprise IT, that’s normal. In a flight control computer, an insulin pump, or a nuclear plant shutdown system, it’s reckless. A “smart” control that no one can fully reason about under stress is not a good control in a safety-critical environment.

The central thesis of the paper is simple: In safety-critical domains, good cybersecurity should look like good engineering: explainable, predictable, testable, and safe under stress. If a control makes the system harder to understand, harder to certify, or harder to operate in an emergency, it’s not a feature—it’s a liability.


The Garlic Model: A Mental Map for Where Security Belongs

To help reason about where different kinds of security controls actually belong, the paper introduces the Garlic Model—a layered view of safety-critical systems inspired by a cut bulb of garlic.

Cloves (Core Layer)

The cloves are the safety-critical core:

  • Flight control computers and primary avionics.
  • Therapy algorithms in medical devices.
  • Industrial safety logic and trip systems.
  • Weapons employment and critical navigation functions.

These components must be: deterministic, certifiable, and as simple as possible. They are the parts that must still work when everything else is failing. They must not depend on:

  • Live connectivity to policy servers.
  • Dynamic access decisions.
  • Cloud identity or posture checks.
  • Complex, adaptive security logic.

If your aircraft can’t fly because an identity broker times out, you’ve confused “fail secure” with “fail safe.”

Membranes (Middle Layer)

Surrounding the core are the membranes:

  • Protocol converters and data guards.
  • ARINC 664 firewalls and MILS partitions.
  • Data diodes and unidirectional gateways.

These components can:

  • Enforce static, deterministic rules (format checks, range limits, rate limiting).
  • Isolate the core from malformed or unexpected inputs.
  • Aggregate logs or telemetry for post-event analysis.

They aren’t doing “Zero Trust” in the enterprise sense. They’re doing something much more basic and much more important: they’re keeping the crazy stuff out.

Skin (Outer Layer)

Finally, the outer skin represents:

  • Electronic Flight Bags (EFBs).
  • Maintenance laptops and provisioning tools.
  • OTA update systems and cloud services.
  • Ground stations, mission planning tools, and data lakes.

This is where dynamic cybersecurity belongs:

  • Identity-based access control.
  • Posture and health checks.
  • Continuous authentication and authorization.
  • Threat intel, anomaly detection, and rich logging.

If something goes wrong here, the core should keep doing its job. The aircraft keeps flying. The pump keeps delivering therapy. The plant keeps shutting down safely.

The Garlic Model gives architects, security engineers, and assessors a simple diagnostic question for any proposed control: “Is this a clove thing, a membrane thing, or a skin thing?” If the answer is “skin,” but we are trying to cram it into the clove, we are probably trading safety for fashion.


Real-World Examples: When Assumptions Collide with Reality

The paper uses several case studies—some real, some hypothetical but plausible—to illustrate how well-intentioned security ideas can clash with safety and human factors.

Electronic Flight Bags and the Illusion of “Just an App”

EFBs are often treated as “just tablets” with charts and PDFs. In reality, they are software-rich, networked endpoints that may connect over Wi-Fi, Bluetooth, or wired interfaces to the aircraft.

Regulators typically constrain EFBs to “situational awareness” roles, but history shows that boundaries blur. If an EFB can send data to the aircraft—even indirectly—it can become a path for:

  • Corrupting a navigation database mid-flight.
  • Injecting malformed route or performance data.
  • Creating confusion by desynchronizing what the EFB shows vs. what the avionics believe.

A Zero Trust mindset at the **outer layer** is actually helpful here—strong identity, integrity checks, and controlled interfaces on the EFB and its uplinks. But the **core avionics** shouldn’t trust any of it blindly, and they definitely shouldn’t depend on EFBs to function.

ADS-B / TIS-B: Trusting Broadcasts in a World of Spoofing

ADS-B and TIS-B were designed for surveillance and traffic awareness, not for hostile RF environments. They lack strong authentication and are known to be spoofable. Security research has demonstrated that false targets can be injected into

02 January 2026

Part 3: A “No-Login” VT Console for Direwolf (and other dashboards)

This is Part 3 of a three-part series:

  • Part 1: Stable udev device paths for RTL-SDR (/dev/sdr-<serial>)
  • Part 2: Run Direwolf RX iGate in Docker (least privilege, single-device passthrough)
  • Part 3 (this post): Show logs/status on a dedicated Linux virtual terminal (VT) without logging in

The goal: when you walk up to the box (or attach a monitor/keyboard), you can hit Ctrl+Alt+F3 and see a live status screen—without anyone having to log in.


Why this method (and why not watch | tail)

A common quick-and-dirty approach is something like:

watch -n 0.5 "docker logs ... | tail -n 60" > /dev/tty3

It works, but it burns CPU (polling every 0.5s, re-running docker logs, piping, tailing, redrawing). We can do better.

The efficient approach is:

  • Use docker logs --follow (one process, blocks until new lines arrive)
  • Use --tail to show recent history once at startup
  • Let systemd attach stdout directly to the VT (no shell redirection required)

What we’re building

We’ll separate the “producer” from the “display,” so you can reuse this pattern for other boards later (weather, NOTAM text, system health, etc.).

  • Producer script: outputs plain text to stdout (example: Direwolf Docker logs)
  • systemd service: sends stdout to a specific VT (/dev/tty3) and restarts on failure
  • Disable getty on that VT so there’s no login prompt and nothing overwrites the screen

Step 1 — Choose the VT and disable getty on it

Pick a VT that you want to “own.” In this example we’ll use tty3.

Check if a getty is running on tty3

systemctl status getty@tty3.service

If it’s active, disable it so it doesn’t draw a login prompt over your dashboard:

sudo systemctl disable --now getty@tty3.service

Optional (stronger): mask it to prevent accidental re-enable by other tooling:

sudo systemctl mask getty@tty3.service

To restore later:

sudo systemctl unmask getty@tty3.service
sudo systemctl enable --now getty@tty3.service

Step 2 — Create the producer script (Direwolf Docker logs)

Create a small script that prints what you want on the VT. Keep it boring: one job, clean stdout. This example streams the last 60 lines and then follows.

Create: /usr/local/bin/board-direwolf-logs

sudo tee /usr/local/bin/board-direwolf-logs >/dev/null <<'EOF'
#!/bin/sh
set -eu

CONTAINER_NAME="${CONTAINER_NAME:-direwolf}"
TAIL_LINES="${TAIL_LINES:-60}"

# One process; blocks until new log lines arrive
exec /usr/bin/docker logs --follow --tail="${TAIL_LINES}" --timestamps "${CONTAINER_NAME}"
EOF

sudo chmod 0755 /usr/local/bin/board-direwolf-logs

You can test it directly (this should stream in your current terminal):

/usr/local/bin/board-direwolf-logs

Stop it with Ctrl+C.


Step 3 — Create the systemd service that writes to the VT

Now we create a template unit so you can choose the VT at enable-time: direwolf-vt@tty3.service, direwolf-vt@tty4.service, etc.

Create: /etc/systemd/system/direwolf-vt@.service

sudo tee /etc/systemd/system/direwolf-vt@.service >/dev/null <<'EOF'
[Unit]
Description=Direwolf board on %I (no-login VT)
After=docker.service
Requires=docker.service

[Service]
Type=simple

# Producer script (stdout only)
Environment=CONTAINER_NAME=direwolf
Environment=TAIL_LINES=60
ExecStart=/usr/local/bin/board-direwolf-logs

# Send stdout to the VT directly
StandardOutput=tty
TTYPath=/dev/%I
TTYReset=yes
TTYVHangup=yes
TTYVTDisallocate=yes

# Keep it lightweight
Nice=10
CPUSchedulingPolicy=batch
IOSchedulingClass=idle

# If docker restarts or the container rotates, keep coming back
Restart=always
RestartSec=1

[Install]
WantedBy=multi-user.target
EOF

sudo systemctl daemon-reload

Step 4 — Enable the service on tty3

sudo systemctl enable --now direwolf-vt@tty3.service

Then switch to the console:

  • Keyboard: Ctrl+Alt+F3
  • Or command: sudo chvt 3

You should see:

  • ~60 lines of recent logs
  • Then live updates as new logs arrive

Optional: keep Docker logs from growing forever

If you haven’t already, set Docker log rotation for the container (Compose example):

logging:
  driver: "json-file"
  options:
    max-size: "10m"
    max-file: "3"

This keeps the log files bounded while your VT board follows “current” output.


Troubleshooting

Nothing appears on tty3

  • Confirm you’re actually on VT3 (Ctrl+Alt+F3).
  • Verify the service is running: systemctl status direwolf-vt@tty3.service
  • Verify the producer works: /usr/local/bin/board-direwolf-logs

Login prompt keeps overwriting the board

  • Disable and optionally mask getty@tty3.service (Step 1).

Permission issues talking to Docker

  • This service runs as root by default so it can access the Docker socket and the VT cleanly.
  • If you run it as a non-root user, you’ll need access to /var/run/docker.sock and the VT device. (Also note: membership in the docker group is effectively root-equivalent.)

Reusing the pattern for other “boards”

To display something else (weather text, a local status script, etc.), you only swap the producer script. Keep the same service, or clone it into another unit.

Example: create another script /usr/local/bin/board-weather that prints a text dashboard once, then sleeps/loops as you prefer. Enable it on another VT (say tty4) with a similar service.


Wrap-up

You now have an appliance-style console:

  • No polling loops (watch)
  • No wasteful pipes (tail)
  • One blocking stream (docker logs --follow)
  • Direct VT output via systemd (StandardOutput=tty)
  • No-login display (getty disabled on that VT)

In the next iteration, you can replace “logs” with a real curated dashboard (still plain text) while keeping the same display mechanism.

Part 2: Running Direwolf from an RTL-SDR in Docker (RX iGate, least privilege)

This is the second post in a three-part mini-series:

  • Part 1: Stable device paths for RTL-SDR (udev creates a predictable /dev/sdr-<serial>)
  • Part 2 (this post): Run Direwolf (receive-only iGate) from an RTL-SDR using Docker, without exposing the whole USB bus
  • Part 3: Display logs / status on a dedicated virtual terminal (VT) without logging in

Why this approach

The RTL-SDR tools (via libusb) typically expect the device to be reachable at the canonical usbfs path: /dev/bus/usb/<bus>/<dev>. The catch is that the bus/dev numbers change across reboots and re-plugs.

In Part 1 we solved stability on the host by creating a consistent udev symlink: /dev/sdr-<serial>. In this post we’ll keep Docker locked down by passing only that one stable node into the container, and then using a small entrypoint script to:

  1. Find the SDR in /sys/bus/usb/devices by serial
  2. Compute the live bus/dev numbers
  3. Create /dev/bus/usb/BBB/DDD inside the container (as a symlink to /dev/sdr-<serial>)
  4. Drop privileges and run rtl_fm | direwolf

Result: predictable device selection, minimal device exposure, and a clean “works after reboot” startup.


Directory layout

/srv/direwolf/
├─ Dockerfile
├─ docker-compose.yml
├─ entrypoint.sh
├─ direwolf.conf
└─ .env

Notes:

  • No host-specific paths in this post. Use /srv/direwolf (or any directory you like).
  • We mount direwolf.conf so changes don’t require rebuilds.
  • We intentionally do not mount any general USB directories.

Step 1 — Create a non-interactive service user on the host

Use a dedicated host user for running this service. It should not be interactive. Prefer /usr/sbin/nologin (or similar).

# Example (adjust to your distro’s tools)
sudo useradd --system --create-home --shell /usr/sbin/nologin direwolf

# Confirm UID/GID for .env
id direwolf

If your stable RTL-SDR rule assigns a dedicated group (recommended), ensure your service user is in that group.


Step 2 — Create .env (no secrets in this template)

Create /srv/direwolf/.env. This post uses placeholders; replace with your values.

DW_UID=YOUR_UID_HERE
DW_GID=YOUR_GID_HERE

# Serial used by the stable host symlink: /dev/sdr-<serial>
RTL_SERIAL=YOUR_RTL_SERIAL_HERE

# run | diag
MODE=run

# RTL-FM / APRS defaults (optional)
APRS_FREQ=144.390M
RTL_RATE=48000
RTL_GAIN=40
RTL_PPM=0

Diagnostics mode (MODE=diag) keeps the container up without starting Direwolf, so you can run rtl_test, rtl_fm, and other checks interactively.


Step 3 — Dockerfile (build Direwolf, disable DNS-SD, include su-exec)

This Dockerfile builds Direwolf and explicitly disables DNS-SD compilation (so no Avahi runtime dependency). It installs su-exec so the entrypoint can drop privileges after creating the usbfs symlink.

FROM alpine:3.20

ARG DIREWOLF_BRANCH=master

# Build deps can include avahi-dev if your toolchain expects it, even though DNS-SD is disabled.
ENV BUILD_DEPS="build-base cmake git pkgconf alsa-lib-dev avahi-dev" \
    RUNTIME_DEPS="alsa-lib libgcc libstdc++ rtl-sdr libusb ca-certificates tzdata su-exec"

RUN apk add --no-cache $BUILD_DEPS $RUNTIME_DEPS \
    && git clone https://github.com/wb2osz/direwolf.git /tmp/direwolf \
    && cd /tmp/direwolf \
    && git checkout $DIREWOLF_BRANCH \
    && mkdir build && cd build \
    && cmake .. -DOPTIONAL_DNSSD=OFF \
    && make -j"$(nproc)" \
    && make install \
    && make install-conf \
    && rm -rf /tmp/direwolf \
    && apk del $BUILD_DEPS

# A place for logs if you choose to enable file logging later
RUN mkdir -p /usr/local/etc/direwolf /var/log/direwolf

COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

EXPOSE 8000

# Start as root so we can create /dev/bus/usb/BBB/DDD, then drop privileges in entrypoint
USER root:root
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

Step 4 — Entrypoint script (map USB node, then drop privileges)

Save this as /srv/direwolf/entrypoint.sh and make it executable (chmod +x). It does four key things:

  • Reads DW_UID, DW_GID, and RTL_SERIAL from .env
  • Waits for /dev/sdr-<serial> to exist (the only device node we pass in)
  • Finds bus/dev numbers by serial from /sys, and creates /dev/bus/usb/BBB/DDD
  • Runs rtl_fm | direwolf as an unprivileged user (or sleeps in diag mode)
#!/bin/sh
set -eu

DW_UID="${DW_UID:-YOUR_UID_HERE}"
DW_GID="${DW_GID:-YOUR_GID_HERE}"
SERIAL="${RTL_SERIAL:-YOUR_RTL_SERIAL_HERE}"
MODE="${MODE:-run}"

SRC_DEV="/dev/sdr-${SERIAL}"

# Create group/user at runtime (no rebuild needed). Ignore errors if they already exist.
addgroup -g "${DW_GID}" -S direwolf 2>/dev/null || true
adduser  -u "${DW_UID}" -S -D -H -G direwolf -s /sbin/nologin direwolf 2>/dev/null || true

# Wait briefly for the mapped device node to appear
i=0
while [ $i -lt 50 ]; do
  if [ -c "${SRC_DEV}" ]; then
    break
  fi
  i=$((i+1))
  sleep 0.2
done

if [ ! -c "${SRC_DEV}" ]; then
  echo "ERROR: ${SRC_DEV} not present in container."
  echo "Check docker-compose.yml devices: - /dev/sdr-${SERIAL}:/dev/sdr-${SERIAL}"
  exit 1
fi

# Find sysfs USB device with matching serial; get busnum/devnum
SYSDEV=""
for d in /sys/bus/usb/devices/*; do
  [ -f "$d/serial" ] || continue
  s="$(cat "$d/serial" 2>/dev/null || true)"
  if [ "$s" = "$SERIAL" ]; then
    SYSDEV="$d"
    break
  fi
done

if [ -z "$SYSDEV" ]; then
  echo "ERROR: Could not find USB device with serial '$SERIAL' in /sys/bus/usb/devices."
  exit 1
fi

BUSNUM="$(cat "$SYSDEV/busnum")"
DEVNUM="$(cat "$SYSDEV/devnum")"
BUS="$(printf '%03d' "$BUSNUM")"
DEV="$(printf '%03d' "$DEVNUM")"

mkdir -p "/dev/bus/usb/$BUS"

# Create canonical path libusb expects, pointing to our mapped node
ln -sf "$SRC_DEV" "/dev/bus/usb/$BUS/$DEV"

echo "RTL-SDR serial=$SERIAL mapped: $SRC_DEV  ->  /dev/bus/usb/$BUS/$DEV"

if [ "$MODE" = "diag" ]; then
  echo "MODE=diag: container will stay up for interactive diagnostics."
  exec su-exec "${DW_UID}:${DW_GID}" sh -lc "sleep infinity"
fi

# Run rtl_fm -> direwolf (Direwolf must be configured with: ADEVICE stdin null)
APRS_FREQ="${APRS_FREQ:-144.390M}"
RTL_RATE="${RTL_RATE:-48000}"
RTL_GAIN="${RTL_GAIN:-40}"
RTL_PPM="${RTL_PPM:-0}"

exec su-exec "${DW_UID}:${DW_GID}" sh -lc \
  "rtl_fm -d \"$SERIAL\" -M fm -f \"$APRS_FREQ\" -s \"$RTL_RATE\" -g \"$RTL_GAIN\" -p \"$RTL_PPM\" - \
   | /usr/local/bin/direwolf -c /usr/local/etc/direwolf/direwolf.conf -r \"$RTL_RATE\" -t 0"

Step 5 — Direwolf config (sanitized template)

This is a minimal receive-only configuration for stdin audio (from rtl_fm). Replace the callsign and APRS-IS credentials with your own. Do not publish your passcode.

# Audio input from rtl_fm via stdin, and no TX audio device
ADEVICE stdin null
ACHANNELS 1

# Identify this station (replace values)
MYCALL N0CALL-10

# APRS-IS login (replace values; keep passcode private)
IGLOGIN N0CALL-10 <APRS_PASSCODE>

# APRS-IS server (regional option)
IGSERVER noam.aprs2.net

# Basic AFSK 1200
MODEM 1200
CHANNEL 0

# Optional: beacons
# If you enable beacons, DO NOT publish real coordinates in public examples.
# PBEACON delay=1 every=60 sendto=0 symbol="I&" overlay=R lat=XX^XX.XXN long=XXX^XX.XXW comment="APRS RX iGate" via=WIDE1-1 compress=0
# IBEACON SENDTO=IG delay=30 every=30

# Optional: disable file logging and rely on Docker logs (recommended for containers)
# LOGDIR /var/log/direwolf/

Step 6 — docker-compose.yml (least privilege + one-device passthrough)

This compose file:

  • Builds the image locally
  • Passes only the stable host node /dev/sdr-<serial>
  • Mounts direwolf.conf read-only so edits don’t require rebuilds
  • Keeps privileges low (drops everything, then adds only what’s needed for privilege drop)
  • Rotates Docker logs so nothing grows forever
services:
  direwolf:
    build:
      context: .
      dockerfile: Dockerfile

    container_name: direwolf
    restart: always

    env_file:
      - ./.env

    # Edit config without rebuild
    volumes:
      - ./direwolf.conf:/usr/local/etc/direwolf/direwolf.conf:ro

    # Pass ONLY the stable RTL-SDR node created by udev (Part 1)
    devices:
      - "/dev/sdr-${RTL_SERIAL}:/dev/sdr-${RTL_SERIAL}"

    # Hardening: no ambient privileges; add only what's needed for su-exec to drop privileges
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    cap_add:
      - SETUID
      - SETGID

    logging:
      driver: "json-file"
      options:
        max-size: "10m"
        max-file: "3"

If you see an error like su-exec: setgroups(...): Operation not permitted, it means the container can’t change identity. The fix is exactly the cap_add block above.


Bring-up and verification

Build and start

cd /srv/direwolf
docker compose build --no-cache
docker compose up -d --force-recreate
docker logs -f direwolf

Diagnostics mode

Set MODE=diag in .env, recreate, then exec into the container:

docker compose up -d --force-recreate
docker logs -f direwolf
docker exec -it direwolf sh

# inside container
rtl_test -t
rtl_test -d <SERIAL> -t

Expected “working” logs

  • RTL-SDR serial=... mapped: /dev/sdr-... -> /dev/bus/usb/BBB/DDD
  • Found 1 device(s) from rtl_test
  • Direwolf stays running (no immediate End of file on stdin)

Troubleshooting

rtl_test: “No supported devices found”

  • Confirm the host has the stable node: ls -l /dev/sdr-<serial>
  • Confirm Compose is passing it through (check docker compose config)
  • Use diag mode and run rtl_test -t inside the container

Direwolf exits immediately: “End of file on stdin”

  • This means rtl_fm exited (so stdin closed). Look earlier in container logs for why.
  • Verify the config uses ADEVICE stdin null and that you are invoking Direwolf with -c.

su-exec permission errors

  • Keep cap_add: [SETUID, SETGID] when you also cap_drop: ALL.
  • Install su-exec in the runtime image.

Up next: a VT dashboard without logging in

Part 3 will take the now-stable service logs and display them on a dedicated virtual terminal (VT) without requiring an interactive login. This keeps the box headless-friendly while still giving you an “appliance-style” status screen when you’re physically near it.

01 January 2026

Part 1: Least-Privilege Radio Services Stack with RTL-SDR and Docker Compose

This guide walks through building a secure, maintainable radio services host using RTL-SDR devices and Docker Compose while maintaining least privilege at every layer.

The design supports:

  • ADS-B reception (1090 MHz and 978 MHz)
  • APRS receive-only decoding (2 m and 70 cm)
  • APRS Internet-to-RF gating (separate Dire Wolf instance)
  • Meshtastic
  • A weather station

The key goals are:

  • No containers running as root
  • No applications running as root
  • Stable, serial-based USB device naming
  • Tightly scoped device access per container
  • Composable services rather than a single monolithic container

Architecture Overview

Each radio function runs in its own container, but the stack is managed as a single logical unit using Docker Compose. This preserves isolation while keeping operations simple.

  • ADS-B container: 1090 MHz + 978 MHz RTL-SDR receivers
  • Dire Wolf RX container: APRS VHF + UHF receive-only
  • Dire Wolf IS→RF container: gated, filtered Internet-to-RF traffic
  • Meshtastic container: LoRa mesh device via USB serial
  • Weather container: USB or RF-based sensors

Containers never see raw USB buses. Each container only receives the specific device node it requires.


Why Stable USB Naming Matters

RTL-SDR devices are indistinguishable by default. Enumeration order can change across reboots, kernel updates, or even simple re-plugs.

The correct solution is:

  • Program a unique serial number into each RTL-SDR
  • Use udev rules to create stable, human-readable device names
  • Grant permissions by group, not by user or blanket device access

A detailed background discussion and rationale for this approach is available here:

Stable USB Names for RTL-SDR and Related Devices


Step 1: Create Service Users and Device Groups

Create one group to own SDR devices and separate non-login users for each service class. This avoids privilege sharing between unrelated services.

# Group that owns RTL-SDR device nodes
sudo groupadd --system rtlsdr

# Service users (no shell, no home directories)
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid rtlsdr direwolf
sudo useradd --system --no-create-home --shell /usr/sbin/nologin --gid rtlsdr adsb
sudo useradd --system --no-create-home --shell /usr/sbin/nologin meshtastic
sudo useradd --system --no-create-home --shell /usr/sbin/nologin weather

These users will never log in interactively. They exist solely to map numeric UID/GID values into containers.


Step 2: Assign Unique RTL-SDR Serial Numbers

Each RTL-SDR must have a unique serial number burned into EEPROM.

sudo apt-get update
sudo apt-get install -y rtl-sdr

# Verify detection
rtl_test -t

# Program serials (one device at a time, replug after each)
sudo rtl_eeprom -s ADSB1090
sudo rtl_eeprom -s ADSB978
sudo rtl_eeprom -s APRS2M
sudo rtl_eeprom -s APRS70

These serial values will be referenced directly in udev rules.


Step 3: udev Rules for Stable Device Names

Create a dedicated directory for SDR symlinks and bind permissions tightly.

sudo mkdir -p /dev/rtl-sdr/by-id

sudo tee /etc/udev/rules.d/20-rtl-sdr.rules >/dev/null <<'EOF'
SUBSYSTEM=="usb", ATTR{idVendor}=="0bda", ATTR{idProduct}=="2838", \
  GROUP="rtlsdr", MODE="0660"

SUBSYSTEM=="usb", ATTR{serial}=="ADSB1090", \
  SYMLINK+="rtl-sdr/by-id/rtl_adsb1090"

SUBSYSTEM=="usb", ATTR{serial}=="ADSB978", \
  SYMLINK+="rtl-sdr/by-id/rtl_adsb978"

SUBSYSTEM=="usb", ATTR{serial}=="APRS2M", \
  SYMLINK+="rtl-sdr/by-id/rtl_aprs2m"

SUBSYSTEM=="usb", ATTR{serial}=="APRS70", \
  SYMLINK+="rtl-sdr/by-id/rtl_aprs70"
EOF

sudo udevadm control --reload-rules
sudo udevadm trigger

Verify:

ls -l /dev/rtl-sdr/by-id/

Step 4: Block Kernel DVB Drivers (If Needed)

Some systems automatically bind RTL-SDR devices to DVB drivers, preventing userspace SDR tools from accessing them.

sudo tee /etc/modprobe.d/rtl-sdr-blacklist.conf >/dev/null <<'EOF'
blacklist dvb_usb_rtl28xxu
blacklist rtl2832
blacklist rtl2830
EOF

sudo update-initramfs -u
# Reboot recommended

Step 5: Rootless Docker

Docker itself should run without root privileges. Rootless Docker ensures containers cannot escape to host root even if compromised.

sudo apt-get install -y uidmap dbus-user-session slirp4netns fuse-overlayfs
dockerd-rootless-setuptool.sh install
loginctl enable-linger "$USER"

export DOCKER_HOST=unix:///run/user/$(id -u)/docker.sock
docker info

Device access still works because udev grants permissions to the owning group.


Step 6: Directory Layout

/opt/radio-stack/
  docker-compose.yml
  .env
  direwolf/
    rx/
      direwolf.conf
    inet2rf/
      direwolf.conf
  meshtastic/
    config/
  weather/
    config/

Configuration files should be mounted read-only into containers.


Step 7: Docker Compose (Non-Root Containers)

services:
  adsb:
    image: ghcr.io/sdr-enthusiasts/docker-adsb-ultrafeeder
    user: "UID_ADSB:GID_RTLSDR"
    devices:
      - /dev/rtl-sdr/by-id/rtl_adsb1090:/dev/rtl1090
      - /dev/rtl-sdr/by-id/rtl_adsb978:/dev/rtl978
    read_only: true
    cap_drop: [ALL]
    security_opt:
      - no-new-privileges:true

  direwolf_rx:
    image: ghcr.io/wb2osz/direwolf
    user: "UID_DIREWOLF:GID_RTLSDR"
    devices:
      - /dev/rtl-sdr/by-id/rtl_aprs2m:/dev/rtl2m
      - /dev/rtl-sdr/by-id/rtl_aprs70:/dev/rtl70
    volumes:
      - ./direwolf/rx:/etc/direwolf:ro
    read_only: true
    cap_drop: [ALL]

  direwolf_inet2rf:
    image: ghcr.io/wb2osz/direwolf
    user: "UID_DIREWOLF:GID_RTLSDR"
    volumes:
      - ./direwolf/inet2rf:/etc/direwolf:ro
    read_only: true
    cap_drop: [ALL]

Additional containers (Meshtastic, weather) follow the same pattern: non-root user, only required devices, read-only filesystem.


Why This Is Better Than a Single Container

  • Each service has a minimal device attack surface
  • Failures are isolated
  • Upgrades can be staged independently
  • Security boundaries are explicit and auditable

A single container is technically possible, but it collapses these boundaries and undermines least-privilege design.


Conclusion

By combining stable USB naming, udev-based permissions, rootless Docker, and single-purpose containers, this architecture achieves:

  • Predictable hardware behavior
  • Strong containment
  • Operational clarity
  • Long-term maintainability

This approach scales cleanly as additional radios or services are added, without re-architecting the system.

07 December 2025

Debian + Fluxbox Setup (2025 refresh)

— Testing Rolling, Anti-Cinnamon Guardrails

This is a 2025 refresh of my Debian + Fluxbox notes, updated specifically for Debian Testing (rolling). The major addition this year is a set of guardrails to prevent Cinnamon from being installed during upgrades or via meta/task packages, plus a pre-flight step that removes Cinnamon if it already snuck in.

What’s new in this 2025 Testing update

  • Targets Debian Testing (rolling) rather than a fixed stable snapshot.
  • Pre-guardrail cleanup: detect and purge Cinnamon if present.
  • Two-layer anti-Cinnamon safety:
    • Hard APT pin refuses Cinnamon and its task/meta packages.
    • Lean APT defaults disable automatic Recommends/Suggests to prevent surprise DE pulls.
  • Audio modernized: media keys now use wpctl (PipeWire-native), not amixer.
  • Completes the daily-driver baseline: adds CUPS and a Debian-repo browser.

Assumptions

  • Fresh minimal install of Debian Testing.
  • You want Xorg + Fluxbox (not a full desktop environment).
  • You want NetworkManager to own networking.
  • You want a clean, stable lightweight workflow even as Testing evolves.

Install command

Save the script below as debian-fluxbox-setup-testing-2025.sh, then run:

sudo bash debian-fluxbox-setup-testing-2025.sh <username>

One-shot script (2025 Testing rolling)

#!/usr/bin/env bash
###############################################################################
# Debian Testing + Fluxbox setup helper (2025, data-driven components)
#
# PURPOSE
#   Turn a minimal Debian Testing install into "my normal workstation"
#   using a small set of data-driven components and profiles.
#
# KEY FEATURES
#   - Components are defined in a single list (COMPONENT_SPECS).
#   - Profiles: workstation (default), full, minimal, guardrails.
#   - Generic per-component flags: --no-, --enable-, --with-.
#   - Interactive wizard: --interactive.
#   - Dry-run: --dry-run (print plan, no changes).
#   - Per-user defaults config: ~/.config/debian-fluxbox-setup.conf
#
# HOW TO ADD A NEW COMPONENT / OPTION
#   1) Pick an ID (no spaces, used in flags & config), e.g. "dev-tools".
#   2) Decide:
#        - A short human description.
#        - Which apt packages to install (space-separated, or empty).
#        - An optional hook function name to run after install (or empty).
#        - Which profiles enable this by default:
#            workstation, full, minimal, guardrails
#
#   3) Add ONE LINE to COMPONENT_SPECS with this format:
#        "id|Description|pkg1 pkg2 ...|hook_function|profile1,profile2,..."
#
#      Examples:
#        - Package-only component:
#            "dev-tools|Developer tools (git, vim, htop)|git vim htop||workstation,full"
#
#        - Component with a post-install hook:
#            "my-net-tweaks|Custom network tweaks||my_net_hook|full"
#
#          and somewhere above in the script:
#            my_net_hook() {
#              # do custom sysctl, config files, etc.
#            }
#
#   4) After that, you automatically get:
#        - CLI flags:
#            --no-dev-tools        (disable)
#            --enable-dev-tools    (enable)
#            --with-dev-tools      (alias for --enable-dev-tools)
#        - Interactive prompt line.
#        - Config-file override:
#            dev-tools=true        or dev-tools=false
#        - Inclusion in any profile(s) you listed.
#
# CONFIG FILE (OPTIONAL)
#   Path: ~/.config/debian-fluxbox-setup.conf (for the user invoking sudo)
#
#   Supported keys:
#      PROFILE=                    # workstation, full, minimal, guardrails
#      =true|false         # e.g. audio=false, printing=true
#
#   Example:
#      PROFILE=minimal
#      audio=true
#      browsers=true
#
# PROFILES
#   workstation  (default)  2025 blog behavior: guardrails + X + NM + utils +
#                            bt/batt tray + PipeWire audio + printing + browsers +
#                            config + dev-tools.
#   full                    workstation + extras (vlc, libreoffice, etc.) +
#                            media-graphics, media-audio, games.
#   minimal                 guardrails + X + NM + basic utils, but:
#                            - NO audio
#                            - NO printing
#                            - NO browsers
#                            - NO bt/batt tray
#                            - NO extras
#   guardrails              Cinnamon cleanup + APT guardrails only.
#
# AUDIO BACKENDS
#   - Default in workstation/full: PipeWire stack (component: audio).
#   - Optional non-default ALSA stack (component: audio-alsa).
#   - Convenience flag:
#       --alsa / --alsa-audio  => disable PipeWire audio, enable ALSA audio.
#
# EXAMPLES
#   Default workstation profile for $SUDO_USER:
#      sudo ./debian-fluxbox-setup-2025.sh
#
#   Explicit user + full profile:
#      sudo ./debian-fluxbox-setup-2025.sh --user myuser --profile full
#
#   Minimal profile:
#      sudo ./debian-fluxbox-setup-2025.sh --user myuser --profile minimal
#
#   Guardrails only:
#      sudo ./debian-fluxbox-setup-2025.sh --profile guardrails
#
#   Drop specific components:
#      sudo ./debian-fluxbox-setup-2025.sh --profile full --no-printing --no-audio
#
#   Use ALSA audio instead of PipeWire:
#      sudo ./debian-fluxbox-setup-2025.sh --profile workstation --alsa
#
#   Dry-run (no changes, just a plan):
#      ./debian-fluxbox-setup-2025.sh --profile minimal --dry-run
#
#   Interactive:
#      sudo ./debian-fluxbox-setup-2025.sh --interactive
###############################################################################
set -euo pipefail

SCRIPT_NAME="${0##*/}"

usage() {
  cat <               Disable a component (e.g. --no-audio, --no-printing).
  --enable-           Enable a component (e.g. --enable-audio).
  --with-             Alias for --enable-.
  --extras                Alias for --enable-extras.

Audio backend convenience:
  --alsa
  --alsa-audio            Disable PipeWire audio, enable ALSA audio stack.

Other:
  -h, --help              Show this help and exit.

Config file:
  ~/.config/debian-fluxbox-setup.conf (for the user invoking sudo)
  Supported keys:
    PROFILE=
    =true|false   (e.g. audio=false, printing=true)
EOF
}

###############################################################################
# Low-level helpers
###############################################################################

os_hint() {
  if [[ -r /etc/os-release ]]; then
    if ! grep -qiE 'testing' /etc/os-release; then
      echo "[!] /etc/os-release does not appear to say 'testing'."
      echo "    Continuing anyway (you may be on a testing-derived snapshot)."
    fi
  fi
}

pre_clean_cinnamon() {
  echo "[*] Checking for existing Cinnamon install..."

  # SAFETY CHECK: Prevent purging if inside a GUI
  if [[ -n "${DISPLAY:-}" ]]; then
    echo "[!] WARNING: You appear to be running inside a graphical session (DISPLAY=$DISPLAY)."
    echo "    Purging Cinnamon while logged into it (or a DM) might crash your session."
    echo "    It is STRONGLY recommended to run this component from a TTY (Ctrl+Alt+F3)."
    echo
    read -r -p "    Press Enter to continue anyway (risk of crash), or Ctrl+C to abort." || exit 1
  fi

  local CINN_PKGS_INSTALLED
  CINN_PKGS_INSTALLED="$(
    dpkg -l 2>/dev/null | awk '{print $2}' | \
      grep -E '^cinnamon($|-)|^task-cinnamon-desktop$|^cinnamon-desktop-environment$' || true
  )"

  if [[ -n "${CINN_PKGS_INSTALLED}" ]]; then
    echo "[!] Cinnamon-related packages detected:"
    echo "${CINN_PKGS_INSTALLED}" | sed 's/^/    - /'

    echo "[*] Purging Cinnamon-related packages..."
    apt-get purge -y \
      task-cinnamon-desktop \
      cinnamon-desktop-environment \
      'cinnamon*' || true

    echo "[*] Autoremoving orphaned dependencies..."
    apt-get autoremove -y || true
    echo "[*] Cinnamon removal complete."
  else
    echo "[*] No Cinnamon packages detected."
  fi
}

setup_guardrails() {
  echo "[*] Creating APT guardrails..."
  cat > /etc/apt/apt.conf.d/99lean-desktop <<'EOF'
APT::Install-Recommends "false";
APT::Install-Suggests "false";
EOF

  cat > /etc/apt/preferences.d/no-cinnamon.pref <<'EOF'
Package: cinnamon
Pin: release *
Pin-Priority: -1

Package: cinnamon-*
Pin: release *
Pin-Priority: -1

Package: task-cinnamon-desktop
Pin: release *
Pin-Priority: -1

Package: cinnamon-desktop-environment
Pin: release *
Pin-Priority: -1
EOF
}

apt_update() {
  echo "[*] Updating apt..."
  apt-get update
}

network_post_install() {
  echo "[*] Enabling NetworkManager service..."
  systemctl enable --now NetworkManager

  echo "[*] Setting /etc/network/interfaces to loopback-only (NM-friendly)..."
  if [[ -f /etc/network/interfaces ]]; then
    cp -a /etc/network/interfaces "/etc/network/interfaces.bak.$(date +%Y%m%d%H%M%S)"
  fi
  cat > /etc/network/interfaces <<'EOF'
# NetworkManager-friendly baseline.
# Loopback only.
source /etc/network/interfaces.d/*

auto lo
iface lo inet loopback
EOF
}

setup_fluxbox_config() {
  if [[ -z "${USER_NAME:-}" || -z "${USER_HOME:-}" ]]; then
    echo "[!] Fluxbox config requested but USER_NAME/USER_HOME not set; skipping."
    return 0
  fi

  # Check which components are enabled so we can conditionally add menu entries
  # and keybindings that actually match the installed toolset.
  local utils_enabled="${COMP_ENABLED[utils]:-false}"
  local browsers_enabled="${COMP_ENABLED[browsers]:-false}"
  local network_enabled="${COMP_ENABLED[network]:-false}"
  local audio_pipewire_enabled="${COMP_ENABLED[audio]:-false}"
  local audio_alsa_enabled="${COMP_ENABLED[audio-alsa]:-false}"
  local printing_enabled="${COMP_ENABLED[printing]:-false}"

  echo "[*] Creating Fluxbox config skeleton for ${USER_NAME}..."
  install -d -m 0755 "${USER_HOME}/.fluxbox"
  chown -R "${USER_NAME}:${USER_NAME}" "${USER_HOME}/.fluxbox"

  ###########################################################################
  # Menu
  ###########################################################################
  if [[ ! -f "${USER_HOME}/.fluxbox/menu" ]]; then
    # Begin menu and always-present terminal
    cat > "${USER_HOME}/.fluxbox/menu" <<'EOF'
[begin] (fluxbox)
  [exec] (Terminal) {xterm}
EOF

    # File manager + display tools if utils=true
    if [[ "${utils_enabled}" == "true" ]]; then
      cat >> "${USER_HOME}/.fluxbox/menu" <<'EOF'
  [exec] (File Manager) {thunar}
  [exec] (Display Settings) {arandr}
EOF
    fi

    # Browsers if browsers=true
    if [[ "${browsers_enabled}" == "true" ]]; then
      cat >> "${USER_HOME}/.fluxbox/menu" <<'EOF'
  [exec] (Web Browser - Chromium) {chromium}
  [exec] (Web Browser - Firefox ESR) {firefox-esr}
EOF
    fi

    # Network connections editor if network=true
    if [[ "${network_enabled}" == "true" ]]; then
      cat >> "${USER_HOME}/.fluxbox/menu" <<'EOF'
  [exec] (Network Connections) {nm-connection-editor}
EOF
    fi

    # Audio control if any audio backend is enabled
    if [[ "${audio_pipewire_enabled}" == "true" ]]; then
      cat >> "${USER_HOME}/.fluxbox/menu" <<'EOF'
  [exec] (Audio Control) {pavucontrol}
EOF
    elif [[ "${audio_alsa_enabled}" == "true" ]]; then
      cat >> "${USER_HOME}/.fluxbox/menu" <<'EOF'
  [exec] (Audio Mixer) {alsamixer}
EOF
    fi

    # Printing GUI if printing=true
    if [[ "${printing_enabled}" == "true" ]]; then
      cat >> "${USER_HOME}/.fluxbox/menu" <<'EOF'
  [exec] (Printers) {system-config-printer}
EOF
    fi

    # System submenu (always present)
    cat >> "${USER_HOME}/.fluxbox/menu" <<'EOF'
  [submenu] (System)
    [exec] (Reconfigure) {fluxbox-remote reconfigure}
    [exec] (Restart) {fluxbox-remote restart}
    [exec] (Logout) {fluxbox-remote exit}
  [end]
[end]
EOF
  fi

  ###########################################################################
  # Keys
  ###########################################################################
  if [[ ! -f "${USER_HOME}/.fluxbox/keys" ]]; then
    # Base keybindings (non-audio)
    cat > "${USER_HOME}/.fluxbox/keys" <<'EOF'
# Basic Fluxbox keybindings

Mod1 Tab :NextWindow (workspace=[current])
Mod1 Shift Tab :PrevWindow (workspace=[current])

Mod1 F4 :Close

Mod1 z :RootMenu
Mod1 Shift z :HideMenus
Mod1 x :ExecCommand xterm

Control Shift n :ExecCommand chromium --incognito

XF86MonBrightnessUp   :ExecCommand /usr/bin/xbacklight -inc 5
XF86MonBrightnessDown :ExecCommand /usr/bin/xbacklight -dec 5
EOF

    # Audio keybindings (PipeWire) if enabled
    if [[ "${audio_pipewire_enabled}" == "true" ]]; then
      cat >> "${USER_HOME}/.fluxbox/keys" <<'EOF'

# Audio volume keys (PipeWire via wpctl)
XF86AudioLowerVolume :ExecCommand wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%-
XF86AudioRaiseVolume :ExecCommand wpctl set-volume @DEFAULT_AUDIO_SINK@ 5%+
XF86AudioMute        :ExecCommand wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle
EOF
    fi

    # Audio keybindings (ALSA) if enabled
    if [[ "${audio_alsa_enabled}" == "true" ]]; then
      cat >> "${USER_HOME}/.fluxbox/keys" <<'EOF'

# Audio volume keys (ALSA via amixer)
XF86AudioLowerVolume :ExecCommand amixer set Master 5%- unmute
XF86AudioRaiseVolume :ExecCommand amixer set Master 5%+ unmute
XF86AudioMute        :ExecCommand amixer set Master toggle
EOF
    fi
  fi

  ###########################################################################
  # Startup and .xinitrc
  ###########################################################################
  cat > "${USER_HOME}/.fluxbox/startup" <<'EOF'
#!/bin/sh

xset -b

command -v numlockx       >/dev/null 2>&1 && numlockx on &
command -v nm-applet      >/dev/null 2>&1 && nm-applet &
command -v blueman-applet >/dev/null 2>&1 && blueman-applet &
command -v cbatticon      >/dev/null 2>&1 && cbatticon &
command -v pasystray      >/dev/null 2>&1 && pasystray &

command -v fbsetbg        >/dev/null 2>&1 && fbsetbg -l &

xterm -geometry 120x55+0+0 &

exec /usr/bin/fluxbox
EOF

  chmod +x "${USER_HOME}/.fluxbox/startup"

  # .xinitrc
  if [[ ! -f "${USER_HOME}/.xinitrc" ]]; then
    cat > "${USER_HOME}/.xinitrc" <<'EOF'
#!/bin/sh
exec startfluxbox
EOF
    chmod +x "${USER_HOME}/.xinitrc"
  fi

  chown -R "${USER_NAME}:${USER_NAME}" \
    "${USER_HOME}/.fluxbox" \
    "${USER_HOME}/.xinitrc"
}

postrun_summary() {
  echo
  echo "=================================================================="
  echo "[*] Post-run verification"
  echo "=================================================================="
  echo
  echo "[1/7] Cinnamon packages installed (should be none if cleanup ran):"
  # Use if/else to avoid grep failure triggering set -e
  if dpkg -l | awk '{print $2}' | grep -qE '^cinnamon($|-)|^task-cinnamon-desktop$|^cinnamon-desktop-environment$'; then
    echo "  [!] Cinnamon packages still present:"
    dpkg -l | awk '{print $2}' | grep -E '^cinnamon($|-)|^task-cinnamon-desktop$|^cinnamon-desktop-environment$' || true
  else
    echo "  [✓] none"
  fi
  echo
  echo "[2/7] APT policy for cinnamon (expect Pin-Priority -1 if guardrails enabled):"
  apt-cache policy cinnamon 2>/dev/null || echo "  (package not visible in current cache)"
  echo
  echo "[3/7] APT guardrail files:"
  ls -l /etc/apt/apt.conf.d/99lean-desktop /etc/apt/preferences.d/no-cinnamon.pref 2>/dev/null || \
    echo "  (guardrail files not present)"
  echo
  echo "[4/7] CUPS packages:"
  dpkg -l | awk '{print $2}' | grep -E '^cups$|^cups-client$' || echo "  (cups not installed)"
  echo
  echo "[5/7] Browser packages:"
  dpkg -l | awk '{print $2}' | grep -E '^chromium$|^firefox-esr$' || echo "  (no Debian-repo browser installed)"
  echo
  echo "[6/7] PipeWire control tool (wpctl):"
  if command -v wpctl >/dev/null 2>&1; then
    echo "  [✓] found at: $(command -v wpctl)"
  else
    echo "  [!] wpctl not found (likely using ALSA-only or no audio component)"
  fi
  echo
  echo "[7/7] wpctl status (may be minimal pre-login):"
  if command -v wpctl >/dev/null 2>&1 && [[ -n "${USER_NAME:-}" ]]; then
    sudo -u "${USER_NAME}" wpctl status 2>/dev/null || echo "  (no active user PipeWire session yet)"
  else
    echo "  (skipped)"
  fi
  echo
  echo "[✓] Setup complete."
  echo
  echo "Next steps:"
  if [[ -n "${USER_N
AME:-}" ]]; then
    echo "  - Log in as ${USER_NAME}"
  fi
  echo "  - If not using a display manager, run: startx"
  echo "  - Use nm-applet to join Wi-Fi (if installed)"
  echo
  echo "Guardrails (if enabled):"
  echo "  - /etc/apt/preferences.d/no-cinnamon.pref"
  echo "  - /etc/apt/apt.conf.d/99lean-desktop"
  echo
  echo "To re-enable Recommends/Suggests globally:"
  echo "  sudo rm /etc/apt/apt.conf.d/99lean-desktop"
  echo
  echo "To remove the Cinnamon block:"
  echo "  sudo rm /etc/apt/preferences.d/no-cinnamon.pref"
  echo "=================================================================="
}

###############################################################################
# Data-driven component definitions
#
# Format of each entry:
#   "id|Description|packages|hook_function|default_profiles"
#
#   id               : short token, used in flags (--no-id, --enable-id)
#   Description      : human-readable description shown in prompts/plan
#   packages         : space-separated apt package names (or empty)
#   hook_function    : shell function name to run after packages (or empty)
#   default_profiles : comma-separated profiles that enable this by default:
#                        workstation,full,minimal,guardrails
###############################################################################

COMPONENT_SPECS=(
  "cinnamon-clean|Pre-clean Cinnamon packages||pre_clean_cinnamon|workstation,full,minimal,guardrails"
  "guardrails|APT guardrails (no Cinnamon, no Recommends/Suggests)||setup_guardrails|workstation,full,minimal,guardrails"
  "x|Xorg + Fluxbox core|xorg xinit x11-xserver-utils xterm fluxbox fonts-dejavu-core xbacklight||workstation,full,minimal"
  "network|NetworkManager and NM-friendly config|network-manager network-manager-gnome|network_post_install|workstation,full,minimal"
  "utils|Thunar & desktop utilities|thunar thunar-volman arandr numlockx||workstation,full,minimal"
  "bt-batt|Bluetooth & battery tray|blueman cbatticon||workstation,full"
  "audio|Audio (PipeWire stack)|pipewire-audio pipewire-pulse wireplumber pavucontrol pasystray||workstation,full"
  "audio-alsa|Audio (ALSA stack)|alsa-utils alsa-tools alsa-oss||"
  "printing|Printing stack (CUPS)|cups cups-client system-config-printer||workstation,full"
  "browsers|Browsers (Chromium & Firefox ESR)|chromium firefox-esr||workstation,full"
  "extras|Extras (evince, vlc, libreoffice, etc.)|evince vlc libreoffice xtrlock xfburn simple-scan calibre||full"
  "config|Fluxbox user config skeleton (per-user)||setup_fluxbox_config|workstation,full,minimal"
  "dev-tools|Developer tools (git, vim, tmux, htop, build-essential)|git vim tmux htop build-essential curl||workstation,full"
  "media-graphics|Graphics tools (GIMP, Inkscape)|gimp inkscape||full"
  "media-audio|Audio editor tools (Audacity)|audacity||full"
  "dosbox|Retro DOSBox gaming (DOSBox Staging)|dosbox-staging||full"
  "casual-games|Lightweight games (ScummVM, Xboard, XMahjongg, XGalaga)|scummvm xboard gnuchess xmahjongg xgalaga||full"
)

# Arrays to hold parsed component data
declare -a COMPONENT_IDS
declare -A COMP_DESC COMP_PKGS COMP_HOOK COMP_DEFAULTS COMP_ENABLED

for spec in "${COMPONENT_SPECS[@]}"; do
  IFS='|' read -r id desc pkgs hook defaults <<<"$spec"
  COMPONENT_IDS+=("$id")
  COMP_DESC["$id"]="$desc"
  COMP_PKGS["$id"]="$pkgs"
  COMP_HOOK["$id"]="$hook"
  COMP_DEFAULTS["$id"]="$defaults"
done

###############################################################################
# Profiles & config
###############################################################################

INTERACTIVE="false"
DRY_RUN="false"
PROFILE="workstation"

apply_profile() {
  local id defaults
  for id in "${COMPONENT_IDS[@]}"; do
    defaults="${COMP_DEFAULTS[$id]}"
    if [[ ",${defaults}," == *",${PROFILE},"* ]]; then
      COMP_ENABLED["$id"]="true"
    else
      COMP_ENABLED["$id"]="false"
    fi
  done
}

load_config() {
  local base_user base_home cfg_path line key value val_lc

  base_user="${SUDO_USER:-root}"
  base_home="$(eval echo "~${base_user}")"
  cfg_path="${base_home}/.config/debian-fluxbox-setup.conf"

  if [[ ! -r "${cfg_path}" ]]; then
    return 0
  fi

  echo "[*] Loading config from ${cfg_path}"

  while IFS= read -r line || [[ -n "$line" ]]; do
    line="${line%%#*}"
    line="${line#"${line%%[![:space:]]*}"}"
    line="${line%"${line##*[![:space:]]}"}"
    [[ -z "$line" ]] && continue
    [[ "$line" != *=* ]] && continue

    key="${line%%=*}"
    value="${line#*=}"
    key="${key%"${key##*[![:space:]]}"}"
    key="${key#"${key%%[![:space:]]*}"}"
    value="${value%"${value##*[![:space:]]}"}"
    value="${value#"${value%%[![:space:]]}"}"

    case "$key" in
      PROFILE)
        PROFILE="$value"
        apply_profile
        ;;
      *)
        if [[ -n "${COMP_DESC[$key]+_}" ]]; then
          val_lc="$(printf '%s' "$value" | tr 'A-Z' 'a-z')"
          case "$val_lc" in
            true|1|yes|on)  COMP_ENABLED["$key"]="true" ;;
            false|0|no|off) COMP_ENABLED["$key"]="false" ;;
            *)
              echo "[*] Ignoring invalid boolean for ${key} in config: ${value}"
              ;;
          esac
        else
          echo "[*] Ignoring unknown config key: $key"
        fi
        ;;
    esac
  done < "${cfg_path}"
}

# Initialize from default profile, then config
apply_profile
load_config

###############################################################################
# CLI parsing
###############################################################################

USER_NAME="${SUDO_USER:-}"

while [[ $# -gt 0 ]]; do
  case "$1" in
    -u|--user)
      if [[ $# -lt 2 ]]; then
        echo "Error: --user requires an argument" >&2
        usage
        exit 1
      fi
      USER_NAME="$2"
      shift 2
      ;;
    -i|--interactive)
      INTERACTIVE="true"
      shift
      ;;
    -p|--profile)
      if [[ $# -lt 2 ]]; then
        echo "Error: --profile requires an argument" >&2
        usage
        exit 1
      fi
      PROFILE="$2"
      apply_profile
      shift 2
      ;;
    --profile=*)
      PROFILE="${1#*=}"
      apply_profile
      shift
      ;;
    --alsa|--alsa-audio)
      # Switch audio backend: disable PipeWire audio, enable ALSA audio.
      if [[ -n "${COMP_DESC[audio]+_}" ]]; then
        COMP_ENABLED["audio"]="false"
      fi
      if [[ -n "${COMP_DESC[audio-alsa]+_}" ]]; then
        COMP_ENABLED["audio-alsa"]="true"
      fi
      shift
      ;;
    --dry-run)
      DRY_RUN="true"
      shift
      ;;
    --no-*)
      {
        id="${1#--no-}"
        if [[ -n "${COMP_DESC[$id]+_}" ]]; then
          COMP_ENABLED["$id"]="false"
          shift
        else
          echo "Unknown component in flag: $1" >&2
          usage
          exit 1
        fi
      }
      ;;
    --enable-*|--with-*)
      {
        id="${1#--enable-}"
        id="${id#--with-}"
        if [[ -n "${COMP_DESC[$id]+_}" ]]; then
          COMP_ENABLED["$id"]="true"
          shift
        else
          echo "Unknown component in flag: $1" >&2
          usage
          exit 1
        fi
      }
      ;;
    --extras)
      if [[ -n "${COMP_DESC[extras]+_}" ]]; then
        COMP_ENABLED["extras"]="true"
      fi
      shift
      ;;
    -h|--help)
      usage
      exit 0
      ;;
    --)
      shift
      break
      ;;
    *)
      echo "Unknown option: $1" >&2
      usage
      exit 1
      ;;
  esac
done

###############################################################################
# Interactive wizard
###############################################################################

ask_component() {
  local id="$1"
  local current="${COMP_ENABLED[$id]:-false}"
  local desc="${COMP_DESC[$id]}"
  local def_str reply

  if [[ "$current" == "true" ]]; then
    def_str="Y/n"
  else
    def_str="y/N"
  fi

  while true; do
    read -r -p "  - ${desc}? [$def_str] " reply || { echo; echo "Aborted."; exit 1; }
    reply="${reply:-}"
    if [[ -z "$reply" ]]; then
      break
    fi
    case "$reply" in
      [Yy]*) COMP_ENABLED["$id"]="true";  break ;;
      [Nn]*) COMP_ENABLED["$id"]="false"; break ;;
      *)     echo "Please answer y or n." ;;
    esac
  done
}

if [[ "${INTERACTIVE}" == "true" ]]; then
  echo "=== Debian + Fluxbox setup interactive mode ==="
  echo

  if [[ -z "${USER_NAME}" ]]; then
    read -r -p "Target username (must already exist, or leave blank for system-only): " USER_NAME
  else
    tmp_user=""
    read -r -p "Target username [${USER_NAME}] (blank to keep): " tmp_user || true
    if [[ -n "${tmp_user:-}" ]]; then
      USER_NAME="${tmp_user}"
    fi
  fi

  echo
  echo "Profile base (current: ${PROFILE}):"
  echo "  workstation, full, minimal, guardrails"
  tmp_profile=""
  read -r -p "Profile name (or blank to keep '${PROFILE}'): " tmp_profile || true
  if [[ -n "${tmp_profile:-}" ]]; then
    PROFILE="${tmp_profile}"
    apply_profile
  fi

  echo
  echo "Toggle components (Y = include, n = drop):"
  for id in "${COMPONENT_IDS[@]}"; do
    ask_component "$id"
  done
  echo
fi

###############################################################################
# Pre-flight checks and plan
###############################################################################

USER_HOME=""
if [[ -n "${USER_NAME:-}" ]]; then
  if ! id "${USER_NAME}" >/dev/null 2>&1; then
    echo "User '${USER_NAME}' does not exist." >&2
    exit 1
  fi
  USER_HOME="$(eval echo "~${USER_NAME}")"
fi

# Root check (skip for dry-run so you can inspect plans as non-root).
if [[ "${DRY_RUN}" != "true" && "$(id -u)" -ne 0 ]]; then
  echo "Please run as root (use sudo)." >&2
  exit 1
fi

# Config component needs a user when actually writing files.
if [[ "${DRY_RUN}" != "true" && "${COMP_ENABLED[config]:-false}" == "true" && -z "${USER_NAME:-}" ]]; then
  echo "Error: 'config' component enabled but no target username was provided." >&2
  echo "Specify --user USER or disable with --no-config or use guardrails-only profile." >&2
  exit 1
fi

os_hint

echo "=================================================================="
echo "Debian + Fluxbox setup - plan"
echo "  Target user:   ${USER_NAME:-}"
echo "  Profile:       ${PROFILE}"
echo "  Dry-run mode:  ${DRY_RUN}"
echo
echo "Components:"
for id in "${COMPONENT_IDS[@]}"; do
  printf "  - %-12s : %s [%s]\n" "$id" "${COMP_DESC[$id]}" "${COMP_ENABLED[$id]:-false}"
done
echo "=================================================================="
echo

# Dry-run: show high-level steps and exit before touching the system.
if [[ "${DRY_RUN}" == "true" ]]; then
  echo "Dry-run mode: no changes will be made."
  echo "Steps that WOULD be executed:"
  echo "  - apt-get update"
  for id in "${COMPONENT_IDS[@]}"; do
    if [[ "${COMP_ENABLED[$id]:-false}" == "true" ]]; then
      pkgs="${COMP_PKGS[$id]}"
      hook="${COMP_HOOK[$id]}"
      echo "  - ${COMP_DESC[$id]}"
      if [[ -n "$pkgs" ]]; then
        echo "      packages: $pkgs"
      fi
      if [[ -n "$hook" ]]; then
        echo "      hook: $hook"
      fi
    fi
  done
  echo
  echo "No packages will be installed or removed, and no files will be written in dry-run mode."
  exit 0
fi

###############################################################################
# Component runner
###############################################################################

run_component() {
  local id="$1"
  local pkgs="${COMP_PKGS[$id]}"
  local hook="${COMP_HOOK[$id]}"

  if [[ "${COMP_ENABLED[$id]:-false}" != "true" ]]; then
    echo "[*] Skipping ${COMP_DESC[$id]}."
    return 0
  fi

  if [[ -n "$pkgs" ]]; then
    echo "[*] Installing packages for ${COMP_DESC[$id]}..."
    # shellcheck disable=SC2086
    apt-get install -y $pkgs
  fi

  if [[ -n "$hook" ]]; then
    "$hook"
  fi
}

###############################################################################
# Execution
###############################################################################

apt_update

for id in "${COMPONENT_IDS[@]}"; do
  run_component "$id"
done

postrun_summary

Additional software (optional, still fits the Fluxbox ethos)

If you want parity with the older “daily driver” list, these still pair nicely with a lightweight setup:

  • Document/PDF: evince
  • Media: vlc
  • Office: libreoffice
  • Locking: xtrlock
  • Optical: xfburn (if you still burn discs)
  • Scanning: simple-scan (if you need it)
  • E-books: calibre

Why I’m comfortable with these guardrails

  • Testing changes quickly. The pin ensures Cinnamon can’t be reintroduced via drift.
  • Lean APT defaults reduce surprises. Install “nice-to-haves” explicitly, when you want them.
  • You can disable this any time. Removing two small files restores default behavior.

Quick troubleshooting

  • Wi-Fi not showing up? Confirm your interfaces aren’t declared in /etc/network/interfaces beyond loopback and that NetworkManager is enabled.
  • wpctl keys not working on first boot? Log into Fluxbox once so your user PipeWire session is active; then test again.
  • Printing not detected? Install model-specific drivers if needed and confirm CUPS is running.

04 December 2025

Opt-In Directory Browsing in Caddy Using a Hidden Sentinel File

Sometimes you want Caddy to behave like a normal Single Page Application (SPA) host, but still have the option to enable directory listings only in specific folders without turning browsing on globally.

In my case, I wanted:

  • No directory listing at the site root (/)
  • No browsing by default in any directory
  • Opt-in browsing in a directory only if a special hidden file exists
  • Real 404s for missing static files (e.g. .css, .txt)
  • SPA fallback for “virtual” routes (no file extension)

The solution: use a hidden sentinel file (I chose .browse) and Caddy’s matchers to decide:

  • When to show a directory listing
  • When to serve static files or return 404
  • When to fall back to /index.html for SPA routing

The Final Caddyfile Snippet

.net:443 {
    root * /srv/static

    encode zstd gzip

    # Browsable directories: any non-root path whose directory
    # contains a file named `.browse`
    @browsable_dir {
        not path /      # never browse the site root
        file {
            # Handle both `/dir` and `/dir/` request forms:
            try_files {path}/.browse {path}.browse
        }
    }

    # Requests that look like "static files" (have an extension),
    # e.g. /foo.css, /img/logo.png, /docs/file.pdf
    @static_with_ext path_regexp static_ext \.[^/]+$

    route {
        # 1) Browsable directories (sentinel present)
        handle @browsable_dir {
            file_server browse {
                hide .browse
            }
        }

        # 2) Static-looking requests (with an extension):
        #    serve file if it exists, otherwise 404
        handle @static_with_ext {
            try_files {path} =404

            file_server {
                hide .browse
            }
        }

        # 3) Everything else → SPA fallback
        #    - try to serve real files/dirs
        #    - if none match, fall back to /index.html
        handle {
            try_files {path} {path}/ /index.html
            file_server {
                hide .browse
            }
        }
    }

    # Security headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        Permissions-Policy "camera=(), microphone=(), geolocation=()"
        X-Frame-Options "SAMEORIGIN"
    }

    # Simple text error responses (e.g., "404 Not Found")
    handle_errors {
        respond "{err.status_code} {err.status_text}"
    }
}

How the Matchers Work

1. Browsable directories (@browsable_dir)

@browsable_dir {
    not path /
    file {
        try_files {path}/.browse {path}.browse
    }
}

This does two key things:

  1. not path /
    Ensures the matcher never triggers on the site root. The root path / will never show a directory listing, even if a .browse file exists there.
  2. file { try_files {path}/.browse {path}.browse }
    Checks for a hidden sentinel file named .browse in the directory corresponding to the request path. It works with both /dir and /dir/ request forms.

    For example:
    • Request: /bar/
    • Checks: /srv/static/bar/.browse
    • If that file exists, @browsable_dir matches and browsing is enabled.

2. Static files with extensions (@static_with_ext)

@static_with_ext path_regexp static_ext \.[^/]+$

This matcher catches paths that look like real files because they end with an extension:

  • /foo.css
  • /img/logo.png
  • /docs/file.pdf
  • /foo/thing1.txt

Those requests are handled here:

handle @static_with_ext {
    try_files {path} =404

    file_server {
        hide .browse
    }
}

If the file exists, it’s served normally. If it does not exist, the =404 causes Caddy to emit a real 404, which then flows through handle_errors and becomes a simple text response like:

404 Not Found

Because we use hide .browse in file_server, any direct request to a .browse file will also result in a 404, even if the file exists. The sentinel is never exposed directly, which is exactly what we want.

3. SPA fallback for everything else

handle {
    try_files {path} {path}/ /index.html
    file_server {
        hide .browse
    }
}

Any request that is not:

  • a “browsable directory” (@browsable_dir), and
  • not a “static file with extension” (@static_with_ext)

falls into this final handler. The logic is:

  • Try the request as a file: {path}
  • Then as a directory: {path}/
  • If neither exists, rewrite to /index.html (SPA entrypoint)

That means “virtual” SPA routes like /app/settings or /docs/getting-started will still end up at /index.html, and your JS router can take over from there.


Error Handling

For errors, this simple handler is used:

handle_errors {
    respond "{err.status_code} {err.status_text}"
}

So a missing static file (like /foo/missing.txt) results in a plain-text:

404 Not Found

This keeps error responses easy to see and debug.


Example Directory Structure and Request Behavior

Assume the following directory layout under /srv/static (plus a normal SPA entrypoint at /srv/static/index.html):

/
├── .browse
├── index.html
├── foo
│   └── thing1.txt
└── bar
    ├── .browse
    ├── thing2.txt
    ├── a
    │   └── thing3.txt
    └── b
        ├── .browse
        └── thing4.txt

Here’s how various requests behave with this configuration:

Request URL Matched Handler Result
/ Final handle (SPA) Serves /index.html (SPA entrypoint).
Root never shows a directory listing, even though .browse exists at the root, because of not path / in @browsable_dir.
/.browse @static_with_extfile_server with hide .browse The file exists at /srv/static/.browse, but it is hidden by hide .browse.
Result: 404 Not Found — the sentinel is not directly accessible.
/foo/ Final handle (SPA branch) /foo/ exists as a directory, but there is no .browse inside it and no index.html in /foo/.
Result: 404 Not Found (directory listing is disabled, and there's no index file).
/foo/thing1.txt @static_with_ext Looks like a static file (has .txt extension).
File exists at /srv/static/foo/thing1.txt.
Result: serves thing1.txt (200 OK).
/bar/ @browsable_dir /bar/.browse exists, so @browsable_dir matches.
Result: directory listing for /bar/ via file_server browse, with .browse hidden.
You’ll see entries like thing2.txt, a/, and b/.
/bar/thing2.txt @static_with_ext Static file with extension, exists on disk.
Result: serves thing2.txt (200 OK).
/bar/a/ Final handle /bar/a/ is a real directory, but:
- It does not contain .browse → no directory listing.
- It has no index.html.
Result: 404 Not Found.
/bar/a/thing3.txt @static_with_ext Static file with extension, exists at /srv/static/bar/a/thing3.txt.
Result: serves thing3.txt (200 OK).
/bar/b/ @browsable_dir /bar/b/.browse exists, so this directory is explicitly marked as browsable.
Result: directory listing for /bar/b/, showing thing4.txt (with .browse hidden).
/bar/b/thing4.txt @static_with_ext Normal static file.
Result: serves thing4.txt (200 OK).
/bar/.browse @static_with_extfile_server with hide .browse Sentinel file exists at /srv/static/bar/.browse, but hide .browse makes it behave like a hidden file.
Result: 404 Not Found — again, the sentinel is not directly accessible.
/foo/missing.txt (not in tree) @static_with_ext Matches the static-file matcher, but the file does not exist.
try_files {path} =404 falls through to =404.
Result: 404 Not Found (plain text via handle_errors).

Why Use a Hidden Sentinel File?

This pattern has a few nice properties:

  • Safe by default: Browsing is off everywhere unless you explicitly opt a directory in with .browse.
  • Per-directory control: You can toggle listing for any directory by simply adding or removing a tiny hidden file.
  • Works with SPAs: Non-extension routes still fall back to /index.html, so your front-end router behaves as expected.
  • Real 404s for static files: Missing .css, .js, .txt, etc. return a genuine 404 Not Found.
  • Sentinel stays private: .browse is hidden from directory listings and returns 404 when requested directly.

If you’re hosting a SPA with Caddy and want fine-grained, opt-in directory listings plus correct 404 behavior for static assets, this hidden .browse sentinel pattern is a clean and flexible way to do it.

21 November 2025

Wireshark Capture the Flag (CTF)

This post contains a complete Wireshark-based Capture the Flag (CTF) activity suitable for high school students who are new to cybersecurity. It includes:

  • Teacher Guide – detailed, step-by-step instructions to generate the PCAP and run the activity.
  • Student Handout – ready to print or share digitally.

🧑‍🏫 TEACHER GUIDE – Wireshark CTF in One PCAP

1. Purpose and Overview

This activity is a guided Capture The Flag (CTF) using a single Wireshark capture file that contains:

  • Realistic background network traffic (“noise”)
  • Seven embedded challenges, each hiding a FLAG{...} string

Students will:

  • Learn basic Wireshark navigation and filters
  • Inspect ICMP, DNS, HTTP, FTP, ARP/DHCP
  • Extract files from network captures
  • Understand why cleartext protocols (FTP, HTTP, basic auth) are insecure

This guide explains:

  1. How to prepare the environment
  2. Step-by-step commands to generate the PCAP
  3. How to verify each challenge is present
  4. How to run the activity with students
  5. Full answer key

2. Technical Prerequisites

You’ll need:

  1. One Linux machine or VM to generate the PCAP:
    • Ubuntu / Debian works great
    • Needs internet access (optional but helpful for realistic DNS/web noise)
  2. Installed software on that machine:
    • tcpdump (for capturing)
    • python3 & pip
    • Wireshark (for verification)
    • Optional but recommended: curl, dig (DNS client), ftp, hostnamectl
  3. For the classroom:
    • Each student or team has Wireshark installed
    • A copy of the final final_ctf.pcapng
    • Projector/whiteboard to explain protocol concepts

Note: All traffic you generate is synthetic and local. You’re not hacking anything; you’re simulating traffic to be analyzed offline later.

3. High-Level Build Plan

You will:

  1. Start a single long-running capture: final_ctf.pcapng
  2. Generate:
    • Background noise (pings, normal HTTP/HTTPS, DNS)
    • Seven challenge sequences:
      • ICMP flag
      • HTTP POST with flag in password
      • ARP/DHCP network mapping
      • DNS exfil-style queries
      • HTTP image download
      • FTP private key transfer
      • HTTP Basic Auth credential leak
    • More background noise interspersed
  3. Stop the capture
  4. Verify each challenge in Wireshark
  5. Distribute the final_ctf.pcapng plus the Student Handout
  6. Use the answer key (at the end of this guide) for grading and hints

4. Environment Setup (Once)

On your Linux CTF-builder machine, run:

sudo apt update
sudo apt install -y tcpdump curl dnsutils ftp python3 python3-pip

Install Python libraries:

pip install flask pyftpdlib

Confirm Wireshark is installed (or install via GUI / package manager).

5. Start the Master Capture

  1. Identify your main network interface:
    ip addr
    

    Look for something like eth0, ens33, or wlan0.
    In this guide, we’ll use eth0. Replace it as needed.

  2. Start the capture:
    sudo tcpdump -i eth0 -w final_ctf.pcapng
    

    Leave this terminal open and running. Everything you do now will be recorded.

6. Generate Realistic Background Noise

You can sprinkle these before, between, and after challenges.

6.1 ICMP Noise (Pings)

ping -c 10 8.8.8.8 &
ping -c 10 1.1.1.1 &

6.2 HTTP & HTTPS Noise

curl http://example.com > /dev/null 2>&1
curl http://neverssl.com > /dev/null 2>&1
curl https://www.debian.org > /dev/null 2>&1
curl https://www.python.org > /dev/null 2>&1

6.3 DNS Noise

dig google.com
dig wikipedia.org
dig schooldistrict.local

You can repeat noise periodically between challenges.

7. Generate Each Challenge (Step-by-Step)

You can do these in any order, but following this sequence makes your answer key easier to manage.

Challenge 1 – ICMP “Curious Ping”

Goal for students: Find a flag hidden in the payload of an ICMP echo request.

Flag: FLAG{CURIOUS_PING}

Command:

ping -c 1 -p 464C41477B435552494F55535F50494E477D 8.8.8.8

The hex payload decodes to ASCII FLAG{CURIOUS_PING}.

Verify later:

  1. Open final_ctf.pcapng in Wireshark.
  2. Filter: icmp.
  3. Click the ICMP Echo Request to 8.8.8.8.
  4. Expand ICMP → Data and confirm the hex matches the flag.

Challenge 2 – HTTP POST “Forgotten Login”

Goal: Use “Follow TCP Stream” to find a flag hidden in HTTP POST parameters.

Flag: FLAG{FOLLOW_THE_STREAM}

7.2.1 Run a Simple HTTP Server
mkdir -p /tmp/ctf_http && cd /tmp/ctf_http
python3 -m http.server 8080 &
7.2.2 Generate the POST Request
curl -X POST \
  -d "username=student&password=FLAG{FOLLOW_THE_STREAM}" \
  http://localhost:8080/login

Verify later:

  1. Filter: http && tcp.port == 8080.
  2. Find the HTTP POST /login packet.
  3. Right-click → FollowTCP Stream.
  4. Look for: username=student&password=FLAG{FOLLOW_THE_STREAM}.

Challenge 3 – ARP/DHCP “Who’s in the Network?”

Goal: Observe ARP and DHCP to see devices and recognize a suspicious hostname.

Flag (conceptual, from hostname/clue): FLAG{EVIL_HAXOR}

7.3.1 Create ARP/DHCP Traffic
sudo dhclient -r
sudo dhclient

This generates DHCP/BOOTP and ARP traffic as it requests an IP.
If you have multiple machines, repeat similar operations on them to create a richer ARP table.

7.3.2 (Optional) Set Suspicious Hostname
sudo hostnamectl set-hostname EVIL-HAXOR-666

Then generate a bit more traffic (pings, web requests).

Verify later:

  1. Filter: bootp || dhcp to see DHCP traffic.
  2. Filter: arp to see IP/MAC mapping.
  3. If your DHCP implementation reveals hostname, look for EVIL-HAXOR-666 in DHCP options.
    Otherwise, treat it as a narrative clue: the rogue host is named EVIL-HAXOR-666, so the flag is FLAG{EVIL_HAXOR}.

Challenge 4 – DNS “Conspiracy Queries”

Goal: Spot suspicious DNS queries and reconstruct a fragmented flag.

Flag: FLAG{THIS_DNS_CONSPIRACY}

Split into three queries:

dig FLAG{THIS_.example.com
dig DNS_CONS.example.com
dig PIRACY}.example.com

Verify later:

  1. Filter: dns.
  2. Look at the Query Name column.
  3. You should see:
    • FLAG{THIS_.example.com
    • DNS_CONS.example.com
    • PIRACY}.example.com
  4. Students reconstruct the full flag: FLAG{THIS_DNS_CONSPIRACY}.

Challenge 5 – HTTP File “Lost Photo”

Goal: Export HTTP objects and recover a file (image) that contains the flag.

Flag inside the image: FLAG{HTTP_OBJECT_EXTRACT}

7.5.1 Prepare an Image
  1. Pick any JPEG (e.g., school logo).
  2. Add visible text: FLAG{HTTP_OBJECT_EXTRACT}.
  3. Save it as mascot.jpg.

Serve it:

mkdir -p /tmp/ctf_http2
cp mascot.jpg /tmp/ctf_http2/
cd /tmp/ctf_http2
python3 -m http.server 8081 &
7.5.2 Generate the Download
curl http://localhost:8081/mascot.jpg > /dev/null

Verify later:

  1. Filter: http && tcp.port == 8081.
  2. Go to File → Export Objects → HTTP.
  3. Find and save mascot.jpg.
  4. Open it and confirm the flag text is visible.

Challenge 6 – FTP “Leaky Key”

Goal: See how FTP transfers a private key in cleartext and extract the flag from that file.

Flag in file: FLAG{FTP_LEAKED_PRIVATE_KEY}

7.6.1 Set Up FTP Server
mkdir -p /tmp/ftp_home
cat > /tmp/ftp_home/id_rsa << 'EOF'
-----BEGIN OPENSSH PRIVATE KEY-----
MIIBVgIBADANBgkqhkiG9w0BAQEFAASCAT8wggE7AgEAAkEA...
... (any dummy key-like text is fine) ...
-----END OPENSSH PRIVATE KEY-----
# FLAG{FTP_LEAKED_PRIVATE_KEY}
EOF

Create ftp_server.py:

from pyftpdlib.authorizers import DummyAuthorizer
from pyftpdlib.handlers import FTPHandler
from pyftpdlib.servers import FTPServer

authorizer = DummyAuthorizer()
authorizer.add_user("testuser", "p@ssw0rd", "/tmp/ftp_home", perm="elradfmw")

handler = FTPHandler
handler.authorizer = authorizer

server = FTPServer(("0.0.0.0", 2121), handler)
server.serve_forever()

Run it:

python3 ftp_server.py &
7.6.2 Perform FTP Transfer
ftp localhost 2121 <<EOF
user testuser p@ssw0rd
binary
get id_rsa
bye
EOF

Verify later:

  1. Filter: ftp || ftp-data.
  2. Go to File → Export Objects → FTP.
  3. Save id_rsa.
  4. Open it and confirm the last line contains the flag.

Challenge 7 – HTTP Basic Auth “Secret Password”

Goal: Recognize HTTP Basic Auth, decode credentials, and see the flag in the password.

Flag (as password): FLAG{PASSWORD_IN_BASIC_AUTH}

7.7.1 Create Basic Auth Web Server

Create basic_auth_server.py:

from flask import Flask, Response, request

app = Flask(__name__)

@app.route("/secret")
def secret():
    auth = request.authorization
    if not auth:
        return Response("Auth required", status=401)
    return f"Welcome {auth.username}"

if __name__ == "__main__":
    app.run(host="0.0.0.0", port=5001)

Run it:

python3 basic_auth_server.py &
7.7.2 Send Request with Flag as Password
curl -u admin:FLAG{PASSWORD_IN_BASIC_AUTH} http://localhost:5001/secret

Verify later:

  1. Filter: http && tcp.port == 5001.
  2. Find the HTTP GET /secret.
  3. Inspect the HTTP headers for Authorization: Basic ....
  4. Wireshark may show the decoded credentials directly (or students can base64-decode). The password will be FLAG{PASSWORD_IN_BASIC_AUTH}.

8. Stop Capture and Save

When all challenges and noise are done, return to the tcpdump terminal and press:

Ctrl + C

You should now have final_ctf.pcapng in your working directory. Make a backup copy somewhere safe.

9. Distribution to Students

  • Copy final_ctf.pcapng to each lab machine (USB, shared drive, LMS, etc.).
  • Provide them with the Student Handout (below).
  • Optionally, tell them: “There are 7 flags hidden in this file. Each flag looks like FLAG{SOME_TEXT_HERE}.”

10. Running the Activity

Suggested structure for a 50–90 minute session:

  1. Intro (10–15 minutes)
    • What is Wireshark?
    • What is a “flag”?
    • Show basic Wireshark navigation:
      • Open a PCAP
      • Use the filter bar (e.g., icmp, http)
      • Understand packet list, details, and bytes panes
  2. Guided first challenge (10 minutes) – Solve the ICMP challenge together as a class.
  3. Independent/Team work (30–50 minutes) – Let students hunt for the remaining 6 flags. Offer hints over time.
  4. Debrief (10–15 minutes) – Discuss why unencrypted protocols are dangerous and what secure alternatives exist.

11. Suggested Hints (Optional)

  • Hint 1: Some flags are in ICMP and DNS. Look for unusual values.
  • Hint 2: Some flags hide in web traffic. Watch for HTTP POST/GET requests.
  • Hint 3: One file contains a private key.
  • Hint 4: One password itself is the flag. Look for “Authorization: Basic”.

12. Scoring (Suggested)

  • Challenge 1 (ICMP): 10 points
  • Challenge 2 (HTTP POST): 15 points
  • Challenge 3 (ARP/DHCP): 15 points
  • Challenge 4 (DNS): 20 points
  • Challenge 5 (HTTP image): 15 points
  • Challenge 6 (FTP key): 15 points
  • Challenge 7 (Basic Auth): 10 points

Total: 100 points

13. Answer Key (For Teacher Only)

  1. ICMP Curious Ping
    Filter: icmp
    Location: ICMP Echo Request data
    Flag: FLAG{CURIOUS_PING}
  2. HTTP POST – Forgotten Login
    Filter: http && tcp.port == 8080
    Action: Follow TCP Stream of POST /login
    Flag: FLAG{FOLLOW_THE_STREAM}
  3. ARP/DHCP – Who’s in the Network?
    Filters: arp, bootp || dhcp
    Concept: suspicious hostname EVIL-HAXOR-666
    Flag: FLAG{EVIL_HAXOR}
  4. DNS – Conspiracy
    Filter: dns
    Reassemble from query names:
    FLAG{THIS_ + DNS_CONS + PIRACY}
    Flag: FLAG{THIS_DNS_CONSPIRACY}
  5. HTTP Image – Lost Photo
    Filter: http && tcp.port == 8081
    Export Objects → HTTP → mascot.jpg
    Flag text in image: FLAG{HTTP_OBJECT_EXTRACT}
  6. FTP – Leaky Key
    Filter: ftp || ftp-data
    Export Objects → FTP → id_rsa
    Flag in last line: FLAG{FTP_LEAKED_PRIVATE_KEY}
  7. HTTP Basic Auth – Secret Password
    Filter: http && tcp.port == 5001
    Check Authorization header / decoded creds:
    admin:FLAG{PASSWORD_IN_BASIC_AUTH}
    Flag: FLAG{PASSWORD_IN_BASIC_AUTH}

🧑‍🎓 STUDENT HANDOUT – Wireshark Capture The Flag

Title: “Mystery on the Network: A Wireshark CTF”

1. Scenario

Your school’s network has captured a mysterious packet trace. Inside this captured traffic are seven hidden flags.

Each flag looks like this:

FLAG{SOME_TEXT_HERE}

Your mission is to find as many flags as you can using Wireshark.

2. What You Need

  • Wireshark installed on your computer
  • The capture file: final_ctf.pcapng
  • A teammate or two (optional but encouraged)
  • This handout

3. Getting Started

  1. Open Wireshark.
  2. Go to File → Open… and select final_ctf.pcapng.
  3. Notice the three main parts:
    • Top pane: list of packets
    • Middle pane: details of the selected packet
    • Bottom pane: raw bytes (hex and ASCII)

4. Helpful Wireshark Tips

4.1 Display Filter Bar

At the top of Wireshark is a filter bar. You can type conditions there to show only certain types of traffic. Examples:

  • icmp – only pings
  • dns – only DNS traffic
  • http – only HTTP traffic
  • ftp || ftp-data – both FTP control and data
  • arp – ARP traffic
  • bootp || dhcp – DHCP traffic

After typing a filter, press Enter to apply it.

4.2 Following Streams

Sometimes a flag is hidden in a conversation, not just one packet.

  1. Right-click a packet (e.g., an HTTP or FTP packet).
  2. Choose “Follow → TCP Stream”.
  3. A new window will show the reconstructed conversation.
  4. Look for usernames, passwords, or FLAG{...} strings.

4.3 Exporting Objects (Files)

Some flags are hidden in files transmitted over the network.

  1. In Wireshark, go to File → Export Objects.
  2. Choose the appropriate protocol:
    • HTTP for web files (like images)
    • FTP for files transferred over FTP
  3. Select a file from the list (e.g., mascot.jpg or id_rsa).
  4. Click “Save”, then open the file on your computer.
  5. Look inside it for a FLAG{...}.

5. Your Objectives

There are seven flags total. They are hidden across different protocols and methods, including:

  • ICMP (ping) traffic
  • HTTP web traffic (including forms and images)
  • FTP file transfer
  • DNS queries
  • ARP/DHCP network discovery traffic
  • HTTP Basic Authentication headers

You do not need to guess or brute force anything; every flag is plainly visible somewhere in the data if you look in the right place.

6. Suggested Hunt Order (Optional)

If you’re new to Wireshark, try this sequence:

  1. ICMP Flag
    Filter: icmp
    Look at the contents/payload of the ping packets.
  2. HTTP Login Flag
    Filter: http
    Look for POST requests with form data (e.g., username=...&password=...).
  3. DNS Flag
    Filter: dns
    Look for suspicious or unusual domain names that look like parts of a message.
  4. HTTP File (Image) Flag
    Filter: http
    Use File → Export Objects → HTTP and open any saved files (especially images).
  5. FTP Key Flag
    Filter: ftp || ftp-data
    Use File → Export Objects → FTP to save files and inspect them.
  6. HTTP Basic Auth Flag
    Filter: http
    Look at headers for Authorization: Basic ... and see whether Wireshark shows decoded usernames/passwords.
  7. Network Mapping / ARP/DHCP Flag
    Filters: arp and bootp || dhcp
    Think about how many devices are on the network and whether any hostnames look suspicious.

7. Recording Your Answers

Use a table like this to track your discoveries:

# Protocol / Clue Filter Used Where You Found It (packet / stream / file) Flag
1
2
3
4
5
6
7

Make sure to write the flags exactly as they appear (capitalization, braces, underscores).

8. Rules & Expectations

  • Do not modify, attack, or scan any real systems. This is a closed, offline exercise.
  • Work individually or in small teams as allowed by your teacher.
  • Ask for hints if you get stuck, but try exploring filters and menus first.
  • Be prepared to share which protocol the flag was in and how you discovered it.

9. Reflection Questions

After the activity, be ready to discuss:

  1. Which protocol made it easiest to find sensitive information?
  2. What surprised you most about how much you can see in plain text?
  3. How do encryption and secure protocols (like HTTPS instead of HTTP, SFTP instead of FTP) change this picture?
  4. If you were defending a network, what would you do differently after seeing this capture?

Good luck, and happy hunting!