diff --git a/backend/api/handlers.go b/backend/api/handlers.go index fcdda42..3ca14ce 100644 --- a/backend/api/handlers.go +++ b/backend/api/handlers.go @@ -59,6 +59,10 @@ func (h *Handler) SetupRoutes(mux *http.ServeMux, hub *ws.Hub) { mux.HandleFunc("POST /api/v1/apikeys", h.createAPIKey) mux.HandleFunc("DELETE /api/v1/apikeys/{id}", h.deleteAPIKey) + // System Settings + mux.HandleFunc("GET /api/v1/settings", h.getSettings) + mux.HandleFunc("PUT /api/v1/settings", h.updateSettings) + // Audit-Log mux.HandleFunc("GET /api/v1/audit", h.getAuditLogs) diff --git a/backend/api/settings.go b/backend/api/settings.go new file mode 100644 index 0000000..6e90322 --- /dev/null +++ b/backend/api/settings.go @@ -0,0 +1,58 @@ +package api + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" +) + +// GET /api/v1/settings +func (h *Handler) getSettings(w http.ResponseWriter, r *http.Request) { + settings, err := h.db.GetSettings() + if err != nil { + writeError(w, http.StatusInternalServerError, "Settings laden fehlgeschlagen") + return + } + writeJSON(w, http.StatusOK, map[string]interface{}{"data": settings}) +} + +// PUT /api/v1/settings (JWT only - checked via context) +func (h *Handler) updateSettings(w http.ResponseWriter, r *http.Request) { + // JWT-only: check that user context exists + claims := r.Context().Value(UserContextKey) + if claims == nil { + writeError(w, http.StatusForbidden, "Nur mit JWT-Authentifizierung erlaubt") + return + } + + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "Ungueltige Anfrage") + return + } + + allowedKeys := map[string]bool{ + "backend_url_internal": true, + "backend_url_external": true, + "api_key": true, + } + + var changed []string + for k, v := range body { + if !allowedKeys[k] { + continue + } + if err := h.db.SetSetting(k, v); err != nil { + writeError(w, http.StatusInternalServerError, fmt.Sprintf("Setting '%s' speichern fehlgeschlagen", k)) + return + } + changed = append(changed, k) + } + + if len(changed) > 0 { + h.auditLog(r, "settings.update", "settings", "", "", "Geaendert: "+strings.Join(changed, ", ")) + } + + writeJSON(w, http.StatusOK, map[string]string{"message": "Settings aktualisiert"}) +} diff --git a/backend/db/postgres.go b/backend/db/postgres.go index 5f5ef3b..7c51f96 100644 --- a/backend/db/postgres.go +++ b/backend/db/postgres.go @@ -216,6 +216,17 @@ func (d *Database) migrate() error { )`) d.db.Exec(`CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp DESC)`) + // System Settings Tabelle + d.db.Exec(`CREATE TABLE IF NOT EXISTS system_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + )`) + // Default-Keys anlegen (leere Werte) + for _, k := range []string{"backend_url_internal", "backend_url_external", "api_key"} { + d.db.Exec(`INSERT INTO system_settings (key, value) VALUES ($1, '') ON CONFLICT DO NOTHING`, k) + } + // Retention Policy (90 Tage) d.setupRetention() @@ -1197,6 +1208,42 @@ func (d *Database) GetAuditLogs(limit int, offset int, action string, agentID st return entries, total, rows.Err() } +// --- System Settings --- + +func (d *Database) GetSettings() (map[string]string, error) { + rows, err := d.db.Query("SELECT key, value FROM system_settings ORDER BY key") + if err != nil { + return nil, err + } + defer rows.Close() + settings := make(map[string]string) + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, err + } + settings[k] = v + } + return settings, rows.Err() +} + +func (d *Database) GetSetting(key string) (string, error) { + var value string + err := d.db.QueryRow("SELECT value FROM system_settings WHERE key = $1", key).Scan(&value) + if err != nil { + return "", err + } + return value, nil +} + +func (d *Database) SetSetting(key, value string) error { + _, err := d.db.Exec(` + INSERT INTO system_settings (key, value, updated_at) VALUES ($1, $2, NOW()) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW() + `, key, value) + return err +} + func (d *Database) MigrateConfigAPIKeys(configKeys []string) { for _, key := range configKeys { var exists bool diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 9339d08..901d58c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -236,6 +236,15 @@ class ApiClient { return this.delete(`/api/v1/apikeys/${id}`) } + // System Settings + getSettings() { + return this.get('/api/v1/settings') + } + + updateSettings(data) { + return this.put('/api/v1/settings', data) + } + // Audit Log getAuditLogs(params = {}) { const q = new URLSearchParams() diff --git a/frontend/src/components/AgentPanel.jsx b/frontend/src/components/AgentPanel.jsx index a9c8599..02bcb2a 100644 --- a/frontend/src/components/AgentPanel.jsx +++ b/frontend/src/components/AgentPanel.jsx @@ -2,7 +2,7 @@ import { useEffect, useState } from 'react' import api from '../api/client' import StatusBadge from './StatusBadge' import { copyToClipboard } from '../utils/clipboard' -import { BACKEND_HOST } from '../config' +import { useSettingsStore } from '../stores/settings' import { X, Cpu, MemoryStick, HardDrive, Clock, Network, Shield, Route, Globe, Key, Download, Terminal, Wifi, Search, Plus, Trash2, Copy, Check, ExternalLink, Unplug, Plug, MonitorSmartphone, @@ -572,7 +572,7 @@ function TunnelTab({ agentId, agent }) { const [error, setError] = useState('') const [sshCopied, setSshCopied] = useState(null) - const backendHost = BACKEND_HOST + const backendHost = useSettingsStore.getState().getBackendHost() const loadTunnels = () => { api.getTunnels(agentId) diff --git a/frontend/src/components/InstallGuide.jsx b/frontend/src/components/InstallGuide.jsx index 0b60f58..41d4e20 100644 --- a/frontend/src/components/InstallGuide.jsx +++ b/frontend/src/components/InstallGuide.jsx @@ -1,11 +1,14 @@ import { useState } from 'react' import { Copy, Check, Terminal, Monitor } from 'lucide-react' import { copyToClipboard } from '../utils/clipboard' -import { BACKEND_URL, API_KEY } from '../config' +import { useSettingsStore } from '../stores/settings' export default function InstallGuide({ agentName, platform: initialPlatform }) { const [platform, setPlatform] = useState(initialPlatform || 'freebsd') const [copied, setCopied] = useState(null) + const { settings } = useSettingsStore() + const BACKEND_URL = settings.backend_url_external || settings.backend_url_internal || '' + const API_KEY = settings.api_key || '' const copyText = (text, id) => { copyToClipboard(text) diff --git a/frontend/src/components/ProxmoxPanel.jsx b/frontend/src/components/ProxmoxPanel.jsx index b6a013d..600fc94 100644 --- a/frontend/src/components/ProxmoxPanel.jsx +++ b/frontend/src/components/ProxmoxPanel.jsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' import api from '../api/client' -import { BACKEND_HOST } from '../config' +import { useSettingsStore } from '../stores/settings' import StatusBadge from './StatusBadge' import { X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor, @@ -674,7 +674,7 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) { const [customPort, setCustomPort] = useState('') const [error, setError] = useState('') - const backendHost = BACKEND_HOST + const backendHost = useSettingsStore.getState().getBackendHost() const loadTunnels = () => { api.getTunnels(agentId) diff --git a/frontend/src/pages/Firmware.jsx b/frontend/src/pages/Firmware.jsx index 65db3e8..b972823 100644 --- a/frontend/src/pages/Firmware.jsx +++ b/frontend/src/pages/Firmware.jsx @@ -9,9 +9,10 @@ const PLATFORMS = [ { id: 'windows', label: 'Windows', icon: Cpu }, ] -import { BACKEND_URL } from '../config' +import { useSettingsStore } from '../stores/settings' export default function Firmware() { + const BACKEND_URL = useSettingsStore((s) => s.settings.backend_url_external || s.settings.backend_url_internal || '') const [firmwareData, setFirmwareData] = useState(null) const [installerData, setInstallerData] = useState(null) const [agents, setAgents] = useState([]) diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index aa52e1b..f725847 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -2,8 +2,8 @@ import { useEffect, useState } from 'react' import api from '../api/client' import { useAuthStore } from '../stores/auth' import { copyToClipboard } from '../utils/clipboard' -import { Plus, Trash2, KeyRound, ShieldAlert, Copy, Check, Key } from 'lucide-react' -import { BACKEND_HOST } from '../config' +import { Plus, Trash2, KeyRound, ShieldAlert, Copy, Check, Key, Save, Settings } from 'lucide-react' +import { useSettingsStore } from '../stores/settings' export default function SettingsPage() { const [users, setUsers] = useState([]) @@ -204,22 +204,8 @@ export default function SettingsPage() { - {/* Info */} -
-
- System -
-
-
- Backend - {BACKEND_HOST + ':8443'} -
-
- Authentifizierung - JWT (8h Token) + API-Keys -
-
-
+ {/* System Settings */} + ) } @@ -375,3 +361,111 @@ function APIKeysSection() { ) } + +function SystemSettingsSection() { + const { settings, fetchSettings } = useSettingsStore() + const [form, setForm] = useState({}) + const [saving, setSaving] = useState(false) + const [msg, setMsg] = useState('') + const [copied, setCopied] = useState(null) + + useEffect(() => { + fetchSettings() + }, []) + + useEffect(() => { + setForm({ + backend_url_internal: settings.backend_url_internal || '', + backend_url_external: settings.backend_url_external || '', + api_key: settings.api_key || '', + }) + }, [settings]) + + const handleSave = async () => { + setSaving(true) + setMsg('') + try { + await api.updateSettings(form) + await fetchSettings() + setMsg('Gespeichert') + setTimeout(() => setMsg(''), 3000) + } catch (e) { + setMsg('Fehler: ' + e.message) + } finally { + setSaving(false) + } + } + + const copyText = (text, id) => { + copyToClipboard(text) + setCopied(id) + setTimeout(() => setCopied(null), 2000) + } + + return ( +
+
+ + System-Einstellungen +
+
+
+ + setForm({ ...form, backend_url_internal: e.target.value })} + placeholder="https://192.168.85.13:8443" + className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500 mt-1" + /> +
Fuer Tunnel-Zugriff und lokale Verbindungen
+
+
+ + setForm({ ...form, backend_url_external: e.target.value })} + placeholder="https://example.dyndns.org:8443" + className="w-full bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm focus:outline-none focus:border-orange-500 mt-1" + /> +
Fuer Remote-Firewalls in der Installationsanleitung
+
+
+ +
+ setForm({ ...form, api_key: e.target.value })} + className="flex-1 bg-gray-800 border border-gray-700 rounded px-3 py-2 text-white text-sm font-mono focus:outline-none focus:border-orange-500" + /> + +
+
Wird in Installationsanleitungen angezeigt
+
+
+ + {msg && ( + + {msg} + + )} +
+
+
+ ) +} diff --git a/frontend/src/stores/auth.js b/frontend/src/stores/auth.js index 6694750..8d390bc 100644 --- a/frontend/src/stores/auth.js +++ b/frontend/src/stores/auth.js @@ -1,5 +1,6 @@ import { create } from 'zustand' import api from '../api/client' +import { useSettingsStore } from './settings' export const useAuthStore = create((set) => ({ user: null, @@ -9,6 +10,8 @@ export const useAuthStore = create((set) => ({ login: async (username, password) => { const data = await api.login(username, password) set({ user: data.user, isAuthenticated: true }) + // Load system settings after login + useSettingsStore.getState().fetchSettings() return data }, @@ -25,6 +28,7 @@ export const useAuthStore = create((set) => ({ try { const data = await api.me() set({ user: data.user || data, isAuthenticated: true, loading: false }) + useSettingsStore.getState().fetchSettings() } catch { api.logout() set({ user: null, isAuthenticated: false, loading: false }) diff --git a/frontend/src/stores/settings.js b/frontend/src/stores/settings.js new file mode 100644 index 0000000..4dfdcd9 --- /dev/null +++ b/frontend/src/stores/settings.js @@ -0,0 +1,30 @@ +import { create } from 'zustand' +import api from '../api/client' + +export const useSettingsStore = create((set, get) => ({ + settings: {}, + loaded: false, + + fetchSettings: async () => { + try { + const resp = await api.getSettings() + set({ settings: resp.data || {}, loaded: true }) + } catch { + // ignore - settings not available before login + } + }, + + getSetting: (key, fallback = '') => { + return get().settings[key] || fallback + }, + + getBackendHost: () => { + const url = get().settings.backend_url_internal || '' + try { + return new URL(url).hostname + } catch { + // fallback: strip protocol and port manually + return url.replace(/^https?:\/\//, '').replace(/:\d+$/, '') + } + }, +}))