bastion 0.53.2
ci / personal-data (push) Successful in 5s
ci / test (push) Successful in 3m4s
ci / fuzz (push) Successful in 3m45s

This commit is contained in:
Privatae
2026-10-20 04:42:14 +00:00
commit e4c9b69b46
471 changed files with 159794 additions and 0 deletions
+280
View File
@@ -0,0 +1,280 @@
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, ""
}