Releases: revtex/OpenScanner
v1.3.2 — Auth refresh stability
OpenScanner v1.3.2 — Auth refresh stability
This release fixes the recurring ~15-minute logoffs reported against 1.3.0 and 1.3.1.
Fixed
- Refresh cookie now reaches the v1 refresh endpoint. The refresh-token cookie was scoped to
Path=/api/auth, but 1.3.0 moved the silent-refresh call to/api/v1/auth/refresh. Per RFC 6265 §5.1.4 path-matching, a/api/authcookie does not match/api/v1/auth/refresh, so the browser silently dropped the cookie on every refresh — the server returned401 "no refresh token"the moment the 15-minute access JWT expired, and the user was bounced to the login screen. The cookie is now scoped toPath=/api, which covers both legacy and v1 endpoints. This is the actual root cause of the logoffs in 1.3.0 / 1.3.1. - Audio-element auth recovery now shares the same single-flighted refresh promise as the rest of the app, so a 401 on an
<audio>fetch can no longer race the scheduled refresh and replay the single-use refresh cookie.
Security
- Refresh-token rotation now honors a 30-second replay grace window for already-rotated tokens (OAuth 2.0 Security BCP §4.13). If the same refresh cookie is presented twice within the window — parallel tabs, service-worker retries, page reload mid-rotation — the server returns the cached successor instead of revoking the family. Replays after the grace window still revoke the family, preserving theft detection.
Tests
- Regression coverage that would have caught the cookie-path bug: cookie-attribute tests now assert the cookie path is a prefix of every refresh endpoint (
/api/auth/refreshand/api/v1/auth/refresh), and a new end-to-end test logs in throughhttptest.NewServerwith a realcookiejarand asserts the browser actually sends the cookie back to both paths.
Upgrading
docker compose pull && docker compose up -d
Already-logged-in users will pick up the new cookie path on their next login. If you want to force the rotation immediately, clear site cookies for your OpenScanner origin and log back in.
v1.3.1
v1.3.0 — Native /api/v1/* REST + WebSocket surface
Added
- Native
/api/v1/*REST surface alongside the existing legacy routes. All v1 responses use a structured error envelope ({"error":{"code","message","details"}}) with stable string codes (validation_failed,unauthorized,forbidden,not_found,conflict,unprocessable,rate_limited,internal); 5xx envelopes include the request ID underdetails.requestId. - v1 call-upload endpoint (
POST /api/v1/calls) with native multipart field names (systemId,talkgroupId,startedAt,frequencyHz,durationMs,unitId) and RFC 3339startedAtenforcement (unix timestamps no longer accepted on v1). CompanionPOST /api/v1/calls/testreturns 204 on a valid API key. - v1 listener endpoints:
GET/PUT /api/v1/listener/tg-selection(renamed from/api/auth/tg-selection),GET /api/v1/calls,GET /api/v1/calls/:id/audio,GET /api/v1/calls/:id/transcript, share/bookmark endpoints, and unauthenticated/api/v1/health,/api/v1/setup/*,/api/v1/auth/{login,refresh,logout,password,me}. - v1 admin endpoints under
/api/v1/admin/*for talkgroup/unit/group/tag imports, RadioReference preview (path simplified — no/csvsuffix), transcription status, and Swagger session bootstrap. - Native JSON-object framed WebSocket protocol on
GET /api/v1/ws/listenerandGET /api/v1/ws/admin. Frames carry atypediscriminator (connection.welcome,scanner.config,call.new,call.transcript,listener.count,listener.feedMap.snapshot/update,session.expired,connection.rejected,admin.event,admin.request,admin.response) instead of the legacy 3-letter array opcodes. Admin error responses mirror the REST{code,message,details?}envelope. The frontend connects to the v1 paths; legacy/ws,/api/ws, and/api/admin/wskeep emitting the array-framed protocol unchanged for in-the-wild clients. - RFC 8594 deprecation headers (
Deprecation: true,Sunset,Link: <successor>; rel="successor-version",Cache-Control: no-store) on every legacy/api/*and legacy WebSocket route, pointing at the native/api/v1/*successor. Per-request structured warn log (legacy endpoint hit) records method, path, and a truncated API-key identifier — never the raw key. - Admin endpoint
GET /api/v1/admin/legacy-usagereturning a 24-hour aggregate of legacy-endpoint hits ({method, path, apiKeyIdent, count, lastSeen}), backed by an in-memory ring buffer (no schema change). - Admin dashboard banner that surfaces legacy-API usage from the new endpoint, with an expandable details table (method, path, API key, count, last seen) and per-session dismiss.
Changed
- Frontend now talks to the native
/api/v1/*surface for every REST call (RTK Query base URL, rawfetch()for audio downloads and silent token refresh, the service-worker passthrough rules, the dev-server proxy, and the Swagger UI bootstrap). Tg-selection moves from/api/auth/tg-selectionto/api/v1/listener/tg-selection; RadioReference CSV preview moves to/api/v1/admin/radioreference/preview; legacy-usage report is consumed at/api/v1/admin/legacy-usage. Legacy/api/*routes remain available for non-frontend clients with the existing deprecation headers. - API-key authentication on
/api/v1/*upload routes accepts onlyAuthorization: Bearer <api-key>; the legacyX-API-Keyheader,?key=query parameter, andkey=form field continue to work on legacy routes only. JWT-shaped Bearer tokens on v1 API-key routes are rejected withinvalid_credentials. - Swagger UI now documents every native
/api/v1/*endpoint, not just the three previously annotated handlers. Legacy/api/*annotations remain in place until those routes are retired.
v1.2.1 — postcss security pin
v1.2.1 — postcss security pin
Security
- Pin transitive
postcssto>=8.5.10via a pnpm override to address the Dependabot alert "PostCSS has XSS via unescaped</style>in its CSS stringify output" (medium).
PostCSS is a dev-only transitive dependency of Vite/Tailwind and never reaches the production runtime, but the override removes the alert and ensures contributors building from source pick up the patched version.
Upgrading
- No action needed.
docker compose pull && docker compose up -das usual.
v1.2.0 — Audio HTTP migration & backend handler decomposition
v1.2.0 — Audio HTTP migration & backend handler decomposition
This release moves audio playback off the WebSocket onto a proper HTTP <audio> element backed by /api/calls/:id/audio, decomposes the monolithic backend internal/api package into feature-scoped handler packages, reorganises the frontend, and ships a number of multi-device polish fixes.
Added
- Session cookie (
os_session) issued on login/refresh, cleared on logout.GET /api/calls/:id/audionow accepts eitherAuthorization: Beareror the cookie, so<audio>playback works without client-side header injection. httpOnly, Secure (when HTTPS),SameSite=Strict, scoped to/api. Cross-site requests are rejected via aSec-Fetch-Sitecheck; invalid cookies fall through to anonymous sopublicAccess=truedeployments are unaffected. - Canonical
GET /api/wslistener WebSocket route.GET /wsremains as a compatibility alias.
Changed
- WebSocket
CALmessages no longer carry embedded base64 audio. Audio is fetched on demand from/api/calls/:id/audio. Frontend playback now uses the platform<audio>element backed byMediaElementAudioSourceNode. - Service worker passes
/api/calls/:id/audioand/api/shared/:token/audiostraight through, letting the browser handle Range requests natively. - Backend HTTP handlers decomposed from the monolithic
internal/apipackage into feature-scoped subpackages underinternal/handler/(auth,calls,bookmarks,share,setup,health,admin/{imports,radioreference,transcriptions},routes,shared). No route paths, methods, middleware ordering, response shapes, or status codes changed. internal/handler/calls/calls.go(~1500 LOC) split intoupload.go,audio.go,search.go,transcript.go, slimcalls.go.internal/middleware/middleware.gosplit intocors.go,auth.go,logging.go,limits.go.- Admin CRUD business logic extracted from
internal/wsinto a new transport-agnosticinternal/adminpackage. The WebSocket layer now only routesADM_REQframes toadmin.Operations. Wire protocol and action names unchanged. - Frontend reorg:
services/{ws,audio,util}/,hooks/{shared,scanner,admin}/,types/split into topic modules with a barrelindex.ts,app/slices/{shared,scanner,admin}/. All call sites updated; runtime behaviour unchanged. - Admin Options panel: dropped the noisy "Active" badge from every wired setting (only "Planned" badges remain); audio-conversion description rewritten to match the dropdown.
- Deployment guide reverse-proxy instructions list
/api/wsalongside/wsand/api/admin/wsfor WebSocket-upgrade forwarding.
Fixed
- Lock the primary admin's Allowed Systems selector in the user editor — the first user always has access to every system; badges are read-only with all systems shown as allowed.
- Default
audioEncodingPresetseeded into the settings table is nowmp3_32k(matching the dropdown's "(default)" label) instead ofaac_lc_32k. - Audio playback now silently recovers from a 401 on
/api/calls/:id/audioby triggering a single token refresh and retrying. Fixes the case where a sibling-device login pushes a desktop's access JWT off the active list. - Per-user concurrent JWT cap raised from 5 to 20 (
auth.MaxRefreshFamilies). With a 15-minute access TTL refreshing ~4×/hour, the old limit pushed a desktop's session off the active list within an hour of normal multi-device use. - WebSocket clients (listener and admin) detach handlers and wait for
openbefore closing a still-CONNECTINGsocket during reconnect, suppressing the cosmetic "WebSocket is closed before the connection is established" browser warning. dirmonitorwatcher: rename localreal→realPathto avoid shadowing the Go builtin.
Upgrading
- Back up your
openscanner.dbbefore upgrading. - No config or schema migration required.
- Reverse-proxy users: ensure
/api/wsis also forwarded withUpgrade/Connectionheaders (the existing/wsand/api/admin/wsrules continue to work unchanged). docker compose pull && docker compose up -das usual.
v1.1.2
Security-only patch release: batch of Snyk taint-analysis hardening fixes.
Security
- Audio reads for the call-ingest WebSocket broadcast (calls.go and dirmonitor) now go through
os.Root+io.LimitReader, structurally confined to the recordings directory. GET /api/calls/:id/audioandGET /api/shared/:token/audioopen viaos.Rootand stream withhttp.ServeContentinstead ofc.File, so no joined absolute path reaches gin.openscanner upgrade --binary <path>normalises both source and target paths to absolute cleaned form, requires the source to be a regular file, and confines temp-file cleanup to the target directory.- Dirmonitor
delete_after=1cleanup now removes audio and sidecar files viaos.Root.Removescoped to the watched directory, on top of the existing symlink-resolve guards. - The
openscannerCLI validates--server/OPENSCANNER_SERVERURLs (scheme must be http/https, non-empty host, userinfo/fragments stripped) before anynet/httpcall. - Bookmarks and search-panel download buttons now sanitise server-supplied
audioName(path separators, control chars, quote/angle-bracket chars, leading dots stripped; capped at 200 chars) before assigning to<a download>. Helper lives inservices/downloadFilename.ts.
See CHANGELOG.md for the full list.
v1.1.1
v1.1.0
[1.1.0] — 2026-04-23
Added
- Commit GitHub ruleset definitions under
.github/rulesets/so branch
and tag protection policy is versioned with the code. - Release workflow now builds standalone binaries for Linux, macOS, and
Windows (amd64 + arm64 where applicable) on everyv*tag and
attaches them to the GitHub Release alongside aSHA256SUMS.txt. - Release archives ship the user guides (README, admin, deployment,
recorder) as styled PDFs, and the same PDFs are attached to the
GitHub Release as standalone downloads.
Fixed
- PDF user guides rendered code blocks with ~50pt of phantom left
padding and let long lines overflow the right margin. Pandoc's
built-in skylighting CSS is now neutralised so code aligns with the
block's left edge and wraps cleanly.
v1.0.0 — Initial release
OpenScanner v1.0.0
The first stable release of OpenScanner — a modern, self-hosted web-based radio call manager for monitoring, searching, and sharing scanner traffic in real time.
OpenScanner is a ground-up reimplementation of rdio-scanner, built as a single Go binary with an embedded React frontend. It keeps backward compatibility with rdio-scanner's upload API, so existing Trunk-Recorder rdioscanner_uploader configs and SDRTrunk Rdio Scanner streaming targets work unchanged.
Highlights
- Live scanner — real-time call streaming over WebSocket with hold/avoid, per-user talkgroup selection, bookmarks, call sharing, and live transcript display.
- Full admin dashboard — manage users, systems, talkgroups, units, groups, tags, API keys, directory monitors, downstreams, shared links, transcription, and settings.
- Built-in transcription — optional integration with a go-whisper sidecar for automatic call transcription with model management from the UI, speaker diarization, 15 languages, and GPU acceleration.
- Multi-recorder ingest — HTTP upload plus directory monitoring with native support for Trunk-Recorder, SDRTrunk, DSDPlus, RTLSDR-Airband, ProScan, and generic mask-based sources.
- Auto-populate — systems, talkgroups, groups, tags, and units created automatically from incoming call metadata.
- Audio processing — FFmpeg conversion with four modes (disabled, enabled, normalize, loudnorm) and 8 encoding presets across MP3, AAC-LC, and HE-AAC.
- Call archive — search by system, talkgroup, group, tag, date range, transcript text, or bookmark state, with virtualized lists for large result sets.
- Single binary deployment — embedded SQLite (WAL mode), no external database, pre-built Docker image, and a guided
openscanner setup --interactivefor bare-metal installs on Linux, macOS, and Windows.
Security
- JWT authentication with refresh-token rotation and family revocation on reuse
- bcrypt password hashing (cost ≥ 12)
- Role-based access control (admin / listener)
- API-key auth for uploads (
X-API-Keyheader or?key=query) - Per-IP login rate limiting with 3-strike account lockout
- Per-user share-creation rate limiting
- Per-API-key sliding-window upload rate limiting
- WebSocket session revalidation with forced disconnect on user disable/delete
- Optional AES-256-GCM secrets-at-rest encryption for the JWT signing secret and downstream API keys
- Optional TLS with cert files (+ experimental Let's Encrypt auto-cert)
- Hardened outbound HTTP client for transcription/downstream traffic: redirects disabled, timeouts enforced, response bodies capped; private-network targets permitted by default (homelab-friendly) and gateable with
OPENSCANNER_BLOCK_INTERNAL_HTTP=1
What's new vs. rdio-scanner
OpenScanner is a complete rewrite, not a fork. A few things that didn't exist before:
- Automatic transcription with GPU support, model management, live display, and search
- Auto-populate for talkgroups, groups, tags, and units — not just systems
- Public call sharing with configurable link expiry
- Bookmarks and a filterable archive
- Named user accounts with per-user talkgroup selection, session limits, expiration, and password-change enforcement
- Per-API-key rate limits
- Full CSV import/export for talkgroups and units
- JSON configuration backup/restore
- Dark/light theme toggle
- RadioReference metadata preview and import
- Guided
setup,upgrade,config validate, andservice doctorcommands - Real-time admin operations over WebSocket (no REST polling)
- Optional secrets-at-rest encryption
Install
Docker Compose (recommended):
docker compose up -dThen browse to http://localhost:3022 and complete first-run setup.
Docker image: ghcr.io/revtex/openscanner:1.0.0 (also tagged 1.0 and latest).
From source:
make build
./build/openscanner --listen 0.0.0.0:3022 \
--db-file ./data/openscanner.db \
--recordings-dir ./data/recordingsDocumentation
Known limitations
- Let's Encrypt auto-cert is experimental and not yet exercised in production
- Downstream forwarding between OpenScanner instances is experimental and untested
- Transcription requires a separately deployed go-whisper sidecar (see the deployment guide)
Feedback
Issues and feature requests welcome at github.com/revtex/OpenScanner/issues.