Files
privatae-bastion/client-go/askquarantine.go
T
Privatae 4f8285ef18
ci / personal-data (push) Successful in 4s
ci / test (push) Successful in 3m36s
ci / fuzz (push) Successful in 3m35s
bastion 0.60.0
2026-11-02 00:10:58 +00:00

171 lines
5.4 KiB
Go

package main
// askquarantine.go — the machine holds an account whose asks keep being
// refused, the way quarantine.go holds a Host pairing: five refused asks
// inside ten minutes hold that account's asks for ten minutes, doubling on
// every repeat, forgiven after a clean day (the same arithmetic,
// quarantineEntry.strike). Asking is how an agent on this computer reaches
// the owner, so an agent that keeps asking for what is refused is the local
// form of a Host probing the management lane.
//
// Keyed by the asking account (the kernel's answer on ask.sock, not anything
// the asker says), and kept in the owner's factor directory beside the
// requests themselves, never on the heartbeat: which accounts on this
// computer asked for what is not a Host's business.
//
// What strikes: the owner denying an ask, and refusals that are the asker's
// own doing (not an agent this bastion sees, an ask the owner refused in the
// last day, too many waiting). What does not: the owner having no code set
// up, a store that could not be written — this machine's state, not the
// asker's.
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
const askQuarantineFile = "ask_quarantine.json"
var askQuarantineMu sync.Mutex
func askQuarantinePath(dir string) string { return filepath.Join(dir, askQuarantineFile) }
func askQuarantineLoad(dir string) map[string]*quarantineEntry {
m := map[string]*quarantineEntry{}
if raw, err := os.ReadFile(askQuarantinePath(dir)); err == nil {
_ = json.Unmarshal(raw, &m)
}
return m
}
func askQuarantineSave(dir string, m map[string]*quarantineEntry) error {
raw, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
return atomicWrite(askQuarantinePath(dir), raw)
}
// askStrike counts one refused ask from account. The hold that starts on the
// last strike is audited, so the owner sees it in Activity.
func askStrike(dir, account string, now time.Time) {
if dir == "" || account == "" {
return
}
askQuarantineMu.Lock()
defer askQuarantineMu.Unlock()
m := askQuarantineLoad(dir)
e := m[account]
if e == nil {
e = &quarantineEntry{}
m[account] = e
}
held := e.strike(now)
askQuarantinePrune(m, now)
_ = askQuarantineSave(dir, m)
if held {
dcnAudit(dir, askHoldOp, 0, account, "held until "+e.Until+": "+strconv.Itoa(quarantineStrikes)+
" refused asks within "+askWindowWords())
}
}
const askHoldOp = "local_ask_hold"
// askQuarantinePrune drops the accounts there is nothing left to remember
// about: not held, no refusal inside the window, and no hold within the
// forgiving day (strike would reset their doubling anyway). Accounts are the
// kernel's names, and a user with subordinate ids can present thousands of
// them, so without this the file grows by one entry per id that was ever
// refused and is rewritten whole on every strike.
func askQuarantinePrune(m map[string]*quarantineEntry, now time.Time) {
for acct, e := range m {
if e == nil {
delete(m, acct)
continue
}
if quarantinedAt(e, now) {
continue
}
recent := false
for _, ts := range e.Refusals {
if t, err := time.Parse(time.RFC3339, ts); err == nil && now.Sub(t) < quarantineWindow {
recent = true
break
}
}
if recent {
continue
}
if t, err := time.Parse(time.RFC3339, e.LastHold); err == nil && now.Sub(t) <= quarantineForgive {
continue
}
delete(m, acct)
}
}
// askHeldUntil: when account may ask again, or the zero time.
func askHeldUntil(dir, account string, now time.Time) time.Time {
if dir == "" || account == "" {
return time.Time{}
}
askQuarantineMu.Lock()
defer askQuarantineMu.Unlock()
e := askQuarantineLoad(dir)[account]
if !quarantinedAt(e, now) {
return time.Time{}
}
t, _ := time.Parse(time.RFC3339, e.Until)
return t
}
// askHoldRefusal is what a held account is told.
func askHoldRefusal(account string, until time.Time) string {
return fmt.Sprintf("asks from %s are on hold until %s: %d of them were refused within %s. "+
"The machine's owner can lift the hold", account, until.Local().Format("15:04"), quarantineStrikes,
askWindowWords())
}
func askWindowWords() string { return strconv.Itoa(int(quarantineWindow.Minutes())) + " minutes" }
// askQuarantineClear — the owner lifts the hold, and the strikes with it.
func askQuarantineClear(dir, account string) error {
askQuarantineMu.Lock()
defer askQuarantineMu.Unlock()
m := askQuarantineLoad(dir)
if _, ok := m[account]; !ok {
return fmt.Errorf("asks from %s are not held, and none of theirs were refused recently", account)
}
delete(m, account)
if err := askQuarantineSave(dir, m); err != nil {
return err
}
dcnAudit(dir, askHoldOp, 0, account, "lifted by the owner")
return nil
}
// cmdQuarantineClearAsker is `quarantine clear --asker <account>`. The holds
// live in the owner's factor directory, so only the account that holds it —
// the administrator, on a whole-machine install — finds them.
func cmdQuarantineClearAsker(account string) error {
if account == "" {
return errors.New("which account? the hold names it")
}
dir := existingMachineFactorDir()
if dir == "" {
return errors.New("no holds on asks are kept for this account. On a machine the bastion is installed " +
"for as a whole, they are the administrator's: `sudo -H privatae-bastion quarantine clear --asker " +
account + "`")
}
if err := askQuarantineClear(dir, account); err != nil {
return err
}
fmt.Printf("agents running as %s may ask this machine again\n", account)
return nil
}