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"` } // 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"` // how many compiled profiles changed 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 (no record and never asked: // a BYO host, or an agent that did not ask yet) — the report then omits the field. It reads one small root-written // file and never calls the wrapper. Names that are not plain unit / kernel names are dropped, the list is capped. func (l *Leg) ProfileRefreshStanza() any { path := l.ProfileRecordPath if path == "" { path = DefaultProfileRecordPath } var st ProfileRefreshStatus have := false 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 if profileNameRe.MatchString(rec.Kernel) { st.Kernel = rec.Kernel } if _, err := time.Parse(time.RFC3339, rec.At); err == nil { st.At = rec.At } st.Rewritten = len(rec.Rewritten) for _, u := range rec.Restarted { if len(st.Restarted) < 16 && profileNameRe.MatchString(u) { st.Restarted = append(st.Restarted, u) } } } } l.mu.Lock() ask, at := l.profileAsk, l.profileAskAt l.mu.Unlock() if ask != "" { have = true st.LastAsk, st.LastAskAt = ask, at.UTC().Format(time.RFC3339) } if !have { return nil } return st } func (l *Leg) noteProfileAsk(outcome string) { l.mu.Lock() l.profileAsk, l.profileAskAt = outcome, l.now() l.mu.Unlock() } // 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") 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") 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") 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) } return res.Outcome }