clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

treat an active agent grant as membership for uploads and retire expired agent uploads in maintenance

Dami committed 7456cee
commit
7456cee9607132d5b92f5bc67170c354ff808148
parent
8867da128f1648c5fd19070f2b144ab6ebefc238
author
Dami <dami.etoile@gmail.com>
5 files changed, +170 -7
Minternal/blob/blob.go10++--
Minternal/daemon/blob_authorization_test.go91++++++++++++++++++++++++++++++
Minternal/daemon/scheduler.go1+
Minternal/daemon/services.go73++++++++++++++++++++++++-
Minternal/telemetry/telemetry.go2+-
M internal/blob/blob.go +5 -5
M internal/daemon/blob_authorization_test.go +91 -0
@@ -18,8 +18,99 @@ import (
1818 1919 "github.com/FelineStateMachine/tinyrelay/internal/blob"2020 "github.com/FelineStateMachine/tinyrelay/internal/event"21+ "github.com/FelineStateMachine/tinyrelay/internal/sites"2122 )2223 24+// agentUpload sends one Blossom PUT /upload signed by secret through the25+// tenant and returns the response.26+func agentUpload(t *testing.T, tenant *Tenant, secret, body, typ string) *httptest.ResponseRecorder {27+ t.Helper()28+ sum := sha256.Sum256([]byte(body))29+ token := event.Event{Kind: 24242, CreatedAt: time.Now().Unix(), Tags: [][]string{{"t", "upload"}, {"x", hex.EncodeToString(sum[:])}, {"expiration", strconv.FormatInt(time.Now().Unix()+60, 10)}}, Content: "Authorize file storage"}30+ if err := event.Sign(&token, secret); err != nil {31+ t.Fatal(err)32+ }33+ raw, err := json.Marshal(token)34+ if err != nil {35+ t.Fatal(err)36+ }37+ r := httptest.NewRequest(http.MethodPut, "http://relay.test/upload", strings.NewReader(body))38+ r.Header.Set("Authorization", "Nostr "+base64.RawURLEncoding.EncodeToString(raw))39+ r.Header.Set("Content-Type", typ)40+ w := httptest.NewRecorder()41+ tenant.ServeHTTP(w, r)42+ return w43+}44+45+// TestAgentGrantGovernsUploads covers the blob door for agent keys: an46+// active grant uploads as a member, a paused or revoked one is refused, a47+// sites ttl stamps the claim and the maintenance sweep retires it, and an48+// encrypted grant refuses plain files.49+func TestAgentGrantGovernsUploads(t *testing.T) {50+ _, tenant := testTenant(t)51+ ctx := context.Background()52+ owner := tenant.Policy().Owner53+ agent, _ := event.PublicKey(testAgentSecret)54+ if _, err := tenant.Execute(ctx, owner, "setpolicy", []json.RawMessage{json.RawMessage(`{"writes":"allowlist"}`)}); err != nil {55+ t.Fatal(err)56+ }57+ if w := agentUpload(t, tenant, testAgentSecret, "stranger", "text/plain"); w.Code == http.StatusOK {58+ t.Fatal("a key without a grant uploaded under allowlist writes")59+ }60+ now := time.Now().Unix()61+ own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: agent})62+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now, now+3600, []string{"sites", own, "ttl=2"})); err != nil {63+ t.Fatal(err)64+ }65+ if w := agentUpload(t, tenant, testAgentSecret, "site file", "text/html"); w.Code != http.StatusOK {66+ t.Fatalf("granted agent upload = %d %s", w.Code, w.Body.String())67+ }68+ sum := sha256.Sum256([]byte("site file"))69+ hash := hex.EncodeToString(sum[:])70+ var expires int6471+ if err := tenant.store.DB().QueryRow("SELECT expires FROM blob_claims WHERE sha256=? AND uploader=?", hash, agent).Scan(&expires); err != nil || expires < now+2*86400 || expires > now+2*86400+60 {72+ t.Fatalf("claim expires=%d err=%v, want about %d", expires, err, now+2*86400)73+ }74+ if entries, err := tenant.blobs.ListMetadata(ctx, agent); err != nil || len(entries) != 1 {75+ t.Fatalf("agent list = %d entries, err=%v", len(entries), err)76+ }77+ if _, err := tenant.Execute(ctx, owner, "pauseagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {78+ t.Fatal(err)79+ }80+ if w := agentUpload(t, tenant, testAgentSecret, "while paused", "text/html"); w.Code == http.StatusOK || !strings.Contains(w.Body.String(), "paused") {81+ t.Fatalf("paused agent upload = %d %s", w.Code, w.Body.String())82+ }83+ if _, err := tenant.Execute(ctx, owner, "resumeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {84+ t.Fatal(err)85+ }86+ if w := agentUpload(t, tenant, testAgentSecret, "after resume", "text/html"); w.Code != http.StatusOK {87+ t.Fatalf("resumed agent upload = %d %s", w.Code, w.Body.String())88+ }89+ if _, err := tenant.Execute(ctx, owner, "revokeagent", []json.RawMessage{json.RawMessage(strconv.Quote(agent))}); err != nil {90+ t.Fatal(err)91+ }92+ if w := agentUpload(t, tenant, testAgentSecret, "after revoke", "text/html"); w.Code == http.StatusOK {93+ t.Fatalf("revoked agent upload = %d %s", w.Code, w.Body.String())94+ }95+ // The sweep removes the agent's files once the ttl lapses.96+ if err := tenant.sweepExpiredBlobs(ctx, now+2*86400+61); err != nil {97+ t.Fatal(err)98+ }99+ if _, _, err := tenant.blobs.Get(ctx, hash); err == nil {100+ t.Fatal("expired agent upload still stored")101+ }102+ // A fresh grant with the encrypted flag refuses plain uploads.103+ if err := publishAs(t, tenant, agentGrantEvent(t, agent, now+1, now+3600, []string{"sites", "*", "ttl=1", "encrypted"})); err != nil {104+ t.Fatal(err)105+ }106+ if w := agentUpload(t, tenant, testAgentSecret, "<html>plain</html>", "text/html"); w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "requires encrypted uploads") {107+ t.Fatalf("plain upload under encrypted grant = %d %s", w.Code, w.Body.String())108+ }109+ if w := agentUpload(t, tenant, testAgentSecret, "\x8f\x1a\x9c\x03"+strings.Repeat("\xe2\x71\x05\x9b\x4c", 20), "application/octet-stream"); w.Code != http.StatusOK {110+ t.Fatalf("ciphertext upload under encrypted grant = %d %s", w.Code, w.Body.String())111+ }112+}113+23114 type blobOriginTransport func(*http.Request) (*http.Response, error)24115 25116 func (f blobOriginTransport) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
M internal/daemon/scheduler.go +1 -0
M internal/daemon/services.go +72 -1
@@ -47,7 +47,7 @@ func (t *Tenant) initServices(ctx context.Context) error {
4747 return err4848 }4949 t.blobs, err = blob.New(ctx, blob.Config{50- Root: t.meta.Paths.Root, PublicURL: t.publicURL, Store: t.store, Authorize: t.authorizeBlob,50+ Root: t.meta.Paths.Root, PublicURL: t.publicURL, Store: t.store, Authorize: t.authorizeBlob, UploadTerms: t.blobUploadTerms,5151 Limits: func() blob.Limits {5252 limits := t.Policy().FileLimits5353 return blob.Limits{MaxFileBytes: limits.MaxFileBytes, UserStorageBytes: limits.UserStorageBytes}
@@ -310,10 +310,81 @@ func (t *Tenant) authorizeBlob(r *http.Request, action blob.Action) (string, err
310310 if !allowed {311311 return "", errors.New("restricted: file upload is not allowed")312312 }313+ // An agent's standing is its grant: an active one makes it a member314+ // for uploads, a paused, revoked or expired one stops them.315+ if role == "agent" {316+ if _, err := t.agentUploadGrant(r.Context(), pubkey); err != nil {317+ return "", err318+ }319+ }313320 }314321 return pubkey, nil315322 }316323 324+// agentUploadGrant returns the grant behind an agent key's uploads when it325+// currently allows publishing.326+func (t *Tenant) agentUploadGrant(ctx context.Context, pubkey string) (community.AgentGrant, error) {327+ grant, ok, err := t.community.AgentGrant(ctx, pubkey)328+ if err != nil {329+ return community.AgentGrant{}, fmt.Errorf("check agent upload grant: %w", err)330+ }331+ if !ok {332+ return community.AgentGrant{}, errors.New("restricted: agent grant is missing")333+ }334+ switch {335+ case grant.RevokedAt > 0:336+ return community.AgentGrant{}, errors.New("restricted: agent grant is revoked")337+ case grant.Paused:338+ return community.AgentGrant{}, errors.New("restricted: agent grant is paused")339+ case grant.ExpiresAt <= time.Now().Unix():340+ return community.AgentGrant{}, errors.New("restricted: agent grant has expired")341+ }342+ return grant, nil343+}344+345+// blobUploadTerms is what the blob service asks before it stores an upload:346+// a key whose only standing is an agent grant takes the grant's sites ttl as347+// the expiry of its claims and its encrypted flag as a requirement. Keys348+// with a human role, and agents without a sites tag, upload on no terms.349+func (t *Tenant) blobUploadTerms(ctx context.Context, pubkey string) (blob.UploadTerms, error) {350+ role, err := t.community.Role(ctx, pubkey)351+ if err != nil {352+ return blob.UploadTerms{}, fmt.Errorf("check uploader role: %w", err)353+ }354+ if role != "agent" {355+ return blob.UploadTerms{}, nil356+ }357+ grant, err := t.agentUploadGrant(ctx, pubkey)358+ if err != nil {359+ return blob.UploadTerms{}, err360+ }361+ terms := blob.UploadTerms{Encrypted: grant.UploadsEncrypted()}362+ if days := grant.UploadTTLDays(); days > 0 {363+ terms.ExpiresAt = time.Now().Unix() + int64(days)*86400364+ }365+ return terms, nil366+}367+368+// sweepExpiredBlobs retires uploads whose agent ttl has lapsed. The step is369+// recorded as the blob-expiry storage operation with a bounded outcome and370+// a count, never a hash or a key.371+func (t *Tenant) sweepExpiredBlobs(ctx context.Context, now int64) error {372+ if t.blobs == nil {373+ return nil374+ }375+ started := time.Now()376+ removed, err := t.blobs.SweepExpired(ctx, now)377+ outcome := "ok"378+ if err != nil {379+ outcome = "error"380+ }381+ t.app.telemetry.ObserveStorage("blob-expiry", outcome, time.Since(started))382+ if removed > 0 {383+ t.app.telemetry.Logger().Info("expired uploads removed", "tenant", t.meta.Name, "blobs", removed)384+ }385+ return err386+}387+317388 func (t *Tenant) validateBlobUpload(r *http.Request, hash string) error {318389 // PATCH carries only one chunk. Blossom scopes the final path hash, while319390 // NIP-98 binds the bytes in this individual request body.
M internal/telemetry/telemetry.go +1 -1
@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
236236 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}237237 }238238 239-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}}239+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}, "push": {}, "mcp": {}, "rooms": {}, "blob-expiry": {}}240240 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}, "paused": {}}241241 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}242242 var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification-push": {}, "notification": {}, "callback-delivery": {}}