| 1 | package host |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "net" |
| 10 | "net/http" |
| 11 | "net/http/httptest" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "strings" |
| 15 | "sync/atomic" |
| 16 | "testing" |
| 17 | |
| 18 | "github.com/carlosframework/platform/internal/hostbox" |
| 19 | "github.com/carlosframework/platform/internal/hostguard" |
| 20 | ) |
| 21 | |
| 22 | func TestPlan9JournalRootReadAndReceiptReplay(t *testing.T) { |
| 23 | j, err := hostbox.OpenLaunchJournal(filepath.Join(t.TempDir(), "root.db")) |
| 24 | if err != nil { |
| 25 | t.Fatal(err) |
| 26 | } |
| 27 | defer j.Close() |
| 28 | s, err := hostbox.NewJournalService(j) |
| 29 | if err != nil { |
| 30 | t.Fatal(err) |
| 31 | } |
| 32 | in := hostbox.LaunchInventory{Target: hostguard.FenceTarget{Host: "app.example", Incarnation: "inc", Stamp: 1, Session: 1, Box: "box", Enrollment: "enroll", BootID: "boot", ControllerID: "controller"}, ControllerPID: 17, ProcessStart: "23", NoState: true, Groups: []hostbox.LaunchGroup{{RelativePath: "session", Device: 1, Inode: 2}}} |
| 33 | q := hostbox.JournalRequest{V: 1, Op: "create", Launch: in} |
| 34 | q = plan9CanonicalJournalRequest(t, q) |
| 35 | if _, err = s.Execute(t.Context(), "box/enroll", q); err != nil { |
| 36 | t.Fatal(err) |
| 37 | } |
| 38 | root := ClaimRootHTTP{Journals: s, RequireNewWork: func(context.Context) error { return errors.New("new work blocked") }} |
| 39 | root.Reconcile.Box = "box" |
| 40 | root.Reconcile.Enrollment = "enroll" |
| 41 | peer := StopPeer{UID: 1001, PID: 17, ProcessStart: "23", BootID: "boot", ControllerID: "controller"} |
| 42 | // The receipt predates a subsequent claim change: the replay must still |
| 43 | // return the recorded result, without new claim authority or effects. |
| 44 | body, _ := json.Marshal(q) |
| 45 | response := httptest.NewRecorder() |
| 46 | root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) |
| 47 | if response.Code != http.StatusOK { |
| 48 | t.Fatal(response.Code, response.Body.String()) |
| 49 | } |
| 50 | q = hostbox.JournalRequest{V: 1, Op: "read", ID: "ffeeddccbbaa99887766554433221100", Target: in.Target} |
| 51 | body, _ = json.Marshal(q) |
| 52 | response = httptest.NewRecorder() |
| 53 | root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) |
| 54 | var out hostbox.JournalResponse |
| 55 | if err = json.Unmarshal(response.Body.Bytes(), &out); err != nil || out.Error != "" || out.Launch.Target.Host != in.Target.Host { |
| 56 | t.Fatalf("read: %+v %v", out, err) |
| 57 | } // Positive settlement is allowed after claim authority ends, but cannot |
| 58 | // invent a producer. No claim store is configured here deliberately. |
| 59 | if err = j.RecordProducer(t.Context(), in.Target, "issued"); err != nil { |
| 60 | t.Fatal(err) |
| 61 | } |
| 62 | root.Reconcile.Box = "box" |
| 63 | root.Reconcile.Enrollment = "enroll" |
| 64 | q = hostbox.JournalRequest{V: 1, Op: "complete", Target: in.Target, Producer: "issued"} |
| 65 | q = plan9CanonicalJournalRequest(t, q) |
| 66 | body, _ = json.Marshal(q) |
| 67 | response = httptest.NewRecorder() |
| 68 | root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) |
| 69 | if err = json.Unmarshal(response.Body.Bytes(), &out); err != nil || out.Error != "" || out.Completion.Count != 1 { |
| 70 | t.Fatalf("late completion: %+v %v", out, err) |
| 71 | } |
| 72 | root.Reconcile.Box = "" |
| 73 | root.Reconcile.Enrollment = "" |
| 74 | root.Reconcile.Box = "box" |
| 75 | root.Reconcile.Enrollment = "enroll" |
| 76 | next := in |
| 77 | next.Target.Session++ |
| 78 | newRequest := plan9CanonicalJournalRequest(t, hostbox.JournalRequest{Op: "create", Launch: next}) |
| 79 | body, _ = json.Marshal(newRequest) |
| 80 | response = httptest.NewRecorder() |
| 81 | root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) |
| 82 | if response.Code != http.StatusForbidden { |
| 83 | t.Fatalf("new-work gate bypassed: %d", response.Code) |
| 84 | } |
| 85 | |
| 86 | // Root-only recovery settlement has no remotely callable operation. |
| 87 | q.Op = "settle-recovered" |
| 88 | q.ID = "11112222333344445555666677778888" |
| 89 | body, _ = json.Marshal(q) |
| 90 | response = httptest.NewRecorder() |
| 91 | root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", bytes.NewReader(body)), peer) |
| 92 | if response.Code == http.StatusOK { |
| 93 | t.Fatal("recovery authority exposed") |
| 94 | } |
| 95 | response = httptest.NewRecorder() |
| 96 | root.serveJournal(response, httptest.NewRequest(http.MethodPost, "/claim/journal", strings.NewReader(`{"V":1,"Path":"/etc/passwd"}`)), peer) |
| 97 | if response.Code != http.StatusBadRequest { |
| 98 | t.Fatal("unknown path field accepted") |
| 99 | } |
| 100 | } |
| 101 | func TestPlan9JournalNamespaceUnitIsFlagScoped(t *testing.T) { |
| 102 | raw, err := unitsFS.ReadFile("units/" + claimControlUnit) |
| 103 | if err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | if got := claimUnitContent(claimControlUnit, raw, claimUnitSettings{}); !bytes.Equal(got, raw) { |
| 107 | t.Fatal("flag-off unit changed") |
| 108 | } |
| 109 | got := string(claimUnitContent(claimControlUnit, raw, claimUnitSettings{enabled: true, data: "/data", opt: "/opt/carlos"})) |
| 110 | for _, want := range []string{"StateDirectory=carlos-claims", "StateDirectoryMode=0700", "/var/lib/carlos-claims", "/run/systemd/system"} { |
| 111 | if !strings.Contains(got, want) { |
| 112 | t.Fatalf("missing %s", want) |
| 113 | } |
| 114 | } |
| 115 | } |
| 116 | |
| 117 | func plan9CanonicalJournalRequest(t *testing.T, q hostbox.JournalRequest) hostbox.JournalRequest { |
| 118 | t.Helper() |
| 119 | var captured hostbox.JournalRequest |
| 120 | client, _ := hostbox.RemoteJournals(func(_ context.Context, in hostbox.JournalRequest) (hostbox.JournalResponse, error) { |
| 121 | captured = in |
| 122 | return hostbox.JournalResponse{V: 1, ID: in.ID}, nil |
| 123 | }) |
| 124 | var err error |
| 125 | switch q.Op { |
| 126 | case "create": |
| 127 | err = client.Create(t.Context(), q.Launch) |
| 128 | case "complete": |
| 129 | _, err = client.CompleteProducer(t.Context(), q.Target, q.Producer, q.Previous) |
| 130 | default: |
| 131 | t.Fatal("unsupported test request") |
| 132 | } |
| 133 | if err != nil { |
| 134 | t.Fatal(err) |
| 135 | } |
| 136 | return captured |
| 137 | } |
| 138 | |
| 139 | func TestPlan9JournalTransportDefiniteRefusalAndLostReply(t *testing.T) { |
| 140 | for _, status := range []int{http.StatusBadRequest, http.StatusForbidden, http.StatusConflict} { |
| 141 | t.Run(fmt.Sprint(status), func(t *testing.T) { |
| 142 | var calls atomic.Int32 |
| 143 | call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1); w.WriteHeader(status) })) |
| 144 | client, _ := hostbox.RemoteJournals(call) |
| 145 | err := client.Create(t.Context(), hostbox.LaunchInventory{}) |
| 146 | var uncertain *hostbox.JournalUncertainError |
| 147 | if err == nil || errors.As(err, &uncertain) { |
| 148 | t.Fatalf("definite refusal became uncertain: %v", err) |
| 149 | } |
| 150 | if calls.Load() != 1 { |
| 151 | t.Fatalf("definite refusal retried %d times", calls.Load()) |
| 152 | } |
| 153 | }) |
| 154 | } |
| 155 | var calls atomic.Int32 |
| 156 | call := plan9JournalSocket(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 157 | if calls.Add(1) == 1 { |
| 158 | conn, _, err := w.(http.Hijacker).Hijack() |
| 159 | if err == nil { |
| 160 | conn.Close() |
| 161 | } |
| 162 | return |
| 163 | } |
| 164 | w.WriteHeader(http.StatusConflict) |
| 165 | })) |
| 166 | client, _ := hostbox.RemoteJournals(call) |
| 167 | err := client.Create(t.Context(), hostbox.LaunchInventory{}) |
| 168 | var uncertain *hostbox.JournalUncertainError |
| 169 | if !errors.As(err, &uncertain) || calls.Load() != 2 { |
| 170 | t.Fatalf("lost first response was incorrectly settled: %v calls=%d", err, calls.Load()) |
| 171 | } |
| 172 | } |
| 173 | func plan9JournalSocket(t *testing.T, handler http.Handler) hostbox.JournalCall { |
| 174 | t.Helper() |
| 175 | dir, err := os.MkdirTemp(os.Getenv("TMPDIR"), "journal-sock-") |
| 176 | if err != nil { |
| 177 | t.Fatal(err) |
| 178 | } |
| 179 | t.Cleanup(func() { os.RemoveAll(dir) }) |
| 180 | path := filepath.Join(dir, "root.sock") |
| 181 | listener, err := net.Listen("unix", path) |
| 182 | if err != nil { |
| 183 | t.Fatal(err) |
| 184 | } |
| 185 | server := &http.Server{Handler: handler} |
| 186 | done := make(chan struct{}) |
| 187 | go func() { defer close(done); _ = server.Serve(listener) }() |
| 188 | t.Cleanup(func() { server.Close(); <-done }) |
| 189 | return rootJournalClient(path) |
| 190 | } |
| 191 | |