@@ -58,6 +58,14 @@ type Server struct {
5858 authAttemptsMu sync.Mutex
5959
6060 certManager * autocert.Manager
61+
62+ scanAttempts map [string ]* scanAttempt // IP -> 404 count
63+ scanAttemptsMu sync.Mutex
64+ }
65+
66+ type scanAttempt struct {
67+ Count int
68+ LockoutBy time.Time
6169}
6270
6371type authAttempt struct {
@@ -162,6 +170,7 @@ func NewServer(cfg *config.Config, authLog *logrus.Logger) *Server {
162170 AuthLog : authLog ,
163171 sessions : make (map [string ]* session ),
164172 authAttempts : make (map [string ]* authAttempt ),
173+ scanAttempts : make (map [string ]* scanAttempt ),
165174 }
166175}
167176
@@ -183,6 +192,8 @@ func (s *Server) setupRoutes() *http.ServeMux {
183192 mux .HandleFunc ("/admin/remove-user" , s .handleRemoveUser )
184193 mux .HandleFunc ("/admin/add-banned-ip" , s .handleAddBannedIP )
185194 mux .HandleFunc ("/admin/remove-banned-ip" , s .handleRemoveBannedIP )
195+ mux .HandleFunc ("/admin/clear-auth-lockout" , s .handleClearAuthLockout )
196+ mux .HandleFunc ("/admin/clear-scan-lockout" , s .handleClearScanLockout )
186197 mux .HandleFunc ("/admin/add-relay" , s .handleAddRelay )
187198 mux .HandleFunc ("/admin/toggle-relay" , s .handleToggleRelay )
188199 mux .HandleFunc ("/admin/restart-relay" , s .handleRestartRelay )
@@ -191,6 +202,7 @@ func (s *Server) setupRoutes() *http.ServeMux {
191202 mux .HandleFunc ("/admin/toggle-transcoder" , s .handleToggleTranscoder )
192203 mux .HandleFunc ("/admin/delete-transcoder" , s .handleDeleteTranscoder )
193204 mux .HandleFunc ("/admin/transcoder-stats" , s .handleTranscoderStats )
205+ mux .HandleFunc ("/admin/security-stats" , s .handleGetSecurityStats )
194206 mux .HandleFunc ("/admin/history" , s .handleHistory )
195207 mux .HandleFunc ("/admin/statistics" , s .handleGetStats )
196208 mux .HandleFunc ("/admin/insights" , s .handleInsights )
@@ -493,6 +505,23 @@ func (s *Server) recordAuthSuccess(ip string) {
493505 delete (s .authAttempts , ip )
494506}
495507
508+ func (s * Server ) recordScanAttempt (ip string ) {
509+ s .scanAttemptsMu .Lock ()
510+ defer s .scanAttemptsMu .Unlock ()
511+
512+ attempt , exists := s .scanAttempts [ip ]
513+ if ! exists {
514+ attempt = & scanAttempt {}
515+ s .scanAttempts [ip ] = attempt
516+ }
517+
518+ attempt .Count ++
519+ if attempt .Count >= 10 {
520+ attempt .LockoutBy = time .Now ().Add (15 * time .Minute )
521+ logrus .Warnf ("IP %s locked out for 15 minutes due to 10 scanning attempts (404s)" , ip )
522+ }
523+ }
524+
496525func (s * Server ) checkAuth (r * http.Request ) (* config.User , bool ) {
497526 // 1. Check Session Cookie first (Web UI)
498527 if cookie , err := r .Cookie ("sid" ); err == nil {
@@ -656,6 +685,23 @@ func (s *Server) isBanned(ipStr string) bool {
656685 if err != nil {
657686 host = ipStr
658687 }
688+
689+ // 1. Check Automated Lockouts (Auth)
690+ s .authAttemptsMu .Lock ()
691+ if att , ok := s .authAttempts [host ]; ok && time .Now ().Before (att .LockoutBy ) {
692+ s .authAttemptsMu .Unlock ()
693+ return true
694+ }
695+ s .authAttemptsMu .Unlock ()
696+
697+ // 2. Check Automated Lockouts (Scanning)
698+ s .scanAttemptsMu .Lock ()
699+ if att , ok := s .scanAttempts [host ]; ok && time .Now ().Before (att .LockoutBy ) {
700+ s .scanAttemptsMu .Unlock ()
701+ return true
702+ }
703+ s .scanAttemptsMu .Unlock ()
704+
659705 ip := net .ParseIP (host )
660706 if ip == nil {
661707 return false
@@ -837,6 +883,8 @@ func (s *Server) handleListener(w http.ResponseWriter, r *http.Request) {
837883 time .Sleep (1 * time .Second )
838884 continue
839885 }
886+ host , _ , _ := net .SplitHostPort (r .RemoteAddr )
887+ s .recordScanAttempt (host )
840888 http .NotFound (w , r )
841889 return
842890 }
@@ -1782,6 +1830,84 @@ func (s *Server) handleEvents(w http.ResponseWriter, r *http.Request) {
17821830 }
17831831}
17841832
1833+ func (s * Server ) handleGetSecurityStats (w http.ResponseWriter , r * http.Request ) {
1834+ if _ , ok := s .checkAuth (r ); ! ok {
1835+ http .Error (w , "Unauthorized" , http .StatusUnauthorized )
1836+ return
1837+ }
1838+
1839+ type ipStat struct {
1840+ IP string `json:"ip"`
1841+ Count int `json:"count"`
1842+ Locked bool `json:"locked"`
1843+ ExpiresIn string `json:"expires_in"`
1844+ }
1845+
1846+ authStats := []ipStat {}
1847+ s .authAttemptsMu .Lock ()
1848+ for ip , att := range s .authAttempts {
1849+ expires := "0s"
1850+ locked := time .Now ().Before (att .LockoutBy )
1851+ if locked {
1852+ expires = time .Until (att .LockoutBy ).Round (time .Second ).String ()
1853+ }
1854+ authStats = append (authStats , ipStat {ip , att .Count , locked , expires })
1855+ }
1856+ s .authAttemptsMu .Unlock ()
1857+
1858+ scanStats := []ipStat {}
1859+ s .scanAttemptsMu .Lock ()
1860+ for ip , att := range s .scanAttempts {
1861+ expires := "0s"
1862+ locked := time .Now ().Before (att .LockoutBy )
1863+ if locked {
1864+ expires = time .Until (att .LockoutBy ).Round (time .Second ).String ()
1865+ }
1866+ scanStats = append (scanStats , ipStat {ip , att .Count , locked , expires })
1867+ }
1868+ s .scanAttemptsMu .Unlock ()
1869+
1870+ // Sort by count desc
1871+ sort .Slice (authStats , func (i , j int ) bool { return authStats [i ].Count > authStats [j ].Count })
1872+ sort .Slice (scanStats , func (i , j int ) bool { return scanStats [i ].Count > scanStats [j ].Count })
1873+
1874+ w .Header ().Set ("Content-Type" , "application/json" )
1875+ json .NewEncoder (w ).Encode (map [string ]interface {}{
1876+ "auth_fails" : authStats ,
1877+ "scanners" : scanStats ,
1878+ })
1879+ }
1880+
1881+ func (s * Server ) handleClearAuthLockout (w http.ResponseWriter , r * http.Request ) {
1882+ if ! s .isCSRFSafe (r ) {
1883+ http .Error (w , "Forbidden" , http .StatusForbidden )
1884+ return
1885+ }
1886+ if _ , ok := s .checkAuth (r ); ! ok {
1887+ return
1888+ }
1889+ ip := r .FormValue ("ip" )
1890+ s .authAttemptsMu .Lock ()
1891+ delete (s .authAttempts , ip )
1892+ s .authAttemptsMu .Unlock ()
1893+ http .Redirect (w , r , "/admin#tab-security" , http .StatusSeeOther )
1894+ }
1895+
1896+ func (s * Server ) handleClearScanLockout (w http.ResponseWriter , r * http.Request ) {
1897+ if ! s .isCSRFSafe (r ) {
1898+ http .Error (w , "Forbidden" , http .StatusForbidden )
1899+ return
1900+ }
1901+ if _ , ok := s .checkAuth (r ); ! ok {
1902+ return
1903+ }
1904+ ip := r .FormValue ("ip" )
1905+ s .scanAttemptsMu .Lock ()
1906+ delete (s .scanAttempts , ip )
1907+ s .scanAttemptsMu .Unlock ()
1908+ http .Redirect (w , r , "/admin#tab-security" , http .StatusSeeOther )
1909+ }
1910+
17851911func (s * Server ) handleGetStats (w http.ResponseWriter , r * http.Request ) {
17861912 if _ , ok := s .checkAuth (r ); ! ok {
17871913 http .Error (w , "Unauthorized" , http .StatusUnauthorized )
0 commit comments