diff --git a/.gitignore b/.gitignore index ab0142b4..9a589bdf 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,4 @@ modules/mesh-files/cmd/mesh-files/mesh-files # The provisioner client built in place (issue 671). modules/mesh-provisioner-client/cmd/mesh-provisioner-client/mesh-provisioner-client modules/brother/cmd/brother/brother +modules/mesh-build-go/cmd/mesh-build-go/mesh-build-go diff --git a/checks/owned/main.go b/checks/owned/main.go index 09512d5d..67762f06 100644 --- a/checks/owned/main.go +++ b/checks/owned/main.go @@ -160,8 +160,14 @@ func Check(root string) ([]Finding, error) { } dirs := map[string]string{} for _, r := range m.Resources { - if r.ID != "" && r.Path != "" { + switch { + case r.ID != "" && r.Path != "": dirs[r.ID] = r.Path + case r.ID != "" && r.Type == "directory": + // A directory the mesh places for the module (`place`): where it lies is the node-engine's, but it is + // the module's own, so a path under it belongs to this module and to no other's directory of the same + // id (issue 1484: two runs-as modules each keep a .ssh in their own state directory). + dirs[r.ID] = "/placed/" + m.Module + "/" + r.ID } } for _, r := range m.Resources { diff --git a/checks/owned/main_test.go b/checks/owned/main_test.go index 0882ba47..6426244f 100644 --- a/checks/owned/main_test.go +++ b/checks/owned/main_test.go @@ -130,3 +130,18 @@ func TestAnArchiveIsNotAContainerAndSameModuleIsFine(t *testing.T) { t.Fatalf("refused %+v", found) } } + +// Issue 1484: a directory the mesh places for a module (no path, `place`) is that module's own: another module's +// directory of the same id, and a path under it, are not inside it. +func TestPlacedDirectoriesOfTwoModulesAreTheirOwn(t *testing.T) { + root := t.TempDir() + for _, name := range []string{"first", "second"} { + manifest(t, root, name, mod(name, + `{"id":"state","type":"directory","place":".","unknown":"program"}`, + dir("ssh", "${dir:state}/.ssh", `,"unknown":"program"`), + `{"id":"conf","type":"file","path":"${dir:ssh}/config"}`)) + } + if found := check(t, root); len(found) != 0 { + t.Fatalf("refused %+v", found) + } +} diff --git a/checks/sshdir/main.go b/checks/sshdir/main.go index 0b7eb4ab..da83ca49 100644 --- a/checks/sshdir/main.go +++ b/checks/sshdir/main.go @@ -5,7 +5,7 @@ // go run . // // Every string of a manifest is read, wherever it stands, except prose and a file's text (Prose): a path, a -// place, a volume. Exits 1 on a finding. +// place, a volume; a path inside a directory of the module's own (`${dir:}`) is not ~/.ssh. Exits 1 on a finding. package main import ( @@ -15,6 +15,7 @@ import ( "path/filepath" "regexp" "sort" + "strings" ) // SSHModule is the one module allowed to name a path under ~/.ssh. @@ -86,7 +87,9 @@ func walk(v any, key string, found func(key, value string)) { walk(e, key, found) } case string: - if underSSH.MatchString(x) { + // A path under a directory of the module's own (${dir:}, as a runs-as module's home) is that account's + // own .ssh, not the operator's ~/.ssh that ssh-client sweeps (issue 1484, ADR 0466 §4). + if underSSH.MatchString(x) && !strings.HasPrefix(x, "${dir:") { found(key, x) } } diff --git a/checks/sshdir/main_test.go b/checks/sshdir/main_test.go index ca78b3e7..411fd995 100644 --- a/checks/sshdir/main_test.go +++ b/checks/sshdir/main_test.go @@ -37,6 +37,21 @@ func TestAnotherModuleNamingSSHIsRefused(t *testing.T) { } } +// Issue 1484: a runs-as module's own account has a .ssh of its own, inside its home (a directory of the module's); +// that is not the operator's ~/.ssh, which only ssh-client names. A path outside any ${dir:} is still refused. +func TestAModulesOwnDirectoryMayHoldADotSSH(t *testing.T) { + root := t.TempDir() + manifest(t, root, "builder", `{"module":"builder","resources":[{"path":"${dir:state}/.ssh"},{"path":"${dir:ssh-dir}/config"}]}`) + manifest(t, root, "other", `{"module":"other","resources":[{"path":"${machine:account-home}/.ssh/config"}]}`) + found, err := Check(root) + if err != nil { + t.Fatal(err) + } + if len(found) != 1 || found[0].Module != "other" { + t.Fatalf("refused %+v; want only other", found) + } +} + func TestTheCatalogueHasNone(t *testing.T) { found, err := Check("../..") if err != nil { diff --git a/modules/ssh-client/cmd/ssh-client-tools/client.go b/modules/ssh-client/cmd/ssh-client-tools/client.go index 454de9dd..3c2c5107 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/client.go +++ b/modules/ssh-client/cmd/ssh-client-tools/client.go @@ -17,13 +17,15 @@ import ( "strings" "sync" "time" + + "git.novox.be/novox/mesh-sdk/go/forgekey" ) // Client answers about one home's ~/.ssh. type Client struct { Home string UID int - Run Runner + Run forgekey.Runner Now func() time.Time // Settle is how long a path in ~/.ssh is left by the sweep after it changed (sweep.go): zero in tests. Settle time.Duration @@ -35,7 +37,7 @@ func NewClient() *Client { if home == "" { home, _ = os.UserHomeDir() } - return &Client{Home: home, UID: os.Getuid(), Run: ExecRunner, Now: time.Now, Settle: time.Minute} + return &Client{Home: home, UID: os.Getuid(), Run: forgekey.ExecRunner, Now: time.Now, Settle: time.Minute} } func (c *Client) ssh(name string) string { return filepath.Join(c.Home, ".ssh", name) } @@ -114,7 +116,7 @@ func matchesAny(host string, patterns []string) bool { return hit } -func named(what string, r Ran) error { +func named(what string, r forgekey.Ran) error { switch { case r.Err == "ENOENT": return fmt.Errorf("%s: the program is not installed on this machine", what) @@ -207,7 +209,7 @@ func (c *Client) Keys(ctx context.Context) ([]Key, error) { k.AgeDays = int(c.Now().Sub(info.ModTime()).Hours() / 24) } if pub, err := os.ReadFile(path + ".pub"); err == nil { - if pk, err := ParseKeyLine(string(pub)); err == nil { + if pk, err := forgekey.ParseKeyLine(string(pub)); err == nil { k.Type, k.Bits, k.Fingerprint, k.Comment, k.Weak = pk.Type, pk.Bits, pk.Fingerprint, pk.Comment, pk.Weak } } else { @@ -225,7 +227,7 @@ func (c *Client) Keys(ctx context.Context) ([]Key, error) { var derived string k.Passphrase, derived = c.passphrase(ctx, path) if derived != "" { - if pk, err := ParseKeyLine(derived); err == nil { + if pk, err := forgekey.ParseKeyLine(derived); err == nil { if k.Fingerprint != "" && !k.PublicMissing && pk.Fingerprint != k.Fingerprint { k.PublicMismatch = fmt.Sprintf("the key is %s; its .pub is %s", pk.Fingerprint, k.Fingerprint) } @@ -264,7 +266,7 @@ func (c *Client) passphrase(ctx context.Context, path string) (string, string) { // AuthorizedKey is one line of authorized_keys, by fingerprint. type AuthorizedKey struct { - PublicKey + forgekey.PublicKey Line int `json:"line"` InMesh bool `json:"in_mesh_region,omitempty"` } @@ -309,7 +311,7 @@ func (c *Client) readAuthorized() ([]AuthorizedKey, []int, error) { case t == "" || strings.HasPrefix(t, "#"): continue } - k, err := ParseKeyLine(t) + k, err := forgekey.ParseKeyLine(t) if err != nil { bad = append(bad, n+1) continue @@ -397,26 +399,26 @@ func atomicWrite(path string, data []byte, mode fs.FileMode) error { // ---- known_hosts ------------------------------------------------------------------------------- // knownFor is what known_hosts holds for one host (port 22, or [host]:port), by fingerprint. -func (c *Client) knownFor(ctx context.Context, host string, port int) ([]PublicKey, error) { +func (c *Client) knownFor(ctx context.Context, host string, port int) ([]forgekey.PublicKey, error) { name := host if port != 22 { name = fmt.Sprintf("[%s]:%d", host, port) } file := c.ssh("known_hosts") if _, err := os.Stat(file); os.IsNotExist(err) { - return []PublicKey{}, nil + return []forgekey.PublicKey{}, nil } r := c.Run(ctx, nil, "ssh-keygen", "-F", name, "-f", file) if r.Err != "" { return nil, named("ssh-keygen -F", r) } // Exit 1 with nothing printed is "not found". - keys := []PublicKey{} + keys := []forgekey.PublicKey{} for _, l := range strings.Split(r.Stdout, "\n") { if l = strings.TrimSpace(l); l == "" || strings.HasPrefix(l, "#") { continue } - if k, err := ParseKeyLine(l); err == nil { + if k, err := forgekey.ParseKeyLine(l); err == nil { k.Comment, k.Options = "", "" keys = append(keys, k) } @@ -425,17 +427,17 @@ func (c *Client) knownFor(ctx context.Context, host string, port int) ([]PublicK } // scan asks a host for its keys now. -func (c *Client) scan(ctx context.Context, host string, port int) ([]PublicKey, []string, error) { +func (c *Client) scan(ctx context.Context, host string, port int) ([]forgekey.PublicKey, []string, error) { r := c.Run(ctx, nil, "ssh-keyscan", "-T", "5", "-p", fmt.Sprint(port), host) if r.Err != "" { return nil, nil, named("ssh-keyscan", r) } - keys, lines := []PublicKey{}, []string{} + keys, lines := []forgekey.PublicKey{}, []string{} for _, l := range strings.Split(r.Stdout, "\n") { if l = strings.TrimSpace(l); l == "" || strings.HasPrefix(l, "#") { continue } - if k, err := ParseKeyLine(l); err == nil { + if k, err := forgekey.ParseKeyLine(l); err == nil { k.Comment, k.Options = "", "" keys = append(keys, k) lines = append(lines, l) @@ -455,7 +457,7 @@ func orElse(s, def string) string { } // compare says how what is known stands against what the host offers now. -func compare(known, live []PublicKey) string { +func compare(known, live []forgekey.PublicKey) string { if len(known) == 0 { return "not known: the first connection would ask" } diff --git a/modules/ssh-client/cmd/ssh-client-tools/firstline.go b/modules/ssh-client/cmd/ssh-client-tools/firstline.go new file mode 100644 index 00000000..e3aa9b47 --- /dev/null +++ b/modules/ssh-client/cmd/ssh-client-tools/firstline.go @@ -0,0 +1,13 @@ +package main + +import "strings" + +// firstLine is the first non-empty line of s, trimmed. +func firstLine(s string) string { + for _, l := range strings.Split(s, "\n") { + if l = strings.TrimSpace(l); l != "" { + return l + } + } + return "" +} diff --git a/modules/ssh-client/cmd/ssh-client-tools/main.go b/modules/ssh-client/cmd/ssh-client-tools/main.go index 98ee3e82..d93b5493 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/main.go +++ b/modules/ssh-client/cmd/ssh-client-tools/main.go @@ -15,6 +15,7 @@ import ( "time" stdio "git.novox.be/novox/mesh-sdk/go" + "git.novox.be/novox/mesh-sdk/go/forgekey" ) func main() { @@ -141,7 +142,7 @@ func flag(args map[string]any, key string, def bool) bool { return def } -// startKeeper runs the forge key's keeper (keykeeper.go) when the runtime launched this process for a node: +// startKeeper runs the forge key's keeper (go/forgekey) when the runtime launched this process for a node: // outside a launch — a build, a check — there is no state to put the entry in. func startKeeper() { node := strings.TrimSpace(os.Getenv("MESH_NODE")) @@ -151,22 +152,27 @@ func startKeeper() { return } go keepSwept(c) - k := &KeyKeeper{Home: c.Home, Node: node, Account: u.Username, Run: ExecRunner, State: keysState{}, Now: time.Now} + k := keeperFor(c.Home, node, u.Username, forgekey.ExecRunner, keysState{}) k.Keep(context.Background(), func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) }) } -// keepSwept sweeps ~/.ssh (sweep.go) now and every KeyRound. +// keeperFor is the machine's account's keeper: go/forgekey's, of kind machine (ADR 0400 §1, ADR 0466 §2), whose key +// file is ~/.ssh/mesh_ed25519 and whose entry lies at . in the keys state. +func keeperFor(home, node, account string, run forgekey.Runner, state forgekey.KeyPutter) *forgekey.KeyKeeper { + return &forgekey.KeyKeeper{Home: home, Node: node, Account: account, Kind: forgekey.KindMachine, Run: run, State: state, Now: time.Now} +} + +// keepSwept sweeps ~/.ssh (sweep.go) now and every forgekey.KeyRound. func keepSwept(c *Client) { say := func(f string, a ...any) { fmt.Fprintf(os.Stderr, "[ssh-client] "+f+"\n", a...) } for { c.SweepOnce(time.Now(), say) - time.Sleep(KeyRound) + time.Sleep(forgekey.KeyRound) } } type keysState struct{} -func (keysState) Put(key string, value any) error { - _, err := stdio.State("keys").Put(key, value) - return err +func (keysState) Put(key string, value any) (uint64, error) { + return stdio.State("keys").Put(key, value) } diff --git a/modules/ssh-client/cmd/ssh-client-tools/sweep.go b/modules/ssh-client/cmd/ssh-client-tools/sweep.go index c297bbf5..b22ea2de 100644 --- a/modules/ssh-client/cmd/ssh-client-tools/sweep.go +++ b/modules/ssh-client/cmd/ssh-client-tools/sweep.go @@ -7,7 +7,7 @@ package main // can lock the operator out); a folder holding a key is gone into rather than moved whole. A socket // is ssh's own runtime and stays, and so do the files ssh and sshd read by themselves (SSHReads). A // path changed in the last Settle is being written (a temporary file of atomicWrite or ssh-keygen) and -// waits for the next sweep. The process sweeps when it starts and every KeyRound (main.go); what the +// waits for the next sweep. The process sweeps when it starts and every KeyRound of go/forgekey (main.go); what the // last sweep did, or why it failed, is kept beside the retired folder for ssh_client_check (issue 1020). import ( @@ -21,19 +21,21 @@ import ( "strings" "syscall" "time" + + "git.novox.be/novox/mesh-sdk/go/forgekey" ) // Declared are the paths under ~/.ssh this module declares (module.json) or its own process makes // (keykeeper.go). A test checks the manifest against this list. var Declared = map[string]bool{ - "config": true, - "known_hosts": true, - "config.d": true, - "config.d/00-mesh": true, - "config.d/10-forge": true, - KeyFile: true, - KeyFile + ".pub": true, - "authorized_keys": true, + "config": true, + "known_hosts": true, + "config.d": true, + "config.d/00-mesh": true, + "config.d/10-forge": true, + forgekey.KeyFile: true, + forgekey.KeyFile + ".pub": true, + "authorized_keys": true, } // SSHReads are the files ssh and sshd read in ~/.ssh by themselves, though this module writes none of @@ -371,7 +373,7 @@ func publicKey(path string) bool { return false } line, _, _ := strings.Cut(string(raw), "\n") - if _, err := ParseKeyLine(line); err == nil { + if _, err := forgekey.ParseKeyLine(line); err == nil { return true } f := strings.Fields(line)