bastion 0.54.0
This commit is contained in:
@@ -0,0 +1,267 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ACCOUNT NAMES FOR WINDOWS SIDS, ASKED ONCE PER SCAN AND NEVER FOR LONG.
|
||||
//
|
||||
// The Windows process scan names each agent's account (winTokenUser) and
|
||||
// classifies it (ownerFactsReader), and both turn a SID into an account and a
|
||||
// domain with LookupAccountSid. On a domain-joined machine that call can go
|
||||
// to a domain controller, and when the controller cannot be reached it waits
|
||||
// for the network to give up. Asked per process, with no cache, a scan of a
|
||||
// few dozen processes could then hold the heartbeat for minutes.
|
||||
//
|
||||
// So a scan holds one sidNames: each SID is asked at most once, both callers
|
||||
// share the answer, and the scan waits on lookups for at most a fixed budget
|
||||
// in total. Once a lookup has used up the budget, every SID not already
|
||||
// answered is unresolved for the rest of the scan: the account shows as its
|
||||
// SID string and the owner's class as unknown, which is what the classifier
|
||||
// already says when the domain could not be read.
|
||||
//
|
||||
// LookupAccountSid cannot be cancelled. A lookup the scan stopped waiting
|
||||
// for finishes in its own goroutine and is then dropped. At most one lookup
|
||||
// runs at a time across the process (the slots channel), so a controller
|
||||
// that stays unreachable for many scans holds one blocked thread, not one per
|
||||
// scan.
|
||||
//
|
||||
// The type has no build tag so the Linux test run checks it with a fake
|
||||
// lookup; agents_windows.go supplies the real one.
|
||||
|
||||
// sidLookupBudget is the most one scan waits on account lookups in total. It
|
||||
// is a bound on the harm, not a measured lookup time: a scan asks once per
|
||||
// distinct account, and a domain controller that does not answer costs a
|
||||
// scan this much of its 60-second beat and no more.
|
||||
var sidLookupBudget = 2 * time.Second
|
||||
|
||||
// sidLookupSlots: one lookup in flight at a time, process-wide.
|
||||
var sidLookupSlots = make(chan struct{}, 1)
|
||||
|
||||
// sidName is one SID's answer. OK is false when the lookup failed, timed
|
||||
// out, or was never started because the budget was gone.
|
||||
type sidName struct {
|
||||
Account, Domain string
|
||||
OK bool
|
||||
}
|
||||
|
||||
type sidNames struct {
|
||||
mu sync.Mutex
|
||||
lookup func(sid string) (account, domain string, err error)
|
||||
slots chan struct{}
|
||||
left time.Duration
|
||||
names map[string]sidName
|
||||
}
|
||||
|
||||
func newSIDNames(lookup func(string) (string, string, error), budget time.Duration, slots chan struct{}) *sidNames {
|
||||
return &sidNames{lookup: lookup, slots: slots, left: budget, names: map[string]sidName{}}
|
||||
}
|
||||
|
||||
// resolve answers one SID, from the scan's cache when it has been asked.
|
||||
func (c *sidNames) resolve(sid string) sidName {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if n, ok := c.names[sid]; ok {
|
||||
return n
|
||||
}
|
||||
n := c.ask(sid)
|
||||
c.names[sid] = n
|
||||
return n
|
||||
}
|
||||
|
||||
// ask runs one lookup within what is left of the budget. Called with mu held.
|
||||
func (c *sidNames) ask(sid string) sidName {
|
||||
if c.left <= 0 {
|
||||
return sidName{}
|
||||
}
|
||||
start := time.Now()
|
||||
var n sidName
|
||||
finished := boundedSIDCall(c.slots, c.left, func() {
|
||||
a, d, err := c.lookup(sid)
|
||||
n = sidName{Account: a, Domain: d, OK: err == nil}
|
||||
})
|
||||
if !finished {
|
||||
c.left = 0
|
||||
return sidName{}
|
||||
}
|
||||
c.left -= time.Since(start)
|
||||
return n
|
||||
}
|
||||
|
||||
// boundedSIDCall runs call on its own goroutine while holding one of slots,
|
||||
// and waits for it at most budget in all, the wait for a free slot included.
|
||||
// It reports whether call finished in time; only then may the caller read
|
||||
// what call wrote. A call it stopped waiting for keeps its slot until it
|
||||
// returns, so a lane whose lookup never returns has one blocked goroutine,
|
||||
// and every later caller on that lane waits its budget and is told no.
|
||||
func boundedSIDCall(slots chan struct{}, budget time.Duration, call func()) bool {
|
||||
if budget <= 0 {
|
||||
return false
|
||||
}
|
||||
timer := time.NewTimer(budget)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case slots <- struct{}{}:
|
||||
case <-timer.C:
|
||||
return false // an earlier lookup on this lane still holds the slot
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
defer func() { <-slots }()
|
||||
call()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
return true
|
||||
case <-timer.C:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// ACCOUNT LOOKUPS OUTSIDE A SCAN: the approvals pipe and the folder agent's drop.
|
||||
//
|
||||
// Both resolve one SID when something happens (a tray connects, a store's
|
||||
// agent is started), not once per scan, so they cannot share a scan's budget.
|
||||
// They share one process-wide cache instead (sidDirectory): an answer is kept
|
||||
// for sidDirectoryTTL, so the tray's polling asks the directory once per
|
||||
// account per ten minutes, and a lookup that answers after its caller stopped
|
||||
// waiting still fills the cache for the next caller. Only an answer is
|
||||
// cached; a failure or a timeout is asked again next time.
|
||||
//
|
||||
// Each caller has its own lane (slot and budget), so a lookup hung on one
|
||||
// lane never blocks the other or the scan's: at most one goroutine per lane is
|
||||
// ever held by a directory that does not answer.
|
||||
//
|
||||
// What a timeout means is the caller's decision, and both fail closed: the
|
||||
// pipe refuses an approve (pipeApprove), the drop refuses to start the agent
|
||||
// (faWinDropOwnerAccount).
|
||||
|
||||
// sidAccount is one SID's answer: the account, its domain and its SID type
|
||||
// (SID_NAME_USE).
|
||||
type sidAccount struct {
|
||||
Account, Domain string
|
||||
Type uint32
|
||||
}
|
||||
|
||||
// sidOutcome is how a lookup outside a scan ended.
|
||||
type sidOutcome int
|
||||
|
||||
const (
|
||||
sidResolved sidOutcome = iota // the directory answered with an account
|
||||
sidUnmapped // the directory answered: no such account
|
||||
sidTimedOut // the directory did not answer within the lane's budget
|
||||
)
|
||||
|
||||
// sidLane is one caller's single-flight slot and per-lookup budget.
|
||||
type sidLane struct {
|
||||
slots chan struct{}
|
||||
budget time.Duration
|
||||
}
|
||||
|
||||
const (
|
||||
sidDirectoryTTL = 10 * time.Minute
|
||||
sidDirectoryMax = 256 // accounts kept; a machine has far fewer people
|
||||
)
|
||||
|
||||
type sidCached struct {
|
||||
acct sidAccount
|
||||
at time.Time
|
||||
}
|
||||
|
||||
type sidDirectory struct {
|
||||
mu sync.Mutex
|
||||
lookup func(sid string) (sidAccount, error)
|
||||
now func() time.Time
|
||||
names map[string]sidCached
|
||||
}
|
||||
|
||||
func newSIDDirectory(lookup func(string) (sidAccount, error)) *sidDirectory {
|
||||
return &sidDirectory{lookup: lookup, now: time.Now, names: map[string]sidCached{}}
|
||||
}
|
||||
|
||||
// resolve answers sid from the cache, or asks the directory on lane.
|
||||
func (d *sidDirectory) resolve(sid string, lane sidLane) (sidAccount, sidOutcome) {
|
||||
if a, ok := d.cached(sid); ok {
|
||||
return a, sidResolved
|
||||
}
|
||||
var (
|
||||
acct sidAccount
|
||||
err error
|
||||
)
|
||||
finished := boundedSIDCall(lane.slots, lane.budget, func() {
|
||||
// Asked again with the slot held: the lookup this caller queued
|
||||
// behind may have just answered for the same SID.
|
||||
if a, ok := d.cached(sid); ok {
|
||||
acct = a
|
||||
return
|
||||
}
|
||||
acct, err = d.lookup(sid)
|
||||
if err == nil {
|
||||
d.store(sid, acct) // also when the caller has stopped waiting
|
||||
}
|
||||
})
|
||||
switch {
|
||||
case !finished:
|
||||
return sidAccount{}, sidTimedOut
|
||||
case err != nil:
|
||||
return sidAccount{}, sidUnmapped
|
||||
}
|
||||
return acct, sidResolved
|
||||
}
|
||||
|
||||
func (d *sidDirectory) cached(sid string) (sidAccount, bool) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if c, ok := d.names[sid]; ok && d.now().Sub(c.at) < sidDirectoryTTL {
|
||||
return c.acct, true
|
||||
}
|
||||
return sidAccount{}, false
|
||||
}
|
||||
|
||||
func (d *sidDirectory) store(sid string, acct sidAccount) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
if len(d.names) >= sidDirectoryMax {
|
||||
for k, c := range d.names {
|
||||
if d.now().Sub(c.at) >= sidDirectoryTTL {
|
||||
delete(d.names, k)
|
||||
}
|
||||
}
|
||||
if len(d.names) >= sidDirectoryMax {
|
||||
d.names = map[string]sidCached{}
|
||||
}
|
||||
}
|
||||
d.names[sid] = sidCached{acct: acct, at: d.now()}
|
||||
}
|
||||
|
||||
// sidAccountLabel is DOMAIN\account, or the bare account with no domain.
|
||||
func sidAccountLabel(a sidAccount) string {
|
||||
if a.Domain != "" {
|
||||
return a.Domain + `\` + a.Account
|
||||
}
|
||||
return a.Account
|
||||
}
|
||||
|
||||
// winAccountLabel is how the scan names a token's account: DOMAIN\account,
|
||||
// or the SID itself when it could not be resolved.
|
||||
func winAccountLabel(sid string, names *sidNames) string {
|
||||
n := names.resolve(sid)
|
||||
switch {
|
||||
case !n.OK:
|
||||
return sid
|
||||
case n.Domain != "":
|
||||
return n.Domain + `\` + n.Account
|
||||
}
|
||||
return n.Account
|
||||
}
|
||||
|
||||
// winAccountDomain is the account's domain for the owner classifier, "" when
|
||||
// the SID could not be resolved (classifyWinOwner then says unknown).
|
||||
func winAccountDomain(sid string, names *sidNames) string {
|
||||
if n := names.resolve(sid); n.OK {
|
||||
return n.Domain
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user