//go:build linux package host import ( "bytes" "context" "errors" "log" "net" "net/http" "os" "path/filepath" "strings" "sync" "testing" "time" "golang.org/x/sys/unix" "github.com/carlosframework/platform/internal/moves" "github.com/carlosframework/platform/internal/release" ) // TestHeldReleaseRootRefusesUnsafeRenderJournals (#267, platform-1e): the // render-hash journal lives under the agent's /data, so root reads it only // through the anchored path: no symlink at any component, a regular file // with one link, one descriptor, a bounded size. A journal that is a // symlink to a root-only file holding the right hash, a directory on its // path that is a symlink to such a journal, and a FIFO are each refused at // once, and root selects and publishes nothing. func TestHeldReleaseRootRefusesUnsafeRenderJournals(t *testing.T) { ctx := context.Background() journalPath := func(f *hrFixture) string { return filepath.Join(moves.JournalDir(f.data, p1Host, p1ID), "journal.json") } // rootOnly is a journal with the right hash in a directory only its // owner can read: what a symlink would make root read on the agent's // behalf. rootOnly := func(t *testing.T) string { t.Helper() dir := t.TempDir() g := &hrFixture{data: dir} g.journal(t, p1ID, hrRendered) if err := os.Chmod(filepath.Join(moves.JournalDir(dir, p1Host, p1ID), "journal.json"), 0o600); err != nil { t.Fatal(err) } return moves.JournalDir(dir, p1Host, p1ID) } for _, tc := range []struct { name string prep func(t *testing.T, f *hrFixture) }{ {"a symlink to a root-only journal", func(t *testing.T, f *hrFixture) { p := journalPath(f) if err := os.Remove(p); err != nil { t.Fatal(err) } if err := os.Symlink(filepath.Join(rootOnly(t), "journal.json"), p); err != nil { t.Fatal(err) } }}, {"a symlinked directory on the path", func(t *testing.T, f *hrFixture) { dir := moves.JournalDir(f.data, p1Host, p1ID) if err := os.RemoveAll(dir); err != nil { t.Fatal(err) } if err := os.Symlink(rootOnly(t), dir); err != nil { t.Fatal(err) } }}, {"a FIFO", func(t *testing.T, f *hrFixture) { p := journalPath(f) if err := os.Remove(p); err != nil { t.Fatal(err) } if err := unix.Mkfifo(p, 0o644); err != nil { t.Fatal(err) } }}, {"a hard link", func(t *testing.T, f *hrFixture) { if err := os.Link(journalPath(f), filepath.Join(t.TempDir(), "other")); err != nil { t.Skipf("no hard link across temp dirs: %v", err) } }}, {"an oversized journal", func(t *testing.T, f *hrFixture) { if err := os.WriteFile(journalPath(f), []byte(strings.Repeat(" ", heldRenderJournalMax+1)), 0o644); err != nil { t.Fatal(err) } }}, } { t.Run(tc.name, func(t *testing.T) { f := newHRFixture(t) tc.prep(t, f) done := make(chan error, 1) go func() { _, err := f.root.Serve(ctx, hrPeer, f.request(heldReleasePublish)) done <- err }() select { case err := <-done: if err == nil { t.Fatal("root published on an unsafe journal") } if errors.Is(err, errHeldReleaseNotYet) { // Not yet would make the agent retry an unsafe file forever // without anyone seeing it; it is a refusal. t.Fatalf("an unsafe journal was read as not yet: %v", err) } case <-time.After(5 * time.Second): t.Fatal("root blocked on the journal") } f.untouched(t) }) } } // TestHeldReleaseOverTheRootSocket: the agent's client reaches root's // held-release route on the claim-mode instance-control mux, with root's // peer authentication; root's refusals come back typed; and a root with no // such route (an older build, or one without its providers) and a mux with // no claim handler at all both read as unavailable. func TestHeldReleaseOverTheRootSocket(t *testing.T) { ctx := context.Background() f := newHRFixture(t) authenticated := 0 f.root.Authenticate = func(_ context.Context, conn net.Conn) (StopPeer, error) { if conn == nil { t.Error("no request connection") } authenticated++ return hrPeer, nil } sock := serveClaimRootWithHeldRelease(t, ClaimRootWithLaunch{HeldRelease: f.root}) client := newHeldReleaseRootClient(sock) stale := f.request(heldReleasePublish) stale.Session = 99 if _, err := client.HeldRelease(ctx, stale); !errors.Is(err, release.ErrStaleHeldStage) { t.Fatalf("a stale request over the socket: %v", err) } resp, err := client.HeldRelease(ctx, f.request(heldReleasePublish)) if err != nil || resp.Outcome != heldReleaseOutcomePublished { t.Fatalf("publish over the socket: %+v %v", resp, err) } if v, _ := release.LiveVersion(f.opt, p1Host); v != "v7" { t.Fatalf("live = %q", v) } if authenticated != 2 { t.Fatalf("authenticated %d of 2 requests", authenticated) } f.root.DataDir = t.TempDir() f.set(func(f *hrFixture) { f.claim.Holder.Session = 4 }) if _, err := client.HeldRelease(ctx, f.request(heldReleasePublish)); !errors.Is(err, errHeldReleaseNotYet) { t.Fatalf("not yet over the socket: %v", err) } f.root.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return StopPeer{}, errors.New("not the edge") } if _, err := client.HeldRelease(ctx, f.request(heldReleasePublish)); !errors.Is(err, ErrClaimStopIdentity) { t.Fatalf("an unauthenticated peer: %v", err) } // A body the protocol cannot carry is refused before authority checks. f.root.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return hrPeer, nil } httpc := &http.Client{Transport: &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { return (&net.Dialer{}).DialContext(ctx, "unix", sock) }}} r, err := httpc.Post("http://control/claim/held-release", "application/json", strings.NewReader(`{"schema":"x","path":"/etc/shadow"}`)) if err != nil { t.Fatal(err) } r.Body.Close() if r.StatusCode != http.StatusBadRequest { t.Fatalf("an unknown field: status %d", r.StatusCode) } for name, h := range map[string]http.Handler{ "a root without the route": ClaimRootWithLaunch{}, "a claim root of old": http.NotFoundHandler(), } { t.Run(name, func(t *testing.T) { old := newHeldReleaseRootClient(serveClaimRoot(t, h)) if _, err := old.HeldRelease(ctx, f.request(heldReleaseStage)); !errors.Is(err, errHeldReleaseUnavailable) { t.Fatalf("an old root: %v, want errHeldReleaseUnavailable", err) } }) } t.Run("a mux with the claim off", func(t *testing.T) { dir, err := os.MkdirTemp("", "hr") if err != nil { t.Fatal(err) } t.Cleanup(func() { os.RemoveAll(dir) }) sock := filepath.Join(dir, "c.sock") ln, err := net.Listen("unix", sock) if err != nil { t.Fatal(err) } srv := &http.Server{Handler: http.NewServeMux()} go srv.Serve(ln) t.Cleanup(func() { srv.Close() }) if _, err := newHeldReleaseRootClient(sock).HeldRelease(ctx, f.request(heldReleaseStage)); !errors.Is(err, errHeldReleaseUnavailable) { t.Fatalf("no claim mux: %v", err) } }) } // syncBuffer is a log sink the server goroutine and the test share. type syncBuffer struct { mu sync.Mutex b bytes.Buffer } func (s *syncBuffer) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() return s.b.Write(p) } func (s *syncBuffer) String() string { s.mu.Lock() defer s.mu.Unlock() return s.b.String() } // TestHeldReleaseRootLogsOnlyValidatedFields (fix round 2, P3-2): root // never logs a request's fields before they pass validation, so the // authenticated agent cannot forge lines in instance-control's journal; // after validation it logs them quoted. func TestHeldReleaseRootLogsOnlyValidatedFields(t *testing.T) { ctx := context.Background() f := newHRFixture(t) f.root.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return hrPeer, nil } var logs syncBuffer prev := log.Writer() log.SetOutput(&logs) t.Cleanup(func() { log.SetOutput(prev) }) client := newHeldReleaseRootClient(serveClaimRootWithHeldRelease(t, ClaimRootWithLaunch{HeldRelease: f.root})) forged := f.request(heldReleasePublish) forged.Host = "a.go.example.com\nmoves: FORGED publication of v9" forged.Version = "v7\nmoves: FORGED" if _, err := client.HeldRelease(ctx, forged); err == nil { t.Fatal("a malformed request was accepted") } stale := f.request(heldReleasePublish) stale.Session = 99 if _, err := client.HeldRelease(ctx, stale); !errors.Is(err, release.ErrStaleHeldStage) { t.Fatalf("a stale request: %v", err) } out := logs.String() if strings.Contains(out, "FORGED") { t.Fatalf("root logged an unvalidated field:\n%s", out) } if !strings.Contains(out, `"`+p1Host+`"`) { t.Fatalf("root did not log the validated request quoted:\n%s", out) } }