diff --git a/README.md b/README.md
index 1c7cd99..9064a89 100644
--- a/README.md
+++ b/README.md
@@ -1,9 +1,14 @@
+
+
+
# Nucleus - Vulnerability Scan Orchestrator
Nucleus is a single, self-contained Go binary that acts as an orchestration manager for scheduled [Nuclei](https://github.com/projectdiscovery/nuclei) vulnerability scans.
It serves an embedded Vue 3 SPA web dashboard, reads/writes scan states to a local SQLite database, and automatically dispatches beautifully formatted HTML email reports via SMTP when scans discover vulnerabilities.
+
+
## Prerequisites
- **Go 1.22+**
- **Node.js & npm** (for building the frontend)
@@ -65,6 +70,10 @@ Environment="SMTP_TO=admin@example.com"
# Environment="SMTP_USER=username"
# Environment="SMTP_PASS=password"
+# Optional: Add basic authentication to protect the web dashboard
+# Environment="WEB_USER=admin"
+# Environment="WEB_PASS=supersecret"
+
Restart=always
RestartSec=5
diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index a27f6ba..0e5894a 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -6,9 +6,10 @@
Nucleus
-
+
Targets
Scans History
+ Logout
@@ -20,3 +21,14 @@
+
+
diff --git a/frontend/src/components/FindingsInspector.vue b/frontend/src/components/FindingsInspector.vue
index 5587c1e..f48d21e 100644
--- a/frontend/src/components/FindingsInspector.vue
+++ b/frontend/src/components/FindingsInspector.vue
@@ -69,6 +69,7 @@ const findings = ref([])
const loadFindings = async () => {
try {
const res = await fetch(`/api/scans/${props.id}/findings`)
+ if (res.status === 401) { window.location.href = '/login'; return }
findings.value = await res.json() || []
} catch (e) {
console.error(e)
diff --git a/frontend/src/components/Login.vue b/frontend/src/components/Login.vue
new file mode 100644
index 0000000..4043694
--- /dev/null
+++ b/frontend/src/components/Login.vue
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
Sign In to Nucleus
+
+
+
+
+
+
+
diff --git a/frontend/src/components/ScansHistory.vue b/frontend/src/components/ScansHistory.vue
index dd785d4..734f6c6 100644
--- a/frontend/src/components/ScansHistory.vue
+++ b/frontend/src/components/ScansHistory.vue
@@ -75,6 +75,7 @@ let interval = null
const loadScans = async () => {
try {
const res = await fetch('/api/scans')
+ if (res.status === 401) { window.location.href = '/login'; return }
scans.value = await res.json() || []
} catch (e) {
console.error(e)
diff --git a/frontend/src/components/TargetManager.vue b/frontend/src/components/TargetManager.vue
index 2b4a511..2b65f21 100644
--- a/frontend/src/components/TargetManager.vue
+++ b/frontend/src/components/TargetManager.vue
@@ -84,6 +84,7 @@ const form = ref({ name: '', address: '', schedule: 'manual', customSchedule: ''
const loadTargets = async () => {
try {
const res = await fetch('/api/targets')
+ if (res.status === 401) { window.location.href = '/login'; return }
targets.value = await res.json() || []
} catch (e) {
console.error(e)
diff --git a/frontend/src/router.js b/frontend/src/router.js
index a01a0f4..052cab1 100644
--- a/frontend/src/router.js
+++ b/frontend/src/router.js
@@ -2,14 +2,34 @@ import { createRouter, createWebHistory } from 'vue-router'
import TargetManager from './components/TargetManager.vue'
import ScansHistory from './components/ScansHistory.vue'
import FindingsInspector from './components/FindingsInspector.vue'
+import Login from './components/Login.vue'
const routes = [
- { path: '/', component: TargetManager },
- { path: '/scans', component: ScansHistory },
- { path: '/scans/:id', component: FindingsInspector, props: true }
+ { path: '/login', component: Login },
+ { path: '/', component: TargetManager, meta: { requiresAuth: true } },
+ { path: '/scans', component: ScansHistory, meta: { requiresAuth: true } },
+ { path: '/scans/:id', component: FindingsInspector, props: true, meta: { requiresAuth: true } }
]
export const router = createRouter({
history: createWebHistory(),
routes
})
+
+router.beforeEach(async (to, from, next) => {
+ if (to.meta.requiresAuth) {
+ try {
+ const res = await fetch('/api/auth/status')
+ const status = await res.json()
+ if (status.auth_required && !status.authenticated) {
+ next('/login')
+ } else {
+ next()
+ }
+ } catch (e) {
+ next('/login')
+ }
+ } else {
+ next()
+ }
+})
diff --git a/internal/api/auth.go b/internal/api/auth.go
new file mode 100644
index 0000000..67505ed
--- /dev/null
+++ b/internal/api/auth.go
@@ -0,0 +1,131 @@
+package api
+
+import (
+ "crypto/rand"
+ "encoding/hex"
+ "encoding/json"
+ "net/http"
+ "os"
+ "sync"
+ "time"
+)
+
+var (
+ currentSession string
+ sessionMu sync.Mutex
+)
+
+func generateSession() string {
+ b := make([]byte, 32)
+ rand.Read(b)
+ return hex.EncodeToString(b)
+}
+
+func AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ user := os.Getenv("WEB_USER")
+ pass := os.Getenv("WEB_PASS")
+ if user == "" || pass == "" {
+ next(w, r)
+ return
+ }
+
+ cookie, err := r.Cookie("nucleus_session")
+ if err != nil {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ sessionMu.Lock()
+ valid := currentSession != "" && currentSession == cookie.Value
+ sessionMu.Unlock()
+
+ if !valid {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ next(w, r)
+ }
+}
+
+func LoginHandler(w http.ResponseWriter, r *http.Request) {
+ user := os.Getenv("WEB_USER")
+ pass := os.Getenv("WEB_PASS")
+ if user == "" || pass == "" {
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+
+ var creds struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ }
+ if err := json.NewDecoder(r.Body).Decode(&creds); err != nil {
+ http.Error(w, "Bad Request", http.StatusBadRequest)
+ return
+ }
+
+ if creds.Username != user || creds.Password != pass {
+ http.Error(w, "Unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ sessionMu.Lock()
+ currentSession = generateSession()
+ token := currentSession
+ sessionMu.Unlock()
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "nucleus_session",
+ Value: token,
+ Path: "/",
+ Expires: time.Now().Add(24 * time.Hour),
+ HttpOnly: true,
+ })
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func LogoutHandler(w http.ResponseWriter, r *http.Request) {
+ sessionMu.Lock()
+ currentSession = ""
+ sessionMu.Unlock()
+
+ http.SetCookie(w, &http.Cookie{
+ Name: "nucleus_session",
+ Value: "",
+ Path: "/",
+ Expires: time.Now().Add(-1 * time.Hour),
+ HttpOnly: true,
+ })
+
+ w.WriteHeader(http.StatusOK)
+}
+
+func StatusHandler(w http.ResponseWriter, r *http.Request) {
+ user := os.Getenv("WEB_USER")
+ pass := os.Getenv("WEB_PASS")
+ if user == "" || pass == "" {
+ w.Header().Set("Content-Type", "application/json")
+ w.Write([]byte(`{"auth_required": false, "authenticated": true}`))
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ cookie, err := r.Cookie("nucleus_session")
+ if err != nil {
+ w.Write([]byte(`{"auth_required": true, "authenticated": false}`))
+ return
+ }
+
+ sessionMu.Lock()
+ valid := currentSession != "" && currentSession == cookie.Value
+ sessionMu.Unlock()
+
+ if valid {
+ w.Write([]byte(`{"auth_required": true, "authenticated": true}`))
+ } else {
+ w.Write([]byte(`{"auth_required": true, "authenticated": false}`))
+ }
+}
diff --git a/internal/api/handlers.go b/internal/api/handlers.go
index 0898901..0e687dd 100644
--- a/internal/api/handlers.go
+++ b/internal/api/handlers.go
@@ -12,12 +12,16 @@ import (
)
func RegisterRoutes(mux *http.ServeMux) {
- mux.HandleFunc("GET /api/targets", getTargets)
- mux.HandleFunc("POST /api/targets", createTarget)
- mux.HandleFunc("DELETE /api/targets/{id}", deleteTarget)
- mux.HandleFunc("POST /api/targets/{id}/scan", triggerScan)
- mux.HandleFunc("GET /api/scans", getScans)
- mux.HandleFunc("GET /api/scans/{id}/findings", getFindings)
+ mux.HandleFunc("POST /api/auth/login", LoginHandler)
+ mux.HandleFunc("POST /api/auth/logout", LogoutHandler)
+ mux.HandleFunc("GET /api/auth/status", StatusHandler)
+
+ mux.HandleFunc("GET /api/targets", AuthMiddleware(getTargets))
+ mux.HandleFunc("POST /api/targets", AuthMiddleware(createTarget))
+ mux.HandleFunc("DELETE /api/targets/{id}", AuthMiddleware(deleteTarget))
+ mux.HandleFunc("POST /api/targets/{id}/scan", AuthMiddleware(triggerScan))
+ mux.HandleFunc("GET /api/scans", AuthMiddleware(getScans))
+ mux.HandleFunc("GET /api/scans/{id}/findings", AuthMiddleware(getFindings))
}
func getTargets(w http.ResponseWriter, r *http.Request) {