347 lines
12 KiB
Go
347 lines
12 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. A missing file means "nothing switched
|
|
// off", which is exactly the behaviour that existed before this file. A file
|
|
// that exists but cannot be read means EVERYTHING switched off — see
|
|
// localDenied.
|
|
//
|
|
// 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 WITH the error, and what an error means
|
|
// is decided by the caller, not here. The gate (localDenied) refuses every
|
|
// family while the error stands; the page and --list say why and name
|
|
// `switch --reset`. An empty list must never be read on its own as "nothing
|
|
// is off": that is how a corrupt file used to switch everything back on.
|
|
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("%s is not valid JSON: %w", p, 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.
|
|
//
|
|
// A LIST THAT CANNOT BE READ SWITCHES EVERYTHING OFF. The person at the
|
|
// machine switched something off; a file that will not parse cannot say what,
|
|
// so the only answer that keeps their decision is all of it. The other
|
|
// direction (read it as "nothing is off") turned a session or files switch
|
|
// back on without anyone being told, and the host was served exactly what the
|
|
// person had refused it. Recovery is at the machine: `switch --reset` sets the
|
|
// unreadable file aside and starts an empty list.
|
|
func localDenied(host, family string) bool {
|
|
if host == "" || family == "" {
|
|
return false
|
|
}
|
|
localDenyMu.Lock()
|
|
defer localDenyMu.Unlock()
|
|
f, err := localDenyLoad()
|
|
if err != nil {
|
|
localDenyReportUnreadable(err)
|
|
return true
|
|
}
|
|
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, err := localDenyLoad()
|
|
if err != nil {
|
|
// The same answer the gate gives (localDenied): everything is off.
|
|
return localSwitchFamilies()
|
|
}
|
|
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() answers only yes or no, and
|
|
// the PAGE has to say why every switch reads off: without the reason a person
|
|
// sees their machine refusing everything and no way out.
|
|
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, localDenyUnreadableText(err)
|
|
}
|
|
return out, ""
|
|
}
|
|
|
|
// localDenyUnreadableText is the one sentence the page, --list and the
|
|
// daemon's log give for an unreadable list.
|
|
func localDenyUnreadableText(err error) string {
|
|
return "This machine's list of switched-off capabilities cannot be read (" +
|
|
err.Error() + "). Until it can, EVERY capability is refused to every host. " +
|
|
"To start a fresh list, run: sudo -H privatae-bastion switch --reset " +
|
|
"(the unreadable file is kept beside it)."
|
|
}
|
|
|
|
// localDenyUnreadableLogged is the last unreadable-list error written to the
|
|
// log, so the gate, which runs on every op, logs each distinct failure once
|
|
// instead of once per op. Guarded by localDenyMu.
|
|
var localDenyUnreadableLogged string
|
|
|
|
// localDenyReportUnreadable logs the refusal-of-everything once per distinct
|
|
// error. Caller holds localDenyMu.
|
|
func localDenyReportUnreadable(err error) {
|
|
msg := err.Error()
|
|
if msg == localDenyUnreadableLogged {
|
|
return
|
|
}
|
|
localDenyUnreadableLogged = msg
|
|
fmt.Printf("local switch: %s\n", localDenyUnreadableText(err))
|
|
}
|
|
|
|
// localDenyReset sets an unreadable deny list aside and leaves no list, so
|
|
// nothing is switched off. It refuses when the list reads fine: resetting a
|
|
// good list would silently clear what the person switched off, and that is
|
|
// done one family at a time with `switch <host> <family> on`. The old file is
|
|
// renamed, never deleted, so whatever is still legible in it can be read back.
|
|
func localDenyReset() (string, error) {
|
|
return localDenyResetAt(time.Now().UTC())
|
|
}
|
|
|
|
func localDenyResetAt(now time.Time) (string, error) {
|
|
var kept string
|
|
err := withLocalDenyLock(func() error { // holds localDenyMu and the file lock
|
|
p, err := localDenyPath()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := localDenyLoad(); err == nil {
|
|
return fmt.Errorf("the list of switched-off capabilities reads fine, so there is " +
|
|
"nothing to reset; switch a family back on with: switch <host> <family> on")
|
|
}
|
|
kept = p + ".unreadable-" + now.Format("20060102T150405Z")
|
|
if err := os.Rename(p, kept); err != nil {
|
|
return fmt.Errorf("could not set the unreadable list aside: %w", err)
|
|
}
|
|
localDenyUnreadableLogged = ""
|
|
return nil
|
|
})
|
|
return kept, err
|
|
}
|