Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ VOLUME ["/data"]

ENV DATA_DIR=/data \
DIST_DIR=/app/frontend/dist \
PORT=1928
PORT=1928 \
GOMEMLIMIT=80MiB \
GODEBUG=madvdontneed=1

ENTRYPOINT ["/app/mikroman", "-data-dir=/data", "-dist-dir=/app/frontend/dist"]
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@
* History and chart reads are indexed for their actual shape. Composite indexes on `(router_id, timestamp)`, `(device_id, record_date)`, `(device_id, created_at)` and friends are created by migration `024_query_indexes` and, for installs that never run Alembic, at start-up; planner statistics (`ANALYZE`) are refreshed exactly when indexes are added. Measured on a copy of a deployment database with hundreds of thousands of metric rows: a one-hour interface chart stopped walking the whole index for the router, and switching a preset stopped paying hundreds of milliseconds for device event logs it never reads.
* Tuning knobs live in the UI, not in the environment: background sample interval, housekeeping interval, telemetry stream rate, temperature and CPU alert lines, log retention. The stored value wins and the environment is its default — which matters because a RouterOS container has no `.env` to edit, no shell and no `docker exec`.
* Device history is bounded at both ends: discovery keeps one DHCP lease per MAC (two hosts answering with the same MAC made it record two "changes" every sweep — tens of thousands of rows in six days on one device, which every device read then paid for) and reports a duplicate MAC once rather than 1 440 times a day. The event log itself is capped at the newest 200 rows per device and pruned after 90 days, and both passes run at start-up as well as on the housekeeping tick — age alone would not shrink an installed database, and the process that pays for the accumulated rows should reclaim them as soon as it exists. No query is allowed to load that history implicitly: the relationship is eager by default, so every device sweep names `noload` explicitly.
* **Pure Go High-Performance Core**: Statically compiled binary (`CGO_ENABLED=0`) with modern pure-Go SQLite engine (`modernc.org/sqlite`). Consumes ~4 MB RAM and 0.00% idle CPU, eliminating all interpreter overhead, asyncio futex spinning, and Python memory fragmentation on ARM/MIPS/x86 gateways. Features live bandwidth rate telemetry (`/interface/monitor-traffic` and per-device mangle delta rates with `FlexibleFloat64`/`FlexibleBool` handling), multi-router client isolation with dynamic client caching, router-scoped WebSocket streams with instantaneous frame playback on connect, ISP billing cycle management (`GET`/`POST /api/v1/analytics/billing-cycle`), ISP cycle data limit quota status & thresholds (`GET`/`POST /api/v1/analytics/quota`), full historical traffic analytics (`GET /api/v1/analytics/traffic`) with daily timelines and destination breakdown (`GET /api/v1/analytics/users/{id}/destinations`), peak-preserving system and interface metrics (`GET /api/v1/metrics/{system,interfaces}`), advanced device management with MAC linking, merging, splitting, and suggestions (`/api/v1/devices/*`), one-click RouterOS TLS/SSL certificate generation & protocol toggling (`/api/v1/routers/*`), router logging topic rules (`/api/v1/logs/rules`), automated dual-pair backups with Myers visual diff engine and volatile header normalization (`/api/v1/routers/{id}/backups/*`), RouterOS container lifecycle & storage preparation (`/api/v1/routers/{id}/containers/*`), firmware channel tracking and bootloader updates (`/api/v1/routers/{id}/firmware/*`), and a native Telegram companion bot with conflict-safe polling and threshold alert broadcasting. Multi-stage Docker builds produce a minimal Alpine container under 30 MB (9.7 MB compressed).
* **Pure Go High-Performance Core**: Statically compiled binary (`CGO_ENABLED=0`) with modern pure-Go SQLite engine (`modernc.org/sqlite`). Consumes ~4 MB idle RAM, 0.00% idle CPU, and operates within a strict 64–100 MB RAM ceiling under container workloads via `GOMEMLIMIT=80MiB`, `GODEBUG=madvdontneed=1`, bounded connection pooling, and post-retention WAL truncation, eliminating all interpreter overhead, asyncio futex spinning, and Python memory fragmentation on ARM/MIPS/x86 gateways. Features live bandwidth rate telemetry (`/interface/monitor-traffic` and per-device mangle delta rates with `FlexibleFloat64`/`FlexibleBool` handling), multi-router client isolation with dynamic client caching, router-scoped WebSocket streams with authenticated policy enforcement and instantaneous frame playback on connect, ISP billing cycle management (`GET`/`POST /api/v1/analytics/billing-cycle`), ISP cycle data limit quota status & thresholds (`GET`/`POST /api/v1/analytics/quota`), full historical traffic analytics (`GET /api/v1/analytics/traffic`) with daily timelines and destination breakdown (`GET /api/v1/analytics/users/{id}/destinations`), peak-preserving system and interface metrics (`GET /api/v1/metrics/{system,interfaces}`), advanced device management with MAC linking, merging, splitting, and suggestions (`/api/v1/devices/*`), one-click RouterOS TLS/SSL certificate generation & protocol toggling (`/api/v1/routers/*`), router logging topic rules (`/api/v1/logs/rules`), automated dual-pair backups with Myers visual diff engine and volatile header normalization (`/api/v1/routers/{id}/backups/*`), RouterOS container lifecycle & storage preparation (`/api/v1/routers/{id}/containers/*`), firmware channel tracking and bootloader updates (`/api/v1/routers/{id}/firmware/*`), and a native Telegram companion bot with conflict-safe polling and threshold alert broadcasting. Multi-stage Docker builds produce a minimal Alpine container under 30 MB (9.7 MB compressed).

* **🤖 Dual-Mode Telegram Bot**:
* Operates in both Long Polling (zero-config NAT) and Authenticated Webhook modes.
Expand Down
10 changes: 10 additions & 0 deletions backend-go/cmd/mikroman/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"os"
"os/signal"
"path/filepath"
"runtime"
"runtime/debug"
"strconv"
"syscall"
Expand Down Expand Up @@ -60,6 +61,12 @@ func main() {

slog.Info("Initializing MikroMan Engine (Golang High-Performance Core)", "version", cfg.AppVersion)

// Memory budget for embedded router containers (< 100MB RAM target)
if os.Getenv("GOMEMLIMIT") == "" {
debug.SetMemoryLimit(80 * 1024 * 1024) // 80 MiB soft memory limit
slog.Debug("Enforced default soft memory limit", "limit", "80MiB")
}

// 3. Resolve master cipher
fernet, err := crypto.ResolveKey(cfg.SecretKey, cfg.DataDir)
if err != nil {
Expand Down Expand Up @@ -156,6 +163,9 @@ func main() {
slog.Warn("Metric bucket backfill failed", "err", err)
} else if ran {
slog.Info("Backfilled metric buckets from existing raw samples", "days", backfillDays)
// Free heap and return pages to OS immediately after heavy one-off backfill
runtime.GC()
debug.FreeOSMemory()
}
retentionSvc.StartMetricRetentionLoop(ctx, time.Hour)
}()
Expand Down
103 changes: 58 additions & 45 deletions backend-go/internal/api/middleware.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,49 @@ func CORSMiddleware(next http.Handler) http.Handler {
})
}

// VerifyRequestAuth verifies if the request carries valid credentials (cookie, Bearer token, or query parameter token).
// Returns (username, true) if authenticated, or ("", false) otherwise.
func VerifyRequestAuth(r *http.Request, cfg *config.Config, fernet *crypto.Fernet) (string, bool) {
if cfg == nil || !cfg.AuthEnabled {
return "admin", true
}
if fernet == nil {
return "", false
}

// 1. Query token parameter (?token=...)
if token := r.URL.Query().Get("token"); token != "" {
if payload, err := fernet.VerifySessionToken(token); err == nil && payload != nil {
return payload.Sub, true
}
}

// 2. Check Bearer token or X-API-Key header
authHeader := r.Header.Get("Authorization")
apiKeyHeader := r.Header.Get("X-API-Key")
var token string
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = strings.TrimSpace(authHeader[7:])
} else if apiKeyHeader != "" {
token = strings.TrimSpace(apiKeyHeader)
}
if token != "" {
if payload, err := fernet.VerifySessionToken(token); err == nil && payload != nil {
return payload.Sub, true
}
return "", false
}

// 3. Check Session cookie
if sessionCookie, err := r.Cookie(SessionCookie); err == nil && sessionCookie.Value != "" {
if payload, err := fernet.VerifySessionToken(sessionCookie.Value); err == nil && payload != nil {
return payload.Sub, true
}
}

return "", false
}

// AuthMiddleware enforces session tokens and Double-Submit CSRF on protected routes.
func AuthMiddleware(cfg *config.Config, fernet *crypto.Fernet) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
Expand Down Expand Up @@ -75,59 +118,29 @@ func AuthMiddleware(cfg *config.Config, fernet *crypto.Fernet) func(http.Handler
return
}

// 1. Check Bearer token or X-API-Key header
authHeader := r.Header.Get("Authorization")
apiKeyHeader := r.Header.Get("X-API-Key")
var token string
if strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
token = strings.TrimSpace(authHeader[7:])
} else if apiKeyHeader != "" {
token = strings.TrimSpace(apiKeyHeader)
}

if token != "" {
if fernet != nil {
payload, err := fernet.VerifySessionToken(token)
if err == nil && payload != nil {
ctx := context.WithValue(r.Context(), UserContextKey, payload.Sub)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
}
WriteError(w, http.StatusUnauthorized, "Invalid API token")
return
}

// 2. Check Session cookie
sessionCookie, err := r.Cookie(SessionCookie)
if err != nil || sessionCookie.Value == "" {
username, ok := VerifyRequestAuth(r, cfg, fernet)
if !ok {
WriteError(w, http.StatusUnauthorized, "Authentication required")
return
}

if fernet == nil {
WriteError(w, http.StatusInternalServerError, "Cipher not initialized")
return
}

payload, err := fernet.VerifySessionToken(sessionCookie.Value)
if err != nil || payload == nil {
WriteError(w, http.StatusUnauthorized, "Session expired or invalid")
return
}

// 3. Double-Submit CSRF validation on mutating methods
// Double-Submit CSRF validation on mutating methods for cookie-based requests
if r.Method == http.MethodPost || r.Method == http.MethodPut || r.Method == http.MethodPatch || r.Method == http.MethodDelete {
csrfCookie, err := r.Cookie(CSRFCookie)
csrfHeader := r.Header.Get(CSRFHeader)

if err != nil || csrfCookie.Value == "" || csrfHeader == "" || !crypto.VerifyCSRFToken(csrfHeader, csrfCookie.Value) {
WriteError(w, http.StatusForbidden, "CSRF verification failed")
return
authHeader := r.Header.Get("Authorization")
apiKeyHeader := r.Header.Get("X-API-Key")
// Token-authenticated requests don't require CSRF header
if authHeader == "" && apiKeyHeader == "" && r.URL.Query().Get("token") == "" {
csrfCookie, err := r.Cookie(CSRFCookie)
csrfHeader := r.Header.Get(CSRFHeader)

if err != nil || csrfCookie.Value == "" || csrfHeader == "" || !crypto.VerifyCSRFToken(csrfHeader, csrfCookie.Value) {
WriteError(w, http.StatusForbidden, "CSRF verification failed")
return
}
}
}

ctx := context.WithValue(r.Context(), UserContextKey, payload.Sub)
ctx := context.WithValue(r.Context(), UserContextKey, username)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
Expand Down
1 change: 1 addition & 0 deletions backend-go/internal/api/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ func NewRouter(rc RouterConfig) http.Handler {
// Give the hub history access so a client that connects before the
// first live tick still gets the telemetry bar filled from buckets.
rc.Hub.AttachDatabase(rc.DB)
rc.Hub.AttachAuth(rc.Config, rc.Fernet)
r.With(CORSMiddleware).Get("/ws/telemetry", rc.Hub.HandleWS)
}

Expand Down
30 changes: 30 additions & 0 deletions backend-go/internal/api/ws.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import (
"time"

"github.com/gorilla/websocket"
"github.com/masseselsev/mikroman/internal/config"
"github.com/masseselsev/mikroman/internal/crypto"
"github.com/masseselsev/mikroman/internal/db"
)

Expand Down Expand Up @@ -39,6 +41,8 @@ type Hub struct {
// keeps the old "wait for the first tick" behavior.
bootDB *db.DB
bootCache map[int]bootstrapEntry
cfg *config.Config
fernet *crypto.Fernet
}

func NewHub() *Hub {
Expand All @@ -48,7 +52,33 @@ func NewHub() *Hub {
}
}

// AttachAuth configures authentication credentials for the WebSocket hub.
func (h *Hub) AttachAuth(cfg *config.Config, fernet *crypto.Fernet) {
h.mu.Lock()
defer h.mu.Unlock()
h.cfg = cfg
h.fernet = fernet
}

func (h *Hub) HandleWS(w http.ResponseWriter, r *http.Request) {
h.mu.RLock()
cfg := h.cfg
fernet := h.fernet
h.mu.RUnlock()

// Enforce session authentication when enabled: reject with 1008 (Policy Violation)
if cfg != nil && cfg.AuthEnabled {
if _, ok := VerifyRequestAuth(r, cfg, fernet); !ok {
conn, err := upgrader.Upgrade(w, r, nil)
if err == nil {
closeMsg := websocket.FormatCloseMessage(websocket.ClosePolicyViolation, "Authentication required")
_ = conn.WriteControl(websocket.CloseMessage, closeMsg, time.Now().Add(time.Second))
_ = conn.Close()
}
return
}
}

conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
return
Expand Down
Loading
Loading