gates / gates (push) Successful in 48s
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
218 lines
9.2 KiB
Go
218 lines
9.2 KiB
Go
package osupdate
|
|
|
|
// The security-profile refresh (R-1388, R-1301, `11` §5.12). After a kernel went back and forth a box can load an OLD
|
|
// compiled AppArmor profile for a program (on the N100 this stopped the time service). The root wrapper's mode
|
|
// profile-refresh loads every profile from a fresh compile, once per kernel the box starts on, and restarts only a
|
|
// failed service whose own program's profile was stale. This file decides WHEN to ask: at every agent start and after
|
|
// a config bundle was installed (a new wrapper may know the mode the old one refused) — the wrapper itself answers
|
|
// "not needed" when this kernel was already done. No OS report is sent for it (an unknown layer reads as a GUEST step on
|
|
// the hub); since release 3 the result rides the host report as the `profile_refresh` stanza (ProfileRefreshStanza).
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ProfileRefreshResult is the wrapper's profile_refresh object.
|
|
type ProfileRefreshResult struct {
|
|
Outcome string `json:"outcome"` // refreshed | not_needed | dry_run | failed
|
|
Reason string `json:"reason"`
|
|
Kernel string `json:"kernel"`
|
|
Rewritten []string `json:"rewritten"`
|
|
Restarted []struct {
|
|
Unit string `json:"unit"`
|
|
Program string `json:"program"`
|
|
RC int `json:"rc"`
|
|
Active bool `json:"active"`
|
|
} `json:"restarted"`
|
|
Seconds float64 `json:"seconds"`
|
|
// The wrapper's own record of the refresh BEFORE this ask (it answers them at every ask, also "not needed").
|
|
LastRefreshKernel string `json:"last_refresh_kernel"`
|
|
LastRefreshAt string `json:"last_refresh_at"`
|
|
}
|
|
|
|
// DefaultProfileRecordPath is where the root wrapper keeps the record of its last refresh (PROFILE_STATE, 0644).
|
|
const DefaultProfileRecordPath = "/var/lib/felhom-os-apply/profile-refresh.json"
|
|
|
|
// ProfileRefreshStatus is the host report's `profile_refresh` stanza (R-1383, release 3; hub sysfacts.ProfileRefresh,
|
|
// field-exact): the last refresh the box did — from the root wrapper's own record, so it outlives an agent restart —
|
|
// and how the last ask of THIS agent process ended. The refresh restarted a failed service on the N100 and nobody
|
|
// outside the box's journal could see it.
|
|
type ProfileRefreshStatus struct {
|
|
Kernel string `json:"kernel,omitempty"` // the kernel the last refresh ran on
|
|
At string `json:"at,omitempty"` // RFC3339, when
|
|
Rewritten *int `json:"rewritten,omitempty"` // how many compiled profiles changed; nil = not known to this process
|
|
Restarted []string `json:"restarted,omitempty"` // units it restarted (failed, with a stale profile)
|
|
LastAsk string `json:"last_ask,omitempty"` // refreshed | not_needed | failed | refused | no_answer
|
|
LastAskAt string `json:"last_ask_at,omitempty"`
|
|
}
|
|
|
|
var profileNameRe = regexp.MustCompile(`^[A-Za-z0-9@._:+-]{1,120}$`)
|
|
|
|
// ProfileRefreshStanza builds the stanza, or an UNTYPED nil when there is nothing to say (never asked and no record: a
|
|
// BYO host, or an agent that did not ask yet) — the report then omits the field.
|
|
//
|
|
// Two sources, the first that knows wins per field. (1) What the wrapper ANSWERED to this process's last ask: a
|
|
// refresh gives everything; "not needed" gives the kernel and time of the earlier refresh (the wrapper answers its own
|
|
// record at every ask) — so the last refresh is still told after an agent restart, without the detail. (2) The
|
|
// wrapper's record file, when this process may read it: on boxes where its folder was first made by a signed job it
|
|
// is root-only (0700), so the file is a bonus, never the only source (found by review, 2026-11-14; not read on a box).
|
|
// It never calls the wrapper. Names that are not plain unit / kernel names are dropped, the list is capped.
|
|
func (l *Leg) ProfileRefreshStanza() any {
|
|
l.mu.Lock()
|
|
st, ask, at := l.profileLast, l.profileAsk, l.profileAskAt
|
|
l.mu.Unlock()
|
|
have := ask != ""
|
|
if have {
|
|
st.LastAsk, st.LastAskAt = ask, at.UTC().Format(time.RFC3339)
|
|
}
|
|
path := l.ProfileRecordPath
|
|
if path == "" {
|
|
path = DefaultProfileRecordPath
|
|
}
|
|
if raw, err := os.ReadFile(path); err == nil && len(raw) < 64<<10 {
|
|
var rec struct {
|
|
Kernel string `json:"kernel"`
|
|
At string `json:"at"`
|
|
Rewritten []string `json:"rewritten"`
|
|
Restarted []string `json:"restarted"`
|
|
}
|
|
if json.Unmarshal(raw, &rec) == nil && (rec.Kernel != "" || rec.At != "") {
|
|
have = true
|
|
fromRec := profileStatusFrom(rec.Kernel, rec.At, rec.Rewritten, rec.Restarted, true)
|
|
if st.At == "" || fromRec.At == st.At || st.Rewritten == nil {
|
|
fromRec.LastAsk, fromRec.LastAskAt = st.LastAsk, st.LastAskAt
|
|
st = fromRec
|
|
}
|
|
}
|
|
}
|
|
if !have {
|
|
return nil
|
|
}
|
|
return st
|
|
}
|
|
|
|
// profileStatusFrom checks and bounds what goes to the hub. detail=false: the count and the units are not known.
|
|
func profileStatusFrom(kernel, at string, rewritten, restarted []string, detail bool) ProfileRefreshStatus {
|
|
var st ProfileRefreshStatus
|
|
if profileNameRe.MatchString(kernel) {
|
|
st.Kernel = kernel
|
|
}
|
|
if _, err := time.Parse(time.RFC3339, at); err == nil {
|
|
st.At = at
|
|
}
|
|
if detail {
|
|
n := len(rewritten)
|
|
st.Rewritten = &n
|
|
for _, u := range restarted {
|
|
if len(st.Restarted) < 16 && profileNameRe.MatchString(u) {
|
|
st.Restarted = append(st.Restarted, u)
|
|
}
|
|
}
|
|
}
|
|
return st
|
|
}
|
|
|
|
// noteProfileAsk remembers how an ask ended and, when the wrapper answered, what it said about the last refresh.
|
|
func (l *Leg) noteProfileAsk(outcome string, res *ProfileRefreshResult) {
|
|
l.mu.Lock()
|
|
defer l.mu.Unlock()
|
|
l.profileAsk, l.profileAskAt = outcome, l.now()
|
|
if res == nil {
|
|
return
|
|
}
|
|
switch outcome {
|
|
case "refreshed":
|
|
units := make([]string, 0, len(res.Restarted))
|
|
for _, u := range res.Restarted {
|
|
units = append(units, u.Unit)
|
|
}
|
|
l.profileLast = profileStatusFrom(res.Kernel, l.now().UTC().Format(time.RFC3339), res.Rewritten, units, true)
|
|
case "not_needed":
|
|
if res.LastRefreshAt != "" || res.LastRefreshKernel != "" {
|
|
l.profileLast = profileStatusFrom(res.LastRefreshKernel, res.LastRefreshAt, nil, nil, false)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ProfileGate takes the heavy-op gate (a backup, a restore-test or an OS step must not run beside the refresh).
|
|
type ProfileGate func() (release func(), busy string, ok bool)
|
|
|
|
// ProfileRefresh asks the wrapper once. It returns the outcome ("" when the wrapper could not be asked or refused).
|
|
// It waits for the gate: up to `tries` attempts, `wait` apart. why is the log's short reason for this call.
|
|
func (l *Leg) ProfileRefresh(ctx context.Context, why string, gate ProfileGate, tries int, wait time.Duration) string {
|
|
lg := l.log().With("mode", "profile-refresh", "why", why)
|
|
if !l.Appliance {
|
|
lg.Info("osupdate: profile refresh skipped — not an appliance install (a BYO host belongs to its owner)")
|
|
return ""
|
|
}
|
|
var release func()
|
|
for i := 1; ; i++ {
|
|
if gate == nil {
|
|
break
|
|
}
|
|
r, busy, ok := gate()
|
|
if ok {
|
|
release = r
|
|
break
|
|
}
|
|
if i >= tries || ctx.Err() != nil {
|
|
lg.Warn("osupdate: profile refresh NOT run — the box stayed busy; the next agent start asks again", "busy", busy, "tries", i)
|
|
return ""
|
|
}
|
|
lg.Info("osupdate: profile refresh waits — a heavy operation runs", "busy", busy, "try", i)
|
|
l.sleep(ctx, wait)
|
|
}
|
|
if release != nil {
|
|
defer release()
|
|
}
|
|
unlock := l.lockPass(true) // never beside an OS-update pass
|
|
defer unlock()
|
|
start := l.now()
|
|
wr, err := l.call(ctx, "prefresh"+l.now().UTC().Format("150405"), map[string]any{"release_id": "profile-refresh",
|
|
"layer": LayerHost, "mode": "profile-refresh"})
|
|
took := l.now().Sub(start).Round(100 * time.Millisecond).String()
|
|
switch {
|
|
case err != nil:
|
|
lg.Warn("osupdate: profile refresh — the wrapper gave no answer; the next agent start asks again", "err", err, "took", took)
|
|
l.noteProfileAsk("no_answer", nil)
|
|
return ""
|
|
case wr.refused():
|
|
lg.Info("osupdate: profile refresh refused (an older wrapper that does not know the mode, or a BYO host)", "refused", string(wr.Refused))
|
|
l.noteProfileAsk("refused", nil)
|
|
return ""
|
|
case wr.failed():
|
|
lg.Error("osupdate: profile refresh FAILED — the profiles are as before; the next agent start asks again", "failed", string(wr.Failed), "took", took)
|
|
l.noteProfileAsk("failed", nil)
|
|
return "failed"
|
|
}
|
|
var res ProfileRefreshResult
|
|
_ = json.Unmarshal(wr.ProfileRefresh, &res)
|
|
switch res.Outcome {
|
|
case "refreshed":
|
|
units := make([]string, 0, len(res.Restarted))
|
|
for _, u := range res.Restarted {
|
|
units = append(units, u.Unit)
|
|
}
|
|
if len(units) > 0 {
|
|
lg.Warn("osupdate: profile refresh — security profiles reloaded from a fresh compile; a FAILED service whose profile was stale was restarted",
|
|
"kernel", res.Kernel, "restarted", strings.Join(units, ","), "rewritten", len(res.Rewritten), "wrapper_seconds", res.Seconds, "took", took)
|
|
} else {
|
|
lg.Info("osupdate: profile refresh — security profiles reloaded from a fresh compile", "kernel", res.Kernel,
|
|
"rewritten", len(res.Rewritten), "wrapper_seconds", res.Seconds, "took", took)
|
|
}
|
|
case "not_needed":
|
|
lg.Info("osupdate: profile refresh not needed", "kernel", res.Kernel, "reason", res.Reason)
|
|
default:
|
|
lg.Warn("osupdate: profile refresh — the wrapper answered with no outcome this agent knows", "outcome", res.Outcome)
|
|
}
|
|
if res.Outcome == "refreshed" || res.Outcome == "not_needed" {
|
|
l.noteProfileAsk(res.Outcome, &res)
|
|
}
|
|
return res.Outcome
|
|
}
|