From 2e741522a994eadd8143797a22f48eab1bab697b Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Tue, 29 Sep 2026 23:52:57 +0530 Subject: [PATCH 1/6] store dashboard users and their sessions --- internal/store/migrations/007_users.sql | 21 ++ internal/store/users.go | 254 ++++++++++++++++++++++++ internal/store/users_test.go | 144 ++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 internal/store/migrations/007_users.sql create mode 100644 internal/store/users.go create mode 100644 internal/store/users_test.go diff --git a/internal/store/migrations/007_users.sql b/internal/store/migrations/007_users.sql new file mode 100644 index 0000000..680b08c --- /dev/null +++ b/internal/store/migrations/007_users.sql @@ -0,0 +1,21 @@ +-- Dashboard users. Passwords are PBKDF2-SHA256 hashes with a salt of their +-- own. iterations is kept per user so it can be raised later. + +CREATE TABLE users ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + name text NOT NULL UNIQUE, + password_hash bytea NOT NULL, + salt bytea NOT NULL, + iterations integer NOT NULL, + created_at timestamptz NOT NULL DEFAULT now(), + last_login_at timestamptz +); + +-- Logged in browsers. The token is random, so only its SHA-256 is kept. +CREATE TABLE user_sessions ( + token_hash bytea PRIMARY KEY, + user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + expires_at timestamptz NOT NULL +); +CREATE INDEX ON user_sessions (user_id); diff --git a/internal/store/users.go b/internal/store/users.go new file mode 100644 index 0000000..f7982f4 --- /dev/null +++ b/internal/store/users.go @@ -0,0 +1,254 @@ +package store + +import ( + "context" + "crypto/pbkdf2" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "errors" + "fmt" + "regexp" + "time" + + "github.com/jackc/pgx/v5" +) + +var ( + // ErrBadLogin is returned for an unknown user and for a wrong password + // alike, so a guess learns nothing + ErrBadLogin = errors.New("wrong user name or password") + // ErrBadSession is returned for a session that is unknown or expired + ErrBadSession = errors.New("not logged in") + ErrUserExists = errors.New("user already exists") +) + +const ( + // OWASP's figure for PBKDF2-HMAC-SHA256 + passwordIterations = 600_000 + minPasswordLength = 12 + // SessionTTL is how long a login lasts + SessionTTL = 30 * 24 * time.Hour +) + +var validUserName = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._@-]{0,63}$`) + +// hashSlots limits how many passwords are hashed at once. Each hash takes +// a good part of a second of CPU, which a flood of logins could use up. +var hashSlots = make(chan struct{}, 4) + +// dummySalt is hashed against for unknown users, so a login takes as long +// whether the user exists or not +var dummySalt = make([]byte, 16) + +func hashPassword(ctx context.Context, password string, salt []byte, iterations int) ([]byte, error) { + select { + case hashSlots <- struct{}{}: + case <-ctx.Done(): + return nil, ctx.Err() + } + defer func() { <-hashSlots }() + return pbkdf2.Key(sha256.New, password, salt, iterations, 32) +} + +// NewPassword returns a random password, 120 bits in 20 characters +func NewPassword() (string, error) { + bytes := make([]byte, 15) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.RawURLEncoding.EncodeToString(bytes), nil +} + +func checkNewPassword(password string) error { + if len([]rune(password)) < minPasswordLength { + return fmt.Errorf("%w: a password needs at least %d characters", ErrInvalid, minPasswordLength) + } + return nil +} + +func newPasswordHash(ctx context.Context, password string) (hash []byte, salt []byte, err error) { + if err := checkNewPassword(password); err != nil { + return nil, nil, err + } + salt = make([]byte, 16) + if _, err := rand.Read(salt); err != nil { + return nil, nil, err + } + hash, err = hashPassword(ctx, password, salt, passwordIterations) + return hash, salt, err +} + +// AddUser creates a dashboard user +func (s *Store) AddUser(ctx context.Context, name string, password string) error { + if !validUserName.MatchString(name) { + return fmt.Errorf("%w: user names may use letters, digits, dots, dashes, underscores and @, up to 64 characters", ErrInvalid) + } + hash, salt, err := newPasswordHash(ctx, password) + if err != nil { + return err + } + tag, err := s.pool.Exec(ctx, ` + INSERT INTO users (name, password_hash, salt, iterations) VALUES ($1, $2, $3, $4) + ON CONFLICT (name) DO NOTHING`, name, hash, salt, passwordIterations) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrUserExists + } + return nil +} + +// SetPassword gives a user a new password and ends all of their sessions +func (s *Store) SetPassword(ctx context.Context, name string, password string) error { + hash, salt, err := newPasswordHash(ctx, password) + if err != nil { + return err + } + tx, err := s.pool.Begin(ctx) + if err != nil { + return err + } + defer tx.Rollback(ctx) + + var id int64 + err = tx.QueryRow(ctx, `UPDATE users SET password_hash = $2, salt = $3, iterations = $4 WHERE name = $1 RETURNING id`, + name, hash, salt, passwordIterations).Scan(&id) + if errors.Is(err, pgx.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if _, err := tx.Exec(ctx, "DELETE FROM user_sessions WHERE user_id = $1", id); err != nil { + return err + } + return tx.Commit(ctx) +} + +// RemoveUser deletes a user and ends their sessions +func (s *Store) RemoveUser(ctx context.Context, name string) error { + tag, err := s.pool.Exec(ctx, "DELETE FROM users WHERE name = $1", name) + if err != nil { + return err + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +type User struct { + Name string + CreatedAt time.Time + // LastLogin is zero for a user who never logged in + LastLogin time.Time +} + +func (s *Store) Users(ctx context.Context) ([]User, error) { + rows, err := s.pool.Query(ctx, "SELECT name, created_at, last_login_at FROM users ORDER BY name") + if err != nil { + return nil, err + } + defer rows.Close() + + users := []User{} + for rows.Next() { + var user User + var lastLogin *time.Time + if err := rows.Scan(&user.Name, &user.CreatedAt, &lastLogin); err != nil { + return nil, err + } + if lastLogin != nil { + user.LastLogin = *lastLogin + } + users = append(users, user) + } + return users, rows.Err() +} + +// HasUsers is false until the first user is created. The dashboard stays +// locked until then. +func (s *Store) HasUsers(ctx context.Context) (bool, error) { + var exists bool + err := s.pool.QueryRow(ctx, "SELECT EXISTS (SELECT 1 FROM users)").Scan(&exists) + return exists, err +} + +// CheckPassword returns the user's id when the password is theirs +func (s *Store) CheckPassword(ctx context.Context, name string, password string) (int64, error) { + var id int64 + var hash, salt []byte + var iterations int + err := s.pool.QueryRow(ctx, "SELECT id, password_hash, salt, iterations FROM users WHERE name = $1", name). + Scan(&id, &hash, &salt, &iterations) + if errors.Is(err, pgx.ErrNoRows) { + if _, err := hashPassword(ctx, password, dummySalt, passwordIterations); err != nil { + return 0, err + } + return 0, ErrBadLogin + } + if err != nil { + return 0, err + } + got, err := hashPassword(ctx, password, salt, iterations) + if err != nil { + return 0, err + } + if subtle.ConstantTimeCompare(got, hash) != 1 { + return 0, ErrBadLogin + } + return id, nil +} + +type Session struct { + // Token is only set when the session is created + Token string + User string + Expires time.Time +} + +// Login checks a password and starts a session +func (s *Store) Login(ctx context.Context, name string, password string) (Session, error) { + id, err := s.CheckPassword(ctx, name, password) + if err != nil { + return Session{}, err + } + token, err := newSecret() + if err != nil { + return Session{}, err + } + session := Session{Token: token, User: name, Expires: time.Now().Add(SessionTTL)} + batch := &pgx.Batch{} + batch.Queue("INSERT INTO user_sessions (token_hash, user_id, expires_at) VALUES ($1, $2, $3)", hashSecret(token), id, session.Expires) + batch.Queue("UPDATE users SET last_login_at = now() WHERE id = $1", id) + return session, s.sendBatch(ctx, batch) +} + +// Session returns who a session token belongs to, while it is valid +func (s *Store) Session(ctx context.Context, token string) (Session, error) { + var session Session + err := s.pool.QueryRow(ctx, ` + SELECT u.name, s.expires_at FROM user_sessions s JOIN users u ON u.id = s.user_id + WHERE s.token_hash = $1 AND s.expires_at > now()`, hashSecret(token)).Scan(&session.User, &session.Expires) + if errors.Is(err, pgx.ErrNoRows) { + return Session{}, ErrBadSession + } + return session, err +} + +func (s *Store) Logout(ctx context.Context, token string) error { + _, err := s.pool.Exec(ctx, "DELETE FROM user_sessions WHERE token_hash = $1", hashSecret(token)) + return err +} + +// PurgeSessions deletes expired sessions +func (s *Store) PurgeSessions(ctx context.Context) (int64, error) { + tag, err := s.pool.Exec(ctx, "DELETE FROM user_sessions WHERE expires_at < now()") + if err != nil { + return 0, err + } + return tag.RowsAffected(), nil +} diff --git a/internal/store/users_test.go b/internal/store/users_test.go new file mode 100644 index 0000000..4428fad --- /dev/null +++ b/internal/store/users_test.go @@ -0,0 +1,144 @@ +package store + +import ( + "bytes" + "context" + "errors" + "testing" + "time" +) + +func TestHashPassword(t *testing.T) { + ctx := context.Background() + salt := []byte("0123456789abcdef") + first, err := hashPassword(ctx, "correct horse battery", salt, 1000) + if err != nil { + t.Fatal(err) + } + again, _ := hashPassword(ctx, "correct horse battery", salt, 1000) + other, _ := hashPassword(ctx, "correct horse battery", []byte("fedcba9876543210"), 1000) + if len(first) != 32 || !bytes.Equal(first, again) || bytes.Equal(first, other) { + t.Errorf("expected the same hash for the same salt only, got %x %x %x", first, again, other) + } +} + +func TestNewPassword(t *testing.T) { + password, err := NewPassword() + if err != nil || len(password) != 20 || checkNewPassword(password) != nil { + t.Errorf("expected a usable 20 character password, got %q %v", password, err) + } + if err := checkNewPassword("short"); !errors.Is(err, ErrInvalid) { + t.Errorf("expected ErrInvalid for a short password, got %v", err) + } +} + +func TestUsersAndSessions(t *testing.T) { + st := testStore(t) + ctx := context.Background() + + if has, err := st.HasUsers(ctx); err != nil || has { + t.Fatalf("expected no users in a new database, got %v %v", has, err) + } + if err := st.AddUser(ctx, "alice", "correct horse battery"); err != nil { + t.Fatal(err) + } + if err := st.AddUser(ctx, "alice", "another long password"); !errors.Is(err, ErrUserExists) { + t.Errorf("expected ErrUserExists, got %v", err) + } + if err := st.AddUser(ctx, "bob smith", "correct horse battery"); !errors.Is(err, ErrInvalid) { + t.Errorf("expected ErrInvalid for a name with a space, got %v", err) + } + if err := st.AddUser(ctx, "bob", "short"); !errors.Is(err, ErrInvalid) { + t.Errorf("expected ErrInvalid for a short password, got %v", err) + } + if has, err := st.HasUsers(ctx); err != nil || !has { + t.Errorf("expected a user, got %v %v", has, err) + } + + // an unknown user and a wrong password look the same + if _, err := st.Login(ctx, "mallory", "correct horse battery"); !errors.Is(err, ErrBadLogin) { + t.Errorf("expected ErrBadLogin for an unknown user, got %v", err) + } + if _, err := st.Login(ctx, "alice", "wrong horse battery"); !errors.Is(err, ErrBadLogin) { + t.Errorf("expected ErrBadLogin for a wrong password, got %v", err) + } + + session, err := st.Login(ctx, "alice", "correct horse battery") + if err != nil { + t.Fatal(err) + } + if session.Token == "" || session.User != "alice" || time.Until(session.Expires) < SessionTTL-time.Minute { + t.Errorf("unexpected session %+v", session) + } + found, err := st.Session(ctx, session.Token) + if err != nil || found.User != "alice" || !found.Expires.Equal(session.Expires.Truncate(time.Microsecond)) { + t.Errorf("expected alice's session, got %+v %v", found, err) + } + if _, err := st.Session(ctx, "not-a-token"); !errors.Is(err, ErrBadSession) { + t.Errorf("expected ErrBadSession for an unknown token, got %v", err) + } + + users, err := st.Users(ctx) + if err != nil || len(users) != 1 || users[0].Name != "alice" || users[0].LastLogin.IsZero() { + t.Errorf("expected alice with a last login, got %+v %v", users, err) + } + + // a new password ends the old sessions + second, err := st.Login(ctx, "alice", "correct horse battery") + if err != nil { + t.Fatal(err) + } + if err := st.SetPassword(ctx, "alice", "a brand new password"); err != nil { + t.Fatal(err) + } + if _, err := st.Session(ctx, second.Token); !errors.Is(err, ErrBadSession) { + t.Errorf("expected the session to end with the password change, got %v", err) + } + if _, err := st.Login(ctx, "alice", "correct horse battery"); !errors.Is(err, ErrBadLogin) { + t.Errorf("expected the old password to fail, got %v", err) + } + if err := st.SetPassword(ctx, "nobody", "a brand new password"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound for an unknown user, got %v", err) + } + + third, err := st.Login(ctx, "alice", "a brand new password") + if err != nil { + t.Fatal(err) + } + if err := st.Logout(ctx, third.Token); err != nil { + t.Fatal(err) + } + if _, err := st.Session(ctx, third.Token); !errors.Is(err, ErrBadSession) { + t.Errorf("expected no session after logging out, got %v", err) + } + + // expired sessions do not count and get purged + fourth, err := st.Login(ctx, "alice", "a brand new password") + if err != nil { + t.Fatal(err) + } + if _, err := st.pool.Exec(ctx, "UPDATE user_sessions SET expires_at = now() - INTERVAL '1 minute'"); err != nil { + t.Fatal(err) + } + if _, err := st.Session(ctx, fourth.Token); !errors.Is(err, ErrBadSession) { + t.Errorf("expected an expired session to fail, got %v", err) + } + if purged, err := st.PurgeSessions(ctx); err != nil || purged != 1 { + t.Errorf("expected one expired session purged, got %d %v", purged, err) + } + + // removing a user ends their sessions + fifth, err := st.Login(ctx, "alice", "a brand new password") + if err != nil { + t.Fatal(err) + } + if err := st.RemoveUser(ctx, "alice"); err != nil { + t.Fatal(err) + } + if _, err := st.Session(ctx, fifth.Token); !errors.Is(err, ErrBadSession) { + t.Errorf("expected no session for a removed user, got %v", err) + } + if err := st.RemoveUser(ctx, "alice"); !errors.Is(err, ErrNotFound) { + t.Errorf("expected ErrNotFound removing a user twice, got %v", err) + } +} From 1fa0f8b92375a1386a4bb488f2d5951a2a7fc32b Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Tue, 29 Sep 2026 23:54:16 +0530 Subject: [PATCH 2/6] add login calls and user commands to the collector --- collector/main.go | 19 +++ collector/purge.go | 10 +- collector/users.go | 120 ++++++++++++++++ internal/api/api.go | 4 + internal/api/api.pb.go | 278 +++++++++++++++++++++++++++++++++--- internal/api/api.proto | 31 ++++ internal/api/api_grpc.pb.go | 196 +++++++++++++++++++++++++ internal/api/login.go | 146 +++++++++++++++++++ internal/api/login_test.go | 41 ++++++ 9 files changed, 820 insertions(+), 25 deletions(-) create mode 100644 collector/users.go create mode 100644 internal/api/login.go create mode 100644 internal/api/login_test.go diff --git a/collector/main.go b/collector/main.go index 56ba61c..4619e09 100644 --- a/collector/main.go +++ b/collector/main.go @@ -30,6 +30,11 @@ func main() { tokenHost := flag.String("host", "", "With -enroll-token: only this host name may use the token") tokenUses := flag.Int("uses", 1, "With -enroll-token: how many hosts the token can enroll") tokenTTL := flag.Duration("ttl", time.Hour, "With -enroll-token: how long the token is valid") + addUserName := flag.String("add-user", "", "Create a dashboard user and print its password") + resetPasswordName := flag.String("reset-password", "", "Give a dashboard user a new password and log them out everywhere") + passwordStdin := flag.Bool("password-stdin", false, "With -add-user or -reset-password: read the password from stdin instead of making one up") + removeUserName := flag.String("remove-user", "", "Remove a dashboard user") + listUsersPtr := flag.Bool("list-users", false, "List the dashboard users") envFile := flag.String("env", config.DefaultEnvFile("collector"), "Settings file with KEY=value lines, loaded if it exists") versionPtr := flag.Bool("version", false, "Print the version and exit") flag.Parse() @@ -77,6 +82,20 @@ func main() { printEnrollmentToken(ctx, st, &config, *tokenHost, *tokenUses, *tokenTTL) return } + switch { + case *addUserName != "": + addUser(ctx, st, &config, *addUserName, *passwordStdin) + return + case *resetPasswordName != "": + resetPassword(ctx, st, *resetPasswordName, *passwordStdin) + return + case *removeUserName != "": + removeUser(ctx, st, *removeUserName) + return + case *listUsersPtr: + listUsers(ctx, st) + return + } if err := st.Migrate(ctx); err != nil { log.Fatal("cannot update database schema: ", err) diff --git a/collector/purge.go b/collector/purge.go index c307103..f59b1a3 100644 --- a/collector/purge.go +++ b/collector/purge.go @@ -9,8 +9,8 @@ import ( "github.com/dhamith93/SyMon/internal/store" ) -// purgeOldRecords deletes old resolved alerts and dead enrollment tokens -// once a day. Metric data is dropped by timescale retention policies instead. +// purgeOldRecords deletes old resolved alerts, dead enrollment tokens and +// expired dashboard sessions once a day. Metric data is dropped by timescale retention policies instead. func purgeOldRecords(st *store.Store) { ticker := time.NewTicker(24 * time.Hour) defer ticker.Stop() @@ -29,5 +29,11 @@ func purgeOldRecords(st *store.Store) { logger.Log("error", "token purge: "+err.Error()) } cancel() + + ctx, cancel = context.WithTimeout(context.Background(), time.Minute) + if _, err := st.PurgeSessions(ctx); err != nil { + logger.Log("error", "session purge: "+err.Error()) + } + cancel() } } diff --git a/collector/users.go b/collector/users.go new file mode 100644 index 0000000..5be22b9 --- /dev/null +++ b/collector/users.go @@ -0,0 +1,120 @@ +package main + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "strings" + "text/tabwriter" + "time" + + "github.com/dhamith93/SyMon/internal/config" + "github.com/dhamith93/SyMon/internal/store" +) + +// Dashboard users are managed from the command line. A new password is +// made up and printed once, unless -password-stdin gives one. + +func addUser(ctx context.Context, st *store.Store, config *config.Collector, name string, fromStdin bool) { + migrateOrExit(ctx, st) + password := newPassword(fromStdin) + err := st.AddUser(ctx, name, password) + if errors.Is(err, store.ErrUserExists) { + exit("User " + name + " already exists. Use -reset-password to give it a new password.") + } + if err != nil { + exit("cannot add the user: " + err.Error()) + } + fmt.Printf("Created user %s.\n", name) + if !fromStdin { + printPassword(password) + } + fmt.Println("Log in at " + dashboardURL(config)) +} + +func resetPassword(ctx context.Context, st *store.Store, name string, fromStdin bool) { + migrateOrExit(ctx, st) + password := newPassword(fromStdin) + err := st.SetPassword(ctx, name, password) + if errors.Is(err, store.ErrNotFound) { + exit("There is no user " + name + ".") + } + if err != nil { + exit("cannot set the password: " + err.Error()) + } + fmt.Printf("Set a new password for %s and logged them out everywhere.\n", name) + if !fromStdin { + printPassword(password) + } +} + +func removeUser(ctx context.Context, st *store.Store, name string) { + migrateOrExit(ctx, st) + err := st.RemoveUser(ctx, name) + if errors.Is(err, store.ErrNotFound) { + exit("There is no user " + name + ".") + } + if err != nil { + exit("cannot remove the user: " + err.Error()) + } + fmt.Printf("Removed user %s and logged them out everywhere.\n", name) +} + +func listUsers(ctx context.Context, st *store.Store) { + migrateOrExit(ctx, st) + users, err := st.Users(ctx) + if err != nil { + exit("cannot list users: " + err.Error()) + } + if len(users) == 0 { + fmt.Println("No users yet, so the dashboard is locked. Add one with -add-user .") + return + } + table := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0) + fmt.Fprintln(table, "USER\tCREATED\tLAST LOGIN") + for _, user := range users { + lastLogin := "never" + if !user.LastLogin.IsZero() { + lastLogin = user.LastLogin.Local().Format("Jan 2 2006 15:04") + } + fmt.Fprintf(table, "%s\t%s\t%s\n", user.Name, user.CreatedAt.Local().Format("Jan 2 2006"), lastLogin) + } + table.Flush() +} + +// newPassword reads a password from the first line of stdin, or makes one up +func newPassword(fromStdin bool) string { + if !fromStdin { + password, err := store.NewPassword() + if err != nil { + exit("cannot make a password: " + err.Error()) + } + return password + } + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil && line == "" { + exit("cannot read a password from stdin: " + err.Error()) + } + return strings.TrimRight(line, "\r\n") +} + +func printPassword(password string) { + fmt.Printf("\nPassword, shown only this once:\n\n %s\n\n", password) +} + +// migrateOrExit creates the user tables if the collector has not run since +// an upgrade +func migrateOrExit(ctx context.Context, st *store.Store) { + ctx, cancel := context.WithTimeout(ctx, time.Minute) + defer cancel() + if err := st.Migrate(ctx); err != nil { + exit("cannot update database schema: " + err.Error()) + } +} + +func exit(message string) { + fmt.Fprintln(os.Stderr, message) + os.Exit(1) +} diff --git a/internal/api/api.go b/internal/api/api.go index 594a114..955b7cf 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -35,6 +35,8 @@ type Server struct { forecastMu sync.Mutex forecastAt time.Time diskFull map[string]float64 + + logins loginFailures } // toStatus turns a store error into a grpc status. Unexpected errors are @@ -49,6 +51,8 @@ func toStatus(err error) error { return status.Error(codes.AlreadyExists, err.Error()) case errors.Is(err, store.ErrInvalid): return status.Error(codes.InvalidArgument, err.Error()) + case errors.Is(err, store.ErrBadLogin), errors.Is(err, store.ErrBadSession): + return status.Error(codes.Unauthenticated, err.Error()) case errors.Is(err, context.Canceled): // the caller went away, nothing to report return status.Error(codes.Canceled, "canceled") diff --git a/internal/api/api.pb.go b/internal/api/api.pb.go index a48af09..d4b83d7 100644 --- a/internal/api/api.pb.go +++ b/internal/api/api.pb.go @@ -2059,6 +2059,207 @@ func (x *EndpointSeriesRequest) GetMaxPoints() int32 { return 0 } +type Credentials struct { + state protoimpl.MessageState `protogen:"open.v1"` + User string `protobuf:"bytes,1,opt,name=user,proto3" json:"user,omitempty"` + Password string `protobuf:"bytes,2,opt,name=password,proto3" json:"password,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Credentials) Reset() { + *x = Credentials{} + mi := &file_api_api_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Credentials) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Credentials) ProtoMessage() {} + +func (x *Credentials) ProtoReflect() protoreflect.Message { + mi := &file_api_api_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Credentials.ProtoReflect.Descriptor instead. +func (*Credentials) Descriptor() ([]byte, []int) { + return file_api_api_proto_rawDescGZIP(), []int{31} +} + +func (x *Credentials) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *Credentials) GetPassword() string { + if x != nil { + return x.Password + } + return "" +} + +// token is only set by Login +type SessionInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + User string `protobuf:"bytes,2,opt,name=user,proto3" json:"user,omitempty"` + Expires int64 `protobuf:"varint,3,opt,name=expires,proto3" json:"expires,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionInfo) Reset() { + *x = SessionInfo{} + mi := &file_api_api_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionInfo) ProtoMessage() {} + +func (x *SessionInfo) ProtoReflect() protoreflect.Message { + mi := &file_api_api_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionInfo.ProtoReflect.Descriptor instead. +func (*SessionInfo) Descriptor() ([]byte, []int) { + return file_api_api_proto_rawDescGZIP(), []int{32} +} + +func (x *SessionInfo) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *SessionInfo) GetUser() string { + if x != nil { + return x.User + } + return "" +} + +func (x *SessionInfo) GetExpires() int64 { + if x != nil { + return x.Expires + } + return 0 +} + +type SessionRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Token string `protobuf:"bytes,1,opt,name=token,proto3" json:"token,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionRequest) Reset() { + *x = SessionRequest{} + mi := &file_api_api_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionRequest) ProtoMessage() {} + +func (x *SessionRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_api_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionRequest.ProtoReflect.Descriptor instead. +func (*SessionRequest) Descriptor() ([]byte, []int) { + return file_api_api_proto_rawDescGZIP(), []int{33} +} + +func (x *SessionRequest) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +type UserStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + HasUsers bool `protobuf:"varint,1,opt,name=hasUsers,proto3" json:"hasUsers,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UserStatus) Reset() { + *x = UserStatus{} + mi := &file_api_api_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UserStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UserStatus) ProtoMessage() {} + +func (x *UserStatus) ProtoReflect() protoreflect.Message { + mi := &file_api_api_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UserStatus.ProtoReflect.Descriptor instead. +func (*UserStatus) Descriptor() ([]byte, []int) { + return file_api_api_proto_rawDescGZIP(), []int{34} +} + +func (x *UserStatus) GetHasUsers() bool { + if x != nil { + return x.HasUsers + } + return false +} + var File_api_api_proto protoreflect.FileDescriptor const file_api_api_proto_rawDesc = "" + @@ -2225,7 +2426,19 @@ const file_api_api_proto_rawDesc = "" + "\x06metric\x18\x02 \x01(\tR\x06metric\x12\x12\n" + "\x04from\x18\x03 \x01(\x03R\x04from\x12\x0e\n" + "\x02to\x18\x04 \x01(\x03R\x02to\x12\x1c\n" + - "\tmaxPoints\x18\x05 \x01(\x05R\tmaxPoints2\xa6\a\n" + + "\tmaxPoints\x18\x05 \x01(\x05R\tmaxPoints\"=\n" + + "\vCredentials\x12\x12\n" + + "\x04user\x18\x01 \x01(\tR\x04user\x12\x1a\n" + + "\bpassword\x18\x02 \x01(\tR\bpassword\"Q\n" + + "\vSessionInfo\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\x12\x12\n" + + "\x04user\x18\x02 \x01(\tR\x04user\x12\x18\n" + + "\aexpires\x18\x03 \x01(\x03R\aexpires\"&\n" + + "\x0eSessionRequest\x12\x14\n" + + "\x05token\x18\x01 \x01(\tR\x05token\"(\n" + + "\n" + + "UserStatus\x12\x1a\n" + + "\bhasUsers\x18\x01 \x01(\bR\bhasUsers2\x9a\t\n" + "\x12MonitorDataService\x123\n" + "\x06Enroll\x12\x12.api.EnrollRequest\x1a\x13.api.EnrollResponse\"\x00\x12-\n" + "\n" + @@ -2244,7 +2457,12 @@ const file_api_api_proto_rawDesc = "" + "\tSnapshots\x12\t.api.Void\x1a\x11.api.SnapshotList\"\x00\x127\n" + "\tEndpoints\x12\x15.api.EndpointsRequest\x1a\x11.api.EndpointList\"\x00\x12C\n" + "\x0eEndpointSeries\x12\x1a.api.EndpointSeriesRequest\x1a\x13.api.SeriesResponse\"\x00\x12$\n" + - "\aVersion\x12\t.api.Void\x1a\f.api.Message\"\x00B)Z'github.com/dhamith93/SyMon/internal/apib\x06proto3" + "\aVersion\x12\t.api.Void\x1a\f.api.Message\"\x00\x12-\n" + + "\x05Login\x12\x10.api.Credentials\x1a\x10.api.SessionInfo\"\x00\x127\n" + + "\fCheckSession\x12\x13.api.SessionRequest\x1a\x10.api.SessionInfo\"\x00\x12-\n" + + "\x06Logout\x12\x13.api.SessionRequest\x1a\f.api.Message\"\x00\x121\n" + + "\rCheckPassword\x12\x10.api.Credentials\x1a\f.api.Message\"\x00\x12(\n" + + "\bHasUsers\x12\t.api.Void\x1a\x0f.api.UserStatus\"\x00B)Z'github.com/dhamith93/SyMon/internal/apib\x06proto3" var ( file_api_api_proto_rawDescOnce sync.Once @@ -2258,7 +2476,7 @@ func file_api_api_proto_rawDescGZIP() []byte { return file_api_api_proto_rawDescData } -var file_api_api_proto_msgTypes = make([]protoimpl.MessageInfo, 31) +var file_api_api_proto_msgTypes = make([]protoimpl.MessageInfo, 35) var file_api_api_proto_goTypes = []any{ (*Void)(nil), // 0: api.Void (*Message)(nil), // 1: api.Message @@ -2291,6 +2509,10 @@ var file_api_api_proto_goTypes = []any{ (*EndpointStatus)(nil), // 28: api.EndpointStatus (*EndpointList)(nil), // 29: api.EndpointList (*EndpointSeriesRequest)(nil), // 30: api.EndpointSeriesRequest + (*Credentials)(nil), // 31: api.Credentials + (*SessionInfo)(nil), // 32: api.SessionInfo + (*SessionRequest)(nil), // 33: api.SessionRequest + (*UserStatus)(nil), // 34: api.UserStatus } var file_api_api_proto_depIdxs = []int32{ 6, // 0: api.FleetSummary.hosts:type_name -> api.HostSummary @@ -2319,25 +2541,35 @@ var file_api_api_proto_depIdxs = []int32{ 27, // 23: api.MonitorDataService.Endpoints:input_type -> api.EndpointsRequest 30, // 24: api.MonitorDataService.EndpointSeries:input_type -> api.EndpointSeriesRequest 0, // 25: api.MonitorDataService.Version:input_type -> api.Void - 5, // 26: api.MonitorDataService.Enroll:output_type -> api.EnrollResponse - 1, // 27: api.MonitorDataService.HandlePing:output_type -> api.Message - 1, // 28: api.MonitorDataService.InitAgent:output_type -> api.Message - 1, // 29: api.MonitorDataService.HandleMonitorData:output_type -> api.Message - 1, // 30: api.MonitorDataService.HandleCustomMonitorData:output_type -> api.Message - 7, // 31: api.MonitorDataService.Fleet:output_type -> api.FleetSummary - 9, // 32: api.MonitorDataService.Snapshot:output_type -> api.HostSnapshot - 13, // 33: api.MonitorDataService.QuerySeries:output_type -> api.SeriesResponse - 15, // 34: api.MonitorDataService.Processes:output_type -> api.ProcessesResponse - 16, // 35: api.MonitorDataService.CustomMetricNames:output_type -> api.NameList - 19, // 36: api.MonitorDataService.Alerts:output_type -> api.AlertList - 21, // 37: api.MonitorDataService.DiskForecasts:output_type -> api.DiskForecastList - 24, // 38: api.MonitorDataService.ProcessUsage:output_type -> api.ProcessUsageList - 26, // 39: api.MonitorDataService.Snapshots:output_type -> api.SnapshotList - 29, // 40: api.MonitorDataService.Endpoints:output_type -> api.EndpointList - 13, // 41: api.MonitorDataService.EndpointSeries:output_type -> api.SeriesResponse - 1, // 42: api.MonitorDataService.Version:output_type -> api.Message - 26, // [26:43] is the sub-list for method output_type - 9, // [9:26] is the sub-list for method input_type + 31, // 26: api.MonitorDataService.Login:input_type -> api.Credentials + 33, // 27: api.MonitorDataService.CheckSession:input_type -> api.SessionRequest + 33, // 28: api.MonitorDataService.Logout:input_type -> api.SessionRequest + 31, // 29: api.MonitorDataService.CheckPassword:input_type -> api.Credentials + 0, // 30: api.MonitorDataService.HasUsers:input_type -> api.Void + 5, // 31: api.MonitorDataService.Enroll:output_type -> api.EnrollResponse + 1, // 32: api.MonitorDataService.HandlePing:output_type -> api.Message + 1, // 33: api.MonitorDataService.InitAgent:output_type -> api.Message + 1, // 34: api.MonitorDataService.HandleMonitorData:output_type -> api.Message + 1, // 35: api.MonitorDataService.HandleCustomMonitorData:output_type -> api.Message + 7, // 36: api.MonitorDataService.Fleet:output_type -> api.FleetSummary + 9, // 37: api.MonitorDataService.Snapshot:output_type -> api.HostSnapshot + 13, // 38: api.MonitorDataService.QuerySeries:output_type -> api.SeriesResponse + 15, // 39: api.MonitorDataService.Processes:output_type -> api.ProcessesResponse + 16, // 40: api.MonitorDataService.CustomMetricNames:output_type -> api.NameList + 19, // 41: api.MonitorDataService.Alerts:output_type -> api.AlertList + 21, // 42: api.MonitorDataService.DiskForecasts:output_type -> api.DiskForecastList + 24, // 43: api.MonitorDataService.ProcessUsage:output_type -> api.ProcessUsageList + 26, // 44: api.MonitorDataService.Snapshots:output_type -> api.SnapshotList + 29, // 45: api.MonitorDataService.Endpoints:output_type -> api.EndpointList + 13, // 46: api.MonitorDataService.EndpointSeries:output_type -> api.SeriesResponse + 1, // 47: api.MonitorDataService.Version:output_type -> api.Message + 32, // 48: api.MonitorDataService.Login:output_type -> api.SessionInfo + 32, // 49: api.MonitorDataService.CheckSession:output_type -> api.SessionInfo + 1, // 50: api.MonitorDataService.Logout:output_type -> api.Message + 1, // 51: api.MonitorDataService.CheckPassword:output_type -> api.Message + 34, // 52: api.MonitorDataService.HasUsers:output_type -> api.UserStatus + 31, // [31:53] is the sub-list for method output_type + 9, // [9:31] is the sub-list for method input_type 9, // [9:9] is the sub-list for extension type_name 9, // [9:9] is the sub-list for extension extendee 0, // [0:9] is the sub-list for field type_name @@ -2356,7 +2588,7 @@ func file_api_api_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_api_proto_rawDesc), len(file_api_api_proto_rawDesc)), NumEnums: 0, - NumMessages: 31, + NumMessages: 35, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/api/api.proto b/internal/api/api.proto index ae0a74c..6b75cdd 100644 --- a/internal/api/api.proto +++ b/internal/api/api.proto @@ -237,6 +237,28 @@ message EndpointSeriesRequest { int32 maxPoints = 5; } +// Dashboard login. The dashboard calls these with the shared key. + +message Credentials { + string user = 1; + string password = 2; +} + +// token is only set by Login +message SessionInfo { + string token = 1; + string user = 2; + int64 expires = 3; +} + +message SessionRequest { + string token = 1; +} + +message UserStatus { + bool hasUsers = 1; +} + service MonitorDataService { // agent rpc Enroll(EnrollRequest) returns (EnrollResponse) {} @@ -260,4 +282,13 @@ service MonitorDataService { rpc EndpointSeries(EndpointSeriesRequest) returns (SeriesResponse) {} // the collector's build, in the body rpc Version(Void) returns (Message) {} + + // dashboard login + rpc Login(Credentials) returns (SessionInfo) {} + rpc CheckSession(SessionRequest) returns (SessionInfo) {} + rpc Logout(SessionRequest) returns (Message) {} + // for HTTP basic auth, like Prometheus scraping /metrics + rpc CheckPassword(Credentials) returns (Message) {} + // the dashboard stays locked until the first user exists + rpc HasUsers(Void) returns (UserStatus) {} } diff --git a/internal/api/api_grpc.pb.go b/internal/api/api_grpc.pb.go index a82f772..87e48db 100644 --- a/internal/api/api_grpc.pb.go +++ b/internal/api/api_grpc.pb.go @@ -36,6 +36,11 @@ const ( MonitorDataService_Endpoints_FullMethodName = "/api.MonitorDataService/Endpoints" MonitorDataService_EndpointSeries_FullMethodName = "/api.MonitorDataService/EndpointSeries" MonitorDataService_Version_FullMethodName = "/api.MonitorDataService/Version" + MonitorDataService_Login_FullMethodName = "/api.MonitorDataService/Login" + MonitorDataService_CheckSession_FullMethodName = "/api.MonitorDataService/CheckSession" + MonitorDataService_Logout_FullMethodName = "/api.MonitorDataService/Logout" + MonitorDataService_CheckPassword_FullMethodName = "/api.MonitorDataService/CheckPassword" + MonitorDataService_HasUsers_FullMethodName = "/api.MonitorDataService/HasUsers" ) // MonitorDataServiceClient is the client API for MonitorDataService service. @@ -63,6 +68,14 @@ type MonitorDataServiceClient interface { EndpointSeries(ctx context.Context, in *EndpointSeriesRequest, opts ...grpc.CallOption) (*SeriesResponse, error) // the collector's build, in the body Version(ctx context.Context, in *Void, opts ...grpc.CallOption) (*Message, error) + // dashboard login + Login(ctx context.Context, in *Credentials, opts ...grpc.CallOption) (*SessionInfo, error) + CheckSession(ctx context.Context, in *SessionRequest, opts ...grpc.CallOption) (*SessionInfo, error) + Logout(ctx context.Context, in *SessionRequest, opts ...grpc.CallOption) (*Message, error) + // for HTTP basic auth, like Prometheus scraping /metrics + CheckPassword(ctx context.Context, in *Credentials, opts ...grpc.CallOption) (*Message, error) + // the dashboard stays locked until the first user exists + HasUsers(ctx context.Context, in *Void, opts ...grpc.CallOption) (*UserStatus, error) } type monitorDataServiceClient struct { @@ -243,6 +256,56 @@ func (c *monitorDataServiceClient) Version(ctx context.Context, in *Void, opts . return out, nil } +func (c *monitorDataServiceClient) Login(ctx context.Context, in *Credentials, opts ...grpc.CallOption) (*SessionInfo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SessionInfo) + err := c.cc.Invoke(ctx, MonitorDataService_Login_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *monitorDataServiceClient) CheckSession(ctx context.Context, in *SessionRequest, opts ...grpc.CallOption) (*SessionInfo, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SessionInfo) + err := c.cc.Invoke(ctx, MonitorDataService_CheckSession_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *monitorDataServiceClient) Logout(ctx context.Context, in *SessionRequest, opts ...grpc.CallOption) (*Message, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Message) + err := c.cc.Invoke(ctx, MonitorDataService_Logout_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *monitorDataServiceClient) CheckPassword(ctx context.Context, in *Credentials, opts ...grpc.CallOption) (*Message, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Message) + err := c.cc.Invoke(ctx, MonitorDataService_CheckPassword_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *monitorDataServiceClient) HasUsers(ctx context.Context, in *Void, opts ...grpc.CallOption) (*UserStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UserStatus) + err := c.cc.Invoke(ctx, MonitorDataService_HasUsers_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // MonitorDataServiceServer is the server API for MonitorDataService service. // All implementations must embed UnimplementedMonitorDataServiceServer // for forward compatibility. @@ -268,6 +331,14 @@ type MonitorDataServiceServer interface { EndpointSeries(context.Context, *EndpointSeriesRequest) (*SeriesResponse, error) // the collector's build, in the body Version(context.Context, *Void) (*Message, error) + // dashboard login + Login(context.Context, *Credentials) (*SessionInfo, error) + CheckSession(context.Context, *SessionRequest) (*SessionInfo, error) + Logout(context.Context, *SessionRequest) (*Message, error) + // for HTTP basic auth, like Prometheus scraping /metrics + CheckPassword(context.Context, *Credentials) (*Message, error) + // the dashboard stays locked until the first user exists + HasUsers(context.Context, *Void) (*UserStatus, error) mustEmbedUnimplementedMonitorDataServiceServer() } @@ -329,6 +400,21 @@ func (UnimplementedMonitorDataServiceServer) EndpointSeries(context.Context, *En func (UnimplementedMonitorDataServiceServer) Version(context.Context, *Void) (*Message, error) { return nil, status.Errorf(codes.Unimplemented, "method Version not implemented") } +func (UnimplementedMonitorDataServiceServer) Login(context.Context, *Credentials) (*SessionInfo, error) { + return nil, status.Errorf(codes.Unimplemented, "method Login not implemented") +} +func (UnimplementedMonitorDataServiceServer) CheckSession(context.Context, *SessionRequest) (*SessionInfo, error) { + return nil, status.Errorf(codes.Unimplemented, "method CheckSession not implemented") +} +func (UnimplementedMonitorDataServiceServer) Logout(context.Context, *SessionRequest) (*Message, error) { + return nil, status.Errorf(codes.Unimplemented, "method Logout not implemented") +} +func (UnimplementedMonitorDataServiceServer) CheckPassword(context.Context, *Credentials) (*Message, error) { + return nil, status.Errorf(codes.Unimplemented, "method CheckPassword not implemented") +} +func (UnimplementedMonitorDataServiceServer) HasUsers(context.Context, *Void) (*UserStatus, error) { + return nil, status.Errorf(codes.Unimplemented, "method HasUsers not implemented") +} func (UnimplementedMonitorDataServiceServer) mustEmbedUnimplementedMonitorDataServiceServer() {} func (UnimplementedMonitorDataServiceServer) testEmbeddedByValue() {} @@ -656,6 +742,96 @@ func _MonitorDataService_Version_Handler(srv interface{}, ctx context.Context, d return interceptor(ctx, in, info, handler) } +func _MonitorDataService_Login_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Credentials) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MonitorDataServiceServer).Login(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MonitorDataService_Login_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MonitorDataServiceServer).Login(ctx, req.(*Credentials)) + } + return interceptor(ctx, in, info, handler) +} + +func _MonitorDataService_CheckSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MonitorDataServiceServer).CheckSession(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MonitorDataService_CheckSession_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MonitorDataServiceServer).CheckSession(ctx, req.(*SessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MonitorDataService_Logout_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SessionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MonitorDataServiceServer).Logout(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MonitorDataService_Logout_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MonitorDataServiceServer).Logout(ctx, req.(*SessionRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _MonitorDataService_CheckPassword_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Credentials) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MonitorDataServiceServer).CheckPassword(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MonitorDataService_CheckPassword_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MonitorDataServiceServer).CheckPassword(ctx, req.(*Credentials)) + } + return interceptor(ctx, in, info, handler) +} + +func _MonitorDataService_HasUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(Void) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(MonitorDataServiceServer).HasUsers(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: MonitorDataService_HasUsers_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(MonitorDataServiceServer).HasUsers(ctx, req.(*Void)) + } + return interceptor(ctx, in, info, handler) +} + // MonitorDataService_ServiceDesc is the grpc.ServiceDesc for MonitorDataService service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -731,6 +907,26 @@ var MonitorDataService_ServiceDesc = grpc.ServiceDesc{ MethodName: "Version", Handler: _MonitorDataService_Version_Handler, }, + { + MethodName: "Login", + Handler: _MonitorDataService_Login_Handler, + }, + { + MethodName: "CheckSession", + Handler: _MonitorDataService_CheckSession_Handler, + }, + { + MethodName: "Logout", + Handler: _MonitorDataService_Logout_Handler, + }, + { + MethodName: "CheckPassword", + Handler: _MonitorDataService_CheckPassword_Handler, + }, + { + MethodName: "HasUsers", + Handler: _MonitorDataService_HasUsers_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "api/api.proto", diff --git a/internal/api/login.go b/internal/api/login.go new file mode 100644 index 0000000..8d59834 --- /dev/null +++ b/internal/api/login.go @@ -0,0 +1,146 @@ +package api + +import ( + "context" + "errors" + "strconv" + "sync" + "time" + + "github.com/dhamith93/SyMon/internal/logger" + "github.com/dhamith93/SyMon/internal/store" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// A user name gets loginLimit wrong passwords per loginWindow. After that +// its logins are refused until the window ends, even with the right one. +const ( + loginLimit = 10 + loginWindow = 15 * time.Minute +) + +var errTooManyLogins = status.Error(codes.ResourceExhausted, "too many failed logins, try again later") + +// loginFailures counts wrong passwords per user name +type loginFailures struct { + mu sync.Mutex + byName map[string]failureWindow + // now is time.Now, replaced in tests + now func() time.Time +} + +type failureWindow struct { + count int + since time.Time +} + +func (l *loginFailures) clock() time.Time { + if l.now != nil { + return l.now() + } + return time.Now() +} + +func (l *loginFailures) blocked(name string) bool { + l.mu.Lock() + defer l.mu.Unlock() + window, ok := l.byName[name] + return ok && window.count >= loginLimit && l.clock().Sub(window.since) < loginWindow +} + +func (l *loginFailures) fail(name string) { + l.mu.Lock() + defer l.mu.Unlock() + now := l.clock() + if l.byName == nil { + l.byName = map[string]failureWindow{} + } + // guesses at many names would otherwise grow the map without end + if len(l.byName) > 1000 { + for key, window := range l.byName { + if now.Sub(window.since) >= loginWindow { + delete(l.byName, key) + } + } + } + window, ok := l.byName[name] + if !ok || now.Sub(window.since) >= loginWindow { + window = failureWindow{since: now} + } + window.count++ + l.byName[name] = window +} + +func (l *loginFailures) clear(name string) { + l.mu.Lock() + defer l.mu.Unlock() + delete(l.byName, name) +} + +// checkLogin runs check unless the user is locked out, and counts it when +// the password was wrong +func (s *Server) checkLogin(name string, check func() error) error { + if s.logins.blocked(name) { + return errTooManyLogins + } + err := check() + if errors.Is(err, store.ErrBadLogin) { + s.logins.fail(name) + // the name comes from whoever is logging in, so it is quoted + logger.Log("info", "failed login for "+strconv.Quote(name)) + } + if err != nil { + return toStatus(err) + } + s.logins.clear(name) + return nil +} + +func (s *Server) Login(ctx context.Context, in *Credentials) (*SessionInfo, error) { + var session store.Session + err := s.checkLogin(in.User, func() error { + var err error + session, err = s.Store.Login(ctx, in.User, in.Password) + return err + }) + if err != nil { + return nil, err + } + logger.Log("info", "login by "+strconv.Quote(in.User)) + return &SessionInfo{Token: session.Token, User: session.User, Expires: session.Expires.Unix()}, nil +} + +func (s *Server) CheckPassword(ctx context.Context, in *Credentials) (*Message, error) { + err := s.checkLogin(in.User, func() error { + _, err := s.Store.CheckPassword(ctx, in.User, in.Password) + return err + }) + if err != nil { + return nil, err + } + return &Message{Body: "ok"}, nil +} + +func (s *Server) CheckSession(ctx context.Context, in *SessionRequest) (*SessionInfo, error) { + session, err := s.Store.Session(ctx, in.Token) + if err != nil { + return nil, toStatus(err) + } + return &SessionInfo{User: session.User, Expires: session.Expires.Unix()}, nil +} + +func (s *Server) Logout(ctx context.Context, in *SessionRequest) (*Message, error) { + if err := s.Store.Logout(ctx, in.Token); err != nil { + return nil, toStatus(err) + } + return &Message{Body: "ok"}, nil +} + +func (s *Server) HasUsers(ctx context.Context, in *Void) (*UserStatus, error) { + has, err := s.Store.HasUsers(ctx) + if err != nil { + return nil, toStatus(err) + } + return &UserStatus{HasUsers: has}, nil +} diff --git a/internal/api/login_test.go b/internal/api/login_test.go new file mode 100644 index 0000000..0ea4f5a --- /dev/null +++ b/internal/api/login_test.go @@ -0,0 +1,41 @@ +package api + +import ( + "testing" + "time" +) + +func TestLoginFailures(t *testing.T) { + now := time.Unix(1700000000, 0) + failures := &loginFailures{now: func() time.Time { return now }} + + for i := 0; i < loginLimit-1; i++ { + failures.fail("alice") + } + if failures.blocked("alice") { + t.Fatal("expected alice to have one more try") + } + failures.fail("alice") + if !failures.blocked("alice") || failures.blocked("bob") { + t.Fatal("expected only alice to be locked out") + } + + // the lock ends with the window + now = now.Add(loginWindow) + if failures.blocked("alice") { + t.Error("expected alice's lock to end with the window") + } + failures.fail("alice") + if failures.blocked("alice") { + t.Error("expected a new window to start counting again") + } + + // a good login clears the count + failures.clear("alice") + for i := 0; i < loginLimit-1; i++ { + failures.fail("alice") + } + if failures.blocked("alice") { + t.Error("expected the count to start again after a good login") + } +} From 82729a0a03f5c82316a13f01d53d111da15daeff Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Tue, 29 Sep 2026 23:56:45 +0530 Subject: [PATCH 3/6] require a login for the dashboard's data --- client/.env-example | 2 + client/internal/server/auth.go | 256 ++++++++++++++++++++++++++ client/internal/server/auth_test.go | 171 +++++++++++++++++ client/internal/server/metrics.go | 3 + client/internal/server/server.go | 42 +++-- client/internal/server/server_test.go | 60 +++++- internal/config/config_handler.go | 3 + 7 files changed, 522 insertions(+), 15 deletions(-) create mode 100644 client/internal/server/auth.go create mode 100644 client/internal/server/auth_test.go diff --git a/client/.env-example b/client/.env-example index c9db332..f26497c 100644 --- a/client/.env-example +++ b/client/.env-example @@ -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='' diff --git a/client/internal/server/auth.go b/client/internal/server/auth.go new file mode 100644 index 0000000..226111a --- /dev/null +++ b/client/internal/server/auth.go @@ -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 +} diff --git a/client/internal/server/auth_test.go b/client/internal/server/auth_test.go new file mode 100644 index 0000000..63e60b2 --- /dev/null +++ b/client/internal/server/auth_test.go @@ -0,0 +1,171 @@ +package server + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "testing/fstest" +) + +// call sends a request with an optional session cookie +func call(s *server, method string, url string, body string, cookie string, prepare func(*http.Request)) *httptest.ResponseRecorder { + request := httptest.NewRequest(method, url, strings.NewReader(body)) + if cookie != "" { + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: cookie}) + } + if prepare != nil { + prepare(request) + } + rec := httptest.NewRecorder() + s.routes().ServeHTTP(rec, request) + return rec +} + +func asJSON(r *http.Request) { + r.Header.Set("Content-Type", "application/json") +} + +func sessionCookieOf(rec *httptest.ResponseRecorder) *http.Cookie { + for _, cookie := range rec.Result().Cookies() { + if cookie.Name == sessionCookie { + return cookie + } + } + return nil +} + +func TestDataNeedsLogin(t *testing.T) { + s, _ := newTestServer(t, nil) + for _, url := range []string{"/api/v1/fleet", "/api/v1/config", "/api/v1/nothing"} { + for _, cookie := range []string{"", "stale"} { + if rec := call(s, "GET", url, "", cookie, nil); rec.Code != http.StatusUnauthorized { + t.Errorf("%s with cookie %q: expected 401, got %d %s", url, cookie, rec.Code, rec.Body) + } + } + } + if rec := call(s, "GET", "/api/v1/fleet", "", testSession, nil); rec.Code != http.StatusOK { + t.Errorf("expected the fleet with a session, got %d %s", rec.Code, rec.Body) + } +} + +func TestAppNeedsNoLogin(t *testing.T) { + s, _ := newTestServer(t, fstest.MapFS{"index.html": {Data: []byte("")}}) + for _, url := range []string{"/", "/hosts/web1", "/login"} { + if rec := call(s, "GET", url, "", "", nil); rec.Code != http.StatusOK { + t.Errorf("%s: expected the app without a login, got %d", url, rec.Code) + } + } +} + +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"}` { + 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`) { + t.Errorf("expected 401 with users, got %d %s", rec.Code, rec.Body) + } + fake.noUsers.Store(true) + if rec := call(s, "GET", "/api/v1/session", "", "", nil); rec.Code != 401 || !strings.Contains(rec.Body.String(), `"hasUsers":false`) { + t.Errorf("expected 401 without users, got %d %s", rec.Code, rec.Body) + } +} + +func TestLogin(t *testing.T) { + s, _ := newTestServer(t, nil) + good := `{"user":"alice","password":"correct horse battery"}` + + rec := call(s, "POST", "/api/v1/login", good, "", func(r *http.Request) { + asJSON(r) + r.Header.Set("X-Forwarded-Proto", "https") + }) + cookie := sessionCookieOf(rec) + if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != `{"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 != "/" { + t.Errorf("unexpected cookie %+v", cookie) + } + if rec := call(s, "GET", "/api/v1/fleet", "", cookie.Value, nil); rec.Code != 200 { + t.Errorf("expected the new session to work, got %d", rec.Code) + } + // over plain HTTP the cookie cannot be Secure, or the browser would drop it + if cookie := sessionCookieOf(call(s, "POST", "/api/v1/login", good, "", asJSON)); cookie == nil || cookie.Secure { + t.Errorf("expected a cookie without Secure over HTTP, got %+v", cookie) + } + + tests := []struct { + body string + prepare func(*http.Request) + code int + wantMessage string + }{ + {`{"user":"alice","password":"wrong"}`, asJSON, http.StatusUnauthorized, "wrong user name or password"}, + {`{"user":"locked","password":"x"}`, asJSON, http.StatusTooManyRequests, "too many failed logins"}, + {`not json`, asJSON, http.StatusBadRequest, "send user and password"}, + // a form another site posts is not JSON + {"user=alice&password=correct+horse+battery", func(r *http.Request) { + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") + }, http.StatusUnsupportedMediaType, "JSON"}, + } + for _, tt := range tests { + rec := call(s, "POST", "/api/v1/login", tt.body, "", tt.prepare) + if rec.Code != tt.code || !strings.Contains(rec.Body.String(), tt.wantMessage) || sessionCookieOf(rec) != nil { + t.Errorf("%s: got %d %s, want %d", tt.body, rec.Code, rec.Body, tt.code) + } + } +} + +func TestLogout(t *testing.T) { + s, fake := newTestServer(t, nil) + rec := call(s, "POST", "/api/v1/logout", "{}", testSession, asJSON) + cookie := sessionCookieOf(rec) + if rec.Code != 200 || cookie == nil || cookie.Value != "" || cookie.MaxAge >= 0 { + t.Errorf("expected the cookie to be cleared, got %d %+v", rec.Code, cookie) + } + if fake.loggedOut.Load() != testSession { + t.Errorf("expected the collector to end %q, got %v", testSession, fake.loggedOut.Load()) + } + if rec := call(s, "POST", "/api/v1/logout", "", testSession, nil); rec.Code != http.StatusUnsupportedMediaType { + t.Errorf("expected 415 for a logout that is not JSON, got %d", rec.Code) + } +} + +func TestSessionIsCached(t *testing.T) { + s, fake := newTestServer(t, nil) + for i := 0; i < 3; i++ { + call(s, "GET", "/api/v1/fleet", "", testSession, nil) + } + if checks := fake.sessionChecks.Load(); checks != 1 { + t.Errorf("expected the collector to be asked once, got %d", checks) + } +} + +func TestMetricsAuth(t *testing.T) { + s, fake := newTestServer(t, nil) + s.metricsAuth = true + basic := func(user, password string) func(*http.Request) { + return func(r *http.Request) { r.SetBasicAuth(user, password) } + } + + rec := call(s, "GET", "/metrics", "", "", nil) + if rec.Code != 401 || !strings.HasPrefix(rec.Header().Get("WWW-Authenticate"), "Basic") { + t.Errorf("expected a basic auth challenge, got %d %q", rec.Code, rec.Header().Get("WWW-Authenticate")) + } + if rec := call(s, "GET", "/metrics", "", "", basic("alice", "wrong")); rec.Code != 401 { + t.Errorf("expected 401 for a wrong password, got %d", rec.Code) + } + // a right password is checked once, then remembered + for i := 0; i < 2; i++ { + if rec := call(s, "GET", "/metrics", "", "", basic("alice", "correct horse battery")); rec.Code != 200 { + t.Errorf("expected metrics with alice's password, got %d", rec.Code) + } + } + if checks := fake.passwordChecks.Load(); checks != 2 { + t.Errorf("expected one check for the wrong and one for the right password, got %d", checks) + } + if rec := call(s, "GET", "/metrics", "", testSession, nil); rec.Code != 200 { + t.Errorf("expected metrics for a logged in browser, got %d", rec.Code) + } +} diff --git a/client/internal/server/metrics.go b/client/internal/server/metrics.go index 595e2d2..beccd5b 100644 --- a/client/internal/server/metrics.go +++ b/client/internal/server/metrics.go @@ -20,6 +20,9 @@ func (s *server) getMetrics(w http.ResponseWriter, r *http.Request) { http.Error(w, "metrics are switched off with SYMON_CLIENT_METRICS_ENABLED=false", http.StatusNotFound) return } + if !s.metricsAllowed(w, r) { + return + } response, err := s.collector.Snapshots(r.Context(), &api.Void{}) if err != nil { writeGRPCError(w, "snapshots", err) diff --git a/client/internal/server/server.go b/client/internal/server/server.go index eb5c5c7..b59f49c 100644 --- a/client/internal/server/server.go +++ b/client/internal/server/server.go @@ -33,6 +33,9 @@ type server struct { agentCollector string downloadsDir string metricsEnabled bool + // metricsAuth makes /metrics need a user too + metricsAuth bool + auth authCache } // Run starts the server on the given address, like ":8080" @@ -53,6 +56,7 @@ func Run(address string) { agentCollector: config.AgentCollectorEndpoint, downloadsDir: config.DownloadsDir, metricsEnabled: config.MetricsEnabled, + metricsAuth: config.MetricsAuth, } httpServer := &http.Server{ Addr: address, @@ -63,22 +67,30 @@ func Run(address string) { log.Fatal(httpServer.ListenAndServe()) } +// routes serves the API's data only to logged in browsers. The login calls, +// the app itself and what new hosts download need no login. func (s *server) routes() http.Handler { - mux := http.NewServeMux() - mux.HandleFunc("GET /api/v1/config", s.getConfig) - mux.HandleFunc("GET /api/v1/fleet", s.getFleet) - mux.HandleFunc("GET /api/v1/hosts/{host}", s.getHost) - mux.HandleFunc("GET /api/v1/hosts/{host}/series", s.getSeries) - mux.HandleFunc("GET /api/v1/hosts/{host}/processes", s.getProcesses) - mux.HandleFunc("GET /api/v1/hosts/{host}/process-usage", s.getProcessUsage) - mux.HandleFunc("GET /api/v1/hosts/{host}/custom-metrics", s.getCustomMetrics) - mux.HandleFunc("GET /api/v1/hosts/{host}/disk-forecasts", s.getDiskForecasts) - mux.HandleFunc("GET /api/v1/alerts", s.getAlerts) - mux.HandleFunc("GET /api/v1/endpoints", s.getEndpoints) - mux.HandleFunc("GET /api/v1/endpoints/series", s.getEndpointSeries) - mux.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { + data := http.NewServeMux() + data.HandleFunc("GET /api/v1/config", s.getConfig) + data.HandleFunc("GET /api/v1/fleet", s.getFleet) + data.HandleFunc("GET /api/v1/hosts/{host}", s.getHost) + data.HandleFunc("GET /api/v1/hosts/{host}/series", s.getSeries) + data.HandleFunc("GET /api/v1/hosts/{host}/processes", s.getProcesses) + data.HandleFunc("GET /api/v1/hosts/{host}/process-usage", s.getProcessUsage) + data.HandleFunc("GET /api/v1/hosts/{host}/custom-metrics", s.getCustomMetrics) + data.HandleFunc("GET /api/v1/hosts/{host}/disk-forecasts", s.getDiskForecasts) + data.HandleFunc("GET /api/v1/alerts", s.getAlerts) + data.HandleFunc("GET /api/v1/endpoints", s.getEndpoints) + data.HandleFunc("GET /api/v1/endpoints/series", s.getEndpointSeries) + data.HandleFunc("/api/", func(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusNotFound, "no such endpoint") }) + + mux := http.NewServeMux() + mux.Handle("/api/", s.requireLogin(data)) + mux.HandleFunc("POST /api/v1/login", s.postLogin) + mux.HandleFunc("POST /api/v1/logout", s.postLogout) + mux.HandleFunc("GET /api/v1/session", s.getSession) mux.HandleFunc("GET /metrics", s.getMetrics) mux.HandleFunc("GET /install.sh", s.getInstallScript) mux.HandleFunc("GET /downloads/{file}", s.getDownload) @@ -505,6 +517,10 @@ func writeGRPCError(w http.ResponseWriter, what string, err error) { writeError(w, 499, "canceled") case codes.NotFound: writeError(w, http.StatusNotFound, st.Message()) + case codes.Unauthenticated: + writeError(w, http.StatusUnauthorized, st.Message()) + case codes.ResourceExhausted: + writeError(w, http.StatusTooManyRequests, st.Message()) case codes.InvalidArgument: writeError(w, http.StatusBadRequest, st.Message()) case codes.Unavailable, codes.DeadlineExceeded: diff --git a/client/internal/server/server_test.go b/client/internal/server/server_test.go index c706d70..6a61ab4 100644 --- a/client/internal/server/server_test.go +++ b/client/internal/server/server_test.go @@ -10,8 +10,10 @@ import ( "net/http/httptest" "os" "strings" + "sync/atomic" "testing" "testing/fstest" + "time" "github.com/dhamith93/SyMon/internal/api" "github.com/dhamith93/SyMon/internal/transport" @@ -25,6 +27,57 @@ type fakeCollector struct { api.UnimplementedMonitorDataServiceServer lastSeries *api.SeriesRequest lastProcessUsage *api.ProcessUsageRequest + + // login: testSession is valid, alice's password is "correct horse + // battery", and the user "locked" has failed too often + noUsers atomic.Bool + sessionChecks atomic.Int32 + passwordChecks atomic.Int32 + loggedOut atomic.Value +} + +const testSession = "test-session" + +func (f *fakeCollector) CheckSession(ctx context.Context, in *api.SessionRequest) (*api.SessionInfo, error) { + f.sessionChecks.Add(1) + if in.Token == testSession || in.Token == "new-token" { + return &api.SessionInfo{User: "tester", Expires: time.Now().Add(time.Hour).Unix()}, nil + } + return nil, status.Error(codes.Unauthenticated, "not logged in") +} + +func (f *fakeCollector) checkCredentials(in *api.Credentials) error { + switch { + case in.User == "locked": + return status.Error(codes.ResourceExhausted, "too many failed logins, try again later") + case in.User == "alice" && in.Password == "correct horse battery": + return nil + } + return status.Error(codes.Unauthenticated, "wrong user name or password") +} + +func (f *fakeCollector) Login(ctx context.Context, in *api.Credentials) (*api.SessionInfo, error) { + if err := f.checkCredentials(in); err != nil { + return nil, err + } + return &api.SessionInfo{Token: "new-token", User: in.User, Expires: 1900000000}, nil +} + +func (f *fakeCollector) CheckPassword(ctx context.Context, in *api.Credentials) (*api.Message, error) { + f.passwordChecks.Add(1) + if err := f.checkCredentials(in); err != nil { + return nil, err + } + return &api.Message{Body: "ok"}, nil +} + +func (f *fakeCollector) Logout(ctx context.Context, in *api.SessionRequest) (*api.Message, error) { + f.loggedOut.Store(in.Token) + return &api.Message{Body: "ok"}, nil +} + +func (f *fakeCollector) HasUsers(ctx context.Context, in *api.Void) (*api.UserStatus, error) { + return &api.UserStatus{HasUsers: !f.noUsers.Load()}, nil } func (f *fakeCollector) Fleet(ctx context.Context, in *api.Void) (*api.FleetSummary, error) { @@ -126,7 +179,8 @@ func newTestServer(t *testing.T, files fstest.MapFS) (*server, *fakeCollector) { return &server{collector: api.NewMonitorDataServiceClient(conn), refreshSeconds: 15, files: files, metricsEnabled: true}, fake } -// get calls the server and returns the status, the body and what was logged +// get calls the server as a logged in browser and returns the status, the +// body and what was logged func get(t *testing.T, s *server, url string) (int, string, string) { t.Helper() var logs bytes.Buffer @@ -134,7 +188,9 @@ func get(t *testing.T, s *server, url string) (int, string, string) { t.Cleanup(func() { log.SetOutput(os.Stderr) }) rec := httptest.NewRecorder() - s.routes().ServeHTTP(rec, httptest.NewRequest("GET", url, nil)) + request := httptest.NewRequest("GET", url, nil) + request.AddCookie(&http.Cookie{Name: sessionCookie, Value: testSession}) + s.routes().ServeHTTP(rec, request) return rec.Code, rec.Body.String(), logs.String() } diff --git a/internal/config/config_handler.go b/internal/config/config_handler.go index 3ab33ce..bcf12a0 100644 --- a/internal/config/config_handler.go +++ b/internal/config/config_handler.go @@ -52,6 +52,8 @@ type Client struct { AgentCollectorEndpoint string // MetricsEnabled serves /metrics for Prometheus, on unless set to false MetricsEnabled bool + // MetricsAuth makes /metrics need a user, by session or basic auth + MetricsAuth bool } type AlertProcessor struct { @@ -212,6 +214,7 @@ func GetClient() Client { DownloadsDir: downloadsDir, AgentCollectorEndpoint: os.Getenv("SYMON_CLIENT_AGENT_COLLECTOR_ENDPOINT"), MetricsEnabled: strings.ToUpper(os.Getenv("SYMON_CLIENT_METRICS_ENABLED")) != "FALSE", + MetricsAuth: strings.ToUpper(os.Getenv("SYMON_CLIENT_METRICS_AUTH")) == "TRUE", } } From 1dd988c39fbb2dc87ea3d52994de731277f73ec2 Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Wed, 30 Sep 2026 00:06:03 +0530 Subject: [PATCH 4/6] add login and setup pages to the dashboard --- client/web/e2e/smoke.spec.ts | 21 ++++++- client/web/src/App.svelte | 42 ++++++++++++-- client/web/src/lib/api.ts | 47 ++++++++++++--- client/web/src/lib/auth.svelte.ts | 45 +++++++++++++++ client/web/src/lib/config.svelte.ts | 16 +++--- client/web/src/pages/Login.svelte | 88 +++++++++++++++++++++++++++++ client/web/src/pages/Setup.svelte | 41 ++++++++++++++ 7 files changed, 276 insertions(+), 24 deletions(-) create mode 100644 client/web/src/lib/auth.svelte.ts create mode 100644 client/web/src/pages/Login.svelte create mode 100644 client/web/src/pages/Setup.svelte diff --git a/client/web/e2e/smoke.spec.ts b/client/web/e2e/smoke.spec.ts index 81c6472..f27d461 100644 --- a/client/web/e2e/smoke.spec.ts +++ b/client/web/e2e/smoke.spec.ts @@ -1,5 +1,15 @@ import { expect, test, type Page } from '@playwright/test'; +// the dashboard needs a login, so every test starts logged in as this user +const user = process.env.SYMON_E2E_USER ?? ''; +const password = process.env.SYMON_E2E_PASSWORD ?? ''; + +test.beforeEach(async ({ page }) => { + expect(user && password, 'set SYMON_E2E_USER and SYMON_E2E_PASSWORD').toBeTruthy(); + const response = await page.request.post('/api/v1/login', { data: { user, password } }); + expect(response.ok(), `log in as ${user}`).toBeTruthy(); +}); + // fail on anything the app logs as an error function watchErrors(page: Page): string[] { const errors: string[] = []; @@ -63,8 +73,8 @@ test('unknown pages and hosts are handled', async ({ page }) => { await expect(page.getByText('This host has not sent any data yet.')).toBeVisible(); }); -test('containers show on a host that runs them', async ({ page, request }) => { - const fleet = await (await request.get('/api/v1/fleet')).json(); +test('containers show on a host that runs them', async ({ page }) => { + const fleet = await (await page.request.get('/api/v1/fleet')).json(); const host = fleet.hosts.find((h: { containers: number }) => h.containers > 0); test.skip(!host, 'no host reports containers'); @@ -74,3 +84,10 @@ test('containers show on a host that runs them', async ({ page, request }) => { await expect(table.locator('tbody tr')).not.toHaveCount(0); await expect(page.getByRole('heading', { name: 'Container CPU, share of the host' })).toBeVisible(); }); + +test('logging out brings back the login page', async ({ page }) => { + await page.goto('/'); + await page.getByRole('button', { name: 'Log out' }).click(); + await expect(page.getByRole('heading', { name: 'Log in' })).toBeVisible(); + expect((await page.request.get('/api/v1/fleet')).status()).toBe(401); +}); diff --git a/client/web/src/App.svelte b/client/web/src/App.svelte index ad72aa9..f77deb3 100644 --- a/client/web/src/App.svelte +++ b/client/web/src/App.svelte @@ -1,5 +1,6 @@ @@ -28,10 +36,16 @@ SyMon + {#if auth.state === 'in'} + {auth.user} + + {/if} + + {#if error}{/if} + + + + + diff --git a/client/web/src/pages/Setup.svelte b/client/web/src/pages/Setup.svelte new file mode 100644 index 0000000..d005f3a --- /dev/null +++ b/client/web/src/pages/Setup.svelte @@ -0,0 +1,41 @@ + + +
+
+

Create the first user

+

+ SyMon is locked until it has a user. On the server that runs the collector, run the following. It prints the new user's + password. +

+
sudo /opt/symon/collector_linux_x86_64/collector_linux_x86_64 -add-user <name>
+ +
+
+ + From 0bdf9c29b886d8788ff94837669a41611834dc7a Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Wed, 30 Sep 2026 00:06:03 +0530 Subject: [PATCH 5/6] document the dashboard login --- README.MD | 18 +++++++++++++----- docs/install.md | 26 ++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/README.MD b/README.MD index 685383e..9ae2791 100644 --- a/README.MD +++ b/README.MD @@ -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 @@ -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. @@ -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 ` 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 ` 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) diff --git a/docs/install.md b/docs/install.md index 3e068a1..2a35038 100644 --- a/docs/install.md +++ b/docs/install.md @@ -122,7 +122,15 @@ SYMON_CLIENT_COLLECTOR_ENDPOINT=localhost:9000 SYMON_KEY=... ``` -The dashboard has no login. Keep it on a private network, or put nginx or Apache in front of it for HTTPS and a password. +The dashboard needs a login, and stays locked until the first user exists. Create one, which prints its password: + +```sh +sudo /opt/symon/collector_linux_x86_64/collector_linux_x86_64 -add-user admin +``` + +`-password-stdin` sets a password of your own instead, at least 12 characters. `-list-users`, `-reset-password ` and `-remove-user ` manage users. A login lasts 30 days, and a new password or a removed user logs that user out everywhere within a minute. After 10 wrong passwords a user name is locked for 15 minutes. + +Put Caddy or nginx in front of the dashboard for HTTPS, so passwords and the session cookie are encrypted. The install script and agent downloads need no login, so new hosts can still enroll. ### 5. Start everything @@ -261,7 +269,15 @@ scrape_configs: - targets: ["symon.example.lan:8080"] ``` -Every value has a `host` label. Disks, interfaces, sensors, services and containers have their own labels too. Custom metrics sent in the last 2 days show as `symon_custom_metric` with `name` and `unit` labels, and `symon_custom_metric_timestamp_seconds` says when each was sent, so you can alert when a job stops reporting. `symon_up` is 0 for a host that stopped reporting, and its other values are left out until it reports again. Like the rest of the dashboard, `/metrics` has no login, so keep it behind the same reverse proxy or firewall. To switch it off, add `SYMON_CLIENT_METRICS_ENABLED=false` to `/etc/symon/client.env` and restart the dashboard. +Every value has a `host` label. Disks, interfaces, sensors, services and containers have their own labels too. Custom metrics sent in the last 2 days show as `symon_custom_metric` with `name` and `unit` labels, and `symon_custom_metric_timestamp_seconds` says when each was sent, so you can alert when a job stops reporting. `symon_up` is 0 for a host that stopped reporting, and its other values are left out until it reports again. `/metrics` needs no login unless `SYMON_CLIENT_METRICS_AUTH=true` is in `/etc/symon/client.env`. Then it takes a SyMon user's name and password as HTTP basic auth. Create a user for Prometheus and add it to the scrape config: + +```yaml + basic_auth: + username: prometheus + password_file: /etc/prometheus/symon-password +``` + +To switch `/metrics` off, add `SYMON_CLIENT_METRICS_ENABLED=false` to `/etc/symon/client.env`. Restart the dashboard after changing either. ## Upgrades @@ -278,6 +294,8 @@ sudo systemctl start symon_collector symon_client The collector updates the database schema by itself when it starts. Back up the database first (see below) if you want a way back, because schema changes are not undone by going back to an older build. +Coming from a version without logins, the dashboard is locked after the upgrade until you create a user with `-add-user`, as in [Set up the dashboard](#4-set-up-the-dashboard). + **Checking versions.** Every binary prints its version with `-version`, for example `/opt/symon/collector_linux_x86_64/collector_linux_x86_64 -version`, and logs it when it starts. The dashboard footer shows its own version, and the collector's too when they differ. Each host's page shows the version of its agent, so you can see which hosts still need the upgrade below. **Hosts.** Run the install command again on each host. No token is needed: the script sees the host is already enrolled, replaces the agent with the build the dashboard now serves, and keeps the key and the settings. @@ -347,6 +365,10 @@ sudo -u postgres dropuser symon **The dashboard shows data only for part of a long range.** Longer ranges come from 1 minute and 1 hour averages, which are refreshed every few minutes. Recent data appears there shortly after it arrives. +**Forgot a password.** On the collector's host, `collector -reset-password ` prints a new one. + +**"Too many failed logins".** That user name had 10 wrong passwords within 15 minutes. Wait 15 minutes, or reset the password. + ## Settings reference Every setting, with a comment on what it does, is in the component's `.env-example`, which also ships in each bundle: From d33d4a08f8ad14566c195ad89f7d316c1c957acd Mon Sep 17 00:00:00 2001 From: Dhamith Hewamullage Date: Wed, 30 Sep 2026 00:12:55 +0530 Subject: [PATCH 6/6] pick a reporting host in the smoke tests --- client/web/e2e/smoke.spec.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/client/web/e2e/smoke.spec.ts b/client/web/e2e/smoke.spec.ts index f27d461..7c0e540 100644 --- a/client/web/e2e/smoke.spec.ts +++ b/client/web/e2e/smoke.spec.ts @@ -23,13 +23,18 @@ function watchErrors(page: Page): string[] { test('fleet, host, zoom, processes at a time, alerts', async ({ page }) => { const errors = watchErrors(page); + // hosts that stopped reporting come first and have no recent data to chart + const fleet = await (await page.request.get('/api/v1/fleet')).json(); + const reporting = fleet.hosts.find((h: { up: boolean }) => h.up); + expect(reporting, 'needs a host that is reporting').toBeTruthy(); + const hostName: string = reporting.name; + await page.goto('/'); await expect(page.getByRole('heading', { name: 'Hosts' })).toBeVisible(); - const firstHost = page.locator('a.host').first(); - await expect(firstHost).toBeVisible(); - const hostName = (await firstHost.locator('.name').textContent())!.trim(); + const hostCard = page.locator('a.host').filter({ has: page.getByText(hostName, { exact: true }) }); + await expect(hostCard).toBeVisible(); - await firstHost.click(); + await hostCard.click(); await expect(page).toHaveURL(new RegExp(`/hosts/${encodeURIComponent(hostName)}`)); await expect(page.getByRole('heading', { name: hostName, level: 1 })).toBeVisible(); const cpuChart = page.locator('figure', { hasText: 'CPU usage' }).first().locator('.u-over'); @@ -75,8 +80,8 @@ test('unknown pages and hosts are handled', async ({ page }) => { test('containers show on a host that runs them', async ({ page }) => { const fleet = await (await page.request.get('/api/v1/fleet')).json(); - const host = fleet.hosts.find((h: { containers: number }) => h.containers > 0); - test.skip(!host, 'no host reports containers'); + const host = fleet.hosts.find((h: { up: boolean; containers: number }) => h.up && h.containers > 0); + test.skip(!host, 'no reporting host has containers'); await page.goto(`/hosts/${encodeURIComponent(host.name)}`); const table = page.locator('table', { has: page.locator('caption', { hasText: 'Running containers' }) });