From d139eb68e41daa5f94ee2c64590b67ebd82bb137 Mon Sep 17 00:00:00 2001 From: CMGS Date: Fri, 3 Jul 2026 21:28:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20reseed=20verb=20=E2=80=94=20host?= =?UTF-8?q?-fed=20entropy,=20CRNG=20reseed,=20machine-id=20regen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloned/restored VMs resume from byte-identical snapshot memory, so their CRNG state (and /etc/machine-id) is identical across all clones unless something perturbs it post-boot. Adds a second wire-protocol verb, reseed, so the host can push fresh entropy and order a CRNG reseed (+ optional machine-id regen) over the same vsock connection used for exec. --- agent/agent.go | 11 +++- agent/agent_test.go | 52 +++++++++++++++ agent/protocol.go | 16 +++-- agent/protocol_test.go | 8 +++ agent/reseed_linux.go | 141 +++++++++++++++++++++++++++++++++++++++++ agent/reseed_other.go | 19 ++++++ client/client.go | 72 +++++++++++++++++---- client/client_test.go | 64 +++++++++++++++++++ 8 files changed, 361 insertions(+), 22 deletions(-) create mode 100644 agent/reseed_linux.go create mode 100644 agent/reseed_other.go create mode 100644 client/client_test.go diff --git a/agent/agent.go b/agent/agent.go index 96e30ef..996d394 100644 --- a/agent/agent.go +++ b/agent/agent.go @@ -92,8 +92,15 @@ func (s *Server) handleConn(ctx context.Context, conn net.Conn) { } return } - if first.Type != MsgExec { - if err := enc.SendErrorf("expected first frame type %q, got %q", MsgExec, first.Type); err != nil { + switch first.Type { + case MsgExec: // exec session continues below the switch + case MsgReseed: + if err := runReseed(ctx, first, enc); err != nil { + logger.Warnf(ctx, "reseed session ended: %v", err) + } + return + default: + if err := enc.SendErrorf("expected first frame type %q or %q, got %q", MsgExec, MsgReseed, first.Type); err != nil { logger.Warnf(ctx, "send rejection error frame to %s: %v", conn.RemoteAddr(), err) } return diff --git a/agent/agent_test.go b/agent/agent_test.go index 43ef963..66edcb4 100644 --- a/agent/agent_test.go +++ b/agent/agent_test.go @@ -118,6 +118,58 @@ func TestServerRejectsNonExecFirstFrame(t *testing.T) { } } +// TestServerDispatchesReseedFirstFrame guards the handleConn dispatch: a +// MsgReseed first frame must reach runReseed rather than the unknown-type +// rejection path. On non-Linux dev builds runReseed is the reseed_other +// stub, so a MsgError is the correct terminal frame here. +func TestServerDispatchesReseedFirstFrame(t *testing.T) { + t.Parallel() + _, conn := dialTestServer(t) + + enc := agent.NewEncoder(conn) + if err := enc.Encode(agent.Message{Type: agent.MsgReseed, Data: []byte("host-entropy"), RegenMachineID: true}); err != nil { + t.Fatalf("encode reseed: %v", err) + } + + dec := agent.NewDecoder(conn) + frame, err := dec.Decode() + if err != nil { + t.Fatalf("decode reply: %v", err) + } + if runtime.GOOS != "linux" { + if frame.Type != agent.MsgError { + t.Fatalf("expected MsgError on %s (reseed unsupported), got %#v", runtime.GOOS, frame) + } + if !strings.Contains(frame.Message, "not supported") { + t.Errorf("expected unsupported-OS message, got %q", frame.Message) + } + return + } + if frame.Type != agent.MsgExit && frame.Type != agent.MsgError { + t.Fatalf("expected terminal frame on linux, got %#v", frame) + } +} + +// TestServerRejectsUnknownFirstFrameType guards the dispatch default branch: +// a type that is neither MsgExec nor MsgReseed must still be rejected. +func TestServerRejectsUnknownFirstFrameType(t *testing.T) { + t.Parallel() + _, conn := dialTestServer(t) + + enc := agent.NewEncoder(conn) + if err := enc.Encode(agent.Message{Type: "bogus"}); err != nil { + t.Fatalf("encode bogus first frame: %v", err) + } + dec := agent.NewDecoder(conn) + frame, err := dec.Decode() + if err != nil { + t.Fatalf("decode reply: %v", err) + } + if frame.Type != agent.MsgError { + t.Errorf("expected error frame, got %#v", frame) + } +} + func TestServerNonexistentCommand(t *testing.T) { t.Parallel() ctx, conn := dialTestServer(t) diff --git a/agent/protocol.go b/agent/protocol.go index e65577e..a24a331 100644 --- a/agent/protocol.go +++ b/agent/protocol.go @@ -18,6 +18,7 @@ import ( // both as session-closed; MsgError is never followed by MsgExit. const ( MsgExec = "exec" + MsgReseed = "reseed" MsgStdin = "stdin" MsgStdinClose = "stdin_close" @@ -36,13 +37,14 @@ var errTerminalFrameSent = errors.New("terminal frame already sent") // Message is the union of all frames. Only fields relevant to Type are populated. type Message struct { - Type string `json:"type"` - Argv []string `json:"argv,omitempty"` - Env map[string]string `json:"env,omitempty"` - Data []byte `json:"data,omitempty"` - PID int `json:"pid,omitempty"` - ExitCode int `json:"exit_code,omitempty"` - Message string `json:"message,omitempty"` + Type string `json:"type"` + Argv []string `json:"argv,omitempty"` + Env map[string]string `json:"env,omitempty"` + Data []byte `json:"data,omitempty"` + PID int `json:"pid,omitempty"` + ExitCode int `json:"exit_code,omitempty"` + Message string `json:"message,omitempty"` + RegenMachineID bool `json:"regen_machine_id,omitempty"` } // Decoder reads line-delimited JSON frames off a reader. diff --git a/agent/protocol_test.go b/agent/protocol_test.go index 9c73879..a129861 100644 --- a/agent/protocol_test.go +++ b/agent/protocol_test.go @@ -35,6 +35,14 @@ func TestEncodeDecodeRoundTrip(t *testing.T) { name: "exit with non-zero", msg: Message{Type: MsgExit, ExitCode: 42}, }, + { + name: "reseed with entropy and regen_machine_id", + msg: Message{ + Type: MsgReseed, + Data: []byte{0xde, 0xad, 0xbe, 0xef}, + RegenMachineID: true, + }, + }, { name: "error", msg: Message{Type: MsgError, Message: "kaboom"}, diff --git a/agent/reseed_linux.go b/agent/reseed_linux.go new file mode 100644 index 0000000..0b8573d --- /dev/null +++ b/agent/reseed_linux.go @@ -0,0 +1,141 @@ +//go:build linux + +package agent + +import ( + "context" + "crypto/rand" + "encoding/binary" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "os/exec" + "unsafe" + + "golang.org/x/sys/unix" +) + +const ( + maxReseedEntropyBytes = 512 + machineIDBytes = 16 + + urandomPath = "/dev/urandom" + systemdRandomSeed = "/var/lib/systemd/random-seed" + machineIDPath = "/etc/machine-id" +) + +// runReseed injects host-fed entropy and forces a CRNG reseed so clones don't +// share the snapshot's CRNG state; steps are best-effort, errors joined. +func runReseed(ctx context.Context, req Message, enc *Encoder) error { + var errs []error + + if err := reseedURandom(req.Data); err != nil { + errs = append(errs, err) + } + if err := os.Remove(systemdRandomSeed); err != nil && !errors.Is(err, fs.ErrNotExist) { + errs = append(errs, fmt.Errorf("remove systemd random seed: %w", err)) + } + + if req.RegenMachineID { + if err := regenMachineID(ctx); err != nil { + errs = append(errs, fmt.Errorf("regen machine id: %w", err)) + } + } + + joined := errors.Join(errs...) + if joined != nil { + if err := enc.SendErrorf("reseed: %v", joined); err != nil { + return fmt.Errorf("send reseed error frame: %w", err) + } + return joined + } + return enc.Encode(Message{Type: MsgExit, ExitCode: 0}) +} + +// reseedURandom opens /dev/urandom once and runs both entropy injection and +// CRNG reseed on the same fd, matching the kernel's expected ioctl sequence. +func reseedURandom(data []byte) error { + fd, err := unix.Open(urandomPath, unix.O_WRONLY, 0) + if err != nil { + return fmt.Errorf("open %s: %w", urandomPath, err) + } + + var errs []error + if len(data) > 0 { + if err := addEntropy(fd, data); err != nil { + errs = append(errs, fmt.Errorf("add entropy: %w", err)) + } + } + if err := reseedCRNG(fd); err != nil { + errs = append(errs, fmt.Errorf("reseed crng: %w", err)) + } + if err := unix.Close(fd); err != nil { + errs = append(errs, fmt.Errorf("close %s: %w", urandomPath, err)) + } + return errors.Join(errs...) +} + +// addEntropy injects data into the kernel entropy pool via RNDADDENTROPY. +// x/sys/unix exports no rand_pool_info helper, so the ioctl buffer +// (entropy_count bits, buf_size bytes, then the payload) is built by hand. +func addEntropy(fd int, data []byte) error { + if len(data) > maxReseedEntropyBytes { + data = data[:maxReseedEntropyBytes] + } + entropyBits := uint32(len(data)) * 8 //nolint:gosec // len(data) capped at maxReseedEntropyBytes above + bufSize := uint32(len(data)) //nolint:gosec // len(data) capped at maxReseedEntropyBytes above + + buf := make([]byte, 8+len(data)) + binary.NativeEndian.PutUint32(buf[0:4], entropyBits) + binary.NativeEndian.PutUint32(buf[4:8], bufSize) + copy(buf[8:], data) + + if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), unix.RNDADDENTROPY, uintptr(unsafe.Pointer(&buf[0]))); errno != 0 { //nolint:gosec // ioctl requires a raw pointer to the hand-built rand_pool_info buffer + return fmt.Errorf("ioctl RNDADDENTROPY: %w", errno) + } + return nil +} + +func reseedCRNG(fd int) error { + if _, _, errno := unix.Syscall(unix.SYS_IOCTL, uintptr(fd), unix.RNDRESEEDCRNG, 0); errno != 0 { + return fmt.Errorf("ioctl RNDRESEEDCRNG: %w", errno) + } + return nil +} + +// regenMachineID truncates /etc/machine-id and regenerates it, skipping +// silently on systems that don't have the file (e.g. Android). +func regenMachineID(ctx context.Context) error { + if _, err := os.Stat(machineIDPath); err != nil { + if errors.Is(err, fs.ErrNotExist) { + return nil + } + return fmt.Errorf("stat %s: %w", machineIDPath, err) + } + if err := os.Truncate(machineIDPath, 0); err != nil { + return fmt.Errorf("truncate %s: %w", machineIDPath, err) + } + if path, err := exec.LookPath("systemd-machine-id-setup"); err == nil { + if err := exec.CommandContext(ctx, path).Run(); err != nil { //nolint:gosec // path resolved by LookPath for a fixed binary name, not user input + return fmt.Errorf("run systemd-machine-id-setup: %w", err) + } + return nil + } + return writeRandomMachineID() +} + +// writeRandomMachineID is the fallback when systemd-machine-id-setup is +// unavailable: a fresh random id, matching /etc/machine-id's own format. +func writeRandomMachineID() error { + buf := make([]byte, machineIDBytes) + if _, err := rand.Read(buf); err != nil { + return fmt.Errorf("generate machine id: %w", err) + } + line := hex.EncodeToString(buf) + "\n" + if err := os.WriteFile(machineIDPath, []byte(line), 0o444); err != nil { //nolint:gosec // matches /etc/machine-id's own world-readable convention + return fmt.Errorf("write %s: %w", machineIDPath, err) + } + return nil +} diff --git a/agent/reseed_other.go b/agent/reseed_other.go new file mode 100644 index 0000000..b2cb2ae --- /dev/null +++ b/agent/reseed_other.go @@ -0,0 +1,19 @@ +//go:build !linux + +package agent + +import ( + "context" + "errors" + "fmt" +) + +// Reseed needs Linux-only RNG ioctls; other GOOSes answer with a sentinel error. +var errReseedUnsupported = errors.New("reseed is not supported on this OS") + +func runReseed(_ context.Context, _ Message, enc *Encoder) error { + if err := enc.SendErrorf("%v", errReseedUnsupported); err != nil { + return fmt.Errorf("send reseed error frame: %w", err) + } + return errReseedUnsupported +} diff --git a/client/client.go b/client/client.go index e4e58be..143ddbe 100644 --- a/client/client.go +++ b/client/client.go @@ -17,6 +17,8 @@ import ( const stdinChunkSize = 32 * 1024 +var errNoExitFrame = errors.New("agent: connection closed before exit frame") + // Run executes argv and bridges I/O, returning the child exit code. // nil stdin/stdout/stderr → no-stdin / discard. Matches kubectl exec // AttachIO semantics. @@ -35,18 +37,11 @@ func Run(ctx context.Context, conn io.ReadWriteCloser, argv []string, env map[st return 0, errors.New("client: argv is empty") } - // Sub-ctx so the conn-closer doesn't outlive Run on a longer-lived caller ctx. - runCtx, runCancel := context.WithCancel(ctx) - defer runCancel() - go func() { - <-runCtx.Done() - _ = conn.Close() - }() - - enc := agent.NewEncoder(conn) - if err := enc.Encode(agent.Message{Type: agent.MsgExec, Argv: argv, Env: env}); err != nil { - return 0, fmt.Errorf("send exec frame: %w", err) + enc, dec, runCancel, err := openSession(ctx, conn, agent.Message{Type: agent.MsgExec, Argv: argv, Env: env}) + if err != nil { + return 0, err } + defer runCancel() var stdinReadErr atomic.Pointer[error] if stdin != nil { @@ -55,7 +50,6 @@ func Run(ctx context.Context, conn io.ReadWriteCloser, argv []string, env map[st _ = enc.Encode(agent.Message{Type: agent.MsgStdinClose}) } - dec := agent.NewDecoder(conn) exitCode := 0 var sawExit bool @@ -111,11 +105,63 @@ readLoop: if ctx.Err() != nil { return 0, ctx.Err() } - return 0, errors.New("agent: connection closed before exit frame") + return 0, errNoExitFrame } return exitCode, nil } +// Reseed sends host-fed entropy and a reseed order after a VM clone/restore, +// so N clones sharing byte-identical snapshot memory don't share +// byte-identical CRNG state. nil iff the agent reports exit code 0. +func Reseed(ctx context.Context, conn io.ReadWriteCloser, entropy []byte, regenMachineID bool) error { + _, dec, cancel, err := openSession(ctx, conn, agent.Message{Type: agent.MsgReseed, Data: entropy, RegenMachineID: regenMachineID}) + if err != nil { + return err + } + defer cancel() + for { + frame, err := dec.Decode() + if err != nil { + // Prefer ctx.Err over EOF: ctx-cancel closes the conn, + // surfacing as EOF here. + if ctx.Err() != nil { + return ctx.Err() + } + if errors.Is(err, io.EOF) { + return errNoExitFrame + } + return fmt.Errorf("read frame: %w", err) + } + switch frame.Type { + case agent.MsgExit: + if frame.ExitCode != 0 { + return fmt.Errorf("agent: reseed exited with code %d", frame.ExitCode) + } + return nil + case agent.MsgError: + return fmt.Errorf("agent: %s", frame.Message) + default: + log.WithFunc("client.Reseed").Warnf(ctx, "ignoring unknown frame type %q", frame.Type) + } + } +} + +// openSession wires ctx cancellation to conn.Close and sends the opening frame. +func openSession(ctx context.Context, conn io.ReadWriteCloser, first agent.Message) (*agent.Encoder, *agent.Decoder, context.CancelFunc, error) { + // Sub-ctx so the conn-closer doesn't outlive the session on a longer-lived caller ctx. + sessCtx, cancel := context.WithCancel(ctx) + go func() { + <-sessCtx.Done() + _ = conn.Close() + }() + enc := agent.NewEncoder(conn) + if err := enc.Encode(first); err != nil { + cancel() + return nil, nil, nil, fmt.Errorf("send %s frame: %w", first.Type, err) + } + return enc, agent.NewDecoder(conn), cancel, nil +} + // pumpStdin streams stdin → MsgStdin frames; on EOF sends MsgStdinClose. // Encode errors are silent (child closing stdin early is normal). A // non-EOF Read error is recorded in errOut and triggers cancel so Run's diff --git a/client/client_test.go b/client/client_test.go new file mode 100644 index 0000000..1c4e111 --- /dev/null +++ b/client/client_test.go @@ -0,0 +1,64 @@ +package client_test + +import ( + "net" + "strings" + "testing" + + "github.com/cocoonstack/cocoon-agent/agent" + "github.com/cocoonstack/cocoon-agent/client" +) + +// reseedAgainstReply runs client.Reseed against a fake agent answering with reply; returns the frame the agent saw. +func reseedAgainstReply(t *testing.T, entropy []byte, regenMachineID bool, reply agent.Message) (agent.Message, error) { + t.Helper() + clientConn, serverConn := net.Pipe() + defer clientConn.Close() + defer serverConn.Close() + + var got agent.Message + serverDone := make(chan struct{}) + go func() { + defer close(serverDone) + frame, err := agent.NewDecoder(serverConn).Decode() + if err != nil { + return + } + got = frame + _ = agent.NewEncoder(serverConn).Encode(reply) + }() + + err := client.Reseed(t.Context(), clientConn, entropy, regenMachineID) + <-serverDone + return got, err +} + +func TestReseedSuccess(t *testing.T) { + t.Parallel() + got, err := reseedAgainstReply(t, []byte("host-entropy"), true, agent.Message{Type: agent.MsgExit, ExitCode: 0}) + if err != nil { + t.Fatalf("reseed: %v", err) + } + if got.Type != agent.MsgReseed || string(got.Data) != "host-entropy" || !got.RegenMachineID { + t.Errorf("agent saw frame %+v, want reseed with entropy and regen_machine_id", got) + } +} + +func TestReseedErrorFrame(t *testing.T) { + t.Parallel() + _, err := reseedAgainstReply(t, nil, false, agent.Message{Type: agent.MsgError, Message: "boom"}) + if err == nil { + t.Fatal("expected error for MsgError reply") + } + if !strings.Contains(err.Error(), "boom") { + t.Errorf("expected wrapped agent error, got %v", err) + } +} + +func TestReseedNonZeroExit(t *testing.T) { + t.Parallel() + _, err := reseedAgainstReply(t, []byte("e"), false, agent.Message{Type: agent.MsgExit, ExitCode: 1}) + if err == nil { + t.Fatal("expected error for non-zero exit code") + } +}