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:
104
internal/web/handlers.go
Normal file
104
internal/web/handlers.go
Normal 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
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
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
37
internal/web/templates.go
Normal 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",
|
||||
)
|
||||
}
|
||||
93
internal/web/templates/dashboard.html
Normal file
93
internal/web/templates/dashboard.html
Normal 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}}
|
||||
26
internal/web/templates/fragments/active_sessions.html
Normal file
26
internal/web/templates/fragments/active_sessions.html
Normal 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}}
|
||||
20
internal/web/templates/fragments/stats.html
Normal file
20
internal/web/templates/fragments/stats.html
Normal 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}}
|
||||
56
internal/web/templates/layout.html
Normal file
56
internal/web/templates/layout.html
Normal 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
48
internal/web/web.go
Normal 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
161
internal/web/web_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user