tinyrelayfile
tinyrelay/internal/daemon/private_peer_acceptance_test.go/

internal/daemon/private_peer_acceptance_test.go raw
1package daemon
2
3import (
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
22func 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
148func 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
179func 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
191func 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
243func 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
250func 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}
256func 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}
264func bytesOf(n int) []byte { return make([]byte, n) }
265
266func 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
282func 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
300func 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
321func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs