| 1 | //go:build linux |
| 2 | |
| 3 | package host |
| 4 | |
| 5 | import ( |
| 6 | "bytes" |
| 7 | "context" |
| 8 | "encoding/json" |
| 9 | "errors" |
| 10 | "net" |
| 11 | "net/http" |
| 12 | "testing" |
| 13 | |
| 14 | "github.com/carlosframework/platform/internal/hostclaim" |
| 15 | execlease "github.com/carlosframework/platform/internal/hostexec" |
| 16 | "github.com/carlosframework/platform/internal/provision" |
| 17 | "github.com/carlosframework/platform/internal/rootparticipation" |
| 18 | ) |
| 19 | |
| 20 | func TestPlan14ExecutionBrokerTransport(t *testing.T) { |
| 21 | t.Parallel() |
| 22 | f, root, iq := plan14InstallRegistration(t, hostclaim.KindLanding, provision.LandChecking) |
| 23 | q := execlease.ExecutionRequest{Schema: 1, ID: "request-1", Operation: "executionBegin", Host: iq.Target.Host, Incarnation: iq.Target.Incarnation, Stamp: iq.Target.Stamp, Session: iq.Target.Session, Op: iq.Target.Op} |
| 24 | peer := StopPeer{UID: 1001, PID: 42, ProcessStart: "start", BootID: "boot", ControllerID: "controller"} |
| 25 | root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return peer, nil } |
| 26 | calls := 0 |
| 27 | retired := false |
| 28 | b, err := execlease.NewRootBroker(execlease.RootBrokerConfig{State: &rollbackDisk{dir: t.TempDir()}, Remote: f.claims, Clock: execlease.SystemClock{}, BootID: peer.BootID, Identity: "broker", Authority: func(ctx context.Context, q execlease.ExecutionRequest, p execlease.ControllerProcess) (execlease.ExecutionAuthority, error) { |
| 29 | calls++ |
| 30 | if retired { |
| 31 | return execlease.ExecutionAuthority{}, errReinstallRetired |
| 32 | } |
| 33 | a, e := root.executionAuthority(ctx, q, p) |
| 34 | a.Migration = 1 |
| 35 | return a, e |
| 36 | }, Alive: func(context.Context, execlease.ControllerProcess) error { return nil }, Cleanup: func(context.Context, execlease.ExecutionRecord) error { return nil }, Settle: func(context.Context, execlease.ExecutionRecord) error { return nil }, Exclude: func(_ context.Context, fn func() error) error { return fn() }}) |
| 37 | if err != nil { |
| 38 | t.Fatal(err) |
| 39 | } |
| 40 | root.Execution = b |
| 41 | t.Cleanup(func() { _ = b.Close(context.Background()) }) |
| 42 | socket := serveClaimRoot(t, root) |
| 43 | tr := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { |
| 44 | return (&net.Dialer{}).DialContext(ctx, "unix", socket) |
| 45 | }} |
| 46 | defer tr.CloseIdleConnections() |
| 47 | raw, _ := json.Marshal(q) |
| 48 | for _, field := range []string{`"expiry":"2099-01-01"`, `"pid":42`, `"path":"/data"`, `"duration":3600`, `"safe_until":999999999999`} { |
| 49 | forged := append(append([]byte{}, raw[:len(raw)-1]...), []byte(","+field+"}")...) |
| 50 | req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://control/claim/execution", bytes.NewReader(forged)) |
| 51 | if err != nil { |
| 52 | t.Fatal(err) |
| 53 | } |
| 54 | resp, err := (&http.Client{Transport: tr}).Do(req) |
| 55 | if err != nil { |
| 56 | t.Fatal(err) |
| 57 | } |
| 58 | resp.Body.Close() |
| 59 | if resp.StatusCode != http.StatusBadRequest || calls != 0 { |
| 60 | t.Fatalf("untrusted field %s reached authority: status=%d calls=%d", field, resp.StatusCode, calls) |
| 61 | } |
| 62 | } |
| 63 | oversized := append(append([]byte(nil), raw...), bytes.Repeat([]byte(" "), 16<<10)...) |
| 64 | req, err := http.NewRequestWithContext(t.Context(), http.MethodPost, "http://control/claim/execution", bytes.NewReader(oversized)) |
| 65 | if err != nil { |
| 66 | t.Fatal(err) |
| 67 | } |
| 68 | resp, err := (&http.Client{Transport: tr}).Do(req) |
| 69 | if err != nil { |
| 70 | t.Fatal(err) |
| 71 | } |
| 72 | resp.Body.Close() |
| 73 | if resp.StatusCode != http.StatusBadRequest || calls != 0 { |
| 74 | t.Fatalf("oversized request reached authority: status=%d calls=%d", resp.StatusCode, calls) |
| 75 | } |
| 76 | retired = true |
| 77 | if _, err := rootExecutionClient(socket)(t.Context(), q); !errors.Is(err, errReinstallRetired) || errors.Is(err, execlease.ErrLost) || err.Error() != q.Host+": "+errReinstallRetired.Error() { |
| 78 | t.Fatalf("retired refusal lost: %v", err) |
| 79 | } |
| 80 | retired = false |
| 81 | out, err := rootExecutionClient(socket)(t.Context(), q) |
| 82 | if err != nil { |
| 83 | t.Fatal(err) |
| 84 | } |
| 85 | if out.Status != "active" || out.Token == "" { |
| 86 | t.Fatal(out) |
| 87 | } |
| 88 | again, err := rootExecutionClient(socket)(t.Context(), q) |
| 89 | if err != nil || again != out { |
| 90 | t.Fatalf("replay: %+v %v", again, err) |
| 91 | } |
| 92 | peer.ProcessStart = "reused" |
| 93 | q.Operation = "executionView" |
| 94 | q.ID = "view" |
| 95 | q.Token = out.Token |
| 96 | if _, err := rootExecutionClient(socket)(t.Context(), q); err == nil { |
| 97 | t.Fatal("PID reuse borrowed execution") |
| 98 | } |
| 99 | } |
| 100 | |
| 101 | func TestPlan14ExecutionServingTransport(t *testing.T) { |
| 102 | t.Parallel() |
| 103 | f, root, iq := plan14InstallRegistration(t, hostclaim.KindServing, "") |
| 104 | if _, err := root.commitRootInventory(t.Context(), iq); err != nil { |
| 105 | t.Fatal(err) |
| 106 | } |
| 107 | peer := StopPeer{UID: 1001, PID: 42, ProcessStart: "start", BootID: "boot", ControllerID: "controller"} |
| 108 | root.Stop.Authenticate = func(context.Context, net.Conn) (StopPeer, error) { return peer, nil } |
| 109 | disk := &rollbackDisk{dir: t.TempDir()} |
| 110 | raw, _ := json.Marshal(rootparticipation.Census{Schema: 1, Box: "box-1", Enrollment: "en", Migration: 1, Revision: 1, LaunchRevision: 1, Complete: true}) |
| 111 | if err := disk.Write("census.json", raw); err != nil { |
| 112 | t.Fatal(err) |
| 113 | } |
| 114 | b, err := execlease.NewRootBroker(execlease.RootBrokerConfig{State: disk, Remote: f.claims, Clock: execlease.SystemClock{}, BootID: peer.BootID, Identity: "broker", Authority: root.executionAuthority, Alive: func(context.Context, execlease.ControllerProcess) error { return nil }, Cleanup: func(context.Context, execlease.ExecutionRecord) error { return nil }, Settle: func(context.Context, execlease.ExecutionRecord) error { return nil }, Exclude: func(_ context.Context, fn func() error) error { return fn() }}) |
| 115 | if err != nil { |
| 116 | t.Fatal(err) |
| 117 | } |
| 118 | root.Execution = b |
| 119 | t.Cleanup(func() { _ = b.Close(context.Background()) }) |
| 120 | call := rootExecutionClient(serveClaimRoot(t, root)) |
| 121 | q := execlease.ExecutionRequest{Schema: 1, ID: "serving-view", Operation: "executionView", Host: iq.Target.Host, Incarnation: iq.Target.Incarnation, Stamp: iq.Target.Stamp, Session: iq.Target.Session, Op: iq.Target.Op} |
| 122 | for _, mismatch := range []string{"none", "stamp", "session", "incarnation", "op", "token"} { |
| 123 | t.Run(mismatch, func(t *testing.T) { |
| 124 | bad := q |
| 125 | switch mismatch { |
| 126 | case "stamp": |
| 127 | bad.Stamp++ |
| 128 | case "session": |
| 129 | bad.Session++ |
| 130 | case "incarnation": |
| 131 | bad.Incarnation = "other" |
| 132 | case "op": |
| 133 | bad.Op = "other" |
| 134 | case "token": |
| 135 | bad.Token = "other" |
| 136 | } |
| 137 | out, err := call(t.Context(), bad) |
| 138 | if mismatch == "none" { |
| 139 | if err != nil || out.Status != "serving" || out.Token != "" || out.SafeUntil != 0 { |
| 140 | t.Fatalf("serving response: %+v %v", out, err) |
| 141 | } |
| 142 | } else if err == nil { |
| 143 | t.Fatalf("mismatched %s admitted: %+v", mismatch, out) |
| 144 | } |
| 145 | }) |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | func TestPlan14ExecutionServingResponseShape(t *testing.T) { |
| 150 | q := execlease.ExecutionRequest{Schema: 1, ID: "view", Operation: "executionView", Host: "shop.example.com", Incarnation: "inc", Stamp: 1, Session: 1} |
| 151 | for _, attack := range []string{"none", "active token", "deadline", "lease", "wrong session"} { |
| 152 | t.Run(attack, func(t *testing.T) { |
| 153 | out := execlease.ExecutionResponse{Schema: 1, Host: q.Host, Incarnation: q.Incarnation, Stamp: q.Stamp, Session: q.Session, BootID: "boot", Status: "serving"} |
| 154 | switch attack { |
| 155 | case "active token": |
| 156 | out.Status, out.Token = "active", "unexpected-token" |
| 157 | case "deadline": |
| 158 | out.SafeUntil = 1234 |
| 159 | case "lease": |
| 160 | out.LeaseETag = "lease" |
| 161 | case "wrong session": |
| 162 | out.Session++ |
| 163 | } |
| 164 | socket := serveClaimRoot(t, http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { _ = json.NewEncoder(w).Encode(out) })) |
| 165 | _, err := rootExecutionClient(socket)(t.Context(), q) |
| 166 | if attack == "none" && err != nil { |
| 167 | t.Fatal(err) |
| 168 | } |
| 169 | if attack != "none" && err == nil { |
| 170 | t.Fatalf("accepted %s response", attack) |
| 171 | } |
| 172 | }) |
| 173 | } |
| 174 | } |
| 175 | |