Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 16 additions & 103 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,93 +3,21 @@
Self-hosted, local-first tools for numbers stations, shortwave monitoring and
signal-analysis workflows.

## Current milestone: M5.7

The application currently provides:

- local station catalogue
- structured observation logbook
- persistent UTC schedules and frequencies
- server-side `on now / next` schedule evaluation in UTC
- schedule-to-observation logging
- recording metadata linked to observations
- managed local WAV/FLAC upload under `/data/audio`
- automatic size, SHA-256, duration, sample rate and channel metadata
- compact waveform previews for managed PCM WAV recordings
- compact 64-bin frequency magnitude previews for managed PCM WAV recordings
- compact 24×32 time-frequency mini-spectrograms for managed PCM WAV recordings
- compact local signal fingerprints and deterministic recording similarity search
- local fingerprint clustering with configurable similarity threshold
- exact file-duplicate flagging from matching SHA-256 values inside clusters
- persistent local cluster review/classification with notes and UTC review time
- membership-stable cluster identifiers so reviews only attach to the reviewed member set
- inline local playback for managed recordings with HTTP byte-range seeking
- synchronized playback cursor across waveform and mini-spectrogram previews
- click/drag seeking directly on waveform and mini-spectrogram previews
- keyboard seeking on focused previews with arrow, Home and End keys
- temporary A-B loop regions with repeated playback and highlighted selection
- persistent recording point bookmarks and timestamp-interval annotations
- visual point/interval annotation markers directly on waveform and spectrogram previews
- edit existing annotations while preserving their stable annotation IDs
- direct Bookmark creation by right-clicking a waveform/spectrogram position
- keyboard Bookmark creation with `B` on a focused seek preview
- fixed annotation categories: `call-up`, `station ID`, `message`, `tone`, `noise`, `fade`, `other`
- per-recording annotation filtering by category with category-specific marker styling
- cross-recording annotation search by type, label and notes with direct jump-to-recording/timestamp
- versioned JSON annotation export/import with conservative recording matching and duplicate suppression
- flat CSV annotation export for archival and offline analysis
- versioned metadata-only recording/annotation manifest export with checksums and provenance
- full portable `tar.gz` archival export containing `manifest.json` plus verified managed WAV/FLAC files
- safe restore/import of M5.0 archives with staged extraction, checksum validation and collision protection
- dry-run restore planning with per-recording `import`, `duplicate`, `conflict` and `unmatched` classification
- selective restore of operator-chosen `import` candidates from a validated plan
- deterministic SHA-256 plan tokens binding selective restore to the reviewed archive and local state
- strict rejection of stale plan tokens after archive, recording, annotation or observation changes
- strict rejection of selected duplicates, conflicts, unmatched observations and unknown recording IDs
- persistent local restore receipts for successful full and selective restores
- SHA-256 receipt hash chaining with `previous_hash` and `receipt_hash`
- read-only receipt-chain verification with tamper detection and chain head reporting
- deterministic external receipt-chain anchor export with receipt count and chain-head hash
- verification of saved/offline anchors against the current chain, including valid later extensions
- read-only restore audit history with mode, UTC time, plan token, selection and result counts
- per-recording annotation import/skip counts before restore
- local rebuild of waveform, frequency, spectrogram and fingerprint data from restored managed audio
- local JSON persistence under `/data`
- embedded web UI and JSON API
- dependency-free Go build and non-root OCI runtime

Cluster reviews support `same transmission`, `same station`, `false positive`,
and `duplicate capture`. They are stored locally alongside recording metadata and
do not require AI, an API key, or an external service.

Managed playback reads audio directly from the appliance. Browser-native codec
support determines whether a WAV/FLAC recording can be played; M5.7 does not
transcode audio or send it to an external service. Saved timestamp annotations
persist in `recordings.json`, can be searched across the archive, exported as JSON
or CSV, and safely imported from the versioned JSON format.

M5.7 extends the M5.6 tamper-evident receipt chain with a small external anchor
format. `GET /api/recording-archive/receipts/anchor` downloads a deterministic JSON
object containing `version`, `receipt_count` and `head_hash`. Store that file on a
separate machine, offline medium, Git repository, backup system or other independent
location. `POST /api/recording-archive/receipts/anchor/verify` verifies the saved
anchor against the current local chain. A matching prefix remains valid if legitimate
new receipts have been appended after the anchor was created; rewriting an anchored
prefix changes its hash and is detected. No external provider or API key is required.

The anchor is evidence only when an independent copy is retained outside the same
trust boundary as the receipt store. It is not a digital signature, trusted timestamp
or proof against an attacker who can replace both the local chain and every external
copy of its anchor.

The application does not require an SDR, receiver, API key or external data
provider for these workflows.

See [docs/M5_7.md](docs/M5_7.md) for the current scope.
## Current milestone: M5.8

## Run with Go
The application currently provides a local station catalogue, structured observations and UTC schedules; managed WAV/FLAC recording ingest and playback; waveform, frequency, spectrogram and fingerprint analysis; clustering and human review; timestamp annotations with search and transfer; portable metadata manifests and verified `tar.gz` archives; safe dry-run, selective and full restore; token-bound restore planning; and tamper-evident restore receipts.

M5.7 added deterministic external receipt-chain anchors. M5.8 adds a separate local registry of anchor export requests so the operator can see when the chain was last anchored and how many receipts have accumulated since then. The registry stores only anchor metadata (ID, UTC export time, receipt count and head hash), never archive or audio payloads.

`GET /api/recording-archive/receipts/anchors/status` reports `never-anchored`, `current`, `extended`, or `mismatch`, plus `receipts_since_last_anchor`. `GET /api/recording-archive/receipts/anchors?limit=50` returns anchor-export history newest first. A successful anchor download request is registered automatically.

The local registry is operational metadata, not an independent trust anchor. M5.7's security benefit still requires keeping the downloaded anchor outside the appliance trust boundary. Registration proves that the server generated an export response; it cannot prove that the client safely retained the file.

For a host run, select writable data paths:
Core workflows remain local-first and require no SDR, API key or external provider.

See [docs/M5_8.md](docs/M5_8.md) for the current scope.

## Run with Go

```sh
NUMBER_STATION_TOOLS_DATA=./number-station-tools.json \
Expand Down Expand Up @@ -121,6 +49,8 @@ Then open <http://localhost:8080>.
- `GET /api/recording-archive/receipts?limit=50`
- `GET /api/recording-archive/receipts/verify`
- `GET /api/recording-archive/receipts/anchor`
- `GET /api/recording-archive/receipts/anchors?limit=50`
- `GET /api/recording-archive/receipts/anchors/status`
- `POST /api/recording-archive/receipts/anchor/verify`
- `POST /api/recording-archive/plan`
- `POST /api/recording-archive/import-selected?recording_id=...&plan_token=...`
Expand All @@ -142,24 +72,7 @@ Then open <http://localhost:8080>.

## Direction

Number Station Tools is intended to grow into a workbench for:

- station identifiers, aliases and historical metadata
- UTC transmission schedules and frequencies
- structured observation logging
- message and group transcription
- WAV/FLAC recording archive and interactive playback
- waveform, frequency, spectrogram, fingerprint and signal-analysis workflows
- categorized timestamp annotations, bookmarks and operator notes on recordings
- cross-recording annotation search, portable transfer and timeline navigation
- portable recording manifests, verified archival bundles, token-bound dry-run planning, policy-controlled restore and externally anchorable tamper-evident audit receipts
- human review and classification of signal matches and repeated transmissions
- optional SDR and network-receiver integrations
- optional data-provider imports
- local-first archival and search

Core functionality should remain useful without API keys, and receiver,
recording and observation data should remain local by default.
Number Station Tools is intended to grow into a local-first workbench for station metadata, schedules, observation logging, message transcription, recording archival/playback, signal analysis, annotations, portable verified archives, policy-controlled restore, and independently anchorable tamper-evident audit history. Optional SDR/network-receiver and data-provider integrations may be added later without making them requirements for core use.

## License

Expand Down
76 changes: 76 additions & 0 deletions cmd/number-station-tools/m5_8_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package main

import (
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
)

func TestRestoreAnchorRegistryTracksGrowth(t *testing.T) {
dir := t.TempDir()
receipts, err := openRestoreReceiptStore(filepath.Join(dir, "receipts.json"))
if err != nil { t.Fatal(err) }
registry, err := openRestoreAnchorRegistry(filepath.Join(dir, "anchors.json"))
if err != nil { t.Fatal(err) }
if _, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}); err != nil { t.Fatal(err) }
anchor, err := receipts.anchor()
if err != nil { t.Fatal(err) }
if _, err := registry.record(anchor); err != nil { t.Fatal(err) }
status, err := registry.status(receipts)
if err != nil { t.Fatal(err) }
if !status.Anchored || status.Status != "current" || status.ReceiptsSince != 0 { t.Fatalf("status=%#v", status) }
if _, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}); err != nil { t.Fatal(err) }
status, err = registry.status(receipts)
if err != nil { t.Fatal(err) }
if status.Status != "extended" || status.ReceiptsSince != 1 { t.Fatalf("status=%#v", status) }
}

func TestRestoreAnchorRegistryPersists(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "anchors.json")
registry, err := openRestoreAnchorRegistry(path)
if err != nil { t.Fatal(err) }
anchor := restoreReceiptAnchor{Version: 1, ReceiptCount: 1, HeadHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}
entry, err := registry.record(anchor)
if err != nil { t.Fatal(err) }
reopened, err := openRestoreAnchorRegistry(path)
if err != nil { t.Fatal(err) }
entries := reopened.list(50)
if len(entries) != 1 || entries[0].ID != entry.ID || entries[0].HeadHash != anchor.HeadHash { t.Fatalf("entries=%#v", entries) }
}

func TestRestoreAnchorExportRegistersHistoryAPI(t *testing.T) {
db := restoreTestDB(t)
dir := t.TempDir()
recordingPath := filepath.Join(dir, "recordings.json")
rs, err := openRecordingStore(recordingPath)
if err != nil { t.Fatal(err) }
receipts, err := openRestoreReceiptStore(recordingPath + ".restore-receipts.json")
if err != nil { t.Fatal(err) }
if _, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}); err != nil { t.Fatal(err) }
mux := http.NewServeMux()
registerRecordingRestoreHandler(mux, db, rs, filepath.Join(dir, "audio"))

exportReq := httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts/anchor", nil)
exportW := httptest.NewRecorder()
mux.ServeHTTP(exportW, exportReq)
if exportW.Code != http.StatusOK { t.Fatalf("export status=%d body=%s", exportW.Code, exportW.Body.String()) }

historyReq := httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts/anchors", nil)
historyW := httptest.NewRecorder()
mux.ServeHTTP(historyW, historyReq)
if historyW.Code != http.StatusOK { t.Fatalf("history status=%d body=%s", historyW.Code, historyW.Body.String()) }
var entries []restoreAnchorRegistryEntry
if err := json.Unmarshal(historyW.Body.Bytes(), &entries); err != nil { t.Fatal(err) }
if len(entries) != 1 || entries[0].ReceiptCount != 1 { t.Fatalf("entries=%#v", entries) }

statusReq := httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts/anchors/status", nil)
statusW := httptest.NewRecorder()
mux.ServeHTTP(statusW, statusReq)
if statusW.Code != http.StatusOK { t.Fatalf("status=%d body=%s", statusW.Code, statusW.Body.String()) }
var status restoreAnchorRegistryStatus
if err := json.Unmarshal(statusW.Body.Bytes(), &status); err != nil { t.Fatal(err) }
if status.Status != "current" || status.ReceiptsSince != 0 { t.Fatalf("status=%#v", status) }
}
60 changes: 39 additions & 21 deletions cmd/number-station-tools/recording_bundle_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ func registerRecordingBundleHandlers(mux *http.ServeMux, rs *recordingStore, aud

func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordingStore, audioDir string) {
receipts, receiptStoreErr := openRestoreReceiptStore(rs.path + ".restore-receipts.json")
anchors, anchorRegistryErr := openRestoreAnchorRegistry(rs.path + ".restore-anchors.json")

mux.HandleFunc("GET /api/recording-archive/receipts", func(w http.ResponseWriter, r *http.Request) {
if receiptStoreErr != nil {
Expand Down Expand Up @@ -65,20 +66,54 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin
})

mux.HandleFunc("GET /api/recording-archive/receipts/anchor", func(w http.ResponseWriter, _ *http.Request) {
if receiptStoreErr != nil {
http.Error(w, "restore receipt store is unavailable", http.StatusInternalServerError)
if receiptStoreErr != nil || anchorRegistryErr != nil {
http.Error(w, "restore anchor service is unavailable", http.StatusInternalServerError)
return
}
anchor, err := receipts.anchor()
if err != nil {
http.Error(w, err.Error(), http.StatusConflict)
return
}
if _, err := anchors.record(anchor); err != nil {
http.Error(w, "could not register exported anchor", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Content-Disposition", `attachment; filename="number-station-restore-receipt-anchor.json"`)
writeJSON(w, http.StatusOK, anchor)
})

mux.HandleFunc("GET /api/recording-archive/receipts/anchors", func(w http.ResponseWriter, r *http.Request) {
if anchorRegistryErr != nil {
http.Error(w, "restore anchor registry is unavailable", http.StatusInternalServerError)
return
}
limit := 50
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
parsed, err := strconv.Atoi(raw)
if err != nil || parsed < 1 || parsed > 200 {
http.Error(w, "limit must be between 1 and 200", http.StatusBadRequest)
return
}
limit = parsed
}
writeJSON(w, http.StatusOK, anchors.list(limit))
})

mux.HandleFunc("GET /api/recording-archive/receipts/anchors/status", func(w http.ResponseWriter, _ *http.Request) {
if receiptStoreErr != nil || anchorRegistryErr != nil {
http.Error(w, "restore anchor service is unavailable", http.StatusInternalServerError)
return
}
status, err := anchors.status(receipts)
if err != nil {
writeJSON(w, http.StatusConflict, status)
return
}
writeJSON(w, http.StatusOK, status)
})

mux.HandleFunc("POST /api/recording-archive/receipts/anchor/verify", func(w http.ResponseWriter, r *http.Request) {
if receiptStoreErr != nil {
http.Error(w, "restore receipt store is unavailable", http.StatusInternalServerError)
Expand Down Expand Up @@ -131,17 +166,7 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin
http.Error(w, err.Error(), http.StatusConflict)
return
}
receipt, err := receipts.add(restoreReceipt{
Mode: "selected",
PlanToken: planToken,
SelectedRecordingIDs: selectedIDs,
ImportedRecordings: result.ImportedRecords,
ImportedAnnotations: result.ImportedAnnotations,
Duplicates: result.Duplicates,
Conflicts: result.Conflicts,
Unmatched: result.Unmatched,
SkippedByPolicy: result.SkippedByPolicy,
})
receipt, err := receipts.add(restoreReceipt{Mode: "selected", PlanToken: planToken, SelectedRecordingIDs: selectedIDs, ImportedRecordings: result.ImportedRecords, ImportedAnnotations: result.ImportedAnnotations, Duplicates: result.Duplicates, Conflicts: result.Conflicts, Unmatched: result.Unmatched, SkippedByPolicy: result.SkippedByPolicy})
if err != nil {
http.Error(w, "restore succeeded but receipt persistence failed", http.StatusInternalServerError)
return
Expand All @@ -159,14 +184,7 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
receipt, err := receipts.add(restoreReceipt{
Mode: "full",
ImportedRecordings: result.ImportedRecords,
ImportedAnnotations: result.ImportedAnnotations,
Duplicates: result.Duplicates,
Conflicts: result.Conflicts,
Unmatched: result.Unmatched,
})
receipt, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: result.ImportedRecords, ImportedAnnotations: result.ImportedAnnotations, Duplicates: result.Duplicates, Conflicts: result.Conflicts, Unmatched: result.Unmatched})
if err != nil {
http.Error(w, "restore succeeded but receipt persistence failed", http.StatusInternalServerError)
return
Expand Down
Loading
Loading