Files
ayurishchevandClaude Sonnet 5.5 debf2afed2 Add authentication: admin/agent bearer tokens for the API, login for the dashboard
control-api: every route now carries a mandatory access level (admin / agent /
open) in a route table. All /api/v1/admin/* require the admin token; the
write calls of validator-agent and prober (self-check, events, results,
complete) require a separate static agent token; register, heartbeat and
fetching the assignment stay open. Tokens come from env vars, are compared in
constant time and never logged. An empty token leaves that level open with a
startup warning (backward compatible).

validator-agent / prober: apiclient sends the agent token only to control-api.

admin-dashboard: login/password (from env) with a stateless HMAC session
cookie, Origin-based CSRF check, per-IP brute-force throttle, HX-Redirect for
htmx polls, logout in the sidebar; the dashboard calls control-api with the
admin token. Login page layout fixed after review.

Also: env plumbing in docker-compose/rxprod-compose/systemd/config examples,
e2e script with token assertions, tests, docs (API, SETUP, USAGE, DASHBOARD,
README), plan and review under docs/changes/, bin/ rebuilt with new
SHA256SUMS.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 11:35:24 +03:00

145 lines
4.5 KiB
Go

package dashboard
import (
"fmt"
"net/http"
)
// usedByTag names one check type that references a target group, and
// whether that check type is currently enabled — rendered as a status pill
// next to the group so the relationship (invisible before this page had
// this column) is visible without cross-referencing /check-types by hand.
type usedByTag struct {
Name string
Enabled bool
}
// targetGroupView is targetGroupDTO plus the check types that reference it,
// resolved server-side from ListCheckTypes so the template stays a plain
// range with no cross-referencing logic of its own.
type targetGroupView struct {
targetGroupDTO
UsedBy []usedByTag
}
type targetsStats struct {
Groups int
TotalTargets int
CheckTypesUsing int
Unused int
}
type targetsPageData struct {
PageData
Items []targetGroupView
Stats targetsStats
}
// loadTargetsPage fetches target groups and check types together and
// resolves the group -> referencing-check-types relationship server-side.
// A ListCheckTypes failure degrades to "no usage info" rather than hiding
// the group list — the error still surfaces via the banner.
func (s *Server) loadTargetsPage(r *http.Request) (targetsPageData, error) {
groups, err := s.CA.ListTargetGroups(r.Context())
if err != nil {
return targetsPageData{}, err
}
checkTypes, ctErr := s.CA.ListCheckTypes(r.Context())
usedBy := make(map[string][]usedByTag)
checkTypesUsing := 0
for _, ct := range checkTypes {
if len(ct.Targets) > 0 {
checkTypesUsing++
}
for _, g := range ct.Targets {
usedBy[g] = append(usedBy[g], usedByTag{Name: ct.Name, Enabled: ct.Enabled})
}
}
items := make([]targetGroupView, len(groups))
totalTargets := 0
unused := 0
for i, g := range groups {
items[i] = targetGroupView{targetGroupDTO: g, UsedBy: usedBy[g.Name]}
totalTargets += len(g.Targets)
if len(usedBy[g.Name]) == 0 {
unused++
}
}
return targetsPageData{
Items: items,
Stats: targetsStats{
Groups: len(groups),
TotalTargets: totalTargets,
CheckTypesUsing: checkTypesUsing,
Unused: unused,
},
}, ctErr
}
func (s *Server) handleTargetsPage(w http.ResponseWriter, r *http.Request) {
data, err := s.loadTargetsPage(r)
data.ActiveNav = "targets"
data.Banner = bannerFor(err)
s.renderPage(w, r, "targets_page", data)
}
// renderTargetsTable renders the #targets-table-wrap swap target plus,
// appended to the same response, an out-of-band update of #targets-stats-wrap
// (see targets_stats_oob in targets.html) — that block sits above the create
// form, outside the swap target, so it needs its own refresh to stay live
// after a create/update/delete. Can't use the shared renderFragment helper
// here because of that second OOB block; otherwise mirrors it exactly,
// including always appending error_banner last.
func (s *Server) renderTargetsTable(w http.ResponseWriter, r *http.Request, actionErr error) {
data, listErr := s.loadTargetsPage(r)
if actionErr == nil {
actionErr = listErr
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := s.tmpl.ExecuteTemplate(w, "targets_table", data); err != nil {
s.Log.Error("render fragment", "template", "targets_table", "err", err)
return
}
if err := s.tmpl.ExecuteTemplate(w, "targets_stats_oob", data); err != nil {
s.Log.Error("render targets stats oob", "err", err)
}
if err := s.tmpl.ExecuteTemplate(w, "error_banner", bannerFor(actionErr)); err != nil {
s.Log.Error("render error banner", "err", err)
}
}
func (s *Server) handleTargetCreate(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
s.renderTargetsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
name := r.PostFormValue("name")
targets := splitList(r.PostFormValue("targets"))
if name == "" {
s.renderTargetsTable(w, r, &apiErr{Status: http.StatusBadRequest, Message: "имя группы обязательно"})
return
}
err := s.CA.PutTargetGroup(r.Context(), name, targets)
s.renderTargetsTable(w, r, err)
}
func (s *Server) handleTargetUpdate(w http.ResponseWriter, r *http.Request) {
group := r.PathValue("group")
if err := r.ParseForm(); err != nil {
s.renderTargetsTable(w, r, fmt.Errorf("invalid form: %w", err))
return
}
targets := splitList(r.PostFormValue("targets"))
err := s.CA.PutTargetGroup(r.Context(), group, targets)
s.renderTargetsTable(w, r, err)
}
func (s *Server) handleTargetDelete(w http.ResponseWriter, r *http.Request) {
group := r.PathValue("group")
err := s.CA.DeleteTargetGroup(r.Context(), group)
s.renderTargetsTable(w, r, err)
}