Skip to content

Commit ef62389

Browse files
author
Datanoise
committed
feat: implement automated scanner protection and security stats dashboard
1 parent e15ac90 commit ef62389

2 files changed

Lines changed: 175 additions & 1 deletion

File tree

server/server.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

6371
type 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+
496525
func (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+
17851911
func (s *Server) handleGetStats(w http.ResponseWriter, r *http.Request) {
17861912
if _, ok := s.checkAuth(r); !ok {
17871913
http.Error(w, "Unauthorized", http.StatusUnauthorized)

server/templates/admin.html

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,29 @@ <h2>Active Transcoders</h2>
281281
</div>
282282
</div>
283283
<div id="tab-security" class="tab-content">
284-
<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></div>
284+
<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>
285+
286+
<div style="grid-template-columns: 1fr 1fr; display: grid; gap: 2rem; margin-top: 3rem;">
287+
<section>
288+
<h2>Top Authentication Failures</h2>
289+
<div class="card" style="padding: 0; overflow: hidden;">
290+
<table>
291+
<thead><tr><th>IP Address</th><th>Fails</th><th>Status</th><th>Action</th></tr></thead>
292+
<tbody id="authFailsBody"></tbody>
293+
</table>
294+
</div>
295+
</section>
296+
<section>
297+
<h2>Top Connection Scanners (404s)</h2>
298+
<div class="card" style="padding: 0; overflow: hidden;">
299+
<table>
300+
<thead><tr><th>IP Address</th><th>Hits</th><th>Status</th><th>Action</th></tr></thead>
301+
<tbody id="scannersBody"></tbody>
302+
</table>
303+
</div>
304+
</section>
305+
</div>
306+
</div>
285307
{{end}}
286308
</main>
287309
</div>
@@ -302,6 +324,7 @@ <h2>Active Transcoders</h2>
302324
</tr>
303325
</template>
304326
<script>
327+
const csrfToken = "{{.CSRFToken}}";
305328
function showTab(id, updateHash) {
306329
document.querySelectorAll('.tab-content').forEach(function(t) { t.classList.remove('active'); });
307330
document.querySelectorAll('.nav-link').forEach(function(t) { t.classList.remove('active'); });
@@ -314,6 +337,31 @@ <h2>Active Transcoders</h2>
314337
fetchStatistics();
315338
}
316339
if (id === 'tab-transcoding') fetchTranscoderStats();
340+
if (id === 'tab-security') fetchSecurityStats();
341+
}
342+
343+
function fetchSecurityStats() {
344+
if (window.location.hash !== '#tab-security') return;
345+
fetch('/admin/security-stats').then(r => r.json()).then(data => {
346+
const authBody = document.getElementById('authFailsBody');
347+
const scanBody = document.getElementById('scannersBody');
348+
349+
let authHtml = '';
350+
data.auth_fails.forEach(s => {
351+
const status = s.locked ? `<span style="color:var(--danger); font-weight:bold">LOCKED (${s.expires_in})</span>` : 'Active';
352+
authHtml += `<tr><td>${s.ip}</td><td>${s.count}</td><td>${status}</td><td><form action="/admin/clear-auth-lockout" method="POST"><input type="hidden" name="csrf" value="${csrfToken}"><input type="hidden" name="ip" value="${s.ip}"><button class="btn btn-sm btn-outline">CLEAR</button></form></td></tr>`;
353+
});
354+
authBody.innerHTML = authHtml || '<tr><td colspan="4" style="text-align:center; padding:1rem">No auth failures recorded.</td></tr>';
355+
356+
let scanHtml = '';
357+
data.scanners.forEach(s => {
358+
const status = s.locked ? `<span style="color:var(--danger); font-weight:bold">LOCKED (${s.expires_in})</span>` : 'Active';
359+
scanHtml += `<tr><td>${s.ip}</td><td>${s.count}</td><td>${status}</td><td><form action="/admin/clear-scan-lockout" method="POST"><input type="hidden" name="csrf" value="${csrfToken}"><input type="hidden" name="ip" value="${s.ip}"><button class="btn btn-sm btn-outline">CLEAR</button></form></td></tr>`;
360+
});
361+
scanBody.innerHTML = scanHtml || '<tr><td colspan="4" style="text-align:center; padding:1rem">No scanning attempts recorded.</td></tr>';
362+
363+
setTimeout(fetchSecurityStats, 2000);
364+
});
317365
}
318366

319367
function fetchTranscoderStats() {

0 commit comments

Comments
 (0)