Appliance Recipe: Dish Receiver & Signal Observatory
A kldload appliance that turns a satellite dish, SDR dongles, and a GPU edge node into a full signals observatory — capturing local broadcast, satellite downlinks, airborne transponders (ADS-B, ACARS, VDL2), weather imagery (NOAA APT, GOES HRIT), and wideband RF for SETI-style anomaly detection. Everything records to ZFS, transcodes on hardware, and every output carries a forensic watermark for chain-of-custody provenance.
What this replaces: Commercial signal monitoring equipment, satellite ground stations, and forensic recording systems. A dish, a few SDR dongles, a GPU, and kldload.
Companion recipe: Live TV Streaming covers the video distribution side (SRT/HLS/IPTV). This recipe focuses on signal acquisition, wideband capture, automated analysis, and evidence-grade recording.
Architecture
kldload Signal Observatory
SIGNAL SOURCES PROCESSING OUTPUT
────────────── ────────────── ──────────
Satellite Dish ──► DVB-S2 Tuner ──┐
(Ku/C-band) (TBS/DD) │
├──► tvheadend ──► DVR recordings
RTL-SDR v3 ──► USB ──────────────┤ (mux/EPG) (ZFS snapshots)
(24-1766 MHz) │
├──► GNU Radio ──► wideband IQ capture
HackRF/Airspy ──► USB ──────────┤ (SDR engine) (ZFS raw dataset)
(1 MHz-6 GHz) │
├──► dump1090 ──► ADS-B aircraft map
Dish + Feed Horn ──► LNA ──► SDR ─┤ (1090 MHz)
(L/S-band) │
├──► acarsdec ──► ACARS messages
│ (131 MHz) (airline telemetry)
│
├──► satdump ──► NOAA/GOES imagery
│ (weather) (APT/HRIT decode)
│
└──► anomaly ──► unknown signal log
detector (SETI-style scan)
ALL OUTPUTS ──► ffmpeg (GPU transcode) ──► forensic watermark ──► ZFS
Hardware
| Component | Examples | Cost |
|---|---|---|
| Edge server | Any x86_64 with PCIe + multiple USB 3.0 ports | $300-800 |
| DVB-S2 tuner card | TBS 6904 (4 tuner), Digital Devices Max S8 (8 tuner) | $80-300 |
| Satellite dish + LNB | 60-120cm dish, Ku-band LNB, DiSEqC switch for multi-sat | $50-200 |
| SDR dongle (VHF/UHF) | RTL-SDR Blog v3 or v4 (24-1766 MHz, $30), Nooelec NESDR | $25-40 |
| SDR wideband (optional) | Airspy R2 (24-1800 MHz, 10 MSPS), HackRF One (1 MHz-6 GHz) | $100-350 |
| L-band feed + LNA | Patch antenna + SAWbird+ LNA for Inmarsat/Iridium (1.5 GHz) | $50-100 |
| GPU (hardware transcode) | NVIDIA GTX 1650+ (NVENC) or Intel Arc A380 (QSV) | $100-300 |
| Storage | 2x NVMe for ZFS mirror + 4x HDD raidz2 for bulk capture | $200-600 |
| USB stick | kldload installer | $5 |
An RTL-SDR at $30 covers ADS-B (1090 MHz), ACARS (131 MHz), weather satellites (137 MHz), FM broadcast, and amateur radio. Add a HackRF and you cover everything from 1 MHz to 6 GHz. A satellite dish with a DVB-S2 tuner adds commercial satellite TV and free-to-air broadcasts. Total cost for a full-spectrum observatory: under $1,500.
Step 1: Install kldload
cat > /tmp/answers.env << 'EOF'
KLDLOAD_DISTRO=debian
KLDLOAD_DISK=/dev/sda
KLDLOAD_HOSTNAME=signal-obs
KLDLOAD_USERNAME=admin
KLDLOAD_PASSWORD=changeme
KLDLOAD_PROFILE=server
KLDLOAD_NVIDIA_DRIVERS=1
KLDLOAD_NET_METHOD=dhcp
EOF
kldload-install-target --config /tmp/answers.env
Step 2: Install the signal stack
# SDR tools and libraries
apt install -y rtl-sdr librtlsdr-dev soapysdr-tools gnuradio
# DVB satellite tools
apt install -y dvb-tools dtv-scan-tables w-scan2
# Tvheadend — satellite TV server (mux scanning, EPG, DVR)
apt install -y tvheadend
# Aircraft tracking (ADS-B on 1090 MHz)
apt install -y dump1090-mutability
# ACARS/VDL2 — airline datalink messages
apt install -y build-essential libxml2-dev cmake
git clone https://github.com/TLeconte/acarsdec.git /opt/acarsdec
cd /opt/acarsdec && mkdir build && cd build && cmake .. && make && make install
# Weather satellite imagery (NOAA APT, GOES HRIT, Meteor-M)
pip3 install satdump
# or build from source for full GPU decode:
# git clone https://github.com/SatDump/SatDump.git /opt/satdump
# ffmpeg for hardware transcoding + watermarking
apt install -y ffmpeg python3 python3-pip python3-opencv
pip3 install invisible-watermark numpy scipy
# Signal analysis
apt install -y inspectrum gqrx-sdr python3-matplotlib
Step 3: Verify hardware
# RTL-SDR — should show device info
rtl_test -t
# Found 1 device(s): 0: Realtek, RTL2838UHIDIR, SN:00000001
# HackRF — should show firmware version
hackrf_info
# Serial number: ... Firmware Version: 2024.02.1
# DVB-S2 tuner — check adapters
ls /dev/dvb/
dvb-fe-tool -a 0
# GPU for transcoding
nvidia-smi # NVIDIA
vainfo # Intel QSV
# Quick RTL-SDR capture test (FM broadcast)
rtl_fm -f 101.1e6 -M wbfm -s 200000 -r 48000 - | \
aplay -r 48000 -f S16_LE -t raw -c 1
Step 4: ZFS datasets for signal storage
# Raw IQ captures — massive wideband recordings, no compression
# (IQ data is effectively random noise, incompressible)
zfs create -o mountpoint=/srv/iq-capture \
-o compression=off \
-o recordsize=1M \
-o atime=off \
-o logbias=throughput \
rpool/srv/iq-capture
# DVR recordings — satellite TV, compressed MPEG-TS
zfs create -o mountpoint=/srv/dvr \
-o compression=zstd-3 \
-o recordsize=1M \
-o atime=off \
rpool/srv/dvr
# Decoded imagery — NOAA/GOES/Meteor weather sat images
zfs create -o mountpoint=/srv/imagery \
-o compression=zstd \
-o atime=off \
rpool/srv/imagery
# Aircraft and datalink logs — small, text-heavy, compresses well
zfs create -o mountpoint=/srv/adsb \
-o compression=zstd-9 \
-o recordsize=16K \
rpool/srv/adsb
# Anomaly detection — flagged signals for review
zfs create -o mountpoint=/srv/anomalies \
-o compression=zstd \
-o recordsize=128K \
rpool/srv/anomalies
# Watermarked output — forensically stamped exports
zfs create -o mountpoint=/srv/watermarked \
-o compression=off \
-o recordsize=1M \
rpool/srv/watermarked
# Snapshot all signal datasets hourly
cat > /etc/cron.d/signal-snapshots << 'EOF'
0 * * * * root for ds in iq-capture dvr imagery adsb anomalies; do zfs snapshot rpool/srv/${ds}@auto-$(date +\%Y\%m\%d-\%H\%M); done
15 3 * * * root for ds in iq-capture dvr imagery adsb anomalies; do zfs list -t snapshot -o name -H rpool/srv/${ds} | grep @auto- | head -n -168 | xargs -r -n1 zfs destroy; done
EOF
Step 5: Satellite TV (dish + DVB-S2 tuner)
# Configure tvheadend for satellite reception
systemctl enable --now tvheadend
# Web UI: http://signal-obs:9981
# Configuration flow:
# 1. DVB Inputs > TV Adapters — select DVB-S2 adapter, set LNB type
# 2. DVB Inputs > Networks — add satellite (Astra, Hotbird, Galaxy, etc.)
# 3. DVB Inputs > Muxes — auto-scan transponders
# 4. DVB Inputs > Services — map to channels
# 5. Channel/EPG — enable OTA programme guide
# Record to ZFS DVR dataset
# Configuration > DVR > Recording path: /srv/dvr
# Transcode and redistribute to LAN (see Live TV recipe for full HLS setup)
ffmpeg \
-i "http://localhost:9981/stream/channel/1?profile=pass" \
-c:v hevc_nvenc -preset p4 -b:v 4M \
-c:a aac -b:a 128k \
-f hls -hls_time 4 -hls_list_size 20 -hls_flags delete_segments \
/srv/dvr/live/channel1.m3u8
Step 6: Aircraft tracking (ADS-B + ACARS)
# ADS-B — track every aircraft within ~250 miles
# Uses RTL-SDR tuned to 1090 MHz
cat > /etc/systemd/system/adsb-tracker.service << 'UNIT'
[Unit]
Description=ADS-B aircraft tracker (1090 MHz)
After=network.target
[Service]
Type=simple
ExecStart=/usr/bin/dump1090-mutability \
--device-index 0 \
--net --net-http-port 8090 \
--write-json /srv/adsb/live \
--write-json-every 1 \
--lat YOUR_LAT --lon YOUR_LON
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
systemctl enable --now adsb-tracker
# Web map: http://signal-obs:8090
# Live aircraft positions, altitudes, speeds, callsigns
# ACARS — airline operational messages (131.550, 131.525, 131.725 MHz)
# Requires a second RTL-SDR dongle
cat > /etc/systemd/system/acars-decoder.service << 'UNIT'
[Unit]
Description=ACARS airline message decoder
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/acarsdec \
-d 1 -v \
-f 131.550 131.525 131.725 \
-o 4 -j /srv/adsb/acars.json
Restart=always
[Install]
WantedBy=multi-user.target
UNIT
systemctl enable --now acars-decoder
# Log archival — rotate daily, compress old logs
cat > /etc/cron.d/adsb-archive << 'EOF'
0 0 * * * root cd /srv/adsb && tar czf archive/adsb-$(date +\%Y\%m\%d).tar.gz live/*.json && rm live/*.json
EOF
Step 7: Weather satellite imagery
# NOAA APT (137 MHz) — polar-orbiting, overhead passes ~4x/day
# Produces visible/infrared Earth imagery at ~4km resolution
# Requires RTL-SDR + V-dipole antenna
# Schedule captures based on satellite pass predictions
pip3 install orbit-predictor
cat > /usr/local/bin/noaa-capture.sh << 'CAPTURE'
#!/bin/bash
# Capture a NOAA satellite pass
# Usage: noaa-capture.sh <freq_mhz> <duration_sec> <output_name>
FREQ=$1; DUR=$2; NAME=$3
OUTDIR="/srv/imagery/noaa/$(date +%Y%m%d)"
mkdir -p "$OUTDIR"
# Record raw IQ
timeout "${DUR}" rtl_fm -f "${FREQ}e6" -M fm -s 60000 -r 11025 \
-E deemp -E dc "${OUTDIR}/${NAME}.wav" 2>/dev/null
# Decode APT image
satdump live noaa_apt "${OUTDIR}/${NAME}.wav" "${OUTDIR}/${NAME}" \
--source file 2>/dev/null
echo "[$(date)] NOAA capture: ${NAME} → ${OUTDIR}/" >> /var/log/signal-obs.log
CAPTURE
chmod +x /usr/local/bin/noaa-capture.sh
# GOES HRIT (1694.1 MHz) — geostationary, continuous full-disk imagery
# Requires dish pointed at GOES-16/17, LNA, and SDR
# SatDump handles the entire decode pipeline:
satdump live goes_hrit \
--source rtlsdr --frequency 1694.1e6 --samplerate 2.4e6 \
--output_folder /srv/imagery/goes/
# Meteor-M N2-3 (137.9 MHz) — Russian weather satellite, higher resolution
satdump live meteor_hrpt \
--source rtlsdr --frequency 137.9e6 --samplerate 1e6 \
--output_folder /srv/imagery/meteor/
Step 8: Wideband IQ capture (your own SETI)
# The HackRF covers 1 MHz to 6 GHz — you can record entire spectrum bands
# and analyze them later for signals of interest.
# Record 20 MHz of L-band spectrum (1.5 GHz — Inmarsat, Iridium, GPS)
hackrf_transfer -r /srv/iq-capture/lband-$(date +%Y%m%d-%H%M).raw \
-f 1545000000 -s 20000000 -n 400000000
# That's 20 seconds of 20 MHz bandwidth — ~800 MB of raw IQ
# Record VHF band (aircraft, marine, weather, emergency)
rtl_sdr -f 130e6 -s 2.4e6 -n 48000000 /srv/iq-capture/vhf-$(date +%s).iq
# 20 seconds of 2.4 MHz around 130 MHz
# GNU Radio companion — build flowgraphs for custom demodulation
# Launch: gnuradio-companion (GUI) or use Python API for headless
# Automated spectral scan — sweep a frequency range and log power levels
cat > /usr/local/bin/spectrum-scan.py << 'PYEOF'
#!/usr/bin/env python3
"""
Wideband spectrum scanner — sweeps a frequency range using rtl_power,
logs power levels, and flags anomalies (signals significantly above
the noise floor that don't match known frequencies).
"""
import subprocess, json, time, os
from datetime import datetime
SCAN_RANGE = "24M:1766M:1M" # Full RTL-SDR range in 1 MHz steps
INTEGRATION = "10" # 10 second integration per sweep
KNOWN_SIGNALS_FILE = "/etc/signal-obs/known-signals.json"
ANOMALY_DIR = "/srv/anomalies"
LOG = "/var/log/signal-obs.log"
def scan():
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
outfile = f"/tmp/sweep-{timestamp}.csv"
subprocess.run([
"rtl_power", "-f", SCAN_RANGE, "-i", INTEGRATION,
"-1", "-c", "20%", outfile
], timeout=300)
# Parse results and find anomalies
known = {}
if os.path.exists(KNOWN_SIGNALS_FILE):
known = json.load(open(KNOWN_SIGNALS_FILE))
anomalies = []
with open(outfile) as f:
for line in f:
parts = line.strip().split(",")
if len(parts) < 7: continue
freq_hz = int(parts[2])
freq_mhz = freq_hz / 1e6
powers = [float(x) for x in parts[6:] if x.strip()]
peak = max(powers) if powers else -100
# Flag if peak is >20 dB above noise and not a known signal
if peak > -30 and str(round(freq_mhz, 1)) not in known:
anomalies.append({
"freq_mhz": round(freq_mhz, 2),
"peak_db": round(peak, 1),
"time": timestamp
})
if anomalies:
os.makedirs(ANOMALY_DIR, exist_ok=True)
with open(f"{ANOMALY_DIR}/anomaly-{timestamp}.json", "w") as f:
json.dump(anomalies, f, indent=2)
with open(LOG, "a") as f:
f.write(f"[{timestamp}] {len(anomalies)} anomalies detected\n")
os.unlink(outfile)
return anomalies
if __name__ == "__main__":
while True:
try:
result = scan()
if result:
print(f"Found {len(result)} anomalies")
else:
print("Clean sweep")
except Exception as e:
print(f"Scan error: {e}")
time.sleep(60) # Scan every minute
PYEOF
chmod +x /usr/local/bin/spectrum-scan.py
# Known signals database — don't flag these as anomalies
mkdir -p /etc/signal-obs
cat > /etc/signal-obs/known-signals.json << 'KNOWN'
{
"88.1": "FM broadcast",
"101.1": "FM broadcast",
"108.0": "VOR navigation",
"121.5": "Aviation emergency",
"131.5": "ACARS",
"137.1": "NOAA-15",
"137.9": "NOAA-18",
"156.8": "Marine VHF ch16",
"162.4": "NOAA weather radio",
"406.0": "COSPAS-SARSAT",
"462.5": "FRS/GMRS",
"1090.0": "ADS-B",
"1227.6": "GPS L2",
"1575.4": "GPS L1",
"1694.1": "GOES HRIT"
}
KNOWN
Step 9: Forensic watermarking
Every signal recording, decoded image, and video export carries an invisible forensic watermark — a unique identifier baked into the content that proves who captured it, when, and on what equipment. The same technique used in surveillance evidence chains and broadcast anti-piracy (Nagra, Irdeto). If a recording is ever redistributed or leaked, the watermark traces it back to this station.
# Video watermarking (satellite TV, transcoded streams)
# Burn a station ID + timestamp into every frame at invisible opacity
ffmpeg \
-i "http://localhost:9981/stream/channel/1?profile=pass" \
-vf "drawtext=text='OBS_%{localtime\:%Y%m%d_%H%M%S}_STN01':
fontsize=10:fontcolor=white@0.01:x=10:y=10" \
-c:v hevc_nvenc -preset p4 -b:v 4M \
-c:a copy \
-f mpegts /srv/watermarked/stn01-ch1.ts
# Image watermarking (weather satellite imagery)
cat > /usr/local/bin/watermark-image.py << 'PYEOF'
#!/usr/bin/env python3
"""Embed forensic watermark into satellite imagery."""
import sys, time, struct
from imwatermark import WatermarkEncoder
import cv2, numpy as np
STATION_ID = 1
img = cv2.imread(sys.argv[1])
encoder = WatermarkEncoder()
payload = struct.pack(">HI", STATION_ID, int(time.time()))
encoder.set_watermark('bytes', payload)
marked = encoder.encode(img, 'dwtDctSvd')
out = sys.argv[1].replace('.png', '-wm.png').replace('.jpg', '-wm.jpg')
cv2.imwrite(out, marked)
print(f"Watermarked: {out}")
PYEOF
chmod +x /usr/local/bin/watermark-image.py
# IQ recording provenance — append station metadata to raw captures
# (IQ data can't be visually watermarked, but we embed provenance)
cat > /usr/local/bin/iq-provenance.sh << 'PROV'
#!/bin/bash
# Append provenance metadata to an IQ capture file
FILE="$1"
cat >> "${FILE}.meta" << META
station_id: STN01
capture_time: $(date -u +%Y-%m-%dT%H:%M:%SZ)
hostname: $(hostname)
sha256: $(sha256sum "$FILE" | cut -d' ' -f1)
gps_lat: YOUR_LAT
gps_lon: YOUR_LON
sdr_serial: $(hackrf_info 2>/dev/null | grep Serial | awk '{print $NF}')
META
echo "Provenance written: ${FILE}.meta"
PROV
chmod +x /usr/local/bin/iq-provenance.sh
Step 10: Systemd services
# Spectrum scanner (SETI-style anomaly detection)
cat > /etc/systemd/system/spectrum-scan.service << 'UNIT'
[Unit]
Description=Wideband spectrum anomaly scanner
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/spectrum-scan.py
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
UNIT
# Weather satellite auto-capture (scheduled by pass predictions)
cat > /etc/systemd/system/noaa-scheduler.service << 'UNIT'
[Unit]
Description=NOAA satellite pass scheduler
[Service]
Type=oneshot
ExecStart=/usr/local/bin/noaa-scheduler.py
[Install]
WantedBy=multi-user.target
UNIT
cat > /etc/systemd/system/noaa-scheduler.timer << 'TIMER'
[Unit]
Description=Check for upcoming NOAA passes every 30 minutes
[Timer]
OnBootSec=60
OnUnitActiveSec=30min
[Install]
WantedBy=timers.target
TIMER
systemctl daemon-reload
systemctl enable --now adsb-tracker acars-decoder spectrum-scan noaa-scheduler.timer
Monitoring
# SDR device status
rtl_test -t # RTL-SDR health check
hackrf_info # HackRF status
# DVB signal quality
dvb-fe-tool -a 0 -m # SNR, BER, signal strength
# ADS-B stats
curl -s http://localhost:8090/data/stats.json | python3 -m json.tool
# Anomaly count (last 24 hours)
find /srv/anomalies -name '*.json' -mtime -1 | wc -l
# ZFS storage overview
zfs list -o name,used,compressratio,available -r rpool/srv
# GPU utilization
nvidia-smi --query-gpu=utilization.encoder,memory.used --format=csv
# Disk I/O (important for continuous IQ capture)
zpool iostat -v rpool 5
Why ZFS matters here
Checksums for evidence integrity
Every block on disk is checksummed. Combined with forensic watermarking, you have a complete chain of custody: the watermark proves who captured the signal, ZFS proves the recording hasn't been altered since capture. This matters if your recordings ever need to stand up as evidence.
Snapshots for continuous capture
Hourly ZFS snapshots protect days of signal recordings at near-zero cost. A power failure during a wideband capture? The last snapshot is consistent. Accidentally overwrite a rare satellite pass? Roll back in seconds.
Compression where it helps
Raw IQ is incompressible noise — store it uncompressed. But ADS-B JSON logs, ACARS messages, and weather imagery metadata compress 5-10x with zstd. ZFS lets you set the right policy per dataset.
Throughput for wideband
A 20 MHz wideband capture writes ~40 MB/s of raw IQ continuously. ZFS with recordsize=1M and logbias=throughput on a mirror or raidz2 handles this without dropping samples. The ARC cache absorbs metadata overhead.
Legal and safety notes
- Receive-only is legal almost everywhere. In most countries, you can listen to any signal. Transmitting, jamming, or decrypting signals you don't have authorization for is illegal.
- Satellite TV descrambling requires a valid subscription card. Card sharing or circumventing access controls is illegal in most jurisdictions.
- ADS-B data is unencrypted and intentionally broadcast for safety. Receiving and displaying it is legal. Many operators feed data to FlightAware, FlightRadar24, and ADSBexchange.
- ACARS messages may contain operationally sensitive airline data. Receiving is legal; publishing specific message contents may have restrictions depending on jurisdiction.
- Watermark provenance records should be stored in an encrypted ZFS dataset. The chain of custody is only as strong as its weakest link.
- Amateur radio bands — if you have a ham license, you can transmit too. kldload with GNU Radio makes an excellent digital modes station (FT8, JS8Call, APRS).