312 lines
11 KiB
Go
312 lines
11 KiB
Go
package main
|
|
|
|
// owneracts.go — the machine's owner acts on its bastion from their own
|
|
// account (plan Addendum 6, slice 4).
|
|
//
|
|
// Installed for the whole machine, the bastion keeps its approvals and its
|
|
// local switch in root's state, so `approve`, `deny` and `switch` from the
|
|
// owner's account acted on an empty bastion of that account's own: an ask
|
|
// waiting for the owner was "not found", and a switch was refused for want of
|
|
// sudo. The owner's console session (consolelane.go) has always been able to
|
|
// do these, so ask.sock now does exactly the same for the same account, with
|
|
// the same rules:
|
|
//
|
|
// - approve (the machine's own asks, or a Host's) takes the owner's code,
|
|
// spent and counted as the console spends it (machineCodeSpend for the
|
|
// machine's, codeSpend for a Host's), so wrong codes lock the same way;
|
|
// - switching a family back on takes the owner's code (ownerCodeRefusal),
|
|
// as the console's switch does;
|
|
// - deny, and switching a family off, never do: they only take reach away.
|
|
//
|
|
// Only the owner's account is answered (readLaneRefusal), as for the reads.
|
|
// Each act is one line in the machine's audit log naming the account, beside
|
|
// the approve or deny row its own function writes.
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"os"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type agentActReply struct {
|
|
OK bool `json:"ok"`
|
|
Why string `json:"why,omitempty"`
|
|
Done string `json:"done,omitempty"`
|
|
// Failed: answered for this account, and not done (Why says why); the
|
|
// administrator would be told the same, so it is not a refusal.
|
|
Failed bool `json:"failed,omitempty"`
|
|
User string `json:"user,omitempty"`
|
|
}
|
|
|
|
var actNow = time.Now
|
|
|
|
func serveAct(uid uint32, user string, req agentAskRequest) (r agentActReply) {
|
|
r.User = user
|
|
if why := readLaneRefusal(uid, user); why != "" {
|
|
r.Why = actWording(why)
|
|
return r
|
|
}
|
|
defer func() {
|
|
// The root daemon serves every pairing: an act that panics is that
|
|
// act's failure, never the daemon's end (as runMachineRead does).
|
|
if p := recover(); p != nil {
|
|
uilogf("owner act %q panicked: %v\n", req.Act, p)
|
|
r = agentActReply{User: user, Failed: true, Why: "this act failed inside the machine's bastion; its log says why"}
|
|
}
|
|
}()
|
|
done, what, err := runOwnerAct(req)
|
|
if err != nil {
|
|
r.Failed, r.Why = true, err.Error()
|
|
return r
|
|
}
|
|
ownerActAudit(what, user)
|
|
r.OK, r.Done = true, done
|
|
return r
|
|
}
|
|
|
|
// runOwnerAct does one act; what names it, as resolved, for the audit row.
|
|
func runOwnerAct(req agentAskRequest) (done, what string, err error) {
|
|
switch req.Act {
|
|
case "approve", "deny":
|
|
t, err := resolveStepupTarget(req.Host, req.Machine)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
if !t.machine() {
|
|
// The console's rule for a Host's request, approve and deny alike
|
|
// (ui.go approve and deny both take approvalTarget): only a pairing
|
|
// this machine serves is answered.
|
|
if _, err := approvalTarget(t.host); err != nil {
|
|
return "", "", err
|
|
}
|
|
}
|
|
m := lockedRequests(t.dir)
|
|
id, err := findRequest(m, req.ID)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
target := "the machine's"
|
|
if !t.machine() {
|
|
target = "a Host's (" + t.host + ")"
|
|
}
|
|
what = req.Act + " " + target + " request " + id
|
|
if req.Act == "deny" {
|
|
if err := stepupDenyRequest(t.dir, id); err != nil {
|
|
return "", "", err
|
|
}
|
|
return "denied.", what, nil
|
|
}
|
|
if !stepupEnrolled(t.dir) {
|
|
return "", "", fmt.Errorf("no second factor is enrolled %s", t.forWhom())
|
|
}
|
|
code := strings.TrimSpace(req.Code)
|
|
if code == "" {
|
|
return "", "", errors.New("approving takes the code from your authenticator")
|
|
}
|
|
spend := func() (string, error) { return stepupApproveRequest(t.dir, id, code) }
|
|
var err2 error
|
|
if t.machine() {
|
|
_, err2 = machineCodeSpend(t.dir, actNow(), spend)
|
|
} else {
|
|
_, err2 = codeSpend(t.dir, actNow(), hostCodeLockedHint, spend)
|
|
}
|
|
if err2 != nil {
|
|
return "", "", err2
|
|
}
|
|
if t.machine() {
|
|
return "approved.", what, nil
|
|
}
|
|
return "approved. " + t.host + " picks this up on its next check-in, within a minute.", what, nil
|
|
case "switch":
|
|
host, err := switchResolveHost(req.Host)
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
family := strings.TrimSpace(req.Family)
|
|
if !req.Off {
|
|
if why := ownerCodeRefusal(req.Code, "local_switch_on|"+host+"|"+family); why != "" {
|
|
return "", "", errors.New(strings.TrimSuffix(why, "."))
|
|
}
|
|
}
|
|
if err := localDenySet(host, family, req.Off); err != nil {
|
|
return "", "", err
|
|
}
|
|
state := "on"
|
|
if req.Off {
|
|
state = "off"
|
|
}
|
|
return switchDoneText(host, family, req.Off), "switch " + cleanText(host, 80) + " " + cleanText(family, 40) + " " + state, nil
|
|
case "approvals-drop", "grants-drop", "ops-drop", "device-deny":
|
|
// Narrowing acts: each only takes reach away, so no code, and each
|
|
// is the command's own core, run where the machine's state is.
|
|
var run func() (string, error)
|
|
switch req.Act {
|
|
case "approvals-drop":
|
|
run = func() (string, error) { return stepupDropText(req.Host, req.Machine, req.ID) }
|
|
case "grants-drop":
|
|
run = func() (string, error) { return grantsDropText(req.Host, req.ID) }
|
|
case "ops-drop":
|
|
run = func() (string, error) { return opsDropText(req.Host, req.ID) }
|
|
default:
|
|
run = func() (string, error) { return deviceDenyText(req.ID) }
|
|
}
|
|
done, err := run()
|
|
if err != nil {
|
|
return "", "", err
|
|
}
|
|
what = req.Act + " " + cleanText(req.ID, 40)
|
|
if req.Host != "" {
|
|
what += " for " + cleanText(req.Host, 80)
|
|
}
|
|
return done, what, nil
|
|
}
|
|
return "", "", fmt.Errorf("this bastion has no act called %q", cleanText(req.Act, 40))
|
|
}
|
|
|
|
// lockedRequests reads a factor's requests under stepupMu, released however
|
|
// the read ends: the daemon recovers a panicking act (serveAct), and a lock
|
|
// left held would hang every approval after it.
|
|
func lockedRequests(dir string) map[string]stepupRequest {
|
|
stepupMu.Lock()
|
|
defer stepupMu.Unlock()
|
|
requestsLoadHook()
|
|
return requestsLoad(dir)
|
|
}
|
|
|
|
// requestsLoadHook is a seam for a test that makes the read panic.
|
|
var requestsLoadHook = func() {}
|
|
|
|
// switchDoneText is what a switch says it did, from the terminal or the lane.
|
|
func switchDoneText(host, family string, off bool) string {
|
|
if off {
|
|
return host + " " + family + " off: this machine now refuses it, and tells the host why. Its grant is untouched."
|
|
}
|
|
return host + " " + family + " on: the refusal is cleared, up to whatever its signed grant already allowed."
|
|
}
|
|
|
|
// ownerActAudit is the act's line in the machine's audit log. The owner's
|
|
// factor directory is made if it is not there yet (a deny or a switch off
|
|
// needs no code set up): dcnAuditWith joins its directory onto audit.jsonl,
|
|
// and "" would be the daemon's working directory.
|
|
func ownerActAudit(what, user string) {
|
|
dir, err := machineFactorDir()
|
|
if err != nil {
|
|
uilogf("owner act not audited (%v): %s, from %s\n", err, what, user)
|
|
return
|
|
}
|
|
dcnAuditWith(dir, "owner_lane_act", 0, "",
|
|
what+", from the owner's account "+user+" through its ask socket", nil)
|
|
}
|
|
|
|
// ── the asking side ─────────────────────────────────────────────────────────
|
|
|
|
func actViaSock(req agentAskRequest) (string, error) {
|
|
p := readSockPath()
|
|
c, err := net.DialTimeout("unix", p, 3*time.Second)
|
|
if err != nil {
|
|
return "", &laneError{down: true, why: "nothing answers on " + p + " (" + err.Error() + ")"}
|
|
}
|
|
defer c.Close()
|
|
_ = c.SetDeadline(time.Now().Add(30 * time.Second))
|
|
req.Op = "act"
|
|
if err := json.NewEncoder(c).Encode(req); err != nil {
|
|
return "", &laneError{down: true, why: "the act could not be sent (" + err.Error() + ")"}
|
|
}
|
|
var r agentActReply
|
|
if err := json.NewDecoder(io.LimitReader(c, askMaxBytes)).Decode(&r); err != nil {
|
|
return "", &laneError{down: true, why: "the machine's bastion did not answer (" + err.Error() + ")"}
|
|
}
|
|
if !r.OK && r.Failed {
|
|
return "", errors.New(r.Why)
|
|
}
|
|
if !r.OK {
|
|
return "", &laneError{why: r.Why}
|
|
}
|
|
return r.Done, nil
|
|
}
|
|
|
|
// ownerApproveViaLane shows the request the machine's bastion holds, takes
|
|
// the owner's code at this terminal, and asks the bastion to approve it.
|
|
func ownerApproveViaLane(host string, machine bool, prefix string) error {
|
|
v, err := readMachine("approvals", readOpts{Host: host, Machine: machine}, readApprovals)
|
|
if err != nil {
|
|
// Refused, the read is refused as the approve it was read for.
|
|
var le *laneError
|
|
if errors.As(err, &le) && !le.down {
|
|
err = &laneError{why: actWording(le.why)}
|
|
}
|
|
return laneRefusalError(stepupCommand("approve", host, machine, prefix), err)
|
|
}
|
|
if !v.CodeSetUp {
|
|
return fmt.Errorf("no second factor is enrolled %s, so nothing can be approved yet", v.For)
|
|
}
|
|
var hit *approvalWaiting
|
|
for i := range v.Waiting {
|
|
if strings.HasPrefix(v.Waiting[i].ID, prefix) || strings.HasPrefix(prefix, v.Waiting[i].ID) {
|
|
if hit != nil {
|
|
return fmt.Errorf("more than one waiting request starts with %q; give more of its id", prefix)
|
|
}
|
|
hit = &v.Waiting[i]
|
|
}
|
|
}
|
|
if hit == nil {
|
|
return fmt.Errorf("nothing waiting %s starts with %q; `privatae-bastion approvals%s` lists them",
|
|
v.For, prefix, map[bool]string{true: " --machine", false: ""}[machine])
|
|
}
|
|
fmt.Println(hit.Text)
|
|
fmt.Println()
|
|
code, ok := promptOwnerCode("Approve this? Enter the code from your authenticator (or leave empty to cancel): ")
|
|
if !ok {
|
|
return nil
|
|
}
|
|
// The bastion finds the request again by this id: the one shown, never
|
|
// fewer characters of it than shown. Sent as typed, a short prefix would
|
|
// approve whatever request matched it by then (one an agent filed while
|
|
// the person typed, say), which is not what they read.
|
|
id := hit.ID
|
|
if len(prefix) > len(id) {
|
|
id = prefix
|
|
}
|
|
done, err := actViaSock(agentAskRequest{Act: "approve", Host: host, Machine: machine, ID: id, Code: code})
|
|
if err != nil {
|
|
return laneRefusalError(stepupCommand("approve", host, machine, prefix), err)
|
|
}
|
|
fmt.Println(done)
|
|
return nil
|
|
}
|
|
|
|
// actWording is the reads' refusal said of an act.
|
|
func actWording(why string) string {
|
|
return strings.Replace(why, "answers reads only for", "acts only for", 1)
|
|
}
|
|
|
|
// stepupCommand is approve or deny as the administrator would type it here.
|
|
func stepupCommand(verb, host string, machine bool, id string) string {
|
|
switch {
|
|
case machine:
|
|
return verb + " --machine " + id
|
|
case host != "":
|
|
return verb + " --host " + host + " " + id
|
|
}
|
|
return verb + " " + id
|
|
}
|
|
|
|
// promptOwnerCode reads a code from this terminal; ok is false when the
|
|
// person left it empty, and then nothing is done (said here).
|
|
var promptOwnerCode = func(prompt string) (string, bool) {
|
|
fmt.Print(prompt)
|
|
code, _ := bufio.NewReader(os.Stdin).ReadString('\n')
|
|
code = strings.TrimSpace(code)
|
|
if code == "" {
|
|
fmt.Println("cancelled — nothing done")
|
|
return "", false
|
|
}
|
|
return code, true
|
|
}
|