273 lines
8.9 KiB
Go
273 lines
8.9 KiB
Go
package main
|
|
|
|
// uimachine.go — the sections of This machine (plan Addendum 4, step 4):
|
|
// scheduled jobs that start an agent, the MCP tool servers configured here
|
|
// and what is wrong with them, and what runs that the baseline does not
|
|
// hold. Each loads once when the page opens (hx-trigger="load"), apart from
|
|
// the machine's state above them, which refreshes every three seconds: these
|
|
// read files and walk processes, and none of them changes that fast.
|
|
//
|
|
// Recording a baseline takes the owner's code. It decides what counts as
|
|
// normal here, and everything in it stops being reported, so it is the act
|
|
// an agent clicking through a signed-in console would most want.
|
|
|
|
import (
|
|
"fmt"
|
|
"net/http"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type uiSchedule struct {
|
|
Agent string `json:"agent"`
|
|
Schedule string `json:"schedule"`
|
|
Where string `json:"where"`
|
|
User string `json:"user,omitempty"`
|
|
Command string `json:"command"`
|
|
}
|
|
|
|
func collectSchedules() ([]uiSchedule, bool) {
|
|
entries, scan := scanSchedulers()
|
|
var out []uiSchedule
|
|
for _, e := range entries {
|
|
args, _ := redactSecretArgs(strings.Fields(e.Command))
|
|
out = append(out, uiSchedule{
|
|
Agent: familyLabel(e.Signature), Schedule: cronWords(e.Schedule), Where: e.Kind + " " + e.Name,
|
|
User: e.User, Command: cleanText(strings.Join(args, " "), 160),
|
|
})
|
|
}
|
|
return out, scan.Supported
|
|
}
|
|
|
|
type uiToolGroup struct {
|
|
Client string `json:"client"`
|
|
Servers []uiToolServer `json:"servers"`
|
|
Bad int `json:"with_problems"`
|
|
}
|
|
|
|
// toolProblems is one tool server's problems in plain words: the same
|
|
// sentences an agent's page uses (toolServersFor).
|
|
func toolProblems(s mcpServerObs) []string {
|
|
if s.Disabled {
|
|
return nil
|
|
}
|
|
var p []string
|
|
if s.SecretInPlaintext {
|
|
p = append(p, "a key is written in plain text in "+s.Path)
|
|
}
|
|
if s.PackagePinned == "no" && s.PackageName != "" {
|
|
p = append(p, "runs "+s.PackageName+" with no fixed version, so whatever is published next is what runs")
|
|
}
|
|
if s.RunningState == "running_not_declared" {
|
|
p = append(p, "running, but not in any settings file this machine can read")
|
|
}
|
|
return p
|
|
}
|
|
|
|
func collectToolGroups() []uiToolGroup {
|
|
servers, _ := readMCPConfigs()
|
|
by := map[string]*uiToolGroup{}
|
|
var order []string
|
|
for _, s := range servers {
|
|
c := s.Client
|
|
if c == "" {
|
|
c = "not in any settings file"
|
|
}
|
|
g, ok := by[c]
|
|
if !ok {
|
|
g = &uiToolGroup{Client: familyLabel(c)}
|
|
by[c] = g
|
|
order = append(order, c)
|
|
}
|
|
name := s.Name
|
|
if name == "" {
|
|
name = s.PackageName
|
|
}
|
|
t := uiToolServer{Name: name, Where: s.Path, Problems: toolProblems(s)}
|
|
if len(t.Problems) > 0 {
|
|
g.Bad++
|
|
}
|
|
g.Servers = append(g.Servers, t)
|
|
}
|
|
sort.Strings(order)
|
|
var out []uiToolGroup
|
|
for _, c := range order {
|
|
g := by[c]
|
|
sort.SliceStable(g.Servers, func(i, j int) bool { return len(g.Servers[i].Problems) > len(g.Servers[j].Problems) })
|
|
out = append(out, *g)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// ── the baseline ────────────────────────────────────────────────────────────
|
|
|
|
type uiBaselineView struct {
|
|
Flash string
|
|
FlashClass string
|
|
Has bool
|
|
TakenAt string
|
|
Entries int
|
|
Note string
|
|
OwnerCode bool
|
|
Compared bool
|
|
ComparedAt string
|
|
State string
|
|
Findings []string
|
|
WalkFailed string
|
|
}
|
|
|
|
// lastDelta keeps the last comparison for the page: a delta pass walks every
|
|
// process and digests their files, so it runs when asked, not on every load.
|
|
var lastDelta struct {
|
|
sync.Mutex
|
|
r *deltaReport
|
|
at time.Time
|
|
}
|
|
|
|
func collectBaselineView() uiBaselineView {
|
|
v := uiBaselineView{OwnerCode: stepupEnrolled(existingMachineFactorDir())}
|
|
if f, ok, err := baselineLoad(); err == nil {
|
|
v.Has, v.TakenAt, v.Entries, v.Note = true, f.TakenAt, len(f.Entries), baselineIntegrityNote(f, ok)
|
|
}
|
|
lastDelta.Lock()
|
|
r, at := lastDelta.r, lastDelta.at
|
|
lastDelta.Unlock()
|
|
if r != nil {
|
|
v.Compared, v.ComparedAt, v.State, v.WalkFailed = true, at.Local().Format("15:04:05"), r.Baseline, r.WalkFailed
|
|
for _, ev := range r.Findings {
|
|
v.Findings = append(v.Findings, ev.Level+": "+deltaOneLine(ev))
|
|
}
|
|
}
|
|
return v
|
|
}
|
|
|
|
func (u *uiServer) machineRoutes(mux *http.ServeMux) {
|
|
mux.HandleFunc("/fragments/machine/schedules", u.guarded(u.machineSchedules))
|
|
mux.HandleFunc("/fragments/machine/tools", u.guarded(u.machineTools))
|
|
mux.HandleFunc("/fragments/machine/baseline", u.guarded(u.machineBaseline))
|
|
mux.HandleFunc("/machine/compare", u.guarded(u.machineCompare))
|
|
mux.HandleFunc("/machine/baseline", u.guarded(u.machineRecordBaseline))
|
|
mux.HandleFunc("/machine/report", u.guarded(u.machineReport))
|
|
}
|
|
|
|
// machineReport writes the report (report.go) and hands it to the browser as
|
|
// a file. A POST: writing it walks every process and compares the baseline.
|
|
func (u *uiServer) machineReport(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
now := uiNow()
|
|
rep := buildReport(now, now.Add(-7*24*time.Hour))
|
|
name := "bastion-report-" + now.Format("2006-01-02-1504") + ".md"
|
|
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
|
|
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
renderReportMarkdown(w, rep)
|
|
}
|
|
|
|
func (u *uiServer) machineSchedules(w http.ResponseWriter, r *http.Request) {
|
|
s, ok := collectSchedules()
|
|
u.renderView(w, "machine-schedules", map[string]any{"Items": s, "Supported": ok})
|
|
}
|
|
|
|
func (u *uiServer) machineTools(w http.ResponseWriter, r *http.Request) {
|
|
u.renderView(w, "machine-tools", collectToolGroups())
|
|
}
|
|
|
|
func (u *uiServer) machineBaseline(w http.ResponseWriter, r *http.Request) {
|
|
u.renderView(w, "machine-baseline", collectBaselineView())
|
|
}
|
|
|
|
func (u *uiServer) machineCompare(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
rep := deltaPass(&digestCache{}, time.Now(), deltaRoleOperator)
|
|
lastDelta.Lock()
|
|
lastDelta.r, lastDelta.at = &rep, time.Now()
|
|
lastDelta.Unlock()
|
|
u.renderView(w, "machine-baseline", collectBaselineView())
|
|
}
|
|
|
|
func (u *uiServer) machineRecordBaseline(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "POST only", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
flash, class := recordBaselineFromConsole(r.FormValue("code"))
|
|
v := collectBaselineView()
|
|
v.Flash, v.FlashClass = flash, class
|
|
u.renderView(w, "machine-baseline", v)
|
|
}
|
|
|
|
// recordBaselineFromConsole takes the owner's code, then the same baseline
|
|
// `observe:baseline --force` takes (collectBaseline, baselineSave).
|
|
func recordBaselineFromConsole(code string) (string, string) {
|
|
dir := existingMachineFactorDir()
|
|
if !stepupEnrolled(dir) {
|
|
return "Set up your code under Settings first. Recording a baseline takes it.", "warn"
|
|
}
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
return "Type the code from your authenticator.", "warn"
|
|
}
|
|
approval, err := machineCodeSpend(dir, uiNow(), func() (string, error) {
|
|
return stepupIssueApproval(dir, code, "local_baseline")
|
|
})
|
|
if err != nil {
|
|
return "Not recorded: " + err.Error() + ".", "bad"
|
|
}
|
|
if err := stepupConsumeApproval(dir, approval, "local_baseline"); err != nil {
|
|
return "Not recorded: " + err.Error() + ".", "bad"
|
|
}
|
|
f, _, _, _, err := collectBaseline()
|
|
if err != nil {
|
|
return "Not recorded: " + err.Error() + ".", "bad"
|
|
}
|
|
if err := baselineSave(f); err != nil {
|
|
return "Not recorded: " + err.Error() + ".", "bad"
|
|
}
|
|
dcnAudit(dir, "local_baseline", 0, "", "recorded: "+f.TakenAt)
|
|
lastDelta.Lock()
|
|
lastDelta.r = nil
|
|
lastDelta.Unlock()
|
|
return "Recorded. What runs here now is the baseline; anything new from here on is reported.", "ok"
|
|
}
|
|
|
|
// cronWords says the common cron schedules in words; anything else stays as
|
|
// written, which is what the file says.
|
|
func cronWords(spec string) string {
|
|
f := strings.Fields(spec)
|
|
if len(f) != 5 {
|
|
return spec
|
|
}
|
|
min, hour, dom, mon, dow := f[0], f[1], f[2], f[3], f[4]
|
|
num := func(s string) (int, bool) {
|
|
n, err := strconv.Atoi(s)
|
|
return n, err == nil
|
|
}
|
|
days := map[string]string{"0": "Sunday", "1": "Monday", "2": "Tuesday", "3": "Wednesday", "4": "Thursday",
|
|
"5": "Friday", "6": "Saturday", "7": "Sunday"}
|
|
m, mOK := num(min)
|
|
h, hOK := num(hour)
|
|
switch {
|
|
case strings.HasPrefix(min, "*/") && hour == "*" && dom == "*" && mon == "*" && dow == "*":
|
|
return "every " + strings.TrimPrefix(min, "*/") + " minutes"
|
|
case min == "*" && hour == "*" && dom == "*" && mon == "*" && dow == "*":
|
|
return "every minute"
|
|
case mOK && hour == "*" && dom == "*" && mon == "*" && dow == "*":
|
|
return fmt.Sprintf("every hour at :%02d", m)
|
|
case mOK && hOK && dom == "*" && mon == "*" && dow == "*":
|
|
return fmt.Sprintf("every day at %02d:%02d", h, m)
|
|
case mOK && hOK && dom == "*" && mon == "*" && days[dow] != "":
|
|
return fmt.Sprintf("every %s at %02d:%02d", days[dow], h, m)
|
|
}
|
|
return spec
|
|
}
|