feat: add minimal web dashboard with stats, top credentials, and sessions

Implements Phase 1.5 — an embedded web UI using Go templates, Pico CSS
(dark theme), and htmx for auto-refreshing stats and active sessions.

Adds read query methods to the Store interface (GetDashboardStats,
GetTopUsernames, GetTopPasswords, GetTopIPs, GetRecentSessions) with
implementations for both SQLite and MemoryStore. Introduces the
internal/web package with server, handlers, templates, and tests.
Web server is opt-in via [web] config section and runs alongside
SSH with graceful shutdown. Bumps version to 0.2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 20:59:12 +01:00
parent 85e79c97ac
commit 96c8476f77
20 changed files with 1104 additions and 2 deletions

104
internal/web/handlers.go Normal file
View File

@@ -0,0 +1,104 @@
package web
import (
"net/http"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
type dashboardData struct {
Stats *storage.DashboardStats
TopUsernames []storage.TopEntry
TopPasswords []storage.TopEntry
TopIPs []storage.TopEntry
ActiveSessions []storage.Session
RecentSessions []storage.Session
}
func (s *Server) handleDashboard(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
stats, err := s.store.GetDashboardStats(ctx)
if err != nil {
s.logger.Error("failed to get dashboard stats", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
topUsernames, err := s.store.GetTopUsernames(ctx, 10)
if err != nil {
s.logger.Error("failed to get top usernames", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
topPasswords, err := s.store.GetTopPasswords(ctx, 10)
if err != nil {
s.logger.Error("failed to get top passwords", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
topIPs, err := s.store.GetTopIPs(ctx, 10)
if err != nil {
s.logger.Error("failed to get top IPs", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
activeSessions, err := s.store.GetRecentSessions(ctx, 50, true)
if err != nil {
s.logger.Error("failed to get active sessions", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
recentSessions, err := s.store.GetRecentSessions(ctx, 50, false)
if err != nil {
s.logger.Error("failed to get recent sessions", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
data := dashboardData{
Stats: stats,
TopUsernames: topUsernames,
TopPasswords: topPasswords,
TopIPs: topIPs,
ActiveSessions: activeSessions,
RecentSessions: recentSessions,
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "layout.html", data); err != nil {
s.logger.Error("failed to render dashboard", "err", err)
}
}
func (s *Server) handleFragmentStats(w http.ResponseWriter, r *http.Request) {
stats, err := s.store.GetDashboardStats(r.Context())
if err != nil {
s.logger.Error("failed to get dashboard stats", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "stats", stats); err != nil {
s.logger.Error("failed to render stats fragment", "err", err)
}
}
func (s *Server) handleFragmentActiveSessions(w http.ResponseWriter, r *http.Request) {
sessions, err := s.store.GetRecentSessions(r.Context(), 50, true)
if err != nil {
s.logger.Error("failed to get active sessions", "err", err)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "active_sessions", sessions); err != nil {
s.logger.Error("failed to render active sessions fragment", "err", err)
}
}

1
internal/web/static/htmx.min.js vendored Normal file

File diff suppressed because one or more lines are too long

4
internal/web/static/pico.min.css vendored Normal file

File diff suppressed because one or more lines are too long

37
internal/web/templates.go Normal file
View File

@@ -0,0 +1,37 @@
package web
import (
"embed"
"html/template"
"time"
)
//go:embed templates/*.html templates/fragments/*.html
var templateFS embed.FS
func loadTemplates() (*template.Template, error) {
funcMap := template.FuncMap{
"formatTime": func(t time.Time) string {
return t.Format("2006-01-02 15:04:05 UTC")
},
"truncateID": func(id string) string {
if len(id) > 8 {
return id[:8]
}
return id
},
"derefTime": func(t *time.Time) time.Time {
if t == nil {
return time.Time{}
}
return *t
},
}
return template.New("").Funcs(funcMap).ParseFS(templateFS,
"templates/layout.html",
"templates/dashboard.html",
"templates/fragments/stats.html",
"templates/fragments/active_sessions.html",
)
}

View File

@@ -0,0 +1,93 @@
{{define "content"}}
<section id="stats-section" hx-get="/fragments/stats" hx-trigger="every 30s" hx-swap="innerHTML">
{{template "stats" .Stats}}
</section>
<section>
<h3>Top Credentials & IPs</h3>
<div class="top-grid">
<article>
<header>Top Usernames</header>
<table>
<thead>
<tr><th>Username</th><th>Attempts</th></tr>
</thead>
<tbody>
{{range .TopUsernames}}
<tr><td>{{.Value}}</td><td>{{.Count}}</td></tr>
{{else}}
<tr><td colspan="2">No data</td></tr>
{{end}}
</tbody>
</table>
</article>
<article>
<header>Top Passwords</header>
<table>
<thead>
<tr><th>Password</th><th>Attempts</th></tr>
</thead>
<tbody>
{{range .TopPasswords}}
<tr><td>{{.Value}}</td><td>{{.Count}}</td></tr>
{{else}}
<tr><td colspan="2">No data</td></tr>
{{end}}
</tbody>
</table>
</article>
<article>
<header>Top IPs</header>
<table>
<thead>
<tr><th>IP</th><th>Attempts</th></tr>
</thead>
<tbody>
{{range .TopIPs}}
<tr><td>{{.Value}}</td><td>{{.Count}}</td></tr>
{{else}}
<tr><td colspan="2">No data</td></tr>
{{end}}
</tbody>
</table>
</article>
</div>
</section>
<section>
<h3>Active Sessions</h3>
<div id="active-sessions" hx-get="/fragments/active-sessions" hx-trigger="every 10s" hx-swap="innerHTML">
{{template "active_sessions" .ActiveSessions}}
</div>
</section>
<section>
<h3>Recent Sessions</h3>
<table>
<thead>
<tr>
<th>ID</th>
<th>IP</th>
<th>Username</th>
<th>Shell</th>
<th>Connected</th>
<th>Disconnected</th>
</tr>
</thead>
<tbody>
{{range .RecentSessions}}
<tr>
<td><code>{{truncateID .ID}}</code></td>
<td>{{.IP}}</td>
<td>{{.Username}}</td>
<td>{{.ShellName}}</td>
<td>{{formatTime .ConnectedAt}}</td>
<td>{{if .DisconnectedAt}}{{formatTime (derefTime .DisconnectedAt)}}{{else}}<mark>active</mark>{{end}}</td>
</tr>
{{else}}
<tr><td colspan="6">No sessions</td></tr>
{{end}}
</tbody>
</table>
</section>
{{end}}

View File

@@ -0,0 +1,26 @@
{{define "active_sessions"}}
<table>
<thead>
<tr>
<th>ID</th>
<th>IP</th>
<th>Username</th>
<th>Shell</th>
<th>Connected</th>
</tr>
</thead>
<tbody>
{{range .}}
<tr>
<td><code>{{truncateID .ID}}</code></td>
<td>{{.IP}}</td>
<td>{{.Username}}</td>
<td>{{.ShellName}}</td>
<td>{{formatTime .ConnectedAt}}</td>
</tr>
{{else}}
<tr><td colspan="5">No active sessions</td></tr>
{{end}}
</tbody>
</table>
{{end}}

View File

@@ -0,0 +1,20 @@
{{define "stats"}}
<div class="stats-grid">
<article class="stat-card">
<h2>{{.TotalAttempts}}</h2>
<p>Total Attempts</p>
</article>
<article class="stat-card">
<h2>{{.UniqueIPs}}</h2>
<p>Unique IPs</p>
</article>
<article class="stat-card">
<h2>{{.TotalSessions}}</h2>
<p>Total Sessions</p>
</article>
<article class="stat-card">
<h2>{{.ActiveSessions}}</h2>
<p>Active Sessions</p>
</article>
</div>
{{end}}

View File

@@ -0,0 +1,56 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Oubliette</title>
<link rel="stylesheet" href="/static/pico.min.css">
<script src="/static/htmx.min.js"></script>
<style>
:root {
--pico-font-size: 16px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 1rem;
}
.stat-card {
text-align: center;
padding: 1rem;
}
.stat-card h2 {
margin-bottom: 0.25rem;
font-size: 2rem;
}
.stat-card p {
margin: 0;
color: var(--pico-muted-color);
}
.top-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
}
nav h1 {
margin: 0;
}
nav small {
color: var(--pico-muted-color);
}
</style>
</head>
<body>
<nav class="container">
<ul>
<li><h1>Oubliette</h1></li>
</ul>
<ul>
<li><small>SSH Honeypot Dashboard</small></li>
</ul>
</nav>
<main class="container">
{{block "content" .}}{{end}}
</main>
</body>
</html>

48
internal/web/web.go Normal file
View File

@@ -0,0 +1,48 @@
package web
import (
"embed"
"html/template"
"log/slog"
"net/http"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
//go:embed static/*
var staticFS embed.FS
// Server is the web dashboard HTTP server.
type Server struct {
store storage.Store
logger *slog.Logger
mux *http.ServeMux
tmpl *template.Template
}
// NewServer creates a new web Server with routes registered.
func NewServer(store storage.Store, logger *slog.Logger) (*Server, error) {
tmpl, err := loadTemplates()
if err != nil {
return nil, err
}
s := &Server{
store: store,
logger: logger,
mux: http.NewServeMux(),
tmpl: tmpl,
}
s.mux.Handle("GET /static/", http.FileServerFS(staticFS))
s.mux.HandleFunc("GET /", s.handleDashboard)
s.mux.HandleFunc("GET /fragments/stats", s.handleFragmentStats)
s.mux.HandleFunc("GET /fragments/active-sessions", s.handleFragmentActiveSessions)
return s, nil
}
// ServeHTTP delegates to the internal mux.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}

161
internal/web/web_test.go Normal file
View File

@@ -0,0 +1,161 @@
package web
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.t-juice.club/torjus/oubliette/internal/storage"
)
func newTestServer(t *testing.T) *Server {
t.Helper()
store := storage.NewMemoryStore()
logger := slog.Default()
srv, err := NewServer(store, logger)
if err != nil {
t.Fatalf("creating server: %v", err)
}
return srv
}
func newSeededTestServer(t *testing.T) *Server {
t.Helper()
store := storage.NewMemoryStore()
ctx := context.Background()
for i := 0; i < 5; i++ {
if err := store.RecordLoginAttempt(ctx, "root", "toor", "10.0.0.1"); err != nil {
t.Fatalf("seeding attempt: %v", err)
}
}
if err := store.RecordLoginAttempt(ctx, "admin", "admin", "10.0.0.2"); err != nil {
t.Fatalf("seeding attempt: %v", err)
}
if _, err := store.CreateSession(ctx, "10.0.0.1", "root", "bash"); err != nil {
t.Fatalf("creating session: %v", err)
}
if _, err := store.CreateSession(ctx, "10.0.0.2", "admin", "bash"); err != nil {
t.Fatalf("creating session: %v", err)
}
logger := slog.Default()
srv, err := NewServer(store, logger)
if err != nil {
t.Fatalf("creating server: %v", err)
}
return srv
}
func TestDashboardHandler(t *testing.T) {
t.Run("empty store", func(t *testing.T) {
srv := newTestServer(t)
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "Oubliette") {
t.Error("response should contain 'Oubliette'")
}
if !strings.Contains(body, "No data") {
t.Error("response should contain 'No data' for empty tables")
}
})
t.Run("with data", func(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if !strings.Contains(body, "root") {
t.Error("response should contain username 'root'")
}
if !strings.Contains(body, "10.0.0.1") {
t.Error("response should contain IP '10.0.0.1'")
}
})
}
func TestFragmentStats(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest("GET", "/fragments/stats", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
// Should be a fragment, not a full HTML page.
if strings.Contains(body, "<!DOCTYPE html>") {
t.Error("stats fragment should not contain full HTML document")
}
if !strings.Contains(body, "Total Attempts") {
t.Error("stats fragment should contain 'Total Attempts'")
}
}
func TestFragmentActiveSessions(t *testing.T) {
srv := newSeededTestServer(t)
req := httptest.NewRequest("GET", "/fragments/active-sessions", nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
body := w.Body.String()
if strings.Contains(body, "<!DOCTYPE html>") {
t.Error("active sessions fragment should not contain full HTML document")
}
// Both sessions are active (not ended).
if !strings.Contains(body, "10.0.0.1") {
t.Error("active sessions should contain IP '10.0.0.1'")
}
}
func TestStaticAssets(t *testing.T) {
srv := newTestServer(t)
tests := []struct {
path string
contentType string
}{
{"/static/pico.min.css", "text/css"},
{"/static/htmx.min.js", "text/javascript"},
}
for _, tt := range tests {
t.Run(tt.path, func(t *testing.T) {
req := httptest.NewRequest("GET", tt.path, nil)
w := httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Errorf("status = %d, want 200", w.Code)
}
ct := w.Header().Get("Content-Type")
if !strings.Contains(ct, tt.contentType) {
t.Errorf("Content-Type = %q, want to contain %q", ct, tt.contentType)
}
})
}
}