281 lines
9.4 KiB
Go
281 lines
9.4 KiB
Go
package main
|
|
|
|
// localdeny.go — the switch that turns a granted capability OFF, on this
|
|
// machine, by the person sitting at it.
|
|
//
|
|
// The bastion console can turn a granted capability off: that is local
|
|
// control, and it is needed.
|
|
//
|
|
// THE INVARIANT, AND IT IS NOT A PHASE: this can only ever REMOVE reach. There
|
|
// is no code path here that lets a host do something its signed grant does not
|
|
// already allow, and the test that holds that line drives every op in the
|
|
// vocabulary against every combination rather than asserting it in prose.
|
|
//
|
|
// Turning something back ON does not grant it — it clears a local denial, and
|
|
// the host's signed grant remains the ceiling it was before. That distinction is
|
|
// the whole safety argument: a switch that could widen would make the machine a
|
|
// place to escalate FROM, which inverts the product. So the state stored here is
|
|
// a DENY LIST, never an allow list. An empty file means "nothing switched off",
|
|
// which is exactly the behaviour that existed before this file, and an
|
|
// unreadable file means the same — see localDenyLoad.
|
|
//
|
|
// WHY THE MACHINE MAY DO THIS AT ALL. It already decides what it actually
|
|
// executes: every op passes capsAllow (anchor.go) on the way in, and the box
|
|
// refuses whatever it will not carry out. This adds no power to the machine. It
|
|
// makes an existing refusal reachable by the person whose computer it is,
|
|
// instead of only by the host that holds the grant.
|
|
//
|
|
// THE HOST IS TOLD, and told WHY. A refusal here travels the same way every
|
|
// other refused directive does (directive_refusal.go), so the host sees
|
|
// "refused, switched off here" rather than a silent failure it will retry for
|
|
// ever. A local control the far end cannot see is a control that reads, to the
|
|
// far end, as a fault.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// localDenyFile is the on-disk shape: host -> the families switched off for it.
|
|
type localDenyFile struct {
|
|
// Off maps a pairing's host name to the capability families the person at
|
|
// this machine has switched off for that host.
|
|
Off map[string][]string `json:"off"`
|
|
// At records when each was switched, so the page can say how long something
|
|
// has been off rather than only that it is.
|
|
At map[string]string `json:"at,omitempty"`
|
|
}
|
|
|
|
var localDenyMu sync.Mutex
|
|
|
|
func localDenyPath() (string, error) {
|
|
d, err := configDir()
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return filepath.Join(d, "local-deny.json"), nil
|
|
}
|
|
|
|
// localDenyLoad reads the deny list.
|
|
//
|
|
// EVERY FAILURE RETURNS AN EMPTY LIST, and that direction is deliberate. A
|
|
// deny list that cannot be read must not become a deny-everything: the machine
|
|
// would stop serving its host because of a corrupt file, which is a far worse
|
|
// outcome than a switch not taking effect. The page reports the read error
|
|
// separately so the person is not left believing something is off when it is
|
|
// not.
|
|
func localDenyLoad() (*localDenyFile, error) {
|
|
p, err := localDenyPath()
|
|
if err != nil {
|
|
return &localDenyFile{Off: map[string][]string{}}, err
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
if os.IsNotExist(err) {
|
|
return &localDenyFile{Off: map[string][]string{}}, nil
|
|
}
|
|
return &localDenyFile{Off: map[string][]string{}}, err
|
|
}
|
|
var f localDenyFile
|
|
if err := json.Unmarshal(b, &f); err != nil {
|
|
return &localDenyFile{Off: map[string][]string{}}, fmt.Errorf(
|
|
"this machine's list of switched-off capabilities cannot be read (%w). "+
|
|
"Nothing is being blocked by it while that is true", err)
|
|
}
|
|
if f.Off == nil {
|
|
f.Off = map[string][]string{}
|
|
}
|
|
return &f, nil
|
|
}
|
|
|
|
// localDenySave replaces the list whole, through atomicWrite (ctx.go): temp
|
|
// file, fsync, rename. Two processes use this file, the daemon (the console
|
|
// page, and the gate reading it on every op) and `switch` from a terminal
|
|
// (switch_cli.go), and localDenyMu covers only one of them. A write that
|
|
// truncated in place let the daemon read an empty file mid-write, and
|
|
// localDenyLoad fails OPEN on a file it cannot parse, so a family switched off
|
|
// was let through. A rename shows a reader the old list or the new one.
|
|
func localDenySave(f *localDenyFile) error {
|
|
p, err := localDenyPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
b, err := json.MarshalIndent(f, "", " ")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return atomicWrite(p, b)
|
|
}
|
|
|
|
// withLocalDenyLock runs a read-modify-write of the list holding localDenyMu
|
|
// and an exclusive lock on <configDir>/local-deny.lock, so a switch from the
|
|
// console page and one from `switch` in another process cannot each read the
|
|
// same list and have the second write drop the first one's change. The same
|
|
// shape as withPairingsLock (config.go).
|
|
func withLocalDenyLock(fn func() error) error {
|
|
localDenyMu.Lock()
|
|
defer localDenyMu.Unlock()
|
|
d, err := configDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
lf, err := os.OpenFile(filepath.Join(d, "local-deny.lock"), os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer lf.Close() // closing releases the lock on every platform
|
|
if err := flockFile(lf); err != nil {
|
|
return err
|
|
}
|
|
return fn()
|
|
}
|
|
|
|
// localSwitchFamilies is what the person can switch off: every caps family,
|
|
// plus the box-wide directives (writes.go localFamilyDirectives) — contain,
|
|
// self-update and mirror placement are not in any grant's caps, and before this
|
|
// the anchor host's directives were the one lane the person at the machine had
|
|
// no switch for.
|
|
func localSwitchFamilies() []string {
|
|
return append(append([]string(nil), allCaps...), localFamilyDirectives)
|
|
}
|
|
|
|
// localDenied reports whether a family is switched off for a host here.
|
|
func localDenied(host, family string) bool {
|
|
if host == "" || family == "" {
|
|
return false
|
|
}
|
|
localDenyMu.Lock()
|
|
defer localDenyMu.Unlock()
|
|
f, _ := localDenyLoad()
|
|
for _, c := range f.Off[host] {
|
|
if c == family {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// localDenyFamilies is what is switched off for one host, sorted.
|
|
func localDenyFamilies(host string) []string {
|
|
localDenyMu.Lock()
|
|
defer localDenyMu.Unlock()
|
|
f, _ := localDenyLoad()
|
|
out := append([]string(nil), f.Off[host]...)
|
|
sort.Strings(out)
|
|
return out
|
|
}
|
|
|
|
// localDenySet switches a family off, or back on.
|
|
//
|
|
// `family` MUST be one of allCaps. An unknown family is refused rather than
|
|
// stored: a deny list holding a string nothing will ever match is a switch that
|
|
// silently does nothing, and the person would have no way to tell.
|
|
func localDenySet(host, family string, off bool) error {
|
|
if strings.TrimSpace(host) == "" {
|
|
return fmt.Errorf("no host named")
|
|
}
|
|
known := false
|
|
for _, c := range localSwitchFamilies() {
|
|
if c == family {
|
|
known = true
|
|
break
|
|
}
|
|
}
|
|
if !known {
|
|
return fmt.Errorf("%q is not a capability this machine knows about (%s)",
|
|
family, strings.Join(localSwitchFamilies(), ", "))
|
|
}
|
|
// AND THE HOST HAS TO BE ONE THIS MACHINE HAS HEARD OF. Deny-only means a
|
|
// bogus host name is harmless to the gate — nothing matches it — but the
|
|
// page would answer "X can no longer read your files" about a host that was
|
|
// never paired here, which is a sentence that lies by typo. An
|
|
// unreadable pairing store is NOT a reason to refuse: the switch
|
|
// still only removes reach, and refusing would make a corrupt file block a
|
|
// person from tightening their own machine.
|
|
if ps, err := loadPairings(); err == nil {
|
|
seen := false
|
|
for _, p := range ps {
|
|
if p.HostName == host {
|
|
seen = true
|
|
break
|
|
}
|
|
}
|
|
if !seen {
|
|
return fmt.Errorf("no host called %q is paired with this machine, so there is "+
|
|
"nothing of its to switch off", host)
|
|
}
|
|
}
|
|
|
|
return withLocalDenyLock(func() error {
|
|
f, err := localDenyLoad()
|
|
if err != nil {
|
|
// Refuse rather than overwrite: writing a fresh file over one that did
|
|
// not parse would silently discard whatever the person had already
|
|
// switched off.
|
|
return err
|
|
}
|
|
if f.At == nil {
|
|
f.At = map[string]string{}
|
|
}
|
|
cur := f.Off[host]
|
|
out := cur[:0:0]
|
|
for _, c := range cur {
|
|
if c != family {
|
|
out = append(out, c)
|
|
}
|
|
}
|
|
key := host + "/" + family
|
|
if off {
|
|
out = append(out, family)
|
|
f.At[key] = time.Now().UTC().Format(time.RFC3339)
|
|
} else {
|
|
delete(f.At, key)
|
|
}
|
|
sort.Strings(out)
|
|
if len(out) == 0 {
|
|
delete(f.Off, host)
|
|
} else {
|
|
f.Off[host] = out
|
|
}
|
|
return localDenySave(f)
|
|
})
|
|
}
|
|
|
|
// localDenyRefusal is the sentence a host gets back, and the one the person at
|
|
// the machine reads. One string so the two can never disagree about why.
|
|
func localDenyRefusal(family string) string {
|
|
return "refused: '" + family + "' is switched off on this machine by the person at it. " +
|
|
"The grant is untouched — turning it back on is done here, not from the host."
|
|
}
|
|
|
|
// localDenyIndex is every switched-off (host, family) plus WHY the list could
|
|
// not be read, when it could not.
|
|
//
|
|
// It exists because the per-family localDenied() swallows that error by design
|
|
// — it has to, since a gate must not fail closed on a corrupt file — and the
|
|
// PAGE has the opposite obligation: it must say so, or it shows a person their
|
|
// switches as not existing rather than as not working.
|
|
func localDenyIndex() (map[string]bool, string) {
|
|
localDenyMu.Lock()
|
|
defer localDenyMu.Unlock()
|
|
f, err := localDenyLoad()
|
|
out := map[string]bool{}
|
|
for host, fams := range f.Off {
|
|
for _, fam := range fams {
|
|
out[host+"/"+fam] = true
|
|
}
|
|
}
|
|
if err != nil {
|
|
return out, "This machine's list of switched-off capabilities cannot be read (" +
|
|
err.Error() + "). NOTHING below is being blocked by it while that is true — " +
|
|
"anything you switched off is not in effect."
|
|
}
|
|
return out, ""
|
|
}
|