From e6eed2f6472d8ca691fccfa805d658b56a8f1ec3 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:19 +0100 Subject: [PATCH 1/6] feat: implement IP tracking functionality with database integration --- src/database.py | 99 +++++++++++++++++++++++++++++++++++++++++++++++++ src/models.py | 26 +++++++++++++ 2 files changed, 125 insertions(+) diff --git a/src/database.py b/src/database.py index 84eff37..3998831 100644 --- a/src/database.py +++ b/src/database.py @@ -34,6 +34,7 @@ from models import ( AttackDetection, IpStats, CategoryHistory, + TrackedIp, ) from sanitizer import ( sanitize_ip, @@ -2328,6 +2329,104 @@ class DatabaseManager: finally: self.close_session() + # ── IP Tracking ────────────────────────────────────────────────── + + def track_ip(self, ip: str) -> bool: + """Add an IP to the tracked list with a snapshot of its current stats.""" + session = self.session + sanitized_ip = sanitize_ip(ip) + existing = session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() + if existing: + return True # already tracked + + # Snapshot essential data from ip_stats + stats = session.query(IpStats).filter(IpStats.ip == sanitized_ip).first() + tracked = TrackedIp( + ip=sanitized_ip, + tracked_since=datetime.now(), + category=stats.category if stats else None, + total_requests=stats.total_requests if stats else 0, + country_code=stats.country_code if stats else None, + city=stats.city if stats else None, + last_seen=stats.last_seen if stats else None, + ) + session.add(tracked) + try: + session.commit() + return True + except Exception as e: + session.rollback() + applogger.error(f"Error tracking IP {sanitized_ip}: {e}") + return False + + def untrack_ip(self, ip: str) -> bool: + """Remove an IP from the tracked list.""" + session = self.session + sanitized_ip = sanitize_ip(ip) + tracked = session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() + if not tracked: + return False + session.delete(tracked) + try: + session.commit() + return True + except Exception as e: + session.rollback() + applogger.error(f"Error untracking IP {sanitized_ip}: {e}") + return False + + def is_ip_tracked(self, ip: str) -> bool: + """Check if an IP is currently tracked.""" + session = self.session + sanitized_ip = sanitize_ip(ip) + try: + return session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() is not None + finally: + self.close_session() + + def get_tracked_ips_paginated( + self, + page: int = 1, + page_size: int = 25, + ) -> Dict[str, Any]: + """Get all tracked IPs, paginated. Reads only from tracked_ips table.""" + session = self.session + try: + total = session.query(TrackedIp).count() + total_pages = max(1, (total + page_size - 1) // page_size) + + tracked_rows = ( + session.query(TrackedIp) + .order_by(TrackedIp.tracked_since.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + .all() + ) + + items = [] + for t in tracked_rows: + items.append({ + "ip": t.ip, + "tracked_since": t.tracked_since.isoformat() if t.tracked_since else None, + "category": t.category, + "total_requests": t.total_requests or 0, + "country_code": t.country_code, + "city": t.city, + "last_seen": t.last_seen.isoformat() if t.last_seen else None, + }) + + return { + "tracked_ips": items, + "pagination": { + "page": page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + }, + } + finally: + self.close_session() + # Module-level singleton instance _db_manager = DatabaseManager() diff --git a/src/models.py b/src/models.py index d759e52..727d70a 100644 --- a/src/models.py +++ b/src/models.py @@ -244,6 +244,32 @@ class CategoryHistory(Base): return f" {self.new_category})>" +class TrackedIp(Base): + """ + Manually tracked IP addresses for monitoring. + + Stores a snapshot of essential ip_stats data at tracking time + so the tracked IPs panel never needs to query the large ip_stats table. + """ + + __tablename__ = "tracked_ips" + + ip: Mapped[str] = mapped_column(String(MAX_IP_LENGTH), primary_key=True) + tracked_since: Mapped[datetime] = mapped_column( + DateTime, nullable=False, default=datetime.utcnow + ) + + # Snapshot from ip_stats at tracking time + category: Mapped[Optional[str]] = mapped_column(String(50), nullable=True) + total_requests: Mapped[int] = mapped_column(Integer, default=0, nullable=True) + country_code: Mapped[Optional[str]] = mapped_column(String(2), nullable=True) + city: Mapped[Optional[str]] = mapped_column(String(MAX_CITY_LENGTH), nullable=True) + last_seen: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + def __repr__(self) -> str: + return f"" + + # class IpLog(Base): # """ # Records all IPs that have accessed the honeypot, along with aggregated stats and inferred user category. From 4e024b785e2b02d6c8e492ca8490061faade63ee Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:25 +0100 Subject: [PATCH 2/6] feat: add IP tracking functionality with track and untrack actions --- src/routes/api.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/routes/api.py b/src/routes/api.py index 2338390..661b3cc 100644 --- a/src/routes/api.py +++ b/src/routes/api.py @@ -171,6 +171,38 @@ async def ban_override(request: Request, body: BanOverrideRequest): return JSONResponse(content={"error": "IP not found"}, status_code=404) +# ── Protected IP Tracking API ──────────────────────────────────────── + + +class TrackIpRequest(BaseModel): + ip: str + action: str # "track" or "untrack" + + +@router.post("/api/track-ip") +async def track_ip(request: Request, body: TrackIpRequest): + if not verify_auth(request): + return JSONResponse(content={"error": "Unauthorized"}, status_code=401) + + db = get_db() + if body.action == "track": + success = db.track_ip(body.ip) + elif body.action == "untrack": + success = db.untrack_ip(body.ip) + else: + return JSONResponse( + content={"error": "Invalid action. Use: track, untrack"}, + status_code=400, + ) + + if success: + get_app_logger().info(f"IP tracking: {body.action} on IP {body.ip}") + return JSONResponse( + content={"success": True, "ip": body.ip, "action": body.action} + ) + return JSONResponse(content={"error": "IP not found"}, status_code=404) + + @router.get("/api/all-ip-stats") async def all_ip_stats(request: Request): db = get_db() From 0705bbd169ccbae3cb745dc9de7d2230190838d1 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:30 +0100 Subject: [PATCH 3/6] feat: add tracked IPs panel and endpoints for viewing tracked IPs --- src/routes/htmx.py | 55 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/routes/htmx.py b/src/routes/htmx.py index 1b5ece9..98373e7 100644 --- a/src/routes/htmx.py +++ b/src/routes/htmx.py @@ -343,6 +343,8 @@ async def htmx_ip_insight(ip_address: str, request: Request): stats["blocklist_memberships"] = list(list_on.keys()) if list_on else [] stats["reverse_dns"] = stats.get("reverse") + is_tracked = db.is_ip_tracked(ip_address) + templates = get_templates() return templates.TemplateResponse( "dashboard/partials/ip_insight.html", @@ -351,6 +353,7 @@ async def htmx_ip_insight(ip_address: str, request: Request): "dashboard_path": _dashboard_path(request), "stats": stats, "ip_address": ip_address, + "is_tracked": is_tracked, }, ) @@ -371,6 +374,8 @@ async def htmx_ip_detail(ip_address: str, request: Request): stats["blocklist_memberships"] = list(list_on.keys()) if list_on else [] stats["reverse_dns"] = stats.get("reverse") + is_tracked = db.is_ip_tracked(ip_address) + templates = get_templates() return templates.TemplateResponse( "dashboard/partials/ip_detail.html", @@ -378,6 +383,7 @@ async def htmx_ip_detail(ip_address: str, request: Request): "request": request, "dashboard_path": _dashboard_path(request), "stats": stats, + "is_tracked": is_tracked, }, ) @@ -464,6 +470,55 @@ async def htmx_ban_attackers( ) +# ── Protected Tracked IPs Panel ────────────────────────────────────── + + +@router.get("/htmx/tracked-ips") +async def htmx_tracked_ips(request: Request): + if not verify_auth(request): + return HTMLResponse( + '
' + '

Nice try bozo

' + "
" + 'Diddy' + "
", + status_code=200, + ) + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/tracked_ips_panel.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + }, + ) + + +@router.get("/htmx/tracked-ips/list") +async def htmx_tracked_ips_list( + request: Request, + page: int = Query(1), + page_size: int = Query(25), +): + if not verify_auth(request): + return HTMLResponse( + "

Unauthorized

", status_code=200 + ) + + db = get_db() + result = db.get_tracked_ips_paginated(page=max(1, page), page_size=page_size) + templates = get_templates() + return templates.TemplateResponse( + "dashboard/partials/tracked_ips_table.html", + { + "request": request, + "dashboard_path": _dashboard_path(request), + "items": result["tracked_ips"], + "pagination": result["pagination"], + }, + ) + + @router.get("/htmx/ban/overrides") async def htmx_ban_overrides( request: Request, From 6e575c10ebcbca5cc89fc13677fda71157fb35ba Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:43 +0100 Subject: [PATCH 4/6] feat: implement tracked IP management panel with tracking actions and UI updates --- src/templates/jinja2/dashboard/index.html | 8 +- .../jinja2/dashboard/partials/_ip_detail.html | 13 ++- .../dashboard/partials/tracked_ips_panel.html | 89 +++++++++++++++++++ .../dashboard/partials/tracked_ips_table.html | 55 ++++++++++++ src/templates/static/css/dashboard.css | 55 +++++++++++- src/templates/static/js/dashboard.js | 56 +++++++++++- 6 files changed, 269 insertions(+), 7 deletions(-) create mode 100644 src/templates/jinja2/dashboard/partials/tracked_ips_panel.html create mode 100644 src/templates/jinja2/dashboard/partials/tracked_ips_table.html diff --git a/src/templates/jinja2/dashboard/index.html b/src/templates/jinja2/dashboard/index.html index 7defadf..9e7fee4 100644 --- a/src/templates/jinja2/dashboard/index.html +++ b/src/templates/jinja2/dashboard/index.html @@ -59,7 +59,8 @@ IP Insight - IP Banlist + Tracked IPs + IP Banlist {# Lock icon (not authenticated) #} @@ -197,6 +198,11 @@ + {# ==================== TRACKED IPS TAB (protected, loaded via HTMX with server-side auth) ==================== #} +
+
+
+ {# ==================== IP BANLIST TAB (protected, loaded via HTMX with server-side auth) ==================== #}
diff --git a/src/templates/jinja2/dashboard/partials/_ip_detail.html b/src/templates/jinja2/dashboard/partials/_ip_detail.html index dbdf879..bd5d106 100644 --- a/src/templates/jinja2/dashboard/partials/_ip_detail.html +++ b/src/templates/jinja2/dashboard/partials/_ip_detail.html @@ -12,7 +12,7 @@ {% endif %} - {# Ban/Unban actions — visible only when authenticated #} + {# Ban/Unban + Track/Untrack actions — visible only when authenticated #}
{% if stats.city or stats.country %} diff --git a/src/templates/jinja2/dashboard/partials/tracked_ips_panel.html b/src/templates/jinja2/dashboard/partials/tracked_ips_panel.html new file mode 100644 index 0000000..e671c38 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/tracked_ips_panel.html @@ -0,0 +1,89 @@ +{# IP Tracking management panel #} +
+ + {# Track IP form #} +
+

Tracked IPs

+

+ Track an IP address to monitor its activity. You can also track IPs from the IP Insight page. +

+
+
+ + +
+ +
+

+
+ + {# Tracked IPs list #} +
+

Currently Tracked

+
+
Loading...
+
+
+
+ + diff --git a/src/templates/jinja2/dashboard/partials/tracked_ips_table.html b/src/templates/jinja2/dashboard/partials/tracked_ips_table.html new file mode 100644 index 0000000..da88f66 --- /dev/null +++ b/src/templates/jinja2/dashboard/partials/tracked_ips_table.html @@ -0,0 +1,55 @@ +{# HTMX fragment: Tracked IPs list #} +
+ Page {{ pagination.page }}/{{ pagination.total_pages }} — {{ pagination.total }} tracked +
+ + +
+
+ + + + + + + + + + + + + + + {% for ip in items %} + + + + + + + + + + + {% else %} + + {% endfor %} + +
#IP AddressCategoryTotal RequestsLocationLast SeenTracked Since
{{ loop.index + (pagination.page - 1) * pagination.page_size }}{{ ip.ip | e }}{{ ip.category | default('unknown') | replace('_', ' ') | title }}{{ ip.total_requests }}{{ ip.city | default('') | e }}{% if ip.city and ip.country_code %}, {% endif %}{{ ip.country_code | default('N/A') | e }}{{ ip.last_seen | format_ts }}{{ ip.tracked_since | format_ts }} + + +
No tracked IPs
diff --git a/src/templates/static/css/dashboard.css b/src/templates/static/css/dashboard.css index 45a3fab..1207e4b 100644 --- a/src/templates/static/css/dashboard.css +++ b/src/templates/static/css/dashboard.css @@ -1338,7 +1338,7 @@ tbody { display: inline-flex; align-items: center; justify-content: center; - padding: 4px; + padding: 6px; background: none; border: none; border-radius: 4px; @@ -1346,12 +1346,12 @@ tbody { transition: color 0.2s, background 0.2s; } .ban-icon-btn svg { - width: 18px; - height: 18px; + width: 24px; + height: 24px; fill: currentColor; } .ban-icon-btn .material-symbols-outlined { - font-size: 20px; + font-size: 26px; } .ban-icon-unban { color: #3fb950; @@ -1392,6 +1392,53 @@ tbody { opacity: 1; } +/* IP Tracking buttons */ +.track-form-btn { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 10px 18px; + background: rgba(88, 166, 255, 0.15); + color: #58a6ff; + border: 1px solid rgba(88, 166, 255, 0.3); + border-radius: 6px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} +.track-form-btn:hover:not(:disabled) { + background: rgba(88, 166, 255, 0.3); + border-color: rgba(88, 166, 255, 0.5); +} +.track-form-btn:disabled { + opacity: 0.4; + cursor: not-allowed; +} +.track-form-btn .material-symbols-outlined { + font-size: 18px; +} +.track-icon-track { + color: #58a6ff; +} +.track-icon-track:hover { + background: rgba(88, 166, 255, 0.15); +} +.track-icon-untrack { + color: #8b949e; +} +.track-icon-untrack:hover { + color: #c9d1d9; + background: rgba(139, 148, 158, 0.15); +} +.track-icon-inspect { + color: #d2a8ff; +} +.track-icon-inspect:hover { + background: rgba(210, 168, 255, 0.15); +} + /* Custom confirm/alert modal */ .krawl-modal-overlay { position: fixed; diff --git a/src/templates/static/js/dashboard.js b/src/templates/static/js/dashboard.js index a1e104e..1ead4a3 100644 --- a/src/templates/static/js/dashboard.js +++ b/src/templates/static/js/dashboard.js @@ -48,6 +48,8 @@ document.addEventListener('alpine:init', () => { this.switchToAttacks(); } else if (h === 'banlist') { if (this.authenticated) this.switchToBanlist(); + } else if (h === 'tracked-ips') { + if (this.authenticated) this.switchToTrackedIps(); } else if (h !== 'ip-insight') { if (this.tab !== 'ip-insight') { this.switchToOverview(); @@ -91,6 +93,21 @@ document.addEventListener('alpine:init', () => { }); }, + switchToTrackedIps() { + if (!this.authenticated) return; + this.tab = 'tracked-ips'; + window.location.hash = '#tracked-ips'; + this.$nextTick(() => { + const container = document.getElementById('tracked-ips-htmx-container'); + if (container && typeof htmx !== 'undefined') { + htmx.ajax('GET', `${this.dashboardPath}/htmx/tracked-ips`, { + target: '#tracked-ips-htmx-container', + swap: 'innerHTML' + }); + } + }); + }, + async logout() { try { await fetch(`${this.dashboardPath}/api/auth/logout`, { @@ -99,7 +116,7 @@ document.addEventListener('alpine:init', () => { }); } catch {} this.authenticated = false; - if (this.tab === 'banlist') this.switchToOverview(); + if (this.tab === 'banlist' || this.tab === 'tracked-ips') this.switchToOverview(); }, promptAuth() { @@ -357,6 +374,43 @@ window.ipBanAction = async function(ip, action) { } }; +// Global track action for IP insight page (auth-gated) +window.ipTrackAction = async function(ip, action) { + const data = getAlpineData('[x-data="dashboardApp()"]'); + if (!data || !data.authenticated) { + if (data && typeof data.promptAuth === 'function') data.promptAuth(); + return; + } + const safeIp = escapeHtml(ip); + const label = action === 'track' ? 'track' : 'untrack'; + const confirmed = await krawlModal.confirm(`Are you sure you want to ${label} IP ${safeIp}?`); + if (!confirmed) return; + try { + const resp = await fetch(`${window.__DASHBOARD_PATH__}/api/track-ip`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'same-origin', + body: JSON.stringify({ ip, action }), + }); + const result = await resp.json().catch(() => ({})); + if (resp.ok) { + krawlModal.success(escapeHtml(result.message || `${label} successful for ${ip}`)); + // Refresh tracked IPs list if visible + const container = document.getElementById('tracked-ips-container'); + if (container && typeof htmx !== 'undefined') { + htmx.ajax('GET', `${window.__DASHBOARD_PATH__}/htmx/tracked-ips/list?page=1`, { + target: '#tracked-ips-container', + swap: 'innerHTML' + }); + } + } else { + krawlModal.error(escapeHtml(result.error || `Failed to ${label} IP ${ip}`)); + } + } catch { + krawlModal.error('Request failed'); + } +}; + // Show/hide ban action buttons based on auth state function updateBanActionVisibility(authenticated) { document.querySelectorAll('.ip-ban-actions').forEach(el => { From e814f1232a12bb7e3244c9e31c38d1e2d7c0833b Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 14:57:56 +0100 Subject: [PATCH 5/6] linted code --- src/database.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/database.py b/src/database.py index 3998831..9407429 100644 --- a/src/database.py +++ b/src/database.py @@ -2380,7 +2380,10 @@ class DatabaseManager: session = self.session sanitized_ip = sanitize_ip(ip) try: - return session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() is not None + return ( + session.query(TrackedIp).filter(TrackedIp.ip == sanitized_ip).first() + is not None + ) finally: self.close_session() @@ -2405,15 +2408,19 @@ class DatabaseManager: items = [] for t in tracked_rows: - items.append({ - "ip": t.ip, - "tracked_since": t.tracked_since.isoformat() if t.tracked_since else None, - "category": t.category, - "total_requests": t.total_requests or 0, - "country_code": t.country_code, - "city": t.city, - "last_seen": t.last_seen.isoformat() if t.last_seen else None, - }) + items.append( + { + "ip": t.ip, + "tracked_since": ( + t.tracked_since.isoformat() if t.tracked_since else None + ), + "category": t.category, + "total_requests": t.total_requests or 0, + "country_code": t.country_code, + "city": t.city, + "last_seen": t.last_seen.isoformat() if t.last_seen else None, + } + ) return { "tracked_ips": items, From a261e393072977d1b0d0638767b75b6a4e904f10 Mon Sep 17 00:00:00 2001 From: Lorenzo Venerandi Date: Mon, 9 Mar 2026 15:01:42 +0100 Subject: [PATCH 6/6] chore: bump version to 1.1.6 in Chart.yaml --- helm/Chart.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/helm/Chart.yaml b/helm/Chart.yaml index 6d4d5ab..ec5889f 100644 --- a/helm/Chart.yaml +++ b/helm/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: krawl-chart description: A Helm chart for Krawl honeypot server type: application -version: 1.1.5 -appVersion: 1.1.5 +version: 1.1.6 +appVersion: 1.1.6 keywords: - honeypot - security