Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions agent/agent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
16 changes: 9 additions & 7 deletions agent/protocol.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions agent/protocol_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand Down
141 changes: 141 additions & 0 deletions agent/reseed_linux.go
Original file line number Diff line number Diff line change
@@ -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
}
19 changes: 19 additions & 0 deletions agent/reseed_other.go
Original file line number Diff line number Diff line change
@@ -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
}
72 changes: 59 additions & 13 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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 {
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading