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
18 changes: 13 additions & 5 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ SyMon is a self-hosted monitoring tool for Linux servers, home labs and Raspberr
**Running it**
- Add a host with one command, with a single-use token
- Raw data kept for 7 days, 1 minute averages for 30 days and 1 hour averages for a year, all adjustable
- A login for the dashboard, with users created from the command line
- Each host has its own key, and components can talk over TLS
- A Prometheus endpoint, for Grafana or a Prometheus you already run
- Every part reports its version: `-version` on each binary, the dashboard footer, and each host's page for its agent
Expand Down Expand Up @@ -73,7 +74,7 @@ Runs on every monitored host as root and sends a snapshot to the Collector every
Receives data from agents, stores it in TimescaleDB, and checks the alert rules. It updates the database schema by itself when it starts. It listens on port 9000.

### Client
The web dashboard and its JSON API, on port 8080. It reads everything from the Collector. It also serves the install script and agent builds that new hosts download. The dashboard has no login, so keep it on a private network or put nginx or Apache in front of it.
The web dashboard and its JSON API, on port 8080. It reads everything from the Collector. It also serves the install script and agent builds that new hosts download. It needs a login, see [Security](#security).

### Alert processor
Optional. The Collector sends it alerts as they open, change and resolve, and it passes them on to email, Slack and PagerDuty. Alerts show on the dashboard without it.
Expand All @@ -83,23 +84,30 @@ Optional. The Collector sends it alerts as they open, change and resolve, and it
- **Agent keys.** Each enrolled host gets its own key, stored hashed on the server. It can only send data, and only as that host. `collector -remove-agent <name>` revokes it.
- **Shared key.** The Collector, Client and Alert processor use a shared key from `collector -init`. Each call carries a short-lived token signed with it.
- **TLS.** Traffic between components can be encrypted. See the `*_TLS_*` and `*_CERT_PATH` settings in each component's `.env-example`.
- **Dashboard.** Use a reverse proxy for HTTPS and a login.
- **Dashboard login.** The dashboard stays locked until a user exists. `collector -add-user <name>` creates one and prints its password, `-reset-password`, `-remove-user` and `-list-users` manage them. A login lasts 30 days. After 10 wrong passwords a user name is locked for 15 minutes. The install script and agent downloads stay public, so new hosts can enroll.
- **HTTPS.** Put a reverse proxy like Caddy or nginx in front of the dashboard, so passwords and the session cookie are encrypted.

## Local development

`docker compose up --build` starts TimescaleDB, the Collector, the Alert processor, the Client on http://localhost:8080 and one Agent. The Agent reports on its own container, not the host. The stack is for development only.
`docker compose up --build` starts TimescaleDB, the Collector, the Alert processor, the Client on http://localhost:8080 and one Agent. The Agent reports on its own container, not the host. Create a user to log in with `docker compose exec collector ./collector -add-user dev`. The stack is for development only.

Building needs Go 1.26 and Node.js 22 or newer.

- `make build-all` builds every component, with the dashboard embedded in the Client.
- `go test ./...` runs the Go tests. The store tests run against a real database when `SYMON_TEST_DATABASE_URL` is set.
- `npm run dev` in `client/web` serves the dashboard with live reload against a Client on port 8080. `npm test` and `npm run e2e` run its tests.
- `npm run dev` in `client/web` serves the dashboard with live reload against a Client on port 8080. `npm test` and `npm run e2e` run its tests. The end to end tests log in as `SYMON_E2E_USER` with `SYMON_E2E_PASSWORD`.

Components talk over gRPC, so other tools can read from or push into them. See the [API](internal/api/api.proto) and [alert API](internal/alertapi/alertapi.proto).

## API documentation

The Client exposes a JSON API under `/api/v1`. Times are unix seconds. Errors return a JSON body `{"error": "..."}` with a 4xx or 5xx status.
The Client exposes a JSON API under `/api/v1`. Times are unix seconds. Errors return a JSON body `{"error": "..."}` with a 4xx or 5xx status. Every call needs the session cookie from a login, and answers 401 without it.

* `POST /api/v1/login` with `{"user": "...", "password": "..."}` as JSON
* Sets the `symon_session` cookie. 401 for a wrong password, 429 while the user is locked out
* `POST /api/v1/logout` with `{}` as JSON
* `GET /api/v1/session`
* `{"user": "..."}` when logged in, otherwise 401 with `hasUsers`, false until the first user exists

* `GET /api/v1/fleet`
* Every host with its status, latest usage, number of running containers, number of open alerts, and `diskFullDays`, the days until its first disk is full (null when none is filling up)
Expand Down
2 changes: 2 additions & 0 deletions client/.env-example
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export SYMON_CLIENT_DOWNLOADS_DIR=downloads
export SYMON_CLIENT_AGENT_COLLECTOR_ENDPOINT=
# /metrics for Prometheus, on unless this is false
export SYMON_CLIENT_METRICS_ENABLED=
# true makes /metrics need a SyMon user, as HTTP basic auth for Prometheus
export SYMON_CLIENT_METRICS_AUTH=
export SYMON_CLIENT_LOG_FILE_ENABLED=
export SYMON_CLIENT_LOG_FILE_PATH=
export SYMON_KEY=''
256 changes: 256 additions & 0 deletions client/internal/server/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
package server

import (
"context"
"crypto/sha256"
"encoding/json"
"errors"
"mime"
"net/http"
"strings"
"sync"
"time"

"github.com/dhamith93/SyMon/internal/api"
"github.com/dhamith93/SyMon/internal/logger"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)

// The dashboard's data needs a login. A browser logs in once and gets a
// session cookie. Users and sessions live in the collector's database.

const sessionCookie = "symon_session"

// A checked session or password is trusted this long before the collector
// is asked again. Logging out elsewhere or removing a user takes at most
// this long to lock a browser or scraper out.
const (
sessionCacheTTL = time.Minute
passwordCacheTTL = 5 * time.Minute
)

// errNotLoggedIn is a request without a valid session. Other errors mean
// the collector could not say.
var errNotLoggedIn = errors.New("log in first")

// authCache remembers sessions and passwords the collector accepted, keyed
// by a hash so the secrets themselves are not kept
type authCache struct {
mu sync.Mutex
sessions map[[32]byte]cachedSession
passwords map[[32]byte]time.Time
}

type cachedSession struct {
user string
until time.Time
}

func (c *authCache) session(key [32]byte) (string, bool) {
c.mu.Lock()
defer c.mu.Unlock()
cached, ok := c.sessions[key]
if !ok || time.Now().After(cached.until) {
return "", false
}
return cached.user, true
}

func (c *authCache) keepSession(key [32]byte, user string, expires time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
if c.sessions == nil {
c.sessions = map[[32]byte]cachedSession{}
}
until := time.Now().Add(sessionCacheTTL)
if expires.Before(until) {
until = expires
}
c.sessions[key] = cachedSession{user: user, until: until}
}

func (c *authCache) forgetSession(key [32]byte) {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.sessions, key)
}

func (c *authCache) password(key [32]byte) bool {
c.mu.Lock()
defer c.mu.Unlock()
checked, ok := c.passwords[key]
return ok && time.Since(checked) < passwordCacheTTL
}

func (c *authCache) keepPassword(key [32]byte) {
c.mu.Lock()
defer c.mu.Unlock()
if c.passwords == nil {
c.passwords = map[[32]byte]time.Time{}
}
c.passwords[key] = time.Now()
}

// sessionUser returns who the request's session cookie belongs to
func (s *server) sessionUser(r *http.Request) (string, error) {
cookie, err := r.Cookie(sessionCookie)
if err != nil || cookie.Value == "" {
return "", errNotLoggedIn
}
key := sha256.Sum256([]byte(cookie.Value))
if user, ok := s.auth.session(key); ok {
return user, nil
}
info, err := s.collector.CheckSession(r.Context(), &api.SessionRequest{Token: cookie.Value})
if status.Code(err) == codes.Unauthenticated {
return "", errNotLoggedIn
}
if err != nil {
return "", err
}
s.auth.keepSession(key, info.User, time.Unix(info.Expires, 0))
return info.User, nil
}

// requireLogin answers 401 unless the request has a valid session
func (s *server) requireLogin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := s.sessionUser(r)
switch {
case errors.Is(err, errNotLoggedIn):
writeError(w, http.StatusUnauthorized, err.Error())
case err != nil:
writeGRPCError(w, "session", err)
default:
next.ServeHTTP(w, r)
}
})
}

// getSession says who is logged in. Without a session it says whether
// there are any users yet, since the dashboard stays locked until there are.
func (s *server) getSession(w http.ResponseWriter, r *http.Request) {
user, err := s.sessionUser(r)
if err == nil {
writeJSON(w, map[string]string{"user": user})
return
}
if !errors.Is(err, errNotLoggedIn) {
writeGRPCError(w, "session", err)
return
}
users, err := s.collector.HasUsers(r.Context(), &api.Void{})
if err != nil {
writeGRPCError(w, "users", err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]any{"error": errNotLoggedIn.Error(), "hasUsers": users.HasUsers})
}

// jsonBody is false for a form another site posted. A page elsewhere cannot
// send JSON here without a CORS preflight, which the dashboard never allows.
func jsonBody(r *http.Request) bool {
mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
return err == nil && mediaType == "application/json"
}

// isHTTPS is true when the browser reached the dashboard over HTTPS,
// directly or through a reverse proxy
func isHTTPS(r *http.Request) bool {
return r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https")
}

func (s *server) postLogin(w http.ResponseWriter, r *http.Request) {
if !jsonBody(r) {
writeError(w, http.StatusUnsupportedMediaType, "send the login as JSON")
return
}
var credentials struct {
User string `json:"user"`
Password string `json:"password"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&credentials); err != nil {
writeError(w, http.StatusBadRequest, "send user and password")
return
}
session, err := s.collector.Login(r.Context(), &api.Credentials{User: credentials.User, Password: credentials.Password})
if err != nil {
writeGRPCError(w, "login", err)
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: session.Token,
Path: "/",
Expires: time.Unix(session.Expires, 0),
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, map[string]string{"user": session.User})
}

func (s *server) postLogout(w http.ResponseWriter, r *http.Request) {
if !jsonBody(r) {
writeError(w, http.StatusUnsupportedMediaType, "send the logout as JSON")
return
}
if cookie, err := r.Cookie(sessionCookie); err == nil && cookie.Value != "" {
s.auth.forgetSession(sha256.Sum256([]byte(cookie.Value)))
if _, err := s.collector.Logout(r.Context(), &api.SessionRequest{Token: cookie.Value}); err != nil {
writeGRPCError(w, "logout", err)
return
}
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, map[string]string{})
}

// metricsAllowed is true when /metrics needs no login, or the request has
// a session or a user's password as HTTP basic auth, like Prometheus's
// basic_auth sends. Otherwise it answers 401 itself.
func (s *server) metricsAllowed(w http.ResponseWriter, r *http.Request) bool {
if !s.metricsAuth {
return true
}
if _, err := s.sessionUser(r); err == nil {
return true
}
if user, password, ok := r.BasicAuth(); ok {
err := s.checkPassword(r.Context(), user, password)
if err == nil {
return true
}
if code := status.Code(err); code != codes.Unauthenticated && code != codes.ResourceExhausted {
logger.Log("error", "cannot check a password for /metrics: "+err.Error())
}
}
w.Header().Set("WWW-Authenticate", `Basic realm="SyMon", charset="UTF-8"`)
http.Error(w, "log in with a SyMon user", http.StatusUnauthorized)
return false
}

// checkPassword asks the collector, and remembers a right password for a
// while, since checking one takes a good part of a second
func (s *server) checkPassword(ctx context.Context, user string, password string) error {
key := sha256.Sum256([]byte(user + "\x00" + password))
if s.auth.password(key) {
return nil
}
if _, err := s.collector.CheckPassword(ctx, &api.Credentials{User: user, Password: password}); err != nil {
return err
}
s.auth.keepPassword(key)
return nil
}
Loading
Loading