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
14 changes: 12 additions & 2 deletions README.MD
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,15 @@ SyMon is a self-hosted monitoring tool for Linux servers, home labs and Raspberr

**Endpoints**
- HTTP checks of any URL from the server, with response time and uptime history
- Alerts on HTTPS certificates that are about to expire

**Custom metrics**
- Send any number from a script or cron job and get a chart for it

**Alerts**
- Rules for CPU, memory, swap, disks, disks filling up, services, custom metrics, silent hosts and HTTP endpoints
- Rules for CPU, memory, swap, disks, disks filling up, services, custom metrics, silent hosts, HTTP endpoints and certificates
- Rules are edited on the dashboard and apply right away. They can watch every host, including ones added later
- Every host is watched for going silent from the start
- Warning and critical levels, shown on the dashboard and sent by email, Slack or PagerDuty

**Dashboard**
Expand Down Expand Up @@ -84,7 +87,8 @@ 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 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.
- **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, and users change their own password on the dashboard. 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.
- **Roles.** Admins can change alert rules, viewers can only look. `-add-user <name> -role viewer` creates a viewer, `-set-role <name> -role admin` changes it.
- **HTTPS.** Put a reverse proxy like Caddy or nginx in front of the dashboard, so passwords and the session cookie are encrypted.

## Local development
Expand All @@ -106,6 +110,8 @@ The Client exposes a JSON API under `/api/v1`. Times are unix seconds. Errors re
* `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
* `POST /api/v1/password` with `{"current": "...", "new": "..."}` as JSON
* Changes your own password and logs you out everywhere else
* `GET /api/v1/session`
* `{"user": "..."}` when logged in, otherwise 401 with `hasUsers`, false until the first user exists

Expand All @@ -126,6 +132,10 @@ The Client exposes a JSON API under `/api/v1`. Times are unix seconds. Errors re
* Names of the host's custom metrics
* `GET /api/v1/alerts?host=&open=1&from=&to=`
* Alerts, newest first. `open=1` leaves out resolved ones. Endpoint alerts have an empty `host`
* `GET /api/v1/rules`
* The alert rules, each with `id`, `enabled` and `rule`, the rule in the alerts.json format
* `POST /api/v1/rules` and `PUT /api/v1/rules/{id}` with `{"enabled": true, "rule": {...}}` as JSON, `DELETE /api/v1/rules/{id}`
* For admins only. A deleted or switched off rule resolves its open alerts
* `GET /api/v1/endpoints?from=&to=`
* Every endpoint checked within the range: its newest check, the number of checks, the share that passed and the average response time
* `GET /api/v1/endpoints/series?name=&metric=latency&from=&to=`
Expand Down
94 changes: 74 additions & 20 deletions client/internal/server/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,22 +42,28 @@ type authCache struct {
passwords map[[32]byte]time.Time
}

// userSession is who a session belongs to. role is admin or viewer.
type userSession struct {
user string
role string
}

type cachedSession struct {
user string
userSession
until time.Time
}

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

func (c *authCache) keepSession(key [32]byte, user string, expires time.Time) {
func (c *authCache) keepSession(key [32]byte, session userSession, expires time.Time) {
c.mu.Lock()
defer c.mu.Unlock()
if c.sessions == nil {
Expand All @@ -67,7 +73,7 @@ func (c *authCache) keepSession(key [32]byte, user string, expires time.Time) {
if expires.Before(until) {
until = expires
}
c.sessions[key] = cachedSession{user: user, until: until}
c.sessions[key] = cachedSession{userSession: session, until: until}
}

func (c *authCache) forgetSession(key [32]byte) {
Expand All @@ -76,6 +82,18 @@ func (c *authCache) forgetSession(key [32]byte) {
delete(c.sessions, key)
}

// forgetUser drops a user's cached sessions but one, after the collector
// ended the others
func (c *authCache) forgetUser(user string, keep [32]byte) {
c.mu.Lock()
defer c.mu.Unlock()
for key, cached := range c.sessions {
if cached.user == user && key != keep {
delete(c.sessions, key)
}
}
}

func (c *authCache) password(key [32]byte) bool {
c.mu.Lock()
defer c.mu.Unlock()
Expand All @@ -92,31 +110,32 @@ func (c *authCache) keepPassword(key [32]byte) {
c.passwords[key] = time.Now()
}

// sessionUser returns who the request's session cookie belongs to
func (s *server) sessionUser(r *http.Request) (string, error) {
// sessionOf returns who the request's session cookie belongs to
func (s *server) sessionOf(r *http.Request) (userSession, error) {
cookie, err := r.Cookie(sessionCookie)
if err != nil || cookie.Value == "" {
return "", errNotLoggedIn
return userSession{}, errNotLoggedIn
}
key := sha256.Sum256([]byte(cookie.Value))
if user, ok := s.auth.session(key); ok {
return user, nil
if session, ok := s.auth.session(key); ok {
return session, nil
}
info, err := s.collector.CheckSession(r.Context(), &api.SessionRequest{Token: cookie.Value})
if status.Code(err) == codes.Unauthenticated {
return "", errNotLoggedIn
return userSession{}, errNotLoggedIn
}
if err != nil {
return "", err
return userSession{}, err
}
s.auth.keepSession(key, info.User, time.Unix(info.Expires, 0))
return info.User, nil
session := userSession{user: info.User, role: info.Role}
s.auth.keepSession(key, session, time.Unix(info.Expires, 0))
return session, 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)
_, err := s.sessionOf(r)
switch {
case errors.Is(err, errNotLoggedIn):
writeError(w, http.StatusUnauthorized, err.Error())
Expand All @@ -131,9 +150,9 @@ func (s *server) requireLogin(next http.Handler) http.Handler {
// 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)
session, err := s.sessionOf(r)
if err == nil {
writeJSON(w, map[string]string{"user": user})
writeJSON(w, map[string]string{"user": session.user, "role": session.role})
return
}
if !errors.Is(err, errNotLoggedIn) {
Expand Down Expand Up @@ -190,7 +209,42 @@ func (s *server) postLogin(w http.ResponseWriter, r *http.Request) {
Secure: isHTTPS(r),
SameSite: http.SameSiteLaxMode,
})
writeJSON(w, map[string]string{"user": session.User})
writeJSON(w, map[string]string{"user": session.User, "role": session.Role})
}

// postPassword changes the logged in user's own password. Their other
// sessions end, this one stays.
func (s *server) postPassword(w http.ResponseWriter, r *http.Request) {
if !jsonBody(r) {
writeError(w, http.StatusUnsupportedMediaType, "send the passwords as JSON")
return
}
var passwords struct {
Current string `json:"current"`
New string `json:"new"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&passwords); err != nil {
writeError(w, http.StatusBadRequest, "send current and new")
return
}
session, err := s.sessionOf(r)
if err != nil {
writeError(w, http.StatusUnauthorized, errNotLoggedIn.Error())
return
}
cookie, _ := r.Cookie(sessionCookie)
_, err = s.collector.ChangePassword(r.Context(), &api.ChangePasswordRequest{Token: cookie.Value, Current: passwords.Current, NewPassword: passwords.New})
// the session was just checked, so this is the current password
if status.Code(err) == codes.Unauthenticated {
writeError(w, http.StatusForbidden, "the current password is wrong")
return
}
if err != nil {
writeGRPCError(w, "password change", err)
return
}
s.auth.forgetUser(session.user, sha256.Sum256([]byte(cookie.Value)))
writeJSON(w, map[string]string{})
}

func (s *server) postLogout(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -224,7 +278,7 @@ func (s *server) metricsAllowed(w http.ResponseWriter, r *http.Request) bool {
if !s.metricsAuth {
return true
}
if _, err := s.sessionUser(r); err == nil {
if _, err := s.sessionOf(r); err == nil {
return true
}
if user, password, ok := r.BasicAuth(); ok {
Expand Down
27 changes: 25 additions & 2 deletions client/internal/server/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ func TestAppNeedsNoLogin(t *testing.T) {

func TestSession(t *testing.T) {
s, fake := newTestServer(t, nil)
if rec := call(s, "GET", "/api/v1/session", "", testSession, nil); rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"user":"tester"}` {
if rec := call(s, "GET", "/api/v1/session", "", testSession, nil); rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"role":"admin","user":"tester"}` {
t.Errorf("expected the logged in user, got %d %s", rec.Code, rec.Body)
}
if rec := call(s, "GET", "/api/v1/session", "", "", nil); rec.Code != 401 || !strings.Contains(rec.Body.String(), `"hasUsers":true`) {
Expand All @@ -81,7 +81,7 @@ func TestLogin(t *testing.T) {
r.Header.Set("X-Forwarded-Proto", "https")
})
cookie := sessionCookieOf(rec)
if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"user":"alice"}` || cookie == nil {
if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"role":"admin","user":"alice"}` || cookie == nil {
t.Fatalf("expected a login, got %d %s", rec.Code, rec.Body)
}
if cookie.Value != "new-token" || !cookie.HttpOnly || !cookie.Secure || cookie.SameSite != http.SameSiteLaxMode || cookie.Path != "/" {
Expand Down Expand Up @@ -169,3 +169,26 @@ func TestMetricsAuth(t *testing.T) {
t.Errorf("expected metrics for a logged in browser, got %d", rec.Code)
}
}

func TestChangePassword(t *testing.T) {
s, _ := newTestServer(t, nil)
tests := []struct {
body string
cookie string
prepare func(*http.Request)
code int
want string
}{
{`{"current":"correct horse battery","new":"a brand new password"}`, testSession, asJSON, 200, "{}"},
{`{"current":"wrong","new":"a brand new password"}`, testSession, asJSON, http.StatusForbidden, "current password is wrong"},
{`{"current":"correct horse battery","new":"short"}`, testSession, asJSON, http.StatusBadRequest, "at least 12 characters"},
{`{"current":"correct horse battery","new":"a brand new password"}`, "", asJSON, http.StatusUnauthorized, "log in first"},
{`{"current":"correct horse battery","new":"a brand new password"}`, testSession, nil, http.StatusUnsupportedMediaType, "JSON"},
}
for _, tt := range tests {
rec := call(s, "POST", "/api/v1/password", tt.body, tt.cookie, tt.prepare)
if rec.Code != tt.code || !strings.Contains(rec.Body.String(), tt.want) {
t.Errorf("%s with %q: got %d %s, want %d", tt.body, tt.cookie, rec.Code, rec.Body, tt.code)
}
}
}
112 changes: 112 additions & 0 deletions client/internal/server/rules.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package server

import (
"encoding/json"
"net/http"
"strconv"

"github.com/dhamith93/SyMon/internal/api"
)

// Alert rules: every user can see them, admins can change them. A rule has
// the same fields as an alerts.json entry.

type alertRule struct {
ID int64 `json:"id"`
Enabled bool `json:"enabled"`
Rule json.RawMessage `json:"rule"`
UpdatedAt int64 `json:"updatedAt"`
// empty for rules SyMon set up
UpdatedBy string `json:"updatedBy"`
}

// requireAdmin answers 403 for viewers. It runs behind requireLogin.
func (s *server) requireAdmin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
session, err := s.sessionOf(r)
if err != nil {
writeError(w, http.StatusUnauthorized, errNotLoggedIn.Error())
return
}
if session.role != "admin" {
writeError(w, http.StatusForbidden, "only admins can change alert rules")
return
}
next(w, r)
}
}

func (s *server) getRules(w http.ResponseWriter, r *http.Request) {
response, err := s.collector.AlertRules(r.Context(), &api.Void{})
if err != nil {
writeGRPCError(w, "alert rules", err)
return
}
rules := make([]alertRule, 0, len(response.Rules))
for _, rule := range response.Rules {
rules = append(rules, alertRule{
ID: rule.Id,
Enabled: rule.Enabled,
Rule: json.RawMessage(rule.RuleJson),
UpdatedAt: rule.UpdatedAt,
UpdatedBy: rule.UpdatedBy,
})
}
writeJSON(w, map[string]any{"rules": rules})
}

func (s *server) postRule(w http.ResponseWriter, r *http.Request) {
s.saveRule(w, r, 0)
}

func (s *server) putRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusNotFound, "no such rule")
return
}
s.saveRule(w, r, id)
}

// saveRule creates a rule for id 0, and replaces one otherwise
func (s *server) saveRule(w http.ResponseWriter, r *http.Request, id int64) {
if !jsonBody(r) {
writeError(w, http.StatusUnsupportedMediaType, "send the rule as JSON")
return
}
var body struct {
Enabled bool `json:"enabled"`
Rule json.RawMessage `json:"rule"`
}
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 64<<10)).Decode(&body); err != nil || len(body.Rule) == 0 {
writeError(w, http.StatusBadRequest, "send enabled and rule")
return
}
session, _ := s.sessionOf(r)
saved, err := s.collector.SaveRule(r.Context(), &api.SaveRuleRequest{Id: id, Enabled: body.Enabled, RuleJson: string(body.Rule), By: session.user})
if err != nil {
writeGRPCError(w, "saving a rule", err)
return
}
if id == 0 {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]int64{"id": saved.Id})
return
}
writeJSON(w, map[string]int64{"id": saved.Id})
}

func (s *server) deleteRule(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(r.PathValue("id"), 10, 64)
if err != nil || id <= 0 {
writeError(w, http.StatusNotFound, "no such rule")
return
}
session, _ := s.sessionOf(r)
if _, err := s.collector.DeleteRule(r.Context(), &api.RuleRequest{Id: id, By: session.user}); err != nil {
writeGRPCError(w, "deleting a rule", err)
return
}
writeJSON(w, map[string]int64{"id": id})
}
Loading
Loading