@@ -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+
177239func (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 \n Server: Icecast 2.4.4\r \n Connection: 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
829923func (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+
19172060func (s * Server ) handleGetStats (w http.ResponseWriter , r * http.Request ) {
19182061 if _ , ok := s .checkAuth (r ); ! ok {
19192062 http .Error (w , "Unauthorized" , http .StatusUnauthorized )
0 commit comments