This commit is contained in:
Enot (ded) Skelly
2026-05-20 11:40:04 -07:00
commit 84df3949d1
22 changed files with 2103 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.env
+12
View File
@@ -0,0 +1,12 @@
FROM golang:1.23-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go build -o tower ./cmd/tower
FROM alpine:3.19
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/tower .
ENTRYPOINT ["./tower"]
+98
View File
@@ -0,0 +1,98 @@
package main
import (
"context"
"fmt"
"log"
"net/http"
"os"
"os/signal"
"syscall"
"tower/internal/api/router"
"tower/internal/hub"
"tower/internal/ingest"
"github.com/joho/godotenv"
)
func main() {
_ = godotenv.Load()
addr := os.Getenv("LISTEN_ADDR")
if addr == "" {
addr = ":8080"
}
// ── Hub ──────────────────────────────────────────────────────────────────
h := hub.New()
go h.Run()
// ── MQTT ingest workers ──────────────────────────────────────────────────
// TODO: wire in a real DB handle and ChannelKeyStore once those are built.
// For now the workers start but immediately hit the decode TODO stub.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
broker1 := ingest.New(
ingest.Config{
BrokerName: "mqtt1",
URL: mustEnv("MQTT_BROKER_1_URL"),
Username: mustEnv("MQTT_BROKER_1_USERNAME"),
Password: mustEnv("MQTT_BROKER_1_PASSWORD"),
},
nil, // TODO: replace with *db.Queries or pgxpool handle
h,
nil, // TODO: replace with channel key store
)
broker2 := ingest.New(
ingest.Config{
BrokerName: "mqtt2",
URL: mustEnv("MQTT_BROKER_2_URL"),
Username: mustEnv("MQTT_BROKER_2_USERNAME"),
Password: mustEnv("MQTT_BROKER_2_PASSWORD"),
},
nil, // TODO: replace with *db.Queries or pgxpool handle
h,
nil, // TODO: replace with channel key store
)
go broker1.Start(ctx)
go broker2.Start(ctx)
// ── HTTP server ──────────────────────────────────────────────────────────
r := router.New(h)
srv := &http.Server{
Addr: addr,
Handler: r,
}
go func() {
fmt.Printf("Tower listening on %s\n", addr)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}()
// ── Graceful shutdown ────────────────────────────────────────────────────
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("shutting down...")
cancel() // stops ingest workers
if err := srv.Shutdown(context.Background()); err != nil {
log.Printf("server shutdown error: %v", err)
}
}
// mustEnv returns the value of an env var, or empty string if unset.
// Workers handle missing config gracefully (log + retry), so we don't fatal here.
func mustEnv(key string) string {
v := os.Getenv(key)
if v == "" {
log.Printf("warning: %s is not set", key)
}
return v
}
+322
View File
@@ -0,0 +1,322 @@
-- ============================================================
-- Tower schema migration
-- ============================================================
-- ============================================================
-- IATA CODES
-- ============================================================
CREATE TABLE iata_codes (
iata CHAR(3) PRIMARY KEY,
display_name TEXT,
approx_lat DOUBLE PRECISION,
approx_lng DOUBLE PRECISION,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================================
-- REGIONS
-- ============================================================
CREATE TABLE regions (
id SERIAL PRIMARY KEY,
slug TEXT UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
display_order INT DEFAULT 0,
center_lat DOUBLE PRECISION,
center_lng DOUBLE PRECISION,
zoom_level INT DEFAULT 8,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE region_iatas (
region_id INT NOT NULL REFERENCES regions(id) ON DELETE CASCADE,
iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (region_id, iata)
);
CREATE INDEX idx_region_iatas_iata ON region_iatas(iata);
-- ============================================================
-- NODES (must come before observers due to observer_owners FK)
-- ============================================================
CREATE TABLE nodes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
public_key BYTEA UNIQUE NOT NULL,
node_type SMALLINT NOT NULL,
name TEXT,
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
location_source TEXT,
last_advert_at TIMESTAMPTZ,
supports_multibyte_paths BOOLEAN NOT NULL DEFAULT FALSE,
supports_multibyte_traces BOOLEAN NOT NULL DEFAULT FALSE,
min_firmware_version TEXT GENERATED ALWAYS AS (
CASE
WHEN supports_multibyte_paths THEN '1.14.0+'
WHEN supports_multibyte_traces THEN '1.11.0+'
ELSE NULL
END
) STORED,
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
metadata JSONB
);
CREATE INDEX idx_nodes_type_last_seen ON nodes(node_type, last_seen DESC);
CREATE INDEX idx_nodes_location ON nodes(latitude, longitude)
WHERE latitude IS NOT NULL AND longitude IS NOT NULL;
CREATE INDEX idx_nodes_pubkey ON nodes(public_key);
CREATE INDEX idx_nodes_multibyte_paths ON nodes(supports_multibyte_paths) WHERE supports_multibyte_paths;
CREATE INDEX idx_nodes_multibyte_traces ON nodes(supports_multibyte_traces) WHERE supports_multibyte_traces;
CREATE INDEX idx_nodes_min_firmware ON nodes(min_firmware_version) WHERE min_firmware_version IS NOT NULL;
-- ============================================================
-- OBSERVERS
-- ============================================================
CREATE TABLE observers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
public_key BYTEA UNIQUE NOT NULL,
display_name TEXT,
observer_type TEXT,
software_version TEXT,
hardware_model TEXT,
firmware_version TEXT,
firmware_build TEXT,
radio_freq_mhz REAL,
radio_sf SMALLINT,
radio_bw_khz REAL,
radio_cr SMALLINT,
battery_level REAL,
uptime_seconds BIGINT,
status_metadata JSONB,
last_status_at TIMESTAMPTZ,
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
observation_count BIGINT DEFAULT 0,
metadata JSONB
);
CREATE INDEX idx_observers_last_seen ON observers(last_seen DESC);
CREATE INDEX idx_observers_pubkey ON observers(public_key);
CREATE INDEX idx_observers_type ON observers(observer_type) WHERE observer_type IS NOT NULL;
CREATE TABLE observer_brokers (
observer_id UUID NOT NULL REFERENCES observers(id) ON DELETE CASCADE,
broker_name TEXT NOT NULL,
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_packet_at TIMESTAMPTZ,
auth_ok BOOLEAN DEFAULT TRUE,
PRIMARY KEY (observer_id, broker_name)
);
CREATE TABLE observer_locations (
observer_id UUID NOT NULL REFERENCES observers(id) ON DELETE CASCADE,
iata CHAR(3) REFERENCES iata_codes(iata),
latitude DOUBLE PRECISION,
longitude DOUBLE PRECISION,
reported_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (observer_id, reported_at)
);
CREATE INDEX idx_observer_locations_recent ON observer_locations(observer_id, reported_at DESC);
CREATE TABLE observer_telemetry (
id BIGSERIAL PRIMARY KEY,
observer_id UUID NOT NULL REFERENCES observers(id) ON DELETE CASCADE,
reported_at TIMESTAMPTZ NOT NULL,
battery_voltage_mv INT,
airtime_tx_pct REAL,
airtime_rx_pct REAL,
noise_floor_db REAL,
uptime_seconds BIGINT,
queue_length INT,
debug_flags INT,
receive_errors INT,
UNIQUE (observer_id, reported_at)
);
CREATE INDEX idx_telemetry_reported_brin ON observer_telemetry USING BRIN (reported_at);
CREATE INDEX idx_telemetry_observer_recent ON observer_telemetry(observer_id, reported_at DESC);
CREATE TABLE observer_owners (
observer_id UUID PRIMARY KEY REFERENCES observers(id) ON DELETE CASCADE,
owner_node_id UUID REFERENCES nodes(id),
owner_pubkey BYTEA,
contact_name TEXT,
contact_email TEXT,
notes TEXT,
source TEXT,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_observer_owners_node ON observer_owners(owner_node_id) WHERE owner_node_id IS NOT NULL;
-- ============================================================
-- NODE IATAS AND SHORT IDS
-- ============================================================
CREATE TABLE node_iatas (
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE,
first_heard TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_heard TIMESTAMPTZ NOT NULL DEFAULT NOW(),
observation_count BIGINT DEFAULT 0,
PRIMARY KEY (node_id, iata)
);
CREATE INDEX idx_node_iatas_iata ON node_iatas(iata, last_heard DESC);
CREATE TABLE node_short_ids (
node_id UUID NOT NULL REFERENCES nodes(id) ON DELETE CASCADE,
iata CHAR(3) NOT NULL REFERENCES iata_codes(iata) ON DELETE CASCADE,
prefix_4 BYTEA NOT NULL,
prefix_1 BYTEA GENERATED ALWAYS AS (substring(prefix_4 from 1 for 1)) STORED,
prefix_2 BYTEA GENERATED ALWAYS AS (substring(prefix_4 from 1 for 2)) STORED,
prefix_3 BYTEA GENERATED ALWAYS AS (substring(prefix_4 from 1 for 3)) STORED,
PRIMARY KEY (node_id, iata)
);
CREATE INDEX idx_short_ids_p1 ON node_short_ids(iata, prefix_1);
CREATE INDEX idx_short_ids_p2 ON node_short_ids(iata, prefix_2);
CREATE INDEX idx_short_ids_p3 ON node_short_ids(iata, prefix_3);
CREATE INDEX idx_short_ids_p4 ON node_short_ids(iata, prefix_4);
-- ============================================================
-- PACKETS
-- ============================================================
CREATE TABLE packets (
packet_hash BYTEA PRIMARY KEY,
payload_type SMALLINT NOT NULL,
payload_version SMALLINT NOT NULL,
route_type SMALLINT NOT NULL,
transport_codes_present BOOLEAN DEFAULT FALSE,
region_code INT,
sub_region_code INT,
origin_pubkey BYTEA,
raw_payload BYTEA NOT NULL,
parsed_payload JSONB,
decrypted BOOLEAN DEFAULT FALSE,
channel_hash BYTEA,
first_heard_at TIMESTAMPTZ NOT NULL,
last_heard_at TIMESTAMPTZ NOT NULL,
observation_count INT DEFAULT 0
);
CREATE INDEX idx_packets_first_heard_brin ON packets USING BRIN (first_heard_at);
CREATE INDEX idx_packets_payload_type ON packets(payload_type, first_heard_at DESC);
CREATE INDEX idx_packets_route_type ON packets(route_type, first_heard_at DESC);
CREATE INDEX idx_packets_origin ON packets(origin_pubkey, first_heard_at DESC)
WHERE origin_pubkey IS NOT NULL;
CREATE INDEX idx_packets_channel ON packets(channel_hash, first_heard_at DESC)
WHERE channel_hash IS NOT NULL;
-- ============================================================
-- PACKET OBSERVATIONS
-- ============================================================
CREATE TABLE packet_observations (
id BIGSERIAL PRIMARY KEY,
packet_hash BYTEA NOT NULL REFERENCES packets(packet_hash) ON DELETE CASCADE,
observer_id UUID NOT NULL REFERENCES observers(id),
iata CHAR(3) NOT NULL REFERENCES iata_codes(iata),
heard_at TIMESTAMPTZ NOT NULL,
path_length_byte SMALLINT NOT NULL,
hash_size SMALLINT NOT NULL,
hop_count SMALLINT NOT NULL,
path_bytes BYTEA,
rssi SMALLINT,
snr REAL,
propagation_time_ms INT,
radio_freq_mhz REAL,
spread_factor SMALLINT,
bandwidth_khz REAL,
coding_rate SMALLINT,
source_broker TEXT,
UNIQUE (packet_hash, observer_id, heard_at)
);
CREATE INDEX idx_observations_heard_brin ON packet_observations USING BRIN (heard_at);
CREATE INDEX idx_observations_iata_heard ON packet_observations(iata, heard_at DESC);
CREATE INDEX idx_observations_observer ON packet_observations(observer_id, heard_at DESC);
CREATE INDEX idx_observations_packet ON packet_observations(packet_hash);
-- ============================================================
-- CHANNELS AND CHAT MESSAGES
-- ============================================================
CREATE TABLE channels (
id SERIAL PRIMARY KEY,
channel_hash BYTEA UNIQUE NOT NULL,
name TEXT,
is_hashtag BOOLEAN DEFAULT FALSE,
is_public BOOLEAN DEFAULT FALSE,
key_known BOOLEAN DEFAULT FALSE,
first_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_seen TIMESTAMPTZ NOT NULL DEFAULT NOW(),
message_count BIGINT DEFAULT 0
);
CREATE INDEX idx_channels_last_seen ON channels(last_seen DESC);
CREATE TABLE channel_keys (
channel_id INT PRIMARY KEY REFERENCES channels(id) ON DELETE CASCADE,
key_bytes BYTEA NOT NULL,
added_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
added_by TEXT
);
CREATE TABLE channel_messages (
id BIGSERIAL PRIMARY KEY,
channel_id INT NOT NULL REFERENCES channels(id),
packet_hash BYTEA NOT NULL REFERENCES packets(packet_hash),
sender_name TEXT,
sender_pubkey BYTEA,
content TEXT,
sent_at TIMESTAMPTZ NOT NULL,
UNIQUE (packet_hash)
);
CREATE INDEX idx_channel_messages_channel ON channel_messages(channel_id, sent_at DESC);
CREATE INDEX idx_channel_messages_sent_brin ON channel_messages USING BRIN (sent_at);
-- ============================================================
-- MATERIALIZED VIEWS
-- ============================================================
CREATE MATERIALIZED VIEW mv_hourly_iata_stats AS
SELECT
iata,
date_trunc('hour', heard_at) AS hour,
COUNT(*) AS observation_count,
COUNT(DISTINCT packet_hash) AS unique_packets,
COUNT(DISTINCT observer_id) AS active_observers
FROM packet_observations
WHERE heard_at > NOW() - INTERVAL '7 days'
GROUP BY iata, date_trunc('hour', heard_at);
CREATE UNIQUE INDEX idx_mv_hourly_iata
ON mv_hourly_iata_stats(iata, hour);
CREATE MATERIALIZED VIEW mv_top_nodes_by_iata AS
SELECT
ni.iata,
ni.node_id,
n.name,
n.node_type,
ni.observation_count,
ni.last_heard
FROM node_iatas ni
JOIN nodes n ON n.id = ni.node_id
WHERE ni.last_heard > NOW() - INTERVAL '7 days';
CREATE UNIQUE INDEX idx_mv_top_nodes
ON mv_top_nodes_by_iata(iata, node_id);
+266
View File
@@ -0,0 +1,266 @@
-- ============================================================
-- IATA CODES
-- ============================================================
-- name: UpsertIATA :exec
INSERT INTO iata_codes (iata)
VALUES ($1)
ON CONFLICT (iata) DO NOTHING;
-- name: GetIATA :one
SELECT * FROM iata_codes WHERE iata = $1;
-- name: ListIATAs :many
SELECT * FROM iata_codes ORDER BY iata;
-- ============================================================
-- OBSERVERS
-- ============================================================
-- name: UpsertObserver :one
INSERT INTO observers (public_key, last_seen)
VALUES ($1, NOW())
ON CONFLICT (public_key) DO UPDATE SET
last_seen = NOW(),
observation_count = observers.observation_count + 1
RETURNING *;
-- name: UpdateObserverStatus :exec
UPDATE observers SET
display_name = COALESCE($2, display_name),
observer_type = COALESCE($3, observer_type),
software_version = COALESCE($4, software_version),
hardware_model = COALESCE($5, hardware_model),
firmware_version = COALESCE($6, firmware_version),
firmware_build = COALESCE($7, firmware_build),
radio_freq_mhz = COALESCE($8, radio_freq_mhz),
radio_sf = COALESCE($9, radio_sf),
radio_bw_khz = COALESCE($10, radio_bw_khz),
radio_cr = COALESCE($11, radio_cr),
battery_level = COALESCE($12, battery_level),
uptime_seconds = COALESCE($13, uptime_seconds),
status_metadata = $14,
last_status_at = NOW(),
last_seen = NOW()
WHERE public_key = $1;
-- name: GetObserverByPubkey :one
SELECT * FROM observers WHERE public_key = $1;
-- name: ListObservers :many
SELECT * FROM observers ORDER BY last_seen DESC;
-- ============================================================
-- OBSERVER BROKERS
-- ============================================================
-- name: UpsertObserverBroker :exec
INSERT INTO observer_brokers (observer_id, broker_name, last_seen, last_packet_at)
VALUES ($1, $2, NOW(), NOW())
ON CONFLICT (observer_id, broker_name) DO UPDATE SET
last_seen = NOW(),
last_packet_at = NOW();
-- ============================================================
-- PACKETS
-- ============================================================
-- name: UpsertPacket :one
INSERT INTO packets (
packet_hash,
payload_type,
payload_version,
route_type,
transport_codes_present,
region_code,
sub_region_code,
origin_pubkey,
raw_payload,
parsed_payload,
channel_hash,
first_heard_at,
last_heard_at,
observation_count
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, NOW(), NOW(), 1
)
ON CONFLICT (packet_hash) DO UPDATE SET
last_heard_at = NOW(),
observation_count = packets.observation_count + 1
RETURNING *, (xmax = 0) AS inserted;
-- name: GetPacket :one
SELECT * FROM packets WHERE packet_hash = $1;
-- name: ListPackets :many
SELECT p.*
FROM packets p
WHERE
($1::smallint IS NULL OR p.payload_type = $1)
AND ($2::smallint IS NULL OR p.route_type = $2)
AND ($3::timestamptz IS NULL OR p.first_heard_at >= $3)
AND ($4::timestamptz IS NULL OR p.first_heard_at <= $4)
ORDER BY p.last_heard_at DESC
LIMIT $5;
-- name: ListPacketsAfterID :many
SELECT p.*
FROM packets p
JOIN packet_observations po ON po.packet_hash = p.packet_hash
WHERE po.id > $1
ORDER BY po.id ASC
LIMIT $2;
-- ============================================================
-- PACKET OBSERVATIONS
-- ============================================================
-- name: InsertObservation :one
INSERT INTO packet_observations (
packet_hash,
observer_id,
iata,
heard_at,
path_length_byte,
hash_size,
hop_count,
path_bytes,
rssi,
snr,
propagation_time_ms,
radio_freq_mhz,
spread_factor,
bandwidth_khz,
coding_rate,
source_broker
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16
)
ON CONFLICT (packet_hash, observer_id, heard_at) DO NOTHING
RETURNING *;
-- name: ListObservationsForPacket :many
SELECT * FROM packet_observations
WHERE packet_hash = $1
ORDER BY heard_at ASC;
-- name: ListObservationsForObserver :many
SELECT * FROM packet_observations
WHERE observer_id = $1
AND ($2::timestamptz IS NULL OR heard_at >= $2)
ORDER BY heard_at DESC
LIMIT $3;
-- ============================================================
-- NODES
-- ============================================================
-- name: UpsertNode :one
INSERT INTO nodes (public_key, node_type, name, latitude, longitude, location_source, last_advert_at, last_seen)
VALUES ($1, $2, $3, $4, $5, 'advert', NOW(), NOW())
ON CONFLICT (public_key) DO UPDATE SET
node_type = EXCLUDED.node_type,
name = COALESCE(EXCLUDED.name, nodes.name),
latitude = COALESCE(EXCLUDED.latitude, nodes.latitude),
longitude = COALESCE(EXCLUDED.longitude, nodes.longitude),
location_source = CASE WHEN EXCLUDED.latitude IS NOT NULL THEN 'advert' ELSE nodes.location_source END,
last_advert_at = NOW(),
last_seen = NOW()
RETURNING *;
-- name: SetNodeMultibytePaths :exec
UPDATE nodes SET supports_multibyte_paths = TRUE
WHERE id = $1 AND supports_multibyte_paths = FALSE;
-- name: SetNodeMultibyteTraces :exec
UPDATE nodes SET supports_multibyte_traces = TRUE
WHERE id = $1 AND supports_multibyte_traces = FALSE;
-- name: GetNodeByPubkey :one
SELECT * FROM nodes WHERE public_key = $1;
-- name: ListNodes :many
SELECT * FROM nodes
WHERE
($1::smallint IS NULL OR node_type = $1)
ORDER BY last_seen DESC
LIMIT $2;
-- ============================================================
-- NODE IATAS
-- ============================================================
-- name: UpsertNodeIATA :exec
INSERT INTO node_iatas (node_id, iata, last_heard, observation_count)
VALUES ($1, $2, NOW(), 1)
ON CONFLICT (node_id, iata) DO UPDATE SET
last_heard = NOW(),
observation_count = node_iatas.observation_count + 1;
-- name: UpsertNodeShortID :exec
INSERT INTO node_short_ids (node_id, iata, prefix_4)
VALUES ($1, $2, $3)
ON CONFLICT (node_id, iata) DO NOTHING;
-- ============================================================
-- CHANNELS
-- ============================================================
-- name: UpsertChannel :one
INSERT INTO channels (channel_hash, last_seen)
VALUES ($1, NOW())
ON CONFLICT (channel_hash) DO UPDATE SET
last_seen = NOW(),
message_count = CASE WHEN $2 THEN channels.message_count + 1 ELSE channels.message_count END
RETURNING *;
-- name: SetChannelKeyKnown :exec
UPDATE channels SET key_known = TRUE WHERE channel_hash = $1;
-- name: ListChannels :many
SELECT * FROM channels ORDER BY last_seen DESC LIMIT $1;
-- name: GetChannel :one
SELECT * FROM channels WHERE channel_hash = $1;
-- ============================================================
-- CHANNEL MESSAGES
-- ============================================================
-- name: InsertChannelMessage :exec
INSERT INTO channel_messages (channel_id, packet_hash, sender_name, sender_pubkey, content, sent_at)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (packet_hash) DO NOTHING;
-- name: ListChannelMessages :many
SELECT * FROM channel_messages
WHERE channel_id = $1
AND ($2::timestamptz IS NULL OR sent_at >= $2)
ORDER BY sent_at DESC
LIMIT $3;
-- ============================================================
-- STATS
-- ============================================================
-- name: GetStatsOverview :one
SELECT
COUNT(DISTINCT po.packet_hash) AS total_packets,
COUNT(*) AS total_observations,
COUNT(DISTINCT po.observer_id) AS active_observers,
COUNT(DISTINCT po.iata) AS active_iatas
FROM packet_observations po
WHERE po.heard_at > NOW() - INTERVAL '24 hours'
AND ($1::char(3) IS NULL OR po.iata = $1);
-- name: GetHourlyStats :many
SELECT * FROM mv_hourly_iata_stats
WHERE ($1::char(3) IS NULL OR iata = $1)
AND hour >= NOW() - $2::interval
ORDER BY iata, hour;
-- name: GetTopNodes :many
SELECT * FROM mv_top_nodes_by_iata
WHERE ($1::char(3) IS NULL OR iata = $1)
ORDER BY observation_count DESC
LIMIT $2;
+62
View File
@@ -0,0 +1,62 @@
services:
app:
build: .
env_file: .env
ports:
- "8080:8080"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
restart: unless-stopped
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: tower
POSTGRES_PASSWORD: tower
POSTGRES_DB: tower
volumes:
- postgres_data:/var/lib/postgresql/data
- ./db/migrations:/docker-entrypoint-initdb.d:ro
ports:
- "5432:5432"
healthcheck:
test: ["CMD-SHELL", "pg_isready -U tower"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
redis:
image: redis:7-alpine
volumes:
- redis_data:/data
ports:
- "6379:6379"
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 5s
retries: 5
restart: unless-stopped
caddy:
image: caddy:2-alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
depends_on:
- app
restart: unless-stopped
volumes:
postgres_data:
redis_data:
caddy_data:
caddy_config:
+15
View File
@@ -0,0 +1,15 @@
LISTEN_ADDR=:8080
POSTGRES_DSN=postgres://tower:tower@localhost:5432/tower?sslmode=disable
REDIS_ADDR=localhost:6379
MQTT_BROKER_1_URL=wss://mqtt1.meshcore.ca:443
MQTT_BROKER_1_USERNAME=
MQTT_BROKER_1_PASSWORD=
MQTT_BROKER_2_URL=wss://mqtt2.meshcore.ca:443
MQTT_BROKER_2_USERNAME=
MQTT_BROKER_2_PASSWORD=
PACKET_RETENTION_DAYS=30
+19
View File
@@ -0,0 +1,19 @@
module tower
go 1.26.1
require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/go-chi/chi/v5 v5.2.1
github.com/google/uuid v1.6.0
github.com/meshcore-go/meshcore-go v1.0.6
)
require (
filippo.io/edwards25519 v1.2.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/joho/godotenv v1.5.1 // indirect
golang.org/x/crypto v0.51.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/sync v0.17.0 // indirect
)
+20
View File
@@ -0,0 +1,20 @@
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/go-chi/chi/v5 v5.2.1 h1:KOIHODQj58PmL80G2Eak4WdvUzjSJSm0vG72crDCqb8=
github.com/go-chi/chi/v5 v5.2.1/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/meshcore-go/meshcore-go v1.0.6 h1:Vf/DC2vdr76lW/kF4TntjiUgBObAip8fdrwV10R6o0M=
github.com/meshcore-go/meshcore-go v1.0.6/go.mod h1:u+Lvlg4Wy4blqCAJB+yGL3j2y7JRow+RQfQPjiI294Y=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+59
View File
@@ -0,0 +1,59 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// ChannelsRouter mounts all /channels routes onto a subrouter.
//
// GET /channels → ListChannels
// GET /channels/{channelHash} → GetChannel
// GET /channels/{channelHash}/messages → ListChannelMessages
func ChannelsRouter() http.Handler {
r := chi.NewRouter()
r.Get("/", ListChannels)
r.Route("/{channelHash}", func(r chi.Router) {
r.Get("/", GetChannel)
r.Get("/messages", ListChannelMessages)
})
return r
}
// ListChannels handles GET /api/v1/channels
//
// Query params (all optional):
// limit=50
func ListChannels(w http.ResponseWriter, r *http.Request) {
// TODO: query channels ORDER BY last_seen DESC, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetChannel handles GET /api/v1/channels/{channelHash}
//
// Returns channel detail including key_known status and message count.
// Channel keys are server-side config; key material is never exposed via the API.
func GetChannel(w http.ResponseWriter, r *http.Request) {
// channelHash := chi.URLParam(r, "channelHash")
// TODO: fetch channel, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// ListChannelMessages handles GET /api/v1/channels/{channelHash}/messages
//
// Query params (all optional):
// since=<epoch ms>
// limit=50
// cursor=<opaque>
//
// Returns paginated decrypted channel messages. Messages where key_known=false
// will have content=null.
func ListChannelMessages(w http.ResponseWriter, r *http.Request) {
// channelHash := chi.URLParam(r, "channelHash")
// TODO: fetch channel_messages, paginate, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
+39
View File
@@ -0,0 +1,39 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// IATAsRouter mounts all /iatas routes onto a subrouter.
//
// GET /iatas → ListIATAs
// GET /iatas/{iata} → GetIATA
func IATAsRouter() http.Handler {
r := chi.NewRouter()
r.Get("/", ListIATAs)
r.Get("/{iata}", GetIATA)
return r
}
// ListIATAs handles GET /api/v1/iatas
//
// Returns all known IATA codes with display name and coordinates where set.
// IATAs are auto-created on first packet arrival; config file overrides name/coords.
func ListIATAs(w http.ResponseWriter, r *http.Request) {
// TODO: query iata_codes, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetIATA handles GET /api/v1/iatas/{iata}
//
// Returns detail for a single IATA code including associated region memberships
// and basic recent stats.
func GetIATA(w http.ResponseWriter, r *http.Request) {
// iata := chi.URLParam(r, "iata")
// TODO: fetch iata_codes row + region memberships, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
+60
View File
@@ -0,0 +1,60 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// NodesRouter mounts all /nodes routes onto a subrouter.
//
// GET /nodes → ListNodes
// GET /nodes/{nodeId} → GetNode
// GET /nodes/{nodeId}/observations → ListNodeObservations
func NodesRouter() http.Handler {
r := chi.NewRouter()
r.Get("/", ListNodes)
r.Route("/{nodeId}", func(r chi.Router) {
r.Get("/", GetNode)
r.Get("/observations", ListNodeObservations)
})
return r
}
// ListNodes handles GET /api/v1/nodes
//
// Query params (all optional):
// type=2 (node_type: 1=companion, 2=repeater, 3=room server)
// iata=YOW
// firmwareTier=1.14.0
// limit=50
// cursor=<opaque>
func ListNodes(w http.ResponseWriter, r *http.Request) {
// TODO: query nodes with optional filters, paginate, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetNode handles GET /api/v1/nodes/{nodeId}
//
// Returns full node detail including iatasHeardIn, firmware capability flags,
// minFirmwareVersion, and the latest advert payload.
func GetNode(w http.ResponseWriter, r *http.Request) {
// nodeId := chi.URLParam(r, "nodeId")
// TODO: fetch node, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// ListNodeObservations handles GET /api/v1/nodes/{nodeId}/observations
//
// Query params (all optional):
// since=<epoch ms>
// limit=50
// cursor=<opaque>
func ListNodeObservations(w http.ResponseWriter, r *http.Request) {
// nodeId := chi.URLParam(r, "nodeId")
// TODO: fetch observations for node, paginate, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
+79
View File
@@ -0,0 +1,79 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// ObserversRouter mounts all /observers routes onto a subrouter.
//
// GET /observers → ListObservers
// GET /observers/{observerId} → GetObserver
// GET /observers/{observerId}/telemetry → GetObserverTelemetry
// GET /observers/{observerId}/adverts → ListObserverAdverts
func ObserversRouter() http.Handler {
r := chi.NewRouter()
r.Get("/", ListObservers)
r.Route("/{observerId}", func(r chi.Router) {
r.Get("/", GetObserver)
r.Get("/telemetry", GetObserverTelemetry)
r.Get("/adverts", ListObserverAdverts)
})
return r
}
// ListObservers handles GET /api/v1/observers
//
// Query params (all optional):
//
// iata=YOW
// type=meshcoretomqtt
// broker=mqtt1
// status=online
func ListObservers(w http.ResponseWriter, r *http.Request) {
// TODO: query observers with optional filters, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetObserver handles GET /api/v1/observers/{observerId}
//
// Returns full observer detail including broker badges, type, and recent stats.
// Note: observer_owners data is never exposed via the public API.
func GetObserver(w http.ResponseWriter, r *http.Request) {
// observerId := chi.URLParam(r, "observerId")
// TODO: fetch observer (exclude observer_owners fields), write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetObserverTelemetry handles GET /api/v1/observers/{observerId}/telemetry
//
// Query params (all optional):
//
// range=24h duration string: 24h, 7d, 30d
// afterId=<status id> for deterministic WS reconnection backfill
// limit=100
//
// Returns a time-bucketed array of telemetry points suitable for charting
// (battery, airtime, noise floor, uptime, queue depth, receive errors).
func GetObserverTelemetry(w http.ResponseWriter, r *http.Request) {
// observerId := chi.URLParam(r, "observerId")
// TODO: query status_metadata history, bucket by interval, write JSON response.
// afterId (int64): WHERE id > afterId ORDER BY id ASC LIMIT limit
w.WriteHeader(http.StatusNotImplemented)
}
// ListObserverAdverts handles GET /api/v1/observers/{observerId}/adverts
//
// Query params (all optional):
//
// limit=50
// cursor=<opaque>
func ListObserverAdverts(w http.ResponseWriter, r *http.Request) {
// observerId := chi.URLParam(r, "observerId")
// TODO: fetch advert packets heard by this observer, paginate, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
+54
View File
@@ -0,0 +1,54 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// PacketsRouter mounts all /packets routes onto a subrouter.
//
// GET /packets → ListPackets
// GET /packets/{packetHash} → GetPacket
func PacketsRouter() http.Handler {
r := chi.NewRouter()
r.Get("/", ListPackets)
r.Get("/{packetHash}", GetPacket)
return r
}
// ListPackets handles GET /api/v1/packets
//
// Query params (all optional):
//
// iata=YOW
// payloadType=4
// routeType=1
// since=<epoch ms>
// until=<epoch ms>
// afterId=<observation id> used for deterministic WS reconnection backfill
// limit=50
// cursor=<opaque>
//
// Returns a paginated list of packet summaries with the latest observation
// rolled in, newest first.
func ListPackets(w http.ResponseWriter, r *http.Request) {
// TODO: parse query params, query DB/cache, write JSON response.
//
// afterId (int64) takes precedence over cursor for reconnection backfill:
// WHERE id > afterId ORDER BY id ASC LIMIT limit
// Normal pagination uses cursor (opaque, encodes last seen id+timestamp).
w.WriteHeader(http.StatusNotImplemented)
}
// GetPacket handles GET /api/v1/packets/{packetHash}
//
// Returns the full packet with all observations and each observation's
// resolved path inline.
func GetPacket(w http.ResponseWriter, r *http.Request) {
// packetHash := chi.URLParam(r, "packetHash")
// TODO: fetch packet + observations, resolve paths, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
+42
View File
@@ -0,0 +1,42 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// RegionsRouter mounts all /regions routes onto a subrouter.
//
// GET /regions → ListRegions
// GET /regions/{regionId} → GetRegion
//
// Note: region creation and IATA assignment are managed via the server config
// file, not the API (v1). These endpoints are read-only.
func RegionsRouter() http.Handler {
r := chi.NewRouter()
r.Get("/", ListRegions)
r.Get("/{regionId}", GetRegion)
return r
}
// ListRegions handles GET /api/v1/regions
//
// Returns all super-regions with their associated IATA codes, center
// coordinates, and zoom level for map initialisation.
func ListRegions(w http.ResponseWriter, r *http.Request) {
// TODO: query regions + region_iatas, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetRegion handles GET /api/v1/regions/{regionId}
//
// Returns detail for a single super-region including its full IATA membership
// list and recent aggregate stats.
func GetRegion(w http.ResponseWriter, r *http.Request) {
// regionId := chi.URLParam(r, "regionId")
// TODO: fetch region + iatas, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
+99
View File
@@ -0,0 +1,99 @@
package handlers
import (
"net/http"
"github.com/go-chi/chi/v5"
)
// StatsRouter mounts all /stats routes onto a subrouter.
//
// GET /stats/overview → GetStatsOverview
// GET /stats/observations → GetStatsObservations
// GET /stats/payloadBreakdown → GetStatsPayloadBreakdown
// GET /stats/topNodes → GetStatsTopNodes
// GET /stats/topObservers → GetStatsTopObservers
//
// All endpoints accept either iata= (one or comma-separated) or regionId=
// (expands to all IATAs in that super-region via region_iatas).
func StatsRouter() http.Handler {
r := chi.NewRouter()
r.Get("/overview", GetStatsOverview)
r.Get("/observations", GetStatsObservations)
r.Get("/payloadBreakdown", GetStatsPayloadBreakdown)
r.Get("/topNodes", GetStatsTopNodes)
r.Get("/topObservers", GetStatsTopObservers)
return r
}
// GetStatsOverview handles GET /api/v1/stats/overview
//
// Query params (all optional):
// iata=YOW (one or comma-separated)
// regionId=<id>
//
// Returns top-line figures: total packets and observations last 24h,
// active observers, active IATAs, unique nodes seen.
func GetStatsOverview(w http.ResponseWriter, r *http.Request) {
// TODO: query mv_hourly_iata_stats + live aggregates, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetStatsObservations handles GET /api/v1/stats/observations
//
// Query params (all optional):
// iata=YOW
// regionId=<id>
// range=24h (duration string: 24h, 7d, 30d)
// interval=1h (bucket size: 5m, 1h, 1d)
//
// Returns a time series of observation counts bucketed by interval,
// suitable for charting.
func GetStatsObservations(w http.ResponseWriter, r *http.Request) {
// TODO: query mv_hourly_iata_stats, group by bucket, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetStatsPayloadBreakdown handles GET /api/v1/stats/payloadBreakdown
//
// Query params (all optional):
// iata=YOW
// regionId=<id>
// range=24h
//
// Returns observation counts grouped by payload_type for the given window.
func GetStatsPayloadBreakdown(w http.ResponseWriter, r *http.Request) {
// TODO: query packets + observations filtered by time/iata, group by payload_type.
w.WriteHeader(http.StatusNotImplemented)
}
// GetStatsTopNodes handles GET /api/v1/stats/topNodes
//
// Query params (all optional):
// iata=YOW
// regionId=<id>
// range=24h
// limit=10
//
// Returns the top N nodes by observation contribution count using
// mv_top_nodes_by_iata.
func GetStatsTopNodes(w http.ResponseWriter, r *http.Request) {
// TODO: query mv_top_nodes_by_iata, apply filters, write JSON response.
w.WriteHeader(http.StatusNotImplemented)
}
// GetStatsTopObservers handles GET /api/v1/stats/topObservers
//
// Query params (all optional):
// iata=YOW
// regionId=<id>
// range=24h
// limit=10
//
// Returns the top N observers by observation count for the given window.
func GetStatsTopObservers(w http.ResponseWriter, r *http.Request) {
// TODO: query packet_observations grouped by observer_id, apply filters.
w.WriteHeader(http.StatusNotImplemented)
}
+16
View File
@@ -0,0 +1,16 @@
package middleware
import "net/http"
// NoopAuth is a placeholder for the authentication middleware that will be
// wired onto the private route group when auth is implemented (see Future
// Features → Admin authentication in the design doc).
//
// Replace this with a real JWT/session validation middleware before shipping
// any write endpoints or admin functionality.
func NoopAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// TODO: validate bearer token, set user in context, return 401 on failure.
next.ServeHTTP(w, r)
})
}
+68
View File
@@ -0,0 +1,68 @@
package router
import (
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"tower/internal/api/handlers"
mw "tower/internal/api/middleware"
"tower/internal/hub"
"tower/internal/ws"
)
// New builds and returns the top-level Chi router.
//
// Route shape:
//
// /ws → WebSocket (public in v1)
// /api/v1/ → public group
// /packets → packets subrouter
// /nodes → nodes subrouter
// /observers → observers subrouter
// /channels → channels subrouter
// /iatas → iatas subrouter
// /regions → regions subrouter
// /stats → stats subrouter
//
// The private group is stubbed and ready for the auth middleware drop-in
// described in Future Features → Admin authentication.
func New(h *hub.Hub) http.Handler {
r := chi.NewRouter()
// ── Global middleware ────────────────────────────────────────────────────
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Use(middleware.CleanPath)
r.Use(middleware.StripSlashes)
// ── WebSocket ────────────────────────────────────────────────────────────
r.Get("/ws", ws.Handler(h))
// ── Public REST API (v1) ─────────────────────────────────────────────────
r.Route("/api/v1", func(r chi.Router) {
// Public group — no authentication required (all of v1 is public).
r.Group(func(r chi.Router) {
r.Mount("/packets", handlers.PacketsRouter())
r.Mount("/nodes", handlers.NodesRouter())
r.Mount("/observers", handlers.ObserversRouter())
r.Mount("/channels", handlers.ChannelsRouter())
r.Mount("/iatas", handlers.IATAsRouter())
r.Mount("/regions", handlers.RegionsRouter())
r.Mount("/stats", handlers.StatsRouter())
})
// Private group — auth middleware applied.
// Stubbed for the admin endpoints described in Future Features.
// Swap mw.NoopAuth for a real JWT/session middleware when ready.
r.Group(func(r chi.Router) {
r.Use(mw.NoopAuth)
// r.Mount("/admin", handlers.AdminRouter())
})
})
return r
}
+237
View File
@@ -0,0 +1,237 @@
// Package hub provides the central fan-out broker between the MQTT ingest
// goroutines and connected WebSocket clients.
//
// Design:
// - A single Hub.Run() goroutine owns the client map; no mutexes needed.
// - Ingest goroutines call hub.Broadcast() from any goroutine.
// - Each WebSocket connection gets a *Client with a buffered send channel.
// - If a client's send buffer is full it receives a lagged notification and
// its buffer is drained so it doesn't stall the broadcast loop.
//
// Subscription filtering (by IATA, payload type, channel hash, etc.) is
// enforced here before events are placed on a client's send channel.
package hub
import (
"encoding/json"
"log"
)
// EventType identifies the kind of server-push event. These match the
// discriminator values in the WebSocket protocol ("packetObservation", etc.).
type EventType string
const (
EventPacketObservation EventType = "packetObservation"
EventObserverStatus EventType = "observerStatus"
EventNodeUpdate EventType = "nodeUpdate"
EventChannelMessage EventType = "channelMessage"
)
// Event is a single fan-out unit. Payload is pre-serialised JSON so the
// broadcast loop never touches encoding — it's done once by the ingest path.
type Event struct {
Type EventType
Payload json.RawMessage
// Routing metadata used by the hub to match subscriptions.
// Populated by the ingest layer before calling Broadcast.
IATA string
PayloadType uint8
ChannelHash string // hex string, non-empty only for channelMessage events
}
// Scope mirrors the client-side subscribe message. All fields are optional:
// nil/empty means "no filter on this dimension" (match everything).
// An empty non-nil slice means "match nothing on this dimension".
type Scope struct {
IATAs []string
RegionIATAs []string // pre-expanded from regionId by the WS handler
PayloadTypes []uint8
ChannelHashes []string
Events []EventType
}
// Client represents a connected WebSocket consumer.
type Client struct {
Send chan Event
scope []Scope // OR semantics: event matches if it matches any scope entry
}
// matches returns true if the event satisfies at least one of the client's
// active subscriptions.
func (c *Client) matches(e Event) bool {
if len(c.scope) == 0 {
return true // no subscriptions yet → receive nothing
}
for _, s := range c.scope {
if scopeMatches(s, e) {
return true
}
}
return false
}
func scopeMatches(s Scope, e Event) bool {
if len(s.Events) > 0 && !containsEventType(s.Events, e.Type) {
return false
}
if len(s.IATAs) > 0 || len(s.RegionIATAs) > 0 {
allIATAs := append(s.IATAs, s.RegionIATAs...) //nolint:gocritic
if !containsString(allIATAs, e.IATA) {
return false
}
}
if len(s.PayloadTypes) > 0 && !containsUint8(s.PayloadTypes, e.PayloadType) {
return false
}
if len(s.ChannelHashes) > 0 && !containsString(s.ChannelHashes, e.ChannelHash) {
return false
}
return true
}
// Hub is the central event broker.
type Hub struct {
subscribe chan subscribeMsg
unsubscribe chan unsubscribeMsg
remove chan *Client
broadcast chan Event
}
type subscribeMsg struct {
client *Client
scope Scope
hasScope bool // true when this is an AddScope call, false for NewClient registration
}
type unsubscribeMsg struct {
client *Client
subscriptionID string
}
// New creates a Hub. Call Run() in a goroutine before using it.
func New() *Hub {
return &Hub{
subscribe: make(chan subscribeMsg, 64),
unsubscribe: make(chan unsubscribeMsg, 64),
remove: make(chan *Client, 64),
broadcast: make(chan Event, 512),
}
}
// NewClient creates a Client and registers it with the hub.
// The caller is responsible for calling Remove when the connection closes.
func (h *Hub) NewClient() *Client {
c := &Client{
Send: make(chan Event, 256),
}
// We don't add it to the map here; we send it through the channel so
// Run() is the only goroutine that touches the client map.
h.subscribe <- subscribeMsg{client: c, hasScope: false}
return c
}
// AddScope appends a subscription scope to a client. Called by the WS handler
// when it receives a "subscribe" message from the client.
func (h *Hub) AddScope(c *Client, s Scope) {
h.subscribe <- subscribeMsg{client: c, scope: s, hasScope: true}
}
// Remove deregisters a client and closes its Send channel.
// Safe to call from any goroutine (e.g. the WS handler's defer).
func (h *Hub) Remove(c *Client) {
h.remove <- c
}
// Broadcast enqueues an event for fan-out. Safe to call from any goroutine.
func (h *Hub) Broadcast(e Event) {
select {
case h.broadcast <- e:
default:
log.Println("hub: broadcast channel full, dropping event")
}
}
// Run is the hub's single-goroutine event loop. Call it in a dedicated
// goroutine: go hub.Run().
//
// It processes registrations, removals, and broadcasts sequentially so the
// clients map needs no locking.
func (h *Hub) Run() {
// clients maps a *Client to the set of subscription IDs it holds.
// We use a map[*Client]struct{} for O(1) presence checks and O(n)
// broadcast — fine at the scale Tower targets.
clients := make(map[*Client]struct{})
for {
select {
case msg := <-h.subscribe:
if !msg.hasScope {
// Registration with no scope yet (NewClient path).
clients[msg.client] = struct{}{}
} else {
// AddScope path — client must already be registered.
if _, ok := clients[msg.client]; ok {
msg.client.scope = append(msg.client.scope, msg.scope)
}
}
case c := <-h.remove:
if _, ok := clients[c]; ok {
delete(clients, c)
close(c.Send)
}
case evt := <-h.broadcast:
for c := range clients {
if !c.matches(evt) {
continue
}
select {
case c.Send <- evt:
default:
// Client send buffer full. The WS write pump is responsible
// for detecting its own lagged state and sending the lagged
// message. We just drain one slot so the broadcast loop
// doesn't block.
select {
case <-c.Send:
default:
}
log.Printf("hub: client send buffer full, dropped event type=%s", evt.Type)
}
}
}
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
func containsString(haystack []string, needle string) bool {
for _, s := range haystack {
if s == needle {
return true
}
}
return false
}
func containsUint8(haystack []uint8, needle uint8) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
func containsEventType(haystack []EventType, needle EventType) bool {
for _, v := range haystack {
if v == needle {
return true
}
}
return false
}
+345
View File
@@ -0,0 +1,345 @@
// Package ingest subscribes to a single MeshCore MQTT broker and drives the
// observation pipeline described in the design doc.
//
// Call Start() once per broker in a dedicated goroutine. Both broker instances
// share the same *hub.Hub and *DB handle so dedup and fan-out are centralised.
//
// Pipeline per incoming /packets message:
// 1. Parse topic → extract IATA + publisher pubkey
// 2. Decode hex payload via meshcore-go PacketFromBytes
// 3. Compute content-based packet hash (PacketHash)
// 4. Upsert observers + observer_brokers + iata_codes
// 5. Upsert packets row (ON CONFLICT bump last_heard_at + observation_count)
// 6. Insert packet_observations (ON CONFLICT DO NOTHING for cross-broker dedup)
// 7. If INSERT succeeded: capability detection, payload-type side effects, fan-out
//
// Pipeline per incoming /status message:
// 1. Parse topic → extract publisher pubkey
// 2. Upsert observers row (status_metadata, last_status_at, observer_type, etc.)
// 3. Fan out observerStatus event to hub
package ingest
import (
"context"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"strings"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/meshcore-go/meshcore-go"
"tower/internal/hub"
)
// Config holds the connection parameters for one broker.
type Config struct {
// BrokerName is a short human-readable label ("mqtt1", "mqtt2") used in
// log messages and stored in packet_observations.source_broker.
BrokerName string
// URL is the full broker WebSocket URL, e.g. "wss://mqtt1.meshcore.ca/mqtt"
URL string
Username string
Password string
}
// DB is the minimal database interface the ingest pipeline depends on.
// Wire in your real *pgxpool.Pool implementation here.
type DB interface {
// UpsertObserver upserts the observers row keyed on pubkey.
UpsertObserver(ctx context.Context, pubkey []byte, iata string) error
// UpsertObserverBroker records that this observer was seen on brokerName.
UpsertObserverBroker(ctx context.Context, pubkey []byte, brokerName string) error
// UpsertIATA auto-creates an iata_codes row if it doesn't exist yet.
UpsertIATA(ctx context.Context, iata string) error
// UpsertPacket inserts or bumps the packets row. Returns (isNew, error).
UpsertPacket(ctx context.Context, p UpsertPacketParams) (bool, error)
// InsertObservation inserts a packet_observations row.
// Returns (inserted, error); inserted=false means ON CONFLICT DO NOTHING fired.
InsertObservation(ctx context.Context, o InsertObservationParams) (bool, error)
// SetNodeCapability flips supports_multibyte_paths or supports_multibyte_traces
// for a node, never downgrading an existing TRUE.
SetNodeCapability(ctx context.Context, nodeID string, paths, traces bool) error
// UpsertNode upserts a nodes row from an advert payload.
UpsertNode(ctx context.Context, n UpsertNodeParams) error
// UpsertNodeIATA upserts a node_iatas row.
UpsertNodeIATA(ctx context.Context, nodeID string, iata string) error
// InsertChannelMessage stores a decrypted group text message.
InsertChannelMessage(ctx context.Context, m InsertChannelMessageParams) error
// UpdateObserverStatus updates the observer row from a /status message.
UpdateObserverStatus(ctx context.Context, p UpdateObserverStatusParams) error
}
// UpsertPacketParams mirrors the columns written on packets upsert.
type UpsertPacketParams struct {
PacketHash []byte
RouteType uint8
PayloadType uint8
PayloadVersion uint8
TransportCodes []byte // nil if not FLOOD/DIRECT
RawPayload []byte
ParsedPayload json.RawMessage
OriginPubkey []byte
ChannelHash []byte
}
// InsertObservationParams mirrors the columns written on packet_observations insert.
type InsertObservationParams struct {
PacketHash []byte
ObserverID string
IATA string
HeardAt time.Time
PathLengthByte uint8
HashSize uint8
HopCount uint8
PathBytes []byte
RSSI int16
SNR float32
PropagationTimeMs int32
RadioFreqMHz float32
SpreadFactor int16
BandwidthKHz float32
CodingRate int16
SourceBroker string
}
// UpsertNodeParams carries the fields extracted from a payload type 0x04 advert.
type UpsertNodeParams struct {
PublicKey []byte
Name string
NodeType uint8 // 1=companion, 2=repeater, 3=room server
Latitude *float64
Longitude *float64
}
// InsertChannelMessageParams carries a decrypted group text message.
type InsertChannelMessageParams struct {
ChannelID int
PacketHash []byte
SenderName string
SenderPubkey []byte
Content string
SentAt time.Time
}
// UpdateObserverStatusParams carries the fields parsed from a /status message.
type UpdateObserverStatusParams struct {
PublicKey []byte
StatusMetadata json.RawMessage
LastStatusAt time.Time
BatteryLevel *int
UptimeSeconds *int64
SoftwareVersion string
ObserverType string // only set if we can detect it; never downgrade to unknown
DisplayName string // only set if current value is NULL
}
// ChannelKeyStore is a read-only view of the channel keys loaded from config.
// The ingest layer calls Decrypt and never touches key material directly.
type ChannelKeyStore interface {
// Decrypt attempts to decrypt groupText bytes using the key for channelHash.
// Returns ("", false) if the key is unknown.
Decrypt(channelHash []byte, ciphertext []byte) (plaintext string, ok bool)
}
// Worker holds the dependencies for one broker's ingest loop.
type Worker struct {
cfg Config
db DB
hub *hub.Hub
keys ChannelKeyStore
}
// New creates an ingest Worker. Call Start() to connect and begin processing.
func New(cfg Config, db DB, h *hub.Hub, keys ChannelKeyStore) *Worker {
return &Worker{cfg: cfg, db: db, hub: h, keys: keys}
}
// Start connects to the broker and blocks until ctx is cancelled. It
// reconnects automatically on transient failures using paho's built-in
// reconnect logic.
//
// Intended usage: go worker.Start(ctx)
func (w *Worker) Start(ctx context.Context) {
opts := mqtt.NewClientOptions().
AddBroker(w.cfg.URL).
SetClientID(fmt.Sprintf("tower-%s", w.cfg.BrokerName)).
SetUsername(w.cfg.Username).
SetPassword(w.cfg.Password).
SetAutoReconnect(true).
SetMaxReconnectInterval(30 * time.Second).
SetOnConnectHandler(func(c mqtt.Client) {
log.Printf("ingest[%s]: connected to %s", w.cfg.BrokerName, w.cfg.URL)
w.subscribe(c)
}).
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Printf("ingest[%s]: connection lost: %v", w.cfg.BrokerName, err)
})
client := mqtt.NewClient(opts)
if tok := client.Connect(); tok.Wait() && tok.Error() != nil {
log.Printf("ingest[%s]: initial connect failed: %v", w.cfg.BrokerName, tok.Error())
// paho will retry; we fall through and wait for ctx
}
<-ctx.Done()
client.Disconnect(500)
log.Printf("ingest[%s]: stopped", w.cfg.BrokerName)
}
// subscribe registers the wildcard topic handler after (re)connect.
func (w *Worker) subscribe(client mqtt.Client) {
// meshcore/{IATA}/{pubkey}/packets
// meshcore/{IATA}/{pubkey}/status
// We do NOT subscribe to /internal (Role 2 access).
tok := client.Subscribe("meshcore/#", 1, func(_ mqtt.Client, msg mqtt.Message) {
w.handleMessage(msg)
})
if tok.Wait() && tok.Error() != nil {
log.Printf("ingest[%s]: subscribe error: %v", w.cfg.BrokerName, tok.Error())
}
}
// handleMessage dispatches incoming MQTT messages by subtopic.
func (w *Worker) handleMessage(msg mqtt.Message) {
// Topic shape: meshcore/{IATA}/{pubkey}/{subtopic}
parts := strings.SplitN(msg.Topic(), "/", 4)
if len(parts) != 4 || parts[0] != "meshcore" {
return
}
iata, pubkeyHex, subtopic := parts[1], parts[2], parts[3]
ctx := context.Background()
switch subtopic {
case "packets":
w.handlePacket(ctx, iata, pubkeyHex, msg.Payload())
case "status":
w.handleStatus(ctx, pubkeyHex, msg.Payload())
// "internal" is intentionally not handled (Role 2 access)
}
}
// handlePacket runs the full observation pipeline for a /packets message.
func (w *Worker) handlePacket(ctx context.Context, iata, pubkeyHex string, raw []byte) {
var envelope struct {
Raw string `json:"raw"` // hex-encoded raw LoRa packet bytes
Timestamp string `json:"timestamp"`
Hash string `json:"hash"`
Origin string `json:"origin"`
Type string `json:"type"`
Direction string `json:"direction"`
Time string `json:"time"`
Date string `json:"date"`
Len string `json:"len"`
PacketType string `json:"packet_type"`
Route string `json:"route"`
PayloadLen string `json:"payload_len"`
OriginID string `json:"origin_id"`
SNR string `json:"SNR"`
RSSI string `json:"RSSI"`
}
if err := json.Unmarshal(raw, &envelope); err != nil || envelope.Raw == "" {
log.Printf("ingest[%s]: malformed packet envelope from %s/%s", w.cfg.BrokerName, iata, pubkeyHex)
return
}
hexBytes, err := hex.DecodeString(envelope.Raw)
if err != nil {
log.Printf("ingest[%s]: invalid hex from %s/%s: %v", w.cfg.BrokerName, iata, pubkeyHex, err)
}
// ── Step 2: decode via meshcore-go ──────────────────────────────────────
packet, err := meshcore.PacketFromBytes(hexBytes)
if err != nil {
log.Printf("ingest[%s]: error decoding packet from %s/%s: %v", w.cfg.BrokerName, iata, pubkeyHex, err)
return
}
packetHash := packet.PacketHash()
pathHashes := packet.PathHashes()
parsed := packet.PayloadTypeString()
// NOTE:
// For now we just log and return so the scaffolding compiles.
log.Printf("ingest[%s]: packet from %s/%s", w.cfg.BrokerName, iata, pubkeyHex)
log.Printf("hash[%x]: path[%x], payload type: %s", packetHash, pathHashes, parsed)
// ── Step 36: DB writes ──────────────────────────────────────────────────
// TODO: fill in once meshcore-go is wired.
//
// _ = w.db.UpsertObserver(ctx, pubkeyBytes, iata)
// _ = w.db.UpsertObserverBroker(ctx, pubkeyBytes, w.cfg.BrokerName)
// _ = w.db.UpsertIATA(ctx, iata)
// isNew, _ := w.db.UpsertPacket(ctx, UpsertPacketParams{...})
// inserted, _ := w.db.InsertObservation(ctx, InsertObservationParams{...})
// ── Step 7: side effects (only if observation INSERT succeeded) ──────────
// TODO:
// if inserted {
// w.runCapabilityDetection(ctx, packet, iata)
// w.handlePayloadTypeSideEffects(ctx, packet, iata)
// w.fanOut(packet, observation, isNew)
// }
_ = ctx // suppress unused warning until TODOs are filled in
}
// handleStatus processes a /status message and fans out an observerStatus event.
func (w *Worker) handleStatus(ctx context.Context, pubkeyHex string, raw []byte) {
// TODO: parse status JSON, call w.db.UpdateObserverStatus, fan out event.
//
// params := UpdateObserverStatusParams{...}
// _ = w.db.UpdateObserverStatus(ctx, params)
//
// payload, _ := json.Marshal(statusEvent{...})
// w.hub.Broadcast(hub.Event{Type: hub.EventObserverStatus, Payload: payload})
log.Printf("ingest[%s]: status from %s (TODO)", w.cfg.BrokerName, pubkeyHex)
_ = ctx
}
// runCapabilityDetection checks hash sizes and flips firmware capability flags.
// Called only when the observation INSERT succeeded (no dedup conflict).
//
// Rules (from design doc):
// - hash_size == 1: do nothing (proves nothing about firmware)
// - duplicate hash prefixes within the path: skip entirely
// - non-trace + hash_size 2 or 3 → supports_multibyte_paths = TRUE
// - trace (0x09) + hash_size 2 or 4 → supports_multibyte_traces = TRUE
//
// TODO: implement once meshcore-go PathHashes() is wired.
func (w *Worker) runCapabilityDetection(ctx context.Context, payloadType uint8, hashSize uint8, resolvedNodeIDs []string) {
if hashSize < 2 {
return
}
for _, nodeID := range resolvedNodeIDs {
switch {
case payloadType != 0x09 && (hashSize == 2 || hashSize == 3):
_ = w.db.SetNodeCapability(ctx, nodeID, true, false)
case payloadType == 0x09 && (hashSize == 2 || hashSize == 4):
_ = w.db.SetNodeCapability(ctx, nodeID, false, true)
}
}
}
// fanOut builds and broadcasts the packetObservation event to connected WS clients.
func (w *Worker) fanOut(packetHash string, payloadType uint8, iata string, isFirst bool, observationCount int64, payload json.RawMessage) {
evt := hub.Event{
Type: hub.EventPacketObservation,
Payload: payload,
IATA: iata,
PayloadType: payloadType,
}
w.hub.Broadcast(evt)
}
+171
View File
@@ -0,0 +1,171 @@
// Package ws handles the WebSocket endpoint at GET /ws.
//
// Protocol (from design doc):
//
// On connect: server sends hello { v:1, type:"hello", serverTime:<ms>, connectionId:"uuid" }
//
// Client → Server:
// subscribe { v, type, id, scope } → server replies subscribed { v, type, id, subscriptionId }
// unsubscribe { v, type, id, subscriptionId }
// ping { v, type, id } → server replies pong { v, type, id }
//
// Server → Client events (unsolicited):
// packetObservation, observerStatus, nodeUpdate, channelMessage
// lagged { v, type, droppedCount, since, lastObservationId }
// error { v, type, code, message }
//
// Idle connections (no ping) closed after 90s.
// Client should ping every 30s.
package ws
import (
"context"
"encoding/json"
"log"
"net/http"
"time"
"github.com/google/uuid"
"tower/internal/hub"
)
const (
pingTimeout = 90 * time.Second
writeTimeout = 10 * time.Second
)
// Handler returns an http.HandlerFunc that requires the hub to be injected.
// Wire it via router.New(h) so the hub is available at startup.
func Handler(h *hub.Hub) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
// TODO: upgrade to WebSocket using nhooyr.io/websocket or gorilla/websocket.
// The structure below shows the intended shape; swap the stub conn calls
// for real ones once the library is chosen.
connID := uuid.NewString()
client := h.NewClient()
defer h.Remove(client)
ctx, cancel := context.WithCancel(r.Context())
defer cancel()
// Send hello
hello := map[string]any{
"v": 1,
"type": "hello",
"serverTime": time.Now().UnixMilli(),
"connectionId": connID,
}
helloBytes, _ := json.Marshal(hello)
log.Printf("ws[%s]: connected, hello: %s", connID, helloBytes)
// TODO: conn.Write(ctx, websocket.MessageText, helloBytes)
// Write pump: forward hub events to the WS connection.
go func() {
for {
select {
case evt, ok := <-client.Send:
if !ok {
return // hub closed the channel (client removed)
}
msg := map[string]any{
"v": 1,
"type": "event",
"event": evt.Type,
"data": json.RawMessage(evt.Payload),
}
msgBytes, _ := json.Marshal(msg)
_ = msgBytes
// TODO: conn.Write(ctx, websocket.MessageText, msgBytes)
case <-ctx.Done():
return
}
}
}()
// Read pump: handle subscribe / unsubscribe / ping from the client.
// Drives the idle timeout; any message resets the deadline.
for {
// TODO: _, msgBytes, err := conn.Read(ctx)
// if err != nil { return }
// w.handleClientMessage(ctx, client, h, connID, msgBytes)
// Stub: block until context cancelled.
select {
case <-ctx.Done():
return
case <-time.After(pingTimeout):
log.Printf("ws[%s]: idle timeout", connID)
return
}
}
}
}
// clientMessage is the shape of every client → server message.
type clientMessage struct {
V int `json:"v"`
Type string `json:"type"`
ID string `json:"id"`
SubscriptionID string `json:"subscriptionId,omitempty"`
Scope *subscribeScope `json:"scope,omitempty"`
}
// subscribeScope mirrors the scope object in the subscribe message.
type subscribeScope struct {
IATAs []string `json:"iatas"`
RegionIDs []string `json:"regionIds"`
PayloadTypes []uint8 `json:"payloadTypes"`
RouteTypes []uint8 `json:"routeTypes"`
ChannelHashes []string `json:"channelHashes"`
ObserverIDs []string `json:"observerIds"`
Events []hub.EventType `json:"events"`
}
// handleClientMessage dispatches a parsed client message.
// TODO: call this from the read pump once the WS library is wired.
func handleClientMessage(ctx context.Context, client *hub.Client, h *hub.Hub, connID string, raw []byte) {
var msg clientMessage
if err := json.Unmarshal(raw, &msg); err != nil {
log.Printf("ws[%s]: bad message: %v", connID, err)
return
}
switch msg.Type {
case "subscribe":
if msg.Scope == nil {
return
}
scope := hub.Scope{
IATAs: msg.Scope.IATAs,
PayloadTypes: msg.Scope.PayloadTypes,
ChannelHashes: msg.Scope.ChannelHashes,
Events: msg.Scope.Events,
// RegionIATAs: TODO expand msg.Scope.RegionIDs → IATA list via DB/config lookup
}
h.AddScope(client, scope)
subID := uuid.NewString()
reply, _ := json.Marshal(map[string]any{
"v": 1, "type": "subscribed", "id": msg.ID, "subscriptionId": subID,
})
log.Printf("ws[%s]: subscribed %s → %s", connID, msg.ID, subID)
_ = reply
// TODO: conn.Write(ctx, websocket.MessageText, reply)
case "unsubscribe":
// TODO: remove the specific subscriptionId from client.scope.
// For now scope entries are append-only; implement removal when needed.
log.Printf("ws[%s]: unsubscribe %s (TODO)", connID, msg.SubscriptionID)
case "ping":
reply, _ := json.Marshal(map[string]any{"v": 1, "type": "pong", "id": msg.ID})
_ = reply
// TODO: conn.Write(ctx, websocket.MessageText, reply)
default:
log.Printf("ws[%s]: unknown message type %q", connID, msg.Type)
}
_ = ctx
}
+19
View File
@@ -0,0 +1,19 @@
version: "2"
sql:
- engine: "postgresql"
queries: "db/queries"
schema: "db/migrations"
gen:
go:
package: "db"
out: "db/sqlc"
emit_json_tags: true
emit_pointers_for_null_types: true
emit_empty_slices: true
overrides:
- db_type: "uuid"
go_type: "github.com/google/uuid.UUID"
- db_type: "jsonb"
go_type: "encoding/json.RawMessage"
- db_type: "bytea"
go_type: "[]byte"