| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "net/http" |
| 8 | "net/http/httptest" |
| 9 | "os" |
| 10 | "os/exec" |
| 11 | "path/filepath" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | "time" |
| 15 | |
| 16 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 17 | gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 18 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 19 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 20 | ) |
| 21 | |
| 22 | func TestPrivatePeerGitAndMetadataSyncAcrossTenants(t *testing.T) { |
| 23 | ctx := context.Background() |
| 24 | sourceSecret := strings.Repeat("1" , 64) |
| 25 | destSecret := strings.Repeat("2" , 64) |
| 26 | sourceOwner, err := event.PublicKey(sourceSecret) |
| 27 | if err != nil { |
| 28 | t.Fatal(err) |
| 29 | } |
| 30 | destOwner, err := event.PublicKey(destSecret) |
| 31 | if err != nil { |
| 32 | t.Fatal(err) |
| 33 | } |
| 34 | _, source, sourceServer := privatePeerTenant(t, "source" , sourceOwner) |
| 35 | _, dest, destServer := privatePeerTenant(t, "dest" , destOwner) |
| 36 | configurePrivatePeer(t, source, sourceOwner, dest.records.PublicKey(), destServer.URL) |
| 37 | configurePrivatePeer(t, dest, destOwner, source.records.PublicKey(), sourceServer.URL) |
| 38 | if _, err := source.community.Execute(ctx, sourceOwner, "setmember" , []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q` , destOwner)), json.RawMessage(`{"name":"peer-owner"}` )}); err != nil { |
| 39 | t.Fatal(err) |
| 40 | } |
| 41 | |
| 42 | identifier := "private" |
| 43 | repo, announcement, state := seedPrivateRepository(t, dest, destOwner, destSecret, identifier, destServer.URL) |
| 44 | probe, err := destServer.Client().Get(repo.Clone[0] + "/info/refs?service=git-upload-pack" ) |
| 45 | if err != nil { |
| 46 | t.Fatal(err) |
| 47 | } |
| 48 | probe.Body.Close() |
| 49 | if probe.StatusCode != 401 { |
| 50 | t.Fatalf("private clone probe status=%d url=%s" , probe.StatusCode, repo.Clone[0]) |
| 51 | } |
| 52 | authHeader, err := source.privateHTTPAuth(ctx, "GET" , repo.Clone[0]+"/info/refs?service=git-upload-pack" , "" ) |
| 53 | if err != nil { |
| 54 | t.Fatal(err) |
| 55 | } |
| 56 | request, err := http.NewRequest(http.MethodGet, repo.Clone[0]+"/info/refs?service=git-upload-pack" , nil) |
| 57 | if err != nil { |
| 58 | t.Fatal(err) |
| 59 | } |
| 60 | request.Header.Set("Authorization" , authHeader) |
| 61 | checked, err := destServer.Client().Do(request) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | checked.Body.Close() |
| 66 | if checked.StatusCode != 200 { |
| 67 | t.Fatalf("authenticated clone probe status=%d url=%s owner=%s" , checked.StatusCode, repo.Clone[0], repo.Owner) |
| 68 | } |
| 69 | if err := source.gitSync(ctx, repo); err != nil { |
| 70 | t.Fatalf("private Git sync: %v" , err) |
| 71 | } |
| 72 | var untrustedAuth string |
| 73 | untrustedPeer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 74 | untrustedAuth = r.Header.Get("Authorization" ) |
| 75 | http.NotFound(w, r) |
| 76 | })) |
| 77 | t.Cleanup(untrustedPeer.Close) |
| 78 | publicRepo := repo |
| 79 | // Use an empty local repository so this actually exercises the peer path; |
| 80 | // complete local objects intentionally need no network request. |
| 81 | publicRepo.Identifier = "unconfigured-peer" |
| 82 | publicRepo.Clone = []string{untrustedPeer.URL + "/private.git" } |
| 83 | if err := source.git.FetchMissing(ctx, publicRepo, publicRepo.Clone, publicRepo.Refs); err == nil { |
| 84 | t.Fatal("unconfigured public peer unexpectedly supplied private Git objects" ) |
| 85 | } |
| 86 | if untrustedAuth != "" { |
| 87 | t.Fatalf("unconfigured public peer received credentials: %q" , untrustedAuth) |
| 88 | } |
| 89 | if !gitObjectExists(t, source.meta.Paths.Git, repo, repo.Refs["refs/heads/main" ]) { |
| 90 | t.Fatal("source tenant did not receive the signed Git object" ) |
| 91 | } |
| 92 | if err := source.gitEventSync(ctx, repo); err != nil { |
| 93 | t.Fatalf("private metadata sync: %v" , err) |
| 94 | } |
| 95 | for _, item := range []event.Event{announcement} { |
| 96 | rows, err := source.store.Query(ctx, event.Filter{IDs: []string{item.ID}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1}) |
| 97 | if err != nil || len(rows.Events) != 1 { |
| 98 | t.Fatalf("metadata event %s missing after sync: %v" , item.ID, err) |
| 99 | } |
| 100 | } |
| 101 | var rawStateID string |
| 102 | if err := source.store.DB().QueryRowContext(ctx, "SELECT id FROM events WHERE kind=30618 AND pubkey=? AND d=?" , state.PubKey, identifier).Scan(&rawStateID); err != nil || rawStateID != state.ID { |
| 103 | t.Fatalf("state metadata was not persisted: id=%q err=%v" , rawStateID, err) |
| 104 | } |
| 105 | |
| 106 | if err := source.git.Reload(ctx); err != nil { |
| 107 | t.Fatal(err) |
| 108 | } |
| 109 | if !gitObjectExists(t, source.meta.Paths.Git, repo, repo.Refs["refs/heads/main" ]) { |
| 110 | t.Fatal("source Git object disappeared after reload" ) |
| 111 | } |
| 112 | var publicAuth string |
| 113 | guardPeer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 114 | publicAuth = r.Header.Get("Authorization" ) |
| 115 | if r.URL.Path == "/" { |
| 116 | w.Header().Set("Content-Type" , "application/nostr+json" ) |
| 117 | _, _ = w.Write([]byte(`{"supported_grasps":["GRASP-08"]}` )) |
| 118 | return |
| 119 | } |
| 120 | http.NotFound(w, r) |
| 121 | })) |
| 122 | t.Cleanup(guardPeer.Close) |
| 123 | publicPolicy := source.Policy() |
| 124 | publicPolicy.Features.Grasp08 = false |
| 125 | publicPolicy.Reads = "open" |
| 126 | publicPolicy.PrivatePeers = []string{guardPeer.URL} |
| 127 | if err := source.applyPolicy(ctx, publicPolicy); err == nil { |
| 128 | t.Fatal("private tenant policy was made public while private repository metadata existed" ) |
| 129 | } |
| 130 | _, publicTenant := testTenant(t) |
| 131 | publicPolicy = publicTenant.Policy() |
| 132 | publicPolicy.Features.Grasp = true |
| 133 | publicPolicy.PrivatePeers = []string{guardPeer.URL} |
| 134 | if err := publicTenant.applyPolicy(ctx, publicPolicy); err != nil { |
| 135 | t.Fatal(err) |
| 136 | } |
| 137 | publicCheckRepo := repo |
| 138 | publicCheckRepo.Identifier = "public-profile-peer" |
| 139 | publicCheckRepo.Clone = []string{guardPeer.URL + "/private.git" } |
| 140 | if err := publicTenant.gitSync(ctx, publicCheckRepo); err == nil { |
| 141 | t.Fatal("public profile unexpectedly fetched from a private peer" ) |
| 142 | } |
| 143 | if publicAuth != "" { |
| 144 | t.Fatalf("public profile sent private peer credentials: %q" , publicAuth) |
| 145 | } |
| 146 | } |
| 147 | |
| 148 | func privatePeerTenant(t *testing.T, name, owner string) (*App, *Tenant, *httptest.Server) { |
| 149 | t.Helper() |
| 150 | ctx := context.Background() |
| 151 | app, err := New(ctx, Config{DataDir: t.TempDir(), DefaultTenant: name, AllowPrivateRelays: true}) |
| 152 | if err != nil { |
| 153 | t.Fatal(err) |
| 154 | } |
| 155 | t.Cleanup(func() { _ = app.Close(ctx) }) |
| 156 | meta, err := app.Create(ctx, CreateOptions{Name: name, Owner: owner, Template: "default" }) |
| 157 | if err != nil { |
| 158 | t.Fatal(err) |
| 159 | } |
| 160 | server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { app.ServeHTTP(w, r) })) |
| 161 | t.Cleanup(server.Close) |
| 162 | tenant, err := app.tenant(ctx, meta, server.URL) |
| 163 | if err != nil { |
| 164 | t.Fatal(err) |
| 165 | } |
| 166 | p := tenant.Policy() |
| 167 | p.Features.Grasp = true |
| 168 | p.Features.Grasp02 = true |
| 169 | p.Features.Grasp03 = true |
| 170 | p.Features.Grasp05 = true |
| 171 | p.Features.Grasp08 = true |
| 172 | p.Reads = "members" |
| 173 | if err := tenant.applyPolicy(ctx, p); err != nil { |
| 174 | t.Fatal(err) |
| 175 | } |
| 176 | return app, tenant, server |
| 177 | } |
| 178 | |
| 179 | func configurePrivatePeer(t *testing.T, tenant *Tenant, owner, member, peer string) { |
| 180 | t.Helper() |
| 181 | if _, err := tenant.community.Execute(context.Background(), owner, "setmember" , []json.RawMessage{json.RawMessage(fmt.Sprintf(`%q` , member)), json.RawMessage(`{"name":"peer"}` )}); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | p := tenant.Policy() |
| 185 | p.PrivatePeers = []string{peer} |
| 186 | if err := tenant.applyPolicy(context.Background(), p); err != nil { |
| 187 | t.Fatal(err) |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | func seedPrivateRepository(t *testing.T, tenant *Tenant, owner, secret, identifier, baseURL string) (gitrelay.Repository, event.Event, event.Event) { |
| 192 | t.Helper() |
| 193 | path := filepath.Join(tenant.meta.Paths.Git, owner, identifier+".git" ) |
| 194 | if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { |
| 195 | t.Fatal(err) |
| 196 | } |
| 197 | peerGitRun(t, "init" , "--bare" , path) |
| 198 | work := filepath.Join(t.TempDir(), "work" ) |
| 199 | peerGitRun(t, "init" , work) |
| 200 | peerGitRun(t, "-C" , work, "config" , "user.email" , "peer@example.test" ) |
| 201 | peerGitRun(t, "-C" , work, "config" , "user.name" , "Peer" ) |
| 202 | if err := os.WriteFile(filepath.Join(work, "README.md" ), []byte("private peer\n" ), 0600); err != nil { |
| 203 | t.Fatal(err) |
| 204 | } |
| 205 | if err := os.WriteFile(filepath.Join(work, "medium.bin" ), bytesOf(128<<10), 0600); err != nil { |
| 206 | t.Fatal(err) |
| 207 | } |
| 208 | peerGitRun(t, "-C" , work, "add" , "." ) |
| 209 | peerGitRun(t, "-C" , work, "commit" , "-m" , "private peer" ) |
| 210 | peerGitRun(t, "-C" , work, "branch" , "-M" , "main" ) |
| 211 | sha := strings.TrimSpace(peerGitOutput(t, "-C" , work, "rev-parse" , "HEAD" )) |
| 212 | peerGitRun(t, "-C" , work, "push" , path, "HEAD:refs/heads/main" ) |
| 213 | ownerNPub := testNpub(owner) |
| 214 | cloneURL := baseURL + "/" + ownerNPub + "/" + identifier + ".git" |
| 215 | announcement := event.Event{Kind: event.KIND_REPO, CreatedAt: time.Now().Unix(), Tags: [][]string{{"d" , identifier}, {"clone" , cloneURL}, {"relays" , tenant.RelayURL()}}} |
| 216 | if err := event.Sign(&announcement, secret); err != nil { |
| 217 | t.Fatal(err) |
| 218 | } |
| 219 | if _, err := tenant.router.Publish(context.Background(), announcement, relay.Session{PubKeys: []string{owner}}); err != nil { |
| 220 | t.Fatal(err) |
| 221 | } |
| 222 | storedAnnouncement, err := tenant.store.Query(context.Background(), event.Filter{IDs: []string{announcement.ID}, Tags: map[string][]string{}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}, Limit: 1}) |
| 223 | if err != nil || len(storedAnnouncement.Events) != 1 { |
| 224 | t.Fatalf("destination announcement was not stored: %v" , err) |
| 225 | } |
| 226 | state := event.Event{Kind: event.KIND_REPO_STATE, CreatedAt: time.Now().Unix(), Tags: [][]string{{"d" , identifier}, {"HEAD" , "ref: refs/heads/main" }, {"refs/heads/main" , sha}}} |
| 227 | if err := event.Sign(&state, secret); err != nil { |
| 228 | t.Fatal(err) |
| 229 | } |
| 230 | if _, err := tenant.router.Publish(context.Background(), state, relay.Session{PubKeys: []string{owner}}); err != nil { |
| 231 | t.Fatal(err) |
| 232 | } |
| 233 | repo := gitrelay.Repository{Owner: owner, Identifier: identifier, EventID: announcement.ID, Clone: []string{cloneURL}, Relays: []string{tenant.RelayURL()}, Refs: map[string]string{"refs/heads/main" : sha}, Head: "ref: refs/heads/main" } |
| 234 | if err := tenant.git.CommitAfterStore(context.Background(), announcement, repo); err != nil { |
| 235 | t.Fatal(err) |
| 236 | } |
| 237 | if err := tenant.git.CommitAfterStore(context.Background(), state, repo); err != nil { |
| 238 | t.Fatal(err) |
| 239 | } |
| 240 | return repo, announcement, state |
| 241 | } |
| 242 | |
| 243 | func gitObjectExists(t *testing.T, root string, repo gitrelay.Repository, object string) bool { |
| 244 | t.Helper() |
| 245 | path := filepath.Join(root, repo.Owner, repo.Identifier+".git" ) |
| 246 | cmd := exec.Command("git" , "--git-dir" , path, "cat-file" , "-e" , object) |
| 247 | return cmd.Run() == nil |
| 248 | } |
| 249 | |
| 250 | func peerGitRun(t *testing.T, args ...string) { |
| 251 | t.Helper() |
| 252 | if out, err := exec.Command("git" , args...).CombinedOutput(); err != nil { |
| 253 | t.Fatalf("git %v: %v: %s" , args, err, out) |
| 254 | } |
| 255 | } |
| 256 | func peerGitOutput(t *testing.T, args ...string) string { |
| 257 | t.Helper() |
| 258 | out, err := exec.Command("git" , args...).Output() |
| 259 | if err != nil { |
| 260 | t.Fatal(err) |
| 261 | } |
| 262 | return string(out) |
| 263 | } |
| 264 | func bytesOf(n int) []byte { return make([]byte, n) } |
| 265 | |
| 266 | func testNpub(hexKey string) string { |
| 267 | data := make([]byte, len(hexKey)/2) |
| 268 | for i := range data { |
| 269 | fmt.Sscanf(hexKey[i*2:i*2+2], "%02x" , &data[i]) |
| 270 | } |
| 271 | converted := convertBits(data, 8, 5, true) |
| 272 | charset := "qpzry9x8gf2tvdw0s3jn54khce6mua7l" |
| 273 | values := converted |
| 274 | values = append(values, bech32Checksum("npub" , values)...) |
| 275 | out := "npub1" |
| 276 | for _, value := range values { |
| 277 | out += string(charset[value]) |
| 278 | } |
| 279 | return out |
| 280 | } |
| 281 | |
| 282 | func convertBits(data []byte, from, to uint, pad bool) []byte { |
| 283 | acc, bits := 0, uint(0) |
| 284 | max := (1 << to) - 1 |
| 285 | out := make([]byte, 0, len(data)*int(from)/int(to)+1) |
| 286 | for _, value := range data { |
| 287 | acc = (acc << from) | int(value) |
| 288 | bits += from |
| 289 | for bits >= to { |
| 290 | bits -= to |
| 291 | out = append(out, byte((acc>>bits)&max)) |
| 292 | } |
| 293 | } |
| 294 | if pad && bits > 0 { |
| 295 | out = append(out, byte((acc<<(to-bits))&max)) |
| 296 | } |
| 297 | return out |
| 298 | } |
| 299 | |
| 300 | func bech32Checksum(hrp string, data []byte) []byte { |
| 301 | values := make([]int, 0, len(hrp)*2+len(data)+6) |
| 302 | for _, char := range hrp { |
| 303 | values = append(values, int(char>>5)) |
| 304 | } |
| 305 | values = append(values, 0) |
| 306 | for _, char := range hrp { |
| 307 | values = append(values, int(char&31)) |
| 308 | } |
| 309 | for _, value := range data { |
| 310 | values = append(values, int(value)) |
| 311 | } |
| 312 | values = append(values, 0, 0, 0, 0, 0, 0) |
| 313 | polymod := bech32Polymod(values) ^ 1 |
| 314 | checksum := make([]byte, 6) |
| 315 | for i := range checksum { |
| 316 | checksum[i] = byte((polymod >> uint(5*(5-i))) & 31) |
| 317 | } |
| 318 | return checksum |
| 319 | } |
| 320 | |
| 321 | func bech32Polymod(values []int) uint32 { |
| 322 | generators := [...]uint32{0x3b6a57b2, 0x26508e6d, 0x1ea119fa, 0x3d4233dd, 0x2a1462b3} |
| 323 | value := uint32(1) |
| 324 | for _, item := range values { |
| 325 | top := value >> 25 |
| 326 | value = (value&0x1ffffff)<<5 ^ uint32(item) |
| 327 | for i := 0; i < 5; i++ { |
| 328 | if (top>>uint(i))&1 != 0 { |
| 329 | value ^= generators[i] |
| 330 | } |
| 331 | } |
| 332 | } |
| 333 | return value |
| 334 | } |