Run natively, the core binds api.host, which defaults to 0.0.0.0: every
network interface. Set api.host: "127.0.0.1" (or WAVEKIT_API__HOST) to keep it on
this machine. The Docker app profile publishes its ports on 127.0.0.1 only, so
there the host's loopback is the boundary. To let other devices reach WaveKit, run it
in protected mode: every browser is paired once with a one-time code, and every
route, WebSocket and audio stream then needs that paired session (or the machine
credential). The exceptions are the public liveness probes (/health and
/health/live), GET /api/access/bootstrap, which tells a browser it must pair,
and POST /api/access/pair, which exchanges the one-time code. Raw audio TCP and
the RTL-TCP relay are disabled in protected mode.
- Try it locally: Protected mode locally.
- One container with loopback-published ports: Protected mode in Docker without a forwarder.
- LAN access over HTTPS behind Caddy: Protected reference profile.
- Configuration fields and the pairing API: Protected runtime.
- The machine credential for agents and scripts:
access-command.js machine-tokencopies it into a private0600file without a container shell and never prints it. It writes inside the directory it checked, never into the private directory even if a path is swapped for a link mid-way. It survives restarts while the private directory persists.rotate-credentialis the only revocation. It revokes every copy the running private directory could admit, not one restored from before it: see Backups and the fence. An export that overlaps a rotation can still write the old token to its file; that copy answers 401, so export again after the rotation finishes. See Get a machine token. - A core that loses its private directory stops admitting: see When a core loses its private directory.
- In the default legacy mode the core checks no credential: a token adds nothing, and the bind address above is the only boundary.
A core proves it still owns the private directory by refreshing a lock file every 10 seconds, and a lock unchanged for 30 seconds can be reclaimed. Only proof of a new owner latches: the lock file replaced or gone. Another error reading or refreshing it (an I/O error on a bind mount, say) proves nothing. That check's write is refused, the next refresh retries, the error is logged and counted, and admission carries on. If the lock goes unrefreshed for the whole 30 seconds it latches too, because at that age another owner may take it. A core that was stalled past those 30 seconds (a suspended laptop, say) still gets 10 seconds to prove the lock is unchanged, and keeps it if it is; a check that hangs for those 10 seconds latches, and so do errors that last those 10 seconds. One error right after the stall does not. Three failed refreshes in a row always latch, so a permanent fault cannot hold the core up for ever.
Once latched, the core exits (below). In the seconds before it does:
- Every bearer, cookie and audio request answers 503
ACCESS_UNAVAILABLE: another owner may have rotated the credential, so what the core holds in memory proves nothing. - Open WebSockets, audio streams and downloads are closed, for browser sessions and for the machine credential alike. WebSockets get close code 1008, and one that does not finish closing within a second is cut.
GET /healthanswers 503 withreasonaccess_authority_lost, andGET /health/livestays 200, until the listener closes.
The core exits and restarts. It logs the loss, closes and exits with code 1. A close still running after 10 seconds ends with SIGKILL, even with file system calls hung. A call that hangs after the close finished, or one the kernel cannot interrupt, holds the process until it returns. SIGKILL skips the rest of the close: unwritten logs and data are lost, a voice recording may not be finished, and decoder children can outlive the core. In the image the finish script sends TERM to the known decoders, without waiting; run natively, stop any left over by hand. The list of what you see below describes a close that finishes in time. It exits whatever else is running: a locked-out core cannot be read, steered or stopped by anyone, and only a new start can load the state another owner may have rotated.
The close differs from a stop in one way: another owner may hold the state and the tuner by
now. From the latch on, the core sends no tuner frame: no scanner or temporary tune restore,
no queued write, no reconnect replay, so the receiver stays where it was. It writes no scanner
state (jobs, snapshot, journal, engine and occupancy state, trial memory) and no saved band
gain; a restart reads what was last on disk, and a scan's recovery record stays there. Sockets,
decoder children and listeners still go. Stores with a lock of their own close as usual, because
that lock still proves them theirs: Activity holds a kernel lock, and band plans and operator
data check their writer lock before each write. Station and decoder settings and recording keeps
start no write on close; they only wait for one already running. The wideband recorder is not
fenced: it finishes its voice files on the way out and can still write its .analog-operations
records, which belong to those files.
In the image, s6 starts it again, about 5 seconds after the exit. What you see:
- A recording in progress ends, as on any stop, when the close finishes in time. Finished recordings and every decode so far stay on disk. A voice recording closes its file. A named IQ capture in progress loses its partial file and its request fails, and a scanner capture in progress is discarded.
- Decoders stop, and their output to external sinks is gone for those seconds. Clients are cut off and reconnect once the API is back.
- The tuning is not kept: the core returns to its configured baseline, and a pending temporary tune's restore is dropped.
- A running sweep comes back as
pausedwith reasoncore-restartand goes on only withresume.onBoot. A job paused by an operator stays paused.
The new start loads the current credential, or refuses with a stable code if the state needs repair.
If another core still owns the directory, the restart is refused, and it keeps trying. A refused start never touches the tuner: it checks the locks and opens the private directory before it creates any source. Each failed exit waits 4 seconds in the finish script, and s6 starts the service at most once a second, so the attempts run at:
| The other owner | Refusal | Time for one attempt | Attempts |
|---|---|---|---|
| In the same container | ACCESS_STORAGE_BUSY, at once |
At least 1 s of start and 4 s of wait: 5 s or more | at most 720 an hour |
| In another container | ACCESS_STORAGE_FOREIGN_OWNER, after watching its heartbeat |
40 s + 40 s (the startup check, then the open) and 4 s of wait: about 85 s | about 40 an hour |
Each attempt logs a few lines: Starting WaveKit, the refusal with its code (and, in the second
case, two Waiting for storage lock heartbeat lines), and a start and stop line in
/var/log/wavekit/system.log. A volume that stays broken loops the same way. /health stays
down meanwhile, and the logs say why. Stop the other core and the next attempt succeeds.
Run natively, as with make dev-protected or node dist/index.js, nothing restarts the process:
it ends with code 1 and that log line, and you start it again. make dev-protected ends with its
server, so it deletes its throwaway directory as on any other exit.
Band plans and operator data have locks of their own under the same rule. Losing one of those touches no credential: only that store goes unavailable until a restart.
Two gaps stay open:
- Detection is not instant. A latch comes at the first check after the event: up to 10 seconds after the lock is replaced or removed under a running core. A core that was stalled (a paused container) and lost the directory meanwhile runs that check first thing on resuming, and it takes one file system round trip, usually a few milliseconds. A request that reaches the core in that window is checked against the old credential and can be admitted, and a stream it opens lasts until the check finishes.
- A request already admitted finishes. A plain HTTP request past admission when the latch comes gets its answer, and a write already running or queued at a store can still land. No fencing token stops it.
Every rotation first records a hash of the new credential in credential-fence.json in the
private directory. A stored credential that does not match it is refused with
ACCESS_STORAGE_CREDENTIAL_MISMATCH, so a stalled core's late write cannot bring a revoked
token back: a restart refuses until you run rotate-credential again.
The fence is only as current as the directory it sits in:
- After restoring a private volume from backup, rotate before starting the core. A full restore brings back the fence with the state it matched, from before any later rotation, so every token revoked since is accepted again until you rotate. Nothing outside the volume can tell the core otherwise. Export the token again afterwards.
- Never delete the fence. Without it, a stale state file loads with nothing to compare it against, and a revoked token works again.
A fence that is unsafe (mode not 0600, wrong owner, a link, a directory, oversized) refuses
load and rotation with ACCESS_STORAGE_UNSAFE, and changes nothing. A volume copied without
its modes reaches this. With the core stopped:
- If only the mode or owner is wrong, repair them:
chownit to the core's user andchmod 600 credential-fence.json; the directory itself is0700. Then start the core. The same repair applies when another file in the directory reports this code. - If it is a link, a directory or oversized, its content cannot be trusted. Move it out of the
private directory and run
rotate-credentialstraight away, before the core starts: that writes a new fence and revokes every older token, so nothing was opened by going without one. Then export the token again. Every browser pairs again.
Threat model of the state directory. The core's state and storage directories are trusted:
keep them owned by the core's user with mode 0700. The scanner's recording-ownership rule (no
discovery's delete path reaches a file another discovery lists) protects recordings against the
core's own code paths only. It does not protect them against another local process that can
write to those directories: such a process can delete or replace files there directly.
WaveKit integrates multiple third-party decoder binaries (dsd-fme, multimon-ng, rtl_433, readsb, acarsdec, dumpvdl2, AIS-catcher, Dire Wolf, gr-lora_sdr). These external dependencies require careful version management and security monitoring to maintain a secure deployment.
This document covers:
- Version Pinning Best Practices
- Security Advisory Tracking Process
- Known Security Advisories
- Incident Response
Decoder binaries process untrusted radio signals and can be vulnerable to:
- Buffer overflows from malformed packets
- Denial of service from crafted signals
- Remote code execution in parsing logic
- Memory corruption from unexpected input
Version pinning ensures:
- Reproducibility: Deployments behave consistently across environments
- Security: Known-vulnerable versions can be blocked
- Stability: Unexpected behavior from untested versions is prevented
WaveKit supports version constraints via the minVersion and maxVersion fields in decoder configuration:
# your config, e.g. config/custom.yaml
decoders:
- id: direwolf
type: direwolf
enabled: true
# Version pinning - REQUIRED for security
minVersion: "1.7.1" # Minimum safe version (post-CVE fix)
maxVersion: "1.8.0" # Maximum tested version
options:
kissPort: 8001
- id: dsd
type: dsd-fme
enabled: true
minVersion: "2.0.0"
options:
mode: auto| Constraint | When to Use | Example |
|---|---|---|
minVersion only |
Block known-vulnerable versions | minVersion: "1.7.1" |
maxVersion only |
Limit to tested versions | maxVersion: "2.0.0" |
| Both | Production deployments | minVersion: "1.7.1", maxVersion: "1.8.0" |
| Neither | Development/testing only | Not recommended for production |
When WaveKit starts, it validates decoder versions:
- Version Detection: Runs decoder with
--versionflag - Constraint Check: Compares against configured min/max
- Warning/Error: Logs appropriate message with upgrade instructions
# Example log output for version mismatch
WARN [DecoderManager] Decoder direwolf version 1.6.0 is below minimum required version 1.7.1. Please upgrade direwolf to version 1.7.1 or higher.
| Decoder | Min Version | Max Version | Notes |
|---|---|---|---|
| direwolf | 1.7.1 | - | CVE-2025-34458 fixed in 1.7.1 |
| dsd-fme | 2.0.0 | - | Stable release |
| multimon-ng | 1.2.0 | - | Stable release |
| rtl_433 | 23.11 | - | Active development |
| readsb | 3.14.0 | - | Modern ADS-B stack |
| acarsdec | 3.7.0 | - | Maintained fork |
| dumpvdl2 | 2.5.0 | - | Stable release |
| AIS-catcher | 0.50 | - | Active development |
WaveKit operators should monitor these sources for security advisories:
-
National Vulnerability Database (NVD)
- URL: https://nvd.nist.gov/
- Search for decoder names (e.g., "direwolf", "rtl_433")
-
GitHub Security Advisories
- Check each decoder's GitHub repository
- Enable "Watch" → "Security alerts" for repositories
-
Decoder Mailing Lists / Forums
- Many decoders have dedicated mailing lists
- SDR-focused forums often discuss vulnerabilities
-
WaveKit Security Announcements
- Check WaveKit repository releases for security notes
- Subscribe to WaveKit security mailing list (if available)
Perform this review monthly (or more frequently for critical systems):
# Search NVD for decoder-related CVEs
# Visit: https://nvd.nist.gov/vuln/search
# Search terms: direwolf, rtl_433, multimon-ng, dsd-fme, readsb, acarsdec, dumpvdl2, AIS-catcherCheck each decoder's release page for security-related updates:
| Decoder | Release Page |
|---|---|
| direwolf | https://github.com/wb2osz/direwolf/releases |
| dsd-fme | https://github.com/lwvmobile/dsd-fme/releases |
| multimon-ng | https://github.com/EliasOeworsl/multimon-ng/releases |
| rtl_433 | https://github.com/merbanan/rtl_433/releases |
| readsb | https://github.com/wiedehopf/readsb/releases |
| acarsdec | https://github.com/TLeconte/acarsdec/releases |
| dumpvdl2 | https://github.com/szpajder/dumpvdl2/releases |
| AIS-catcher | https://github.com/jvde-github/AIS-catcher/releases |
If a security issue is found:
- Identify the fixed version
- Update
minVersionin configuration - Test the new version in a staging environment
- Deploy to production
- Document the change in your change log
Maintain a security log with:
- Date of review
- Advisories found (if any)
- Actions taken
- Next review date
For production deployments, consider:
- Dependabot/Renovate: Configure for Docker base images
- CVE Monitoring Services: Snyk, Trivy, or similar
- Custom Scripts: Periodic version checks against known-good lists
Example monitoring script:
#!/bin/bash
# check-decoder-versions.sh
# Run periodically via cron
DECODERS=("direwolf" "dsd-fme" "multimon-ng" "rtl_433")
MIN_VERSIONS=("1.7.1" "2.0.0" "1.2.0" "23.11")
for i in "${!DECODERS[@]}"; do
decoder="${DECODERS[$i]}"
min_version="${MIN_VERSIONS[$i]}"
# Check if decoder is installed and get version
# (Implementation depends on decoder)
echo "Checking $decoder >= $min_version"
done| CVE | Decoder | Severity | Fixed Version | Description |
|---|---|---|---|---|
| CVE-2025-34458 | direwolf | HIGH | 1.7.1 | Buffer overflow in APRS packet parsing |
Affected Versions: < 1.7.1
Description: A buffer overflow vulnerability in Direwolf's APRS packet parsing could allow remote code execution via crafted APRS packets.
Mitigation:
- Upgrade to direwolf 1.7.1 or later (recommended)
- Configure version constraint:
decoders: - id: direwolf type: direwolf minVersion: "1.7.1"
- Network isolation: If upgrade is not immediately possible, isolate the decoder from untrusted networks
References:
- NVD: https://nvd.nist.gov/vuln/detail/CVE-2025-34458
- GitHub Advisory: Check direwolf repository
-
Assess Impact
- Is the vulnerable decoder enabled in your deployment?
- Is the vulnerability exploitable in your environment?
- What data/systems could be affected?
-
Immediate Mitigation
- Disable the affected decoder if not critical
- Apply network-level controls (firewall rules)
- Update version constraints to block vulnerable versions
-
Remediation
- Update to patched version
- Test in staging environment
- Deploy to production
- Verify fix is applied
-
Post-Incident
- Document the incident
- Update monitoring to catch similar issues
- Review other decoders for similar vulnerabilities
Maintain a list of contacts for security incidents:
- WaveKit maintainers: GitHub Issues
- Decoder maintainers: See GitHub repositories above
- Your organization's security team
Use this checklist for new deployments:
- All decoders have
minVersionconfigured - Version constraints block known-vulnerable versions
- Security advisory monitoring is set up
- Monthly security review is scheduled
- Incident response contacts are documented
- Docker images use pinned base image versions
- Network isolation is configured appropriately
- Logging captures version validation warnings