Skip to content

Commit 8d316d9

Browse files
committed
feat(dashboard): live listener map + quarter/year/lifetime traffic + source-format display
GEO MAP - relay/geoip.go: free, key-less country lookup using DB-IP's ip-to-country-lite database (CC-BY-4.0, no signup, no quota). Auto-downloads to /var/lib/tinyice/geoip on first run, refreshes monthly. Lookup is sub-millisecond, lock-free reads, returns ISO alpha-2 country code or "" for private/loopback IPs. - relay/geoip_centroids.go: embedded Natural Earth centroid table (~6 KB, ISO -> name + lat/lon) so the dashboard can plot bubbles without an extra round-trip. - server/geo_tracker.go: live (country, mount) listener counter updated by handleListener via Add/Remove on every subscribe / unsubscribe. - GET /admin/geo: returns aggregated per-country listener counts, sorted by listener count DESC. Optional ?mount= filter. - server/frontend/src/components/GeoMapCard.tsx: Leaflet map using CartoDB Dark Matter tiles (no API key; OpenStreetMap data + CARTO attribution). Refreshes every 15 s. Each country renders as a circle marker at its centroid; radius scales with sqrt(listeners). TRAFFIC WINDOWS - /admin/traffic now also returns quarter (90 d), year (365 d) and lifetime totals alongside day/week/month/all. Same shape, same aggregation logic. - TrafficTotalsCard reworked into a compact dense table — six rows of (window, in, out, mounts) with arrow glyphs in the header. SOURCE FORMAT - streamEventInfo gains source_mount / source_type / source_bitrate fields populated for transcoded outputs. Operator can see at a glance what's being converted into what. - Dashboard streams table now renders "<src-format> -> <out-format>" for transcoded mounts (e.g. "OPUS 160k -> MP3 320k").
1 parent a519e31 commit 8d316d9

42 files changed

Lines changed: 1093 additions & 169 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ require (
4444
github.com/mewkiz/pkg v0.0.0-20250417130911-3f050ff8c56d // indirect
4545
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 // indirect
4646
github.com/mitchellh/mapstructure v1.4.1 // indirect
47+
github.com/oschwald/maxminddb-golang v1.13.1 // indirect
4748
github.com/pion/datachannel v1.6.0 // indirect
4849
github.com/pion/dtls/v3 v3.1.2 // indirect
4950
github.com/pion/ice/v4 v4.2.1 // indirect

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985 h1:h8O1byDZ1uk6RUXMhj1
7272
github.com/mewpkg/term v0.0.0-20241026122259-37a80af23985/go.mod h1:uiPmbdUbdt1NkGApKl7htQjZ8S7XaGUAVulJUJ9v6q4=
7373
github.com/mitchellh/mapstructure v1.4.1 h1:CpVNEelQCZBooIPDn+AR3NpivK/TIKU8bDxdASFVQag=
7474
github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
75+
github.com/oschwald/maxminddb-golang v1.13.1 h1:G3wwjdN9JmIK2o/ermkHM+98oX5fS+k5MbwsmL4MRQE=
76+
github.com/oschwald/maxminddb-golang v1.13.1/go.mod h1:K4pgV9N/GcK694KSTmVSDTODk4IsCNThNdTmnaBZ/F8=
7577
github.com/pion/datachannel v1.6.0 h1:XecBlj+cvsxhAMZWFfFcPyUaDZtd7IJvrXqlXD/53i0=
7678
github.com/pion/datachannel v1.6.0/go.mod h1:ur+wzYF8mWdC+Mkis5Thosk+u/VOL287apDNEbFpsIk=
7779
github.com/pion/dtls/v3 v3.1.2 h1:gqEdOUXLtCGW+afsBLO0LtDD8GnuBBjEy6HRtyofZTc=

relay/geoip.go

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
package relay
2+
3+
import (
4+
"compress/gzip"
5+
"context"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"net"
10+
"net/http"
11+
"os"
12+
"path/filepath"
13+
"strings"
14+
"sync"
15+
"time"
16+
17+
"github.com/DatanoiseTV/tinyice/logger"
18+
"github.com/oschwald/maxminddb-golang"
19+
)
20+
21+
// GeoIP — free, key-less country lookup for listener locations.
22+
//
23+
// We pull DB-IP's "ip-to-country-lite" database, published monthly
24+
// under CC-BY-4.0 with no API key, no signup, and no per-request
25+
// quota. The .mmdb file is ~6 MB; we cache it under
26+
// dataDir/dbip-country-lite.mmdb and refresh whenever it crosses
27+
// 30 days stale.
28+
//
29+
// Attribution requirement (CC-BY-4.0): the dashboard surfaces
30+
// "GeoIP data: db-ip.com / CC BY 4.0" near the map.
31+
32+
const (
33+
dbipFilename = "dbip-country-lite.mmdb"
34+
dbipMaxAge = 30 * 24 * time.Hour
35+
dbipDownloadTmpl = "https://download.db-ip.com/free/dbip-country-lite-%s.mmdb.gz"
36+
dbipUserAgent = "tinyice-geoip/1.0 (+https://github.com/DatanoiseTV/tinyice)"
37+
)
38+
39+
// GeoLookup is the read side of the geoip cache. Listener-connect
40+
// hot path takes a read lock per Lookup; a periodic update goroutine
41+
// takes the write lock to swap in a fresh mmdb. Lookup is allocation-
42+
// free (the maxminddb library reads into the supplied struct).
43+
type GeoLookup struct {
44+
mu sync.RWMutex
45+
reader *maxminddb.Reader
46+
loaded time.Time
47+
dir string
48+
}
49+
50+
// NewGeoLookup loads dataDir/dbip-country-lite.mmdb if present and
51+
// kicks off a background updater. Returns a non-nil GeoLookup even
52+
// when the file is absent or corrupt — Lookup just returns "" until
53+
// the first download lands. Pass an empty dir to disable persistence
54+
// + auto-update entirely (useful in tests).
55+
func NewGeoLookup(dataDir string) *GeoLookup {
56+
g := &GeoLookup{dir: dataDir}
57+
if dataDir == "" {
58+
return g
59+
}
60+
_ = os.MkdirAll(dataDir, 0o755)
61+
if err := g.tryLoad(); err != nil {
62+
logger.L.Infof("geoip: no usable database yet (%v); will fetch in background", err)
63+
}
64+
go g.updater()
65+
return g
66+
}
67+
68+
func (g *GeoLookup) path() string { return filepath.Join(g.dir, dbipFilename) }
69+
70+
// tryLoad opens the cached mmdb under the data directory and swaps
71+
// it in atomically.
72+
func (g *GeoLookup) tryLoad() error {
73+
p := g.path()
74+
st, err := os.Stat(p)
75+
if err != nil {
76+
return err
77+
}
78+
rdr, err := maxminddb.Open(p)
79+
if err != nil {
80+
return err
81+
}
82+
g.mu.Lock()
83+
if g.reader != nil {
84+
_ = g.reader.Close()
85+
}
86+
g.reader = rdr
87+
g.loaded = st.ModTime()
88+
g.mu.Unlock()
89+
logger.L.Infow("geoip: database loaded",
90+
"path", p, "build", st.ModTime().UTC().Format(time.RFC3339), "size_kb", st.Size()/1024)
91+
return nil
92+
}
93+
94+
// Lookup returns an ISO-3166 alpha-2 country code (e.g. "DE") for
95+
// the given IP, or "" when the IP is private / loopback / not
96+
// resolvable / the database isn't loaded. Safe to call concurrently.
97+
func (g *GeoLookup) Lookup(ip string) string {
98+
if g == nil || ip == "" {
99+
return ""
100+
}
101+
addr := net.ParseIP(stripPort(ip))
102+
if addr == nil || addr.IsLoopback() || addr.IsPrivate() ||
103+
addr.IsLinkLocalUnicast() || addr.IsUnspecified() {
104+
return ""
105+
}
106+
g.mu.RLock()
107+
rdr := g.reader
108+
g.mu.RUnlock()
109+
if rdr == nil {
110+
return ""
111+
}
112+
var rec struct {
113+
Country struct {
114+
ISOCode string `maxminddb:"iso_code"`
115+
} `maxminddb:"country"`
116+
}
117+
if err := rdr.Lookup(addr, &rec); err != nil {
118+
return ""
119+
}
120+
return strings.ToUpper(rec.Country.ISOCode)
121+
}
122+
123+
func stripPort(s string) string {
124+
if h, _, err := net.SplitHostPort(s); err == nil {
125+
return h
126+
}
127+
return s
128+
}
129+
130+
// updater runs forever. Sleeps until the loaded mmdb crosses
131+
// dbipMaxAge, then downloads the current month's release and
132+
// reloads. Failures retry with a 30 min → 6 h capped backoff.
133+
func (g *GeoLookup) updater() {
134+
if g.dir == "" {
135+
return
136+
}
137+
backoff := 30 * time.Minute
138+
for {
139+
g.mu.RLock()
140+
age := time.Since(g.loaded)
141+
hasReader := g.reader != nil
142+
g.mu.RUnlock()
143+
var delay time.Duration
144+
switch {
145+
case !hasReader:
146+
delay = 0
147+
case age >= dbipMaxAge:
148+
delay = 0
149+
default:
150+
delay = dbipMaxAge - age
151+
}
152+
if delay > 0 {
153+
time.Sleep(delay)
154+
}
155+
if err := g.fetchAndSwap(); err != nil {
156+
logger.L.Warnw("geoip: update failed; will retry", "error", err.Error(), "retry_in", backoff)
157+
time.Sleep(backoff)
158+
if backoff < 6*time.Hour {
159+
backoff *= 2
160+
}
161+
continue
162+
}
163+
backoff = 30 * time.Minute
164+
}
165+
}
166+
167+
// fetchAndSwap downloads the current month's lite mmdb and atomically
168+
// replaces the cached file + open reader. Tries the current month
169+
// first; on 404 (early-month publishing window) falls back to the
170+
// previous month.
171+
func (g *GeoLookup) fetchAndSwap() error {
172+
now := time.Now().UTC()
173+
candidates := []string{
174+
now.Format("2006-01"),
175+
now.AddDate(0, -1, 0).Format("2006-01"),
176+
}
177+
var lastErr error
178+
for _, ym := range candidates {
179+
url := fmt.Sprintf(dbipDownloadTmpl, ym)
180+
if err := g.downloadTo(url, g.path()); err != nil {
181+
lastErr = err
182+
continue
183+
}
184+
return g.tryLoad()
185+
}
186+
if lastErr == nil {
187+
lastErr = errors.New("no candidate URL succeeded")
188+
}
189+
return lastErr
190+
}
191+
192+
func (g *GeoLookup) downloadTo(url, dest string) error {
193+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
194+
defer cancel()
195+
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
196+
if err != nil {
197+
return err
198+
}
199+
req.Header.Set("User-Agent", dbipUserAgent)
200+
resp, err := http.DefaultClient.Do(req)
201+
if err != nil {
202+
return err
203+
}
204+
defer resp.Body.Close()
205+
if resp.StatusCode != http.StatusOK {
206+
return fmt.Errorf("HTTP %s for %s", resp.Status, url)
207+
}
208+
gz, err := gzip.NewReader(resp.Body)
209+
if err != nil {
210+
return fmt.Errorf("gunzip: %w", err)
211+
}
212+
defer gz.Close()
213+
tmp := dest + ".tmp"
214+
f, err := os.Create(tmp)
215+
if err != nil {
216+
return err
217+
}
218+
if _, err := io.Copy(f, gz); err != nil {
219+
_ = f.Close()
220+
_ = os.Remove(tmp)
221+
return err
222+
}
223+
if err := f.Close(); err != nil {
224+
return err
225+
}
226+
return os.Rename(tmp, dest)
227+
}

0 commit comments

Comments
 (0)