Initial commit: RMM Agent + Backend
- Go Agent (FreeBSD/amd64) fuer OPNsense - Go Backend (Linux/amd64) mit REST API + SQLite - Collector: Hardware, CPU, Memory, Disks, Network, Services, WireGuard, DHCP (KEA/ISC/dnsmasq), Routes, Gateways, Certificates, Plugins, Updates - Persistente Agent-ID - TLS + API-Key Auth - WebSocket-Infrastruktur (WIP): bidirektionaler Command-Kanal, TCP-Tunnel fuer Remote-Zugriff auf Firewall-WebUI - Lastenheft und README
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cynfo/rmm-backend/db"
|
||||
"github.com/cynfo/rmm-backend/models"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
db *db.Database
|
||||
}
|
||||
|
||||
func NewHandler(database *db.Database) *Handler {
|
||||
return &Handler{db: database}
|
||||
}
|
||||
|
||||
// SetupRoutes - Alle API-Routes registrieren
|
||||
func (h *Handler) SetupRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /api/v1/agent/register", h.registerAgent)
|
||||
mux.HandleFunc("POST /api/v1/agent/heartbeat", h.heartbeat)
|
||||
mux.HandleFunc("GET /api/v1/agents", h.listAgents)
|
||||
mux.HandleFunc("GET /api/v1/agents/{id}", h.getAgent)
|
||||
mux.HandleFunc("GET /api/v1/agents/{id}/system", h.getSystemData)
|
||||
mux.HandleFunc("DELETE /api/v1/agents/{id}", h.deleteAgent)
|
||||
}
|
||||
|
||||
func generateID() string {
|
||||
b := make([]byte, 16)
|
||||
rand.Read(b)
|
||||
return hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/register
|
||||
func (h *Handler) registerAgent(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Name == "" || req.Hostname == "" {
|
||||
writeError(w, http.StatusBadRequest, "Name und Hostname sind erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
// Agent-ID bestimmen: vom Agent mitgeliefert > nach Hostname suchen > neu generieren
|
||||
agentID := req.AgentID
|
||||
isExisting := false
|
||||
|
||||
if agentID != "" {
|
||||
// Pruefen ob die ID schon in der DB existiert
|
||||
if existing, _, err := h.db.GetAgent(agentID); err == nil && existing != nil {
|
||||
isExisting = true
|
||||
}
|
||||
}
|
||||
|
||||
if agentID == "" {
|
||||
// Nach bestehendem Agent mit gleichem Hostname suchen
|
||||
if existing, err := h.db.GetAgentByHostname(req.Hostname); err == nil && existing != nil {
|
||||
agentID = existing.ID
|
||||
isExisting = true
|
||||
}
|
||||
}
|
||||
|
||||
if agentID == "" {
|
||||
agentID = generateID()
|
||||
}
|
||||
|
||||
agent := &models.Agent{
|
||||
ID: agentID,
|
||||
Name: req.Name,
|
||||
Hostname: req.Hostname,
|
||||
IP: req.IP,
|
||||
OPNsenseVersion: req.OPNsenseVersion,
|
||||
RegisteredAt: time.Now().UTC(),
|
||||
}
|
||||
|
||||
if err := h.db.RegisterAgent(agent); err != nil {
|
||||
log.Printf("Fehler bei Registrierung: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Registrierung fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
|
||||
action := "registriert"
|
||||
if isExisting {
|
||||
action = "re-registriert"
|
||||
}
|
||||
log.Printf("Agent %s: %s (%s)", action, agent.Name, agent.ID)
|
||||
writeJSON(w, http.StatusCreated, models.RegisterResponse{
|
||||
ID: agent.ID,
|
||||
Message: "Agent erfolgreich registriert",
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/v1/agent/heartbeat
|
||||
func (h *Handler) heartbeat(w http.ResponseWriter, r *http.Request) {
|
||||
var req models.HeartbeatRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "Ungueltige Anfrage")
|
||||
return
|
||||
}
|
||||
|
||||
if req.AgentID == "" {
|
||||
writeError(w, http.StatusBadRequest, "agent_id erforderlich")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.db.SaveSystemData(req.AgentID, &req.SystemData); err != nil {
|
||||
if strings.Contains(err.Error(), "nicht gefunden") {
|
||||
writeError(w, http.StatusNotFound, "Agent nicht gefunden")
|
||||
return
|
||||
}
|
||||
log.Printf("Fehler bei Heartbeat: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Heartbeat fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
|
||||
writeJSON(w, http.StatusOK, models.HeartbeatResponse{Message: "OK"})
|
||||
}
|
||||
|
||||
// GET /api/v1/agents
|
||||
func (h *Handler) listAgents(w http.ResponseWriter, r *http.Request) {
|
||||
agents, err := h.db.GetAgents()
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Laden der Agents: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
|
||||
return
|
||||
}
|
||||
if agents == nil {
|
||||
agents = []models.Agent{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, agents)
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/{id}
|
||||
func (h *Handler) getAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
agent, sysData, err := h.db.GetAgent(id)
|
||||
if err != nil {
|
||||
log.Printf("Fehler: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
|
||||
return
|
||||
}
|
||||
if agent == nil {
|
||||
writeError(w, http.StatusNotFound, "Agent nicht gefunden")
|
||||
return
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"agent": agent,
|
||||
"system_data": sysData,
|
||||
}
|
||||
writeJSON(w, http.StatusOK, result)
|
||||
}
|
||||
|
||||
// GET /api/v1/agents/{id}/system
|
||||
func (h *Handler) getSystemData(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
data, err := h.db.GetSystemData(id)
|
||||
if err != nil {
|
||||
log.Printf("Fehler: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Fehler beim Laden")
|
||||
return
|
||||
}
|
||||
if data == nil {
|
||||
writeError(w, http.StatusNotFound, "Keine Systemdaten vorhanden")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, data)
|
||||
}
|
||||
|
||||
// DELETE /api/v1/agents/{id}
|
||||
func (h *Handler) deleteAgent(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
deleted, err := h.db.DeleteAgent(id)
|
||||
if err != nil {
|
||||
log.Printf("Fehler beim Loeschen: %v", err)
|
||||
writeError(w, http.StatusInternalServerError, "Loeschen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if !deleted {
|
||||
writeError(w, http.StatusNotFound, "Agent nicht gefunden")
|
||||
return
|
||||
}
|
||||
log.Printf("Agent geloescht: %s", id)
|
||||
writeJSON(w, http.StatusOK, map[string]string{"message": "Agent geloescht"})
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// APIKeyAuth - Middleware fuer API-Key Authentifizierung
|
||||
func APIKeyAuth(validKeys []string, next http.Handler) http.Handler {
|
||||
keySet := make(map[string]bool, len(validKeys))
|
||||
for _, k := range validKeys {
|
||||
keySet[k] = true
|
||||
}
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
key := r.Header.Get("X-API-Key")
|
||||
if key == "" || !keySet[key] {
|
||||
http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Logging - Request-Logging Middleware
|
||||
func Logging(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
next.ServeHTTP(w, r)
|
||||
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start))
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ListenAddr string `yaml:"listen_addr"`
|
||||
TLSCert string `yaml:"tls_cert"`
|
||||
TLSKey string `yaml:"tls_key"`
|
||||
APIKeys []string `yaml:"api_keys"`
|
||||
DBPath string `yaml:"db_path"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
cfg := &Config{
|
||||
ListenAddr: ":8443",
|
||||
TLSCert: "certs/server.crt",
|
||||
TLSKey: "certs/server.key",
|
||||
DBPath: "rmm.db",
|
||||
}
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Env overrides
|
||||
if v := os.Getenv("RMM_LISTEN_ADDR"); v != "" {
|
||||
cfg.ListenAddr = v
|
||||
}
|
||||
if v := os.Getenv("RMM_TLS_CERT"); v != "" {
|
||||
cfg.TLSCert = v
|
||||
}
|
||||
if v := os.Getenv("RMM_TLS_KEY"); v != "" {
|
||||
cfg.TLSKey = v
|
||||
}
|
||||
if v := os.Getenv("RMM_DB_PATH"); v != "" {
|
||||
cfg.DBPath = v
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/cynfo/rmm-backend/models"
|
||||
|
||||
_ "modernc.org/sqlite"
|
||||
)
|
||||
|
||||
type Database struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
func New(dbPath string) (*Database, error) {
|
||||
db, err := sql.Open("sqlite", dbPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Datenbank oeffnen: %w", err)
|
||||
}
|
||||
|
||||
// WAL-Modus fuer bessere Performance
|
||||
if _, err := db.Exec("PRAGMA journal_mode=WAL"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
d := &Database{db: db}
|
||||
if err := d.migrate(); err != nil {
|
||||
return nil, fmt.Errorf("Migration fehlgeschlagen: %w", err)
|
||||
}
|
||||
|
||||
return d, nil
|
||||
}
|
||||
|
||||
func (d *Database) migrate() error {
|
||||
schema := `
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
hostname TEXT NOT NULL,
|
||||
ip TEXT NOT NULL,
|
||||
opnsense_version TEXT NOT NULL DEFAULT '',
|
||||
registered_at TEXT NOT NULL,
|
||||
last_heartbeat TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS system_data (
|
||||
agent_id TEXT PRIMARY KEY,
|
||||
data_json TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
FOREIGN KEY (agent_id) REFERENCES agents(id) ON DELETE CASCADE
|
||||
);
|
||||
`
|
||||
_, err := d.db.Exec(schema)
|
||||
return err
|
||||
}
|
||||
|
||||
func (d *Database) Close() error {
|
||||
return d.db.Close()
|
||||
}
|
||||
|
||||
// RegisterAgent - Neuen Agent registrieren oder bestehenden aktualisieren
|
||||
func (d *Database) RegisterAgent(agent *models.Agent) error {
|
||||
_, err := d.db.Exec(`
|
||||
INSERT INTO agents (id, name, hostname, ip, opnsense_version, registered_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
hostname=excluded.hostname,
|
||||
ip=excluded.ip,
|
||||
opnsense_version=excluded.opnsense_version
|
||||
`, agent.ID, agent.Name, agent.Hostname, agent.IP, agent.OPNsenseVersion, agent.RegisteredAt.UTC().Format(time.RFC3339))
|
||||
return err
|
||||
}
|
||||
|
||||
// SaveSystemData - Systemdaten speichern und Heartbeat aktualisieren
|
||||
func (d *Database) SaveSystemData(agentID string, data *models.SystemData) error {
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
|
||||
jsonData, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
return fmt.Errorf("JSON Marshal: %w", err)
|
||||
}
|
||||
|
||||
tx, err := d.db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Heartbeat Zeitstempel aktualisieren
|
||||
res, err := tx.Exec("UPDATE agents SET last_heartbeat = ? WHERE id = ?", now, agentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("Agent %s nicht gefunden", agentID)
|
||||
}
|
||||
|
||||
// Systemdaten upsert
|
||||
_, err = tx.Exec(`
|
||||
INSERT INTO system_data (agent_id, data_json, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(agent_id) DO UPDATE SET
|
||||
data_json=excluded.data_json,
|
||||
updated_at=excluded.updated_at
|
||||
`, agentID, string(jsonData), now)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// GetAgents - Alle Agents auflisten
|
||||
func (d *Database) GetAgents() ([]models.Agent, error) {
|
||||
rows, err := d.db.Query("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents ORDER BY name")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var agents []models.Agent
|
||||
for rows.Next() {
|
||||
var a models.Agent
|
||||
var regAt string
|
||||
var lastHB sql.NullString
|
||||
|
||||
if err := rows.Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||
if lastHB.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||
a.LastHeartbeat = &t
|
||||
}
|
||||
agents = append(agents, a)
|
||||
}
|
||||
return agents, rows.Err()
|
||||
}
|
||||
|
||||
// GetAgent - Einzelnen Agent mit Systemdaten
|
||||
func (d *Database) GetAgentByHostname(hostname string) (*models.Agent, error) {
|
||||
var a models.Agent
|
||||
var regAt string
|
||||
var lastHB sql.NullString
|
||||
|
||||
err := d.db.QueryRow("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE hostname = ? LIMIT 1", hostname).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||
if lastHB.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||
a.LastHeartbeat = &t
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
func (d *Database) GetAgent(id string) (*models.Agent, *models.SystemData, error) {
|
||||
var a models.Agent
|
||||
var regAt string
|
||||
var lastHB sql.NullString
|
||||
|
||||
err := d.db.QueryRow("SELECT id, name, hostname, ip, opnsense_version, registered_at, last_heartbeat FROM agents WHERE id = ?", id).
|
||||
Scan(&a.ID, &a.Name, &a.Hostname, &a.IP, &a.OPNsenseVersion, ®At, &lastHB)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
a.RegisteredAt, _ = time.Parse(time.RFC3339, regAt)
|
||||
if lastHB.Valid {
|
||||
t, _ := time.Parse(time.RFC3339, lastHB.String)
|
||||
a.LastHeartbeat = &t
|
||||
}
|
||||
|
||||
// Systemdaten laden
|
||||
var dataJSON sql.NullString
|
||||
err = d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = ?", id).Scan(&dataJSON)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
var sysData *models.SystemData
|
||||
if dataJSON.Valid {
|
||||
sysData = &models.SystemData{}
|
||||
if err := json.Unmarshal([]byte(dataJSON.String), sysData); err != nil {
|
||||
return &a, nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
return &a, sysData, nil
|
||||
}
|
||||
|
||||
// GetSystemData - Nur Systemdaten eines Agents
|
||||
func (d *Database) GetSystemData(agentID string) (*models.SystemData, error) {
|
||||
var dataJSON string
|
||||
err := d.db.QueryRow("SELECT data_json FROM system_data WHERE agent_id = ?", agentID).Scan(&dataJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var data models.SystemData
|
||||
if err := json.Unmarshal([]byte(dataJSON), &data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &data, nil
|
||||
}
|
||||
|
||||
// DeleteAgent - Agent und zugehoerige Daten loeschen
|
||||
func (d *Database) DeleteAgent(id string) (bool, error) {
|
||||
// Foreign Key Cascade aktivieren
|
||||
d.db.Exec("PRAGMA foreign_keys = ON")
|
||||
|
||||
res, err := d.db.Exec("DELETE FROM agents WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rows, _ := res.RowsAffected()
|
||||
// Sicherheitshalber auch system_data loeschen (falls kein CASCADE)
|
||||
d.db.Exec("DELETE FROM system_data WHERE agent_id = ?", id)
|
||||
return rows > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
module github.com/cynfo/rmm-backend
|
||||
|
||||
go 1.22
|
||||
|
||||
require (
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.29.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/gorilla/websocket v1.5.3 // indirect
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
golang.org/x/sys v0.16.0 // indirect
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
|
||||
modernc.org/libc v1.41.0 // indirect
|
||||
modernc.org/mathutil v1.6.0 // indirect
|
||||
modernc.org/memory v1.7.2 // indirect
|
||||
modernc.org/strutil v1.2.0 // indirect
|
||||
modernc.org/token v1.1.0 // indirect
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
|
||||
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
|
||||
github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
golang.org/x/mod v0.14.0 h1:dGoOF9QVLYng8IHTm7BAyWqCqSheQ5pYWGhzW00YJr0=
|
||||
golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU=
|
||||
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/tools v0.17.0 h1:FvmRgNOcs3kOa+T20R1uhfP9F6HgG2mfxDv1vrx1Htc=
|
||||
golang.org/x/tools v0.17.0/go.mod h1:xsh6VxdV005rRVaS6SSAf9oiAqljS7UZUacMZ8Bnsps=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
|
||||
modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
|
||||
modernc.org/libc v1.41.0 h1:g9YAc6BkKlgORsUWj+JwqoB1wU3o4DE3bM3yvA3k+Gk=
|
||||
modernc.org/libc v1.41.0/go.mod h1:w0eszPsiXoOnoMJgrXjglgLuDy/bt5RR4y3QzUUeodY=
|
||||
modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
|
||||
modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
|
||||
modernc.org/memory v1.7.2 h1:Klh90S215mmH8c9gO98QxQFsY+W451E8AnzjoE2ee1E=
|
||||
modernc.org/memory v1.7.2/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E=
|
||||
modernc.org/sqlite v1.29.1 h1:19GY2qvWB4VPw0HppFlZCPAbmxFU41r+qjKZQdQ1ryA=
|
||||
modernc.org/sqlite v1.29.1/go.mod h1:hG41jCYxOAOoO6BRK66AdRlmOcDzXf7qnwlwjUIOqa0=
|
||||
modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
|
||||
modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"log"
|
||||
"math/big"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/cynfo/rmm-backend/api"
|
||||
"github.com/cynfo/rmm-backend/config"
|
||||
"github.com/cynfo/rmm-backend/db"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cfgPath := "config.yaml"
|
||||
if len(os.Args) > 1 {
|
||||
cfgPath = os.Args[1]
|
||||
}
|
||||
|
||||
cfg, err := config.Load(cfgPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Konfiguration laden fehlgeschlagen: %v", err)
|
||||
}
|
||||
|
||||
// TLS-Zertifikate pruefen/generieren
|
||||
if err := ensureTLS(cfg.TLSCert, cfg.TLSKey); err != nil {
|
||||
log.Fatalf("TLS-Setup fehlgeschlagen: %v", err)
|
||||
}
|
||||
|
||||
// Datenbank initialisieren
|
||||
database, err := db.New(cfg.DBPath)
|
||||
if err != nil {
|
||||
log.Fatalf("Datenbank-Fehler: %v", err)
|
||||
}
|
||||
defer database.Close()
|
||||
|
||||
// Router aufbauen
|
||||
mux := http.NewServeMux()
|
||||
handler := api.NewHandler(database)
|
||||
handler.SetupRoutes(mux)
|
||||
|
||||
// Middleware-Chain: Logging -> Auth -> Handler
|
||||
var chain http.Handler = mux
|
||||
chain = api.APIKeyAuth(cfg.APIKeys, chain)
|
||||
chain = api.Logging(chain)
|
||||
|
||||
log.Printf("RMM Backend startet auf %s (TLS)", cfg.ListenAddr)
|
||||
log.Printf("API-Keys konfiguriert: %d", len(cfg.APIKeys))
|
||||
|
||||
if err := http.ListenAndServeTLS(cfg.ListenAddr, cfg.TLSCert, cfg.TLSKey, chain); err != nil {
|
||||
log.Fatalf("Server-Fehler: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ensureTLS generiert self-signed Zertifikate wenn keine vorhanden
|
||||
func ensureTLS(certPath, keyPath string) error {
|
||||
if _, err := os.Stat(certPath); err == nil {
|
||||
if _, err := os.Stat(keyPath); err == nil {
|
||||
log.Println("TLS-Zertifikate gefunden")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
log.Println("Generiere self-signed TLS-Zertifikat...")
|
||||
|
||||
// Verzeichnis erstellen
|
||||
if dir := filepath.Dir(certPath); dir != "" && dir != "." {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
if dir := filepath.Dir(keyPath); dir != "" && dir != "." {
|
||||
os.MkdirAll(dir, 0755)
|
||||
}
|
||||
|
||||
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Key generieren: %w", err)
|
||||
}
|
||||
|
||||
serial, _ := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{
|
||||
Organization: []string{"RMM Backend"},
|
||||
CommonName: "rmm-backend",
|
||||
},
|
||||
NotBefore: time.Now(),
|
||||
NotAfter: time.Now().Add(365 * 24 * time.Hour * 10), // 10 Jahre
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("192.168.85.13")},
|
||||
DNSNames: []string{"localhost", "rmm-backend"},
|
||||
}
|
||||
|
||||
certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("Zertifikat erstellen: %w", err)
|
||||
}
|
||||
|
||||
certFile, err := os.Create(certPath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer certFile.Close()
|
||||
pem.Encode(certFile, &pem.Block{Type: "CERTIFICATE", Bytes: certDER})
|
||||
|
||||
keyBytes, err := x509.MarshalECPrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
keyFile, err := os.OpenFile(keyPath, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0600)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer keyFile.Close()
|
||||
pem.Encode(keyFile, &pem.Block{Type: "EC PRIVATE KEY", Bytes: keyBytes})
|
||||
|
||||
log.Printf("TLS-Zertifikat generiert: %s, %s", certPath, keyPath)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package models
|
||||
|
||||
import "time"
|
||||
|
||||
// Agent repraesentiert einen registrierten OPNsense-Agent
|
||||
type Agent struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
OPNsenseVersion string `json:"opnsense_version"`
|
||||
RegisteredAt time.Time `json:"registered_at"`
|
||||
LastHeartbeat *time.Time `json:"last_heartbeat,omitempty"`
|
||||
}
|
||||
|
||||
// RegisterRequest - Agent-Registrierung
|
||||
type RegisterRequest struct {
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
Name string `json:"name"`
|
||||
Hostname string `json:"hostname"`
|
||||
IP string `json:"ip"`
|
||||
OPNsenseVersion string `json:"opnsense_version"`
|
||||
}
|
||||
|
||||
// RegisterResponse
|
||||
type RegisterResponse struct {
|
||||
ID string `json:"id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package models
|
||||
|
||||
// SystemData - Alle Systemdaten die der Agent sendet
|
||||
type SystemData struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
Hostname string `json:"hostname"`
|
||||
OPNsenseVersion string `json:"opnsense_version"`
|
||||
FreeBSDVersion string `json:"freebsd_version"`
|
||||
UptimeSeconds int64 `json:"uptime_seconds"`
|
||||
Hardware HardwareInfo `json:"hardware"`
|
||||
CPU CPUInfo `json:"cpu"`
|
||||
Memory MemoryInfo `json:"memory"`
|
||||
Disks []DiskInfo `json:"disks"`
|
||||
NetworkInterfaces []NetworkInterface `json:"network_interfaces"`
|
||||
Services []ServiceInfo `json:"services"`
|
||||
WireGuard []WireGuardTunnel `json:"wireguard,omitempty"`
|
||||
DHCP *DHCPInfo `json:"dhcp,omitempty"`
|
||||
Routes []Route `json:"routes,omitempty"`
|
||||
Gateways []GatewayInfo `json:"gateways,omitempty"`
|
||||
Certificates []CertificateInfo `json:"certificates,omitempty"`
|
||||
Plugins []PluginInfo `json:"plugins,omitempty"`
|
||||
Updates *UpdateInfo `json:"updates,omitempty"`
|
||||
}
|
||||
|
||||
type WireGuardTunnel struct {
|
||||
Interface string `json:"interface"`
|
||||
PublicKey string `json:"public_key"`
|
||||
ListenPort int `json:"listen_port"`
|
||||
Peers []WireGuardPeer `json:"peers"`
|
||||
}
|
||||
|
||||
type WireGuardPeer struct {
|
||||
PublicKey string `json:"public_key"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
AllowedIPs []string `json:"allowed_ips"`
|
||||
LatestHandshake int64 `json:"latest_handshake_epoch"`
|
||||
HandshakeAge string `json:"handshake_age"`
|
||||
TransferRx int64 `json:"transfer_rx_bytes"`
|
||||
TransferTx int64 `json:"transfer_tx_bytes"`
|
||||
Keepalive int `json:"persistent_keepalive"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type DHCPInfo struct {
|
||||
Server string `json:"server"`
|
||||
Leases []DHCPLease `json:"leases"`
|
||||
}
|
||||
|
||||
type DHCPLease struct {
|
||||
IP string `json:"ip"`
|
||||
MAC string `json:"mac"`
|
||||
Hostname string `json:"hostname"`
|
||||
Start string `json:"start,omitempty"`
|
||||
End string `json:"end,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Pool string `json:"pool,omitempty"`
|
||||
}
|
||||
|
||||
type HardwareInfo struct {
|
||||
Manufacturer string `json:"manufacturer"`
|
||||
Model string `json:"model"`
|
||||
Serial string `json:"serial"`
|
||||
BIOSVersion string `json:"bios_version"`
|
||||
}
|
||||
|
||||
type CPUInfo struct {
|
||||
Model string `json:"model"`
|
||||
Cores int `json:"cores"`
|
||||
Threads int `json:"threads"`
|
||||
FreqMHz int `json:"freq_mhz"`
|
||||
UsagePercent float64 `json:"usage_percent"`
|
||||
}
|
||||
|
||||
type MemoryInfo struct {
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
}
|
||||
|
||||
type DiskInfo struct {
|
||||
Filesystem string `json:"filesystem"`
|
||||
TotalBytes int64 `json:"total_bytes"`
|
||||
UsedBytes int64 `json:"used_bytes"`
|
||||
FreeBytes int64 `json:"free_bytes"`
|
||||
MountPoint string `json:"mount_point"`
|
||||
}
|
||||
|
||||
type NetworkInterface struct {
|
||||
Name string `json:"name"`
|
||||
Role string `json:"role"`
|
||||
IP string `json:"ip"`
|
||||
MAC string `json:"mac"`
|
||||
Status string `json:"status"`
|
||||
RxBytes int64 `json:"rx_bytes"`
|
||||
TxBytes int64 `json:"tx_bytes"`
|
||||
}
|
||||
|
||||
type ServiceInfo struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type Route struct {
|
||||
Destination string `json:"destination"`
|
||||
Gateway string `json:"gateway"`
|
||||
Flags string `json:"flags"`
|
||||
Interface string `json:"interface"`
|
||||
}
|
||||
|
||||
type GatewayInfo struct {
|
||||
Name string `json:"name"`
|
||||
Address string `json:"address"`
|
||||
Status string `json:"status"`
|
||||
Loss string `json:"loss,omitempty"`
|
||||
Delay string `json:"delay,omitempty"`
|
||||
Stddev string `json:"stddev,omitempty"`
|
||||
Monitor string `json:"monitor,omitempty"`
|
||||
}
|
||||
|
||||
type CertificateInfo struct {
|
||||
Name string `json:"name"`
|
||||
Subject string `json:"subject"`
|
||||
Issuer string `json:"issuer"`
|
||||
NotBefore string `json:"not_before"`
|
||||
NotAfter string `json:"not_after"`
|
||||
DaysLeft int `json:"days_left"`
|
||||
Serial string `json:"serial"`
|
||||
SAN string `json:"san,omitempty"`
|
||||
KeyUsage string `json:"key_usage,omitempty"`
|
||||
IsCA bool `json:"is_ca"`
|
||||
Status string `json:"status"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type PluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
Version string `json:"version"`
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
type UpdateInfo struct {
|
||||
UpdateAvailable bool `json:"update_available"`
|
||||
PendingCount int `json:"pending_count"`
|
||||
Updates []PendingUpdateInfo `json:"updates"`
|
||||
OPNsenseUpdate string `json:"opnsense_update,omitempty"`
|
||||
}
|
||||
|
||||
type PendingUpdateInfo struct {
|
||||
Package string `json:"package"`
|
||||
CurrentVer string `json:"current_version"`
|
||||
NewVer string `json:"new_version"`
|
||||
}
|
||||
|
||||
// HeartbeatRequest
|
||||
type HeartbeatRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
SystemData SystemData `json:"system_data"`
|
||||
}
|
||||
|
||||
// HeartbeatResponse
|
||||
type HeartbeatResponse struct {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
Reference in New Issue
Block a user