Name the account agents run as on a node, and say whether it can become root
On the control node every agent ran as the operator's account, which has passwordless sudo, so an agent could become root without a person (hq ADR 0266). A node now names an agent account at the controller's terminal only; the agent's module declares it never to become root, the node-engine judges that, and the self-check (DA) raises agent-can-become-root while it does not hold, so ADR 0259's router can rest on it.
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
package inventory
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The agent account (novox/hq ADR 0266): recorded and read back with every node, its home derived when
|
||||
// not stated, cleared by an empty name — and refused when it is root, the operator's own account, or no
|
||||
// login at all, because each of those would say agents have an account of their own while they do not.
|
||||
func TestAgentAccountIsRecordedAndRefusedWhereItWouldNotConfine(t *testing.T) {
|
||||
inv := ForTest(t)
|
||||
ctx := context.Background()
|
||||
if _, err := inv.AddNode(ctx, "anchor"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := inv.SetAccount(ctx, "anchor", "operator", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
n, err := inv.NodeByName(ctx, "anchor")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n.AgentAccount != "" || n.AgentHome() != "" {
|
||||
t.Fatalf("a node that names none has agent account %q, home %q", n.AgentAccount, n.AgentHome())
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
n, _ = inv.NodeByName(ctx, "anchor")
|
||||
if n.AgentAccount != "agent" || n.AgentHome() != "/home/agent" {
|
||||
t.Fatalf("agent account %q, home %q; want agent, /home/agent", n.AgentAccount, n.AgentHome())
|
||||
}
|
||||
all, err := inv.Nodes(ctx)
|
||||
if err != nil || len(all) != 1 || all[0].AgentAccount != "agent" {
|
||||
t.Fatalf("the listing does not carry the agent account: %+v %v", all, err)
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "agent", "/srv/agent"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentHome() != "/srv/agent" {
|
||||
t.Fatalf("the stated home is %q", n.AgentHome())
|
||||
}
|
||||
|
||||
for _, c := range []struct{ account, home, says string }{
|
||||
{"root", "", "may not run as root"},
|
||||
{"operator", "", "operator account"},
|
||||
{"Agent", "", "not a login name"},
|
||||
{"9agent", "", "not a login name"},
|
||||
{"agent", "relative", "absolute"},
|
||||
{"", "/home/x", "without an agent account"},
|
||||
} {
|
||||
err := inv.SetAgentAccount(ctx, "anchor", c.account, c.home)
|
||||
if err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%q %q: %v; want a refusal saying %q", c.account, c.home, err, c.says)
|
||||
}
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "agent" {
|
||||
t.Fatalf("a refusal changed the record: %q", n.AgentAccount)
|
||||
}
|
||||
|
||||
if err := inv.SetAgentAccount(ctx, "anchor", "", ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n, _ = inv.NodeByName(ctx, "anchor"); n.AgentAccount != "" || n.AgentAccountHome != "" {
|
||||
t.Fatalf("clearing left %q %q", n.AgentAccount, n.AgentAccountHome)
|
||||
}
|
||||
if err := inv.SetAgentAccount(ctx, "nowhere", "agent", ""); !errors.Is(err, ErrNoSuchNode) {
|
||||
t.Fatalf("an unknown node: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ type ResourceHealth struct {
|
||||
// Account is the account whose own service manager runs it, or the account a resource of kind account
|
||||
// is (novox/hq ADR 0254).
|
||||
Account string `json:"account,omitempty"`
|
||||
// Root is "never" on an account verdict that judged whether the account can become root without a
|
||||
// person (novox/hq ADR 0266).
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// NodeHealth is a machine's newest statement, as kept.
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
-- A node names the account its agents run as (novox/hq ADR 0266).
|
||||
--
|
||||
-- On the control node every agent session ran as the operator's account, which may become root without
|
||||
-- a password: any agent there could become root without a person. The decision is an account of the
|
||||
-- agents' own, without sudo, beside the operator's, who keeps theirs. Stated by the operator at the
|
||||
-- controller's terminal, like the operator account (migration 0036), and never by a verb or a setting,
|
||||
-- so no agent can change which account it is.
|
||||
--
|
||||
-- Empty rather than null, as the operator account is: empty is a real state, "agents run as the
|
||||
-- operator's account here" — a workstation's today. The home is stored only when it is not
|
||||
-- /home/<account>; empty means derive it.
|
||||
alter table node add column agent_account text not null default '';
|
||||
alter table node add column agent_account_home text not null default '';
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -69,6 +70,14 @@ type Node struct {
|
||||
Account string
|
||||
AccountHome string
|
||||
|
||||
// AgentAccount is the login agents run as on this machine when it is not the operator's — `agent`
|
||||
// on the control node (novox/hq ADR 0266): an account of their own, without sudo, so no agent there
|
||||
// can become root without a person. Empty means agents run as the operator account. Stated at the
|
||||
// controller's terminal only, never by a verb or a setting. AgentAccountHome is its home when not
|
||||
// /home/<account>; empty means derive it.
|
||||
AgentAccount string
|
||||
AgentAccountHome string
|
||||
|
||||
// HostVersion is the version of the host this machine reported running (novox/hq 04-ISSUES/087).
|
||||
// Empty when it has not said since the mesh began keeping it — which is not the same as running
|
||||
// no host, so nothing derives "behind" from an empty one.
|
||||
@@ -91,6 +100,17 @@ func (n Node) Home() string {
|
||||
}
|
||||
}
|
||||
|
||||
// AgentHome is the agent account's home, derived when not stored; empty when no agent account is named.
|
||||
func (n Node) AgentHome() string {
|
||||
if n.AgentAccount == "" {
|
||||
return ""
|
||||
}
|
||||
if n.AgentAccountHome != "" {
|
||||
return n.AgentAccountHome
|
||||
}
|
||||
return "/home/" + n.AgentAccount
|
||||
}
|
||||
|
||||
// Silent is how long since this node was last heard from, and whether it ever was.
|
||||
func (n Node) Silent() (time.Duration, bool) {
|
||||
if n.LastSeen.IsZero() {
|
||||
@@ -147,14 +167,14 @@ func (i *Inventory) AddNodeAs(ctx context.Context, name string, adopted bool) (N
|
||||
// nodeColumns and scanNode are the one reading of a node row, so every way of finding a node
|
||||
// says whether it is adopted.
|
||||
const nodeColumns = `id, name, created, last_seen, adopted, adopted_since, account, account_home,
|
||||
host_version`
|
||||
agent_account, agent_account_home, host_version`
|
||||
|
||||
func scanNode(row pgx.Row) (Node, error) {
|
||||
var n Node
|
||||
var seen, since *time.Time
|
||||
var host *string
|
||||
if err := row.Scan(&n.ID, &n.Name, &n.Created, &seen, &n.Adopted, &since,
|
||||
&n.Account, &n.AccountHome, &host); err != nil {
|
||||
&n.Account, &n.AccountHome, &n.AgentAccount, &n.AgentAccountHome, &host); err != nil {
|
||||
return Node{}, err
|
||||
}
|
||||
if host != nil {
|
||||
@@ -184,6 +204,63 @@ func (i *Inventory) SetAccount(ctx context.Context, node, account, home string)
|
||||
return nil
|
||||
}
|
||||
|
||||
// loginName is what a login may be called: what useradd accepts by default, lower case, a letter or
|
||||
// an underscore first.
|
||||
var loginName = regexp.MustCompile(`^[a-z_][a-z0-9_-]{0,31}$`)
|
||||
|
||||
// SetAgentAccount records the account agents run as on a node, and optionally its home (novox/hq ADR
|
||||
// 0266). An empty account clears it: agents run as the operator account again.
|
||||
//
|
||||
// **Refused, rather than recorded and judged later:** root, which is the very thing the account exists
|
||||
// to keep agents from; the node's operator account, which may become root without a password and is
|
||||
// what agents ran as before — naming it here would say the agents have an account of their own while
|
||||
// they do not; and a name no machine would accept as a login.
|
||||
func (i *Inventory) SetAgentAccount(ctx context.Context, node, account, home string) error {
|
||||
account, home = strings.TrimSpace(account), strings.TrimSpace(home)
|
||||
if account == "" && home != "" {
|
||||
return errors.New("a home without an agent account says nothing; name the account too")
|
||||
}
|
||||
if account != "" {
|
||||
if err := AgentAccountRefusal(account, home); err != nil {
|
||||
return err
|
||||
}
|
||||
n, err := i.NodeByName(ctx, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n.Account != "" && n.Account == account {
|
||||
return fmt.Errorf("%s is %s's operator account: agents would run as the operator, who may become "+
|
||||
"root; clear the agent account instead (node agent-account %s --clear)", account, node, node)
|
||||
}
|
||||
}
|
||||
tag, err := i.store.Pool().Exec(ctx,
|
||||
`update node set agent_account = $1, agent_account_home = $2 where name = $3`, account, home, node)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return fmt.Errorf("%w: %s", ErrNoSuchNode, node)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AgentAccountRefusal is why an agent account cannot be named, or nil: root, a malformed login, or a
|
||||
// home that is not an absolute path.
|
||||
func AgentAccountRefusal(account, home string) error {
|
||||
if account == "root" {
|
||||
return errors.New("agents may not run as root: the agent account exists to keep them from it " +
|
||||
"(novox/hq ADR 0266)")
|
||||
}
|
||||
if !loginName.MatchString(account) {
|
||||
return fmt.Errorf("%q is not a login name: lower case letters, digits, _ and -, a letter or _ first, "+
|
||||
"at most 32", account)
|
||||
}
|
||||
if home != "" && !strings.HasPrefix(home, "/") {
|
||||
return fmt.Errorf("the agent account's home %q is not an absolute path", home)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Nodes are every node record, oldest first.
|
||||
func (i *Inventory) Nodes(ctx context.Context) ([]Node, error) {
|
||||
rows, err := i.store.Pool().Query(ctx,
|
||||
|
||||
Reference in New Issue
Block a user