Let ssh-client use the SDK's forgekey, and let the checks accept a module's own .ssh (issues 1480, 1484), part 2 of 17
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
// go run . <catalogue root>
|
||||
//
|
||||
// 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:<id>}`) 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:<id>}, 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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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 <node>.<account> 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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user