Skip to content

Commit 022b646

Browse files
author
Datanoise
committed
feat: implement comprehensive webhook system for real-time event notifications
1 parent 1e6a940 commit 022b646

4 files changed

Lines changed: 237 additions & 11 deletions

File tree

config/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ type TranscoderConfig struct {
4040
Enabled bool `json:"enabled"`
4141
}
4242

43+
type WebhookConfig struct {
44+
URL string `json:"url"`
45+
Events []string `json:"events"` // "source_connect", "source_disconnect", "metadata_update", "security_lockout"
46+
Enabled bool `json:"enabled"`
47+
}
48+
4349
type Config struct {
4450
BindHost string `json:"bind_host"`
4551
Port string `json:"port"`
@@ -50,6 +56,7 @@ type Config struct {
5056
AdvancedMounts map[string]*MountSettings `json:"advanced_mounts"`
5157
Relays []*RelayConfig `json:"relays"`
5258
Transcoders []*TranscoderConfig `json:"transcoders"`
59+
Webhooks []*WebhookConfig `json:"webhooks"`
5360
BannedIPs []string `json:"banned_ips"`
5461

5562
AdminPassword string `json:"admin_password"`
@@ -175,6 +182,9 @@ func (config *Config) initMapsAndArrays() {
175182
if config.Transcoders == nil {
176183
config.Transcoders = make([]*TranscoderConfig, 0)
177184
}
185+
if config.Webhooks == nil {
186+
config.Webhooks = make([]*WebhookConfig, 0)
187+
}
178188
if config.BannedIPs == nil {
179189
config.BannedIPs = make([]string, 0)
180190
}

relay/relay.go

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -387,31 +387,45 @@ func (s *Stream) Unsubscribe(id string) {
387387
}
388388

389389
// UpdateMetadata updates stream info
390-
func (s *Stream) UpdateMetadata(name, desc, genre, url, bitrate, contentType string, public, visible bool) {
390+
func (s *Stream) UpdateMetadata(name, desc, genre, url, bitrate, contentType string, public, visible bool) bool {
391391
s.mu.Lock()
392392
defer s.mu.Unlock()
393-
if name != "" {
393+
changed := false
394+
if name != "" && s.Name != name {
394395
s.Name = name
396+
changed = true
395397
}
396-
if desc != "" {
398+
if desc != "" && s.Description != desc {
397399
s.Description = desc
400+
changed = true
398401
}
399-
if genre != "" {
402+
if genre != "" && s.Genre != genre {
400403
s.Genre = genre
404+
changed = true
401405
}
402-
if url != "" {
406+
if url != "" && s.URL != url {
403407
s.URL = url
408+
changed = true
404409
}
405-
if bitrate != "" {
410+
if bitrate != "" && s.Bitrate != bitrate {
406411
s.Bitrate = bitrate
412+
changed = true
407413
}
408-
if contentType != "" {
414+
if contentType != "" && s.ContentType != contentType {
409415
s.ContentType = contentType
410416
ct := strings.ToLower(contentType)
411417
s.IsOggStream = strings.Contains(ct, "ogg") || strings.Contains(ct, "opus")
418+
changed = true
412419
}
413-
s.Public = public
414-
s.Visible = visible
420+
if s.Public != public {
421+
s.Public = public
422+
changed = true
423+
}
424+
if s.Visible != visible {
425+
s.Visible = visible
426+
changed = true
427+
}
428+
return changed
415429
}
416430

417431
// SetCurrentSong updates the current song info thread-safely

server/server.go

Lines changed: 145 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,68 @@ func NewServer(cfg *config.Config, authLog *logrus.Logger) *Server {
174174
}
175175
}
176176

177+
func (s *Server) dispatchWebhook(event string, data map[string]interface{}) {
178+
if len(s.Config.Webhooks) == 0 {
179+
return
180+
}
181+
182+
payload := map[string]interface{}{
183+
"event": event,
184+
"timestamp": time.Now().Format(time.RFC3339),
185+
"hostname": s.Config.HostName,
186+
"data": data,
187+
}
188+
189+
jsonPayload, err := json.Marshal(payload)
190+
if err != nil {
191+
logrus.WithError(err).Error("Failed to marshal webhook payload")
192+
return
193+
}
194+
195+
for _, wh := range s.Config.Webhooks {
196+
if !wh.Enabled {
197+
continue
198+
}
199+
200+
// Check if this webhook is interested in this event
201+
interested := false
202+
for _, e := range wh.Events {
203+
if e == event {
204+
interested = true
205+
break
206+
}
207+
}
208+
209+
if !interested {
210+
continue
211+
}
212+
213+
// Dispatch asynchronously
214+
go func(url string, body []byte) {
215+
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
216+
defer cancel()
217+
218+
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(body))
219+
if err != nil {
220+
return
221+
}
222+
req.Header.Set("Content-Type", "application/json")
223+
req.Header.Set("User-Agent", "TinyIce-Webhook/1.0")
224+
225+
resp, err := http.DefaultClient.Do(req)
226+
if err != nil {
227+
logrus.Warnf("Webhook delivery failed to %s: %v", url, err)
228+
return
229+
}
230+
defer resp.Body.Close()
231+
232+
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
233+
logrus.Warnf("Webhook returned non-2xx status from %s: %d", url, resp.StatusCode)
234+
}
235+
}(wh.URL, jsonPayload)
236+
}
237+
}
238+
177239
func (s *Server) setupRoutes() *http.ServeMux {
178240
mux := http.NewServeMux()
179241
mux.HandleFunc("/admin", s.handleAdmin)
@@ -194,6 +256,8 @@ func (s *Server) setupRoutes() *http.ServeMux {
194256
mux.HandleFunc("/admin/remove-banned-ip", s.handleRemoveBannedIP)
195257
mux.HandleFunc("/admin/clear-auth-lockout", s.handleClearAuthLockout)
196258
mux.HandleFunc("/admin/clear-scan-lockout", s.handleClearScanLockout)
259+
mux.HandleFunc("/admin/add-webhook", s.handleAddWebhook)
260+
mux.HandleFunc("/admin/delete-webhook", s.handleDeleteWebhook)
197261
mux.HandleFunc("/admin/add-relay", s.handleAddRelay)
198262
mux.HandleFunc("/admin/toggle-relay", s.handleToggleRelay)
199263
mux.HandleFunc("/admin/restart-relay", s.handleRestartRelay)
@@ -496,6 +560,12 @@ func (s *Server) recordAuthFailure(ip string) {
496560
if attempt.Count >= 5 {
497561
attempt.LockoutBy = time.Now().Add(15 * time.Minute)
498562
logrus.Warnf("IP %s locked out for 15 minutes due to 5 failed auth attempts", ip)
563+
s.dispatchWebhook("security_lockout", map[string]interface{}{
564+
"ip": ip,
565+
"reason": "brute_force_auth",
566+
"until": attempt.LockoutBy.Format(time.RFC3339),
567+
"details": "5 failed authentication attempts",
568+
})
499569
}
500570
}
501571

@@ -519,6 +589,12 @@ func (s *Server) recordScanAttempt(ip string) {
519589
if attempt.Count >= 10 {
520590
attempt.LockoutBy = time.Now().Add(15 * time.Minute)
521591
logrus.Warnf("IP %s locked out for 15 minutes due to 10 scanning attempts (404s)", ip)
592+
s.dispatchWebhook("security_lockout", map[string]interface{}{
593+
"ip": ip,
594+
"reason": "connection_scanning",
595+
"until": attempt.LockoutBy.Format(time.RFC3339),
596+
"details": "10 connection scanning attempts (404s)",
597+
})
522598
}
523599
}
524600

@@ -787,7 +863,14 @@ func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
787863
bufrw.WriteString("HTTP/1.0 200 OK\r\nServer: Icecast 2.4.4\r\nConnection: Keep-Alive\r\n\r\n")
788864
bufrw.Flush()
789865

790-
logrus.WithField("mount", mount).Info("Source connected")
866+
logrus.WithFields(logrus.Fields{"mount": mount, "ip": r.RemoteAddr, "ua": r.Header.Get("User-Agent")}).Info("Source connected")
867+
s.dispatchWebhook("source_connect", map[string]interface{}{
868+
"mount": mount,
869+
"ip": r.RemoteAddr,
870+
"ua": r.Header.Get("User-Agent"),
871+
"name": r.Header.Get("Ice-Name"),
872+
})
873+
791874
stream := s.Relay.GetOrCreateStream(mount)
792875
stream.SourceIP = r.RemoteAddr
793876

@@ -804,6 +887,9 @@ func (s *Server) handleSource(w http.ResponseWriter, r *http.Request) {
804887
}
805888
}
806889
logrus.WithField("mount", mount).Info("Source disconnected")
890+
s.dispatchWebhook("source_disconnect", map[string]interface{}{
891+
"mount": mount,
892+
})
807893
s.Relay.RemoveStream(mount)
808894
}
809895

@@ -823,7 +909,15 @@ func (s *Server) updateSourceMetadata(stream *relay.Stream, mount string, r *htt
823909
}
824910
isPublic := r.Header.Get("Ice-Public") == "1"
825911
isVisible := s.Config.VisibleMounts[mount]
826-
stream.UpdateMetadata(r.Header.Get("Ice-Name"), r.Header.Get("Ice-Description"), r.Header.Get("Ice-Genre"), r.Header.Get("Ice-Url"), bitrate, r.Header.Get("Content-Type"), isPublic, isVisible)
912+
if stream.UpdateMetadata(r.Header.Get("Ice-Name"), r.Header.Get("Ice-Description"), r.Header.Get("Ice-Genre"), r.Header.Get("Ice-Url"), bitrate, r.Header.Get("Content-Type"), isPublic, isVisible) {
913+
s.dispatchWebhook("metadata_update", map[string]interface{}{
914+
"mount": mount,
915+
"name": stream.Name,
916+
"description": stream.Description,
917+
"genre": stream.Genre,
918+
"current_song": stream.CurrentSong,
919+
})
920+
}
827921
}
828922

829923
func (s *Server) handleListener(w http.ResponseWriter, r *http.Request) {
@@ -1914,6 +2008,55 @@ func (s *Server) handleClearScanLockout(w http.ResponseWriter, r *http.Request)
19142008
http.Redirect(w, r, "/admin#tab-security", http.StatusSeeOther)
19152009
}
19162010

2011+
func (s *Server) handleAddWebhook(w http.ResponseWriter, r *http.Request) {
2012+
if !s.isCSRFSafe(r) {
2013+
http.Error(w, "Forbidden", http.StatusForbidden)
2014+
return
2015+
}
2016+
if _, ok := s.checkAuth(r); !ok {
2017+
return
2018+
}
2019+
2020+
url := r.FormValue("url")
2021+
events := r.Form["events"]
2022+
2023+
if url == "" || len(events) == 0 {
2024+
http.Error(w, "URL and at least one event required", http.StatusBadRequest)
2025+
return
2026+
}
2027+
2028+
s.Config.Webhooks = append(s.Config.Webhooks, &config.WebhookConfig{
2029+
URL: url,
2030+
Events: events,
2031+
Enabled: true,
2032+
})
2033+
s.Config.SaveConfig()
2034+
2035+
http.Redirect(w, r, "/admin#tab-webhooks", http.StatusSeeOther)
2036+
}
2037+
2038+
func (s *Server) handleDeleteWebhook(w http.ResponseWriter, r *http.Request) {
2039+
if !s.isCSRFSafe(r) {
2040+
http.Error(w, "Forbidden", http.StatusForbidden)
2041+
return
2042+
}
2043+
if _, ok := s.checkAuth(r); !ok {
2044+
return
2045+
}
2046+
2047+
url := r.FormValue("url")
2048+
newWHs := []*config.WebhookConfig{}
2049+
for _, wh := range s.Config.Webhooks {
2050+
if wh.URL != url {
2051+
newWHs = append(newWHs, wh)
2052+
}
2053+
}
2054+
s.Config.Webhooks = newWHs
2055+
s.Config.SaveConfig()
2056+
2057+
http.Redirect(w, r, "/admin#tab-webhooks", http.StatusSeeOther)
2058+
}
2059+
19172060
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
19182061
if _, ok := s.checkAuth(r); !ok {
19192062
http.Error(w, "Unauthorized", http.StatusUnauthorized)

server/templates/admin.html

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@
7575
<button class="nav-link" onclick="showTab('tab-users')"><i data-lucide="users"></i> User Management</button>
7676
<button class="nav-link" onclick="showTab('tab-relays')"><i data-lucide="refresh-ccw"></i> Edge Relays</button>
7777
<button class="nav-link" onclick="showTab('tab-transcoding')"><i data-lucide="scissors"></i> Transcoding</button>
78+
<button class="nav-link" onclick="showTab('tab-webhooks')"><i data-lucide="webhook"></i> Webhooks</button>
7879
<button class="nav-link" onclick="showTab('tab-security')"><i data-lucide="shield-check"></i> Security & Bans</button>
7980
{{end}}
8081
<button class="nav-link" onclick="showTab('tab-insights')"><i data-lucide="trending-up"></i> Insights</button>
@@ -280,6 +281,64 @@ <h2>Active Transcoders</h2>
280281
</section>
281282
</div>
282283
</div>
284+
<div id="tab-webhooks" class="tab-content">
285+
<div style="display: grid; grid-template-columns: 1fr 1.5fr; gap: 2rem;">
286+
<section>
287+
<h2>Configure Webhook</h2>
288+
<div class="card">
289+
<form action="/admin/add-webhook" method="POST">
290+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
291+
<div class="form-group"><label>Target URL</label><input type="text" name="url" placeholder="https://discord.com/api/webhooks/..." required></div>
292+
<div class="form-group">
293+
<label>Event Triggers</label>
294+
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; text-align: left; font-size: 0.8rem;">
295+
<label style="text-transform: none; display: flex; align-items: center; gap: 0.5rem;"><input type="checkbox" name="events" value="source_connect" checked> Source Connect</label>
296+
<label style="text-transform: none; display: flex; align-items: center; gap: 0.5rem;"><input type="checkbox" name="events" value="source_disconnect" checked> Source Disconnect</label>
297+
<label style="text-transform: none; display: flex; align-items: center; gap: 0.5rem;"><input type="checkbox" name="events" value="metadata_update" checked> Song Changes</label>
298+
<label style="text-transform: none; display: flex; align-items: center; gap: 0.5rem;"><input type="checkbox" name="events" value="security_lockout" checked> Security Alerts</label>
299+
</div>
300+
</div>
301+
<button type="submit" class="btn btn-primary" style="width: 100%;">Save Webhook</button>
302+
</form>
303+
</div>
304+
</section>
305+
<section>
306+
<h2>Active Webhooks</h2>
307+
<div class="card" style="padding: 0; overflow: hidden;">
308+
<table>
309+
<thead>
310+
<tr>
311+
<th>Target URL</th>
312+
<th>Events</th>
313+
<th>Action</th>
314+
</tr>
315+
</thead>
316+
<tbody>
317+
{{range .Config.Webhooks}}
318+
<tr>
319+
<td style="font-size: 0.75rem; color: var(--text-dim); max-width: 200px; overflow: hidden; text-overflow: ellipsis;">{{.URL}}</td>
320+
<td style="font-size: 0.7rem;">
321+
{{range .Events}}
322+
<span class="genre-tag" style="margin-right: 2px; margin-bottom: 2px; font-size: 0.6rem;">{{.}}</span>
323+
{{end}}
324+
</td>
325+
<td>
326+
<form action="/admin/delete-webhook" method="POST" onsubmit="return confirm('Delete?')">
327+
<input type="hidden" name="csrf" value="{{$.CSRFToken}}">
328+
<input type="hidden" name="url" value="{{.URL}}">
329+
<button type="submit" class="btn btn-danger btn-sm">DELETE</button>
330+
</form>
331+
</td>
332+
</tr>
333+
{{else}}
334+
<tr><td colspan="3" style="text-align: center; padding: 2rem;">No webhooks configured.</td></tr>
335+
{{end}}
336+
</tbody>
337+
</table>
338+
</div>
339+
</section>
340+
</div>
341+
</div>
283342
<div id="tab-security" class="tab-content">
284343
<div style="grid-template-columns: 1fr 1fr; display: grid; gap: 2rem;"><section><h2>Ban Address or Range</h2><div class="card"><form action="/admin/add-banned-ip" method="POST"><input type="hidden" name="csrf" value="{{$.CSRFToken}}"><div class="form-group"><label>IP Address or CIDR Range</label><input type="text" name="ip" placeholder="e.g. 1.2.3.4 or 192.168.1.0/24" required></div><button type="submit" class="btn btn-danger">Ban Address</button></form></div></section><section><h2>Access Control List</h2><div class="card" style="padding: 0;"><table><thead><tr><th style="width:70%">Banned IP / Range</th><th style="width:30%">Action</th></tr></thead><tbody>{{range .Config.BannedIPs}}<tr><td>{{.}}</td><td><form action="/admin/remove-banned-ip" method="POST"><input type="hidden" name="csrf" value="{{$.CSRFToken}}"><input type="hidden" name="ip" value="{{.}}"><button type="submit" class="btn btn-outline btn-sm">Unban</button></form></td></tr>{{else}}<tr><td colspan="2" style="text-align:center; padding:2rem">No active bans.</td></tr>{{end}}</tbody></table></div></section></div>
285344

0 commit comments

Comments
 (0)