268 lines
8.2 KiB
Go
268 lines
8.2 KiB
Go
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 ""
|
|
}
|