| 1 | diff --git a/internal/community/agents.go b/internal/community/agents.go |
| 2 | index 1b146e6..696eef3 100644 |
| 3 | --- a/internal/community/agents.go |
| 4 | +++ b/internal/community/agents.go |
| 5 | @@ -17,12 +17,14 @@ import ( |
| 6 | "strings" |
| 7 | |
| 8 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 9 | + "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 10 | ) |
| 11 | |
| 12 | const ( |
| 13 | AgentRateDefault = 60 |
| 14 | AgentRateMax = 600 |
| 15 | AgentGrantMaxDays = 365 |
| 16 | + AgentSiteTTLMax = 365 |
| 17 | agentNameMax = 64 |
| 18 | agentRoomMax = 128 |
| 19 | agentListMax = 256 |
| 20 | @@ -47,6 +49,16 @@ const ( |
| 21 | AgentJobsBoth = "both" |
| 22 | ) |
| 23 | |
| 24 | +// AgentSite is one static site an agent may publish. Label is a site label |
| 25 | +// under the agent's own key or "*" for any of them. TTLDays, when set, bounds |
| 26 | +// how long a manifest and the files behind it live; Encrypted requires the |
| 27 | +// agent's uploads to be encrypted. |
| 28 | +type AgentSite struct { |
| 29 | + Label string `json:"label"` |
| 30 | + TTLDays int `json:"ttl,omitempty"` |
| 31 | + Encrypted bool `json:"encrypted,omitempty"` |
| 32 | +} |
| 33 | + |
| 34 | // AgentScope is the part of a grant the gate checks on every event. |
| 35 | type AgentScope struct { |
| 36 | Kinds []int `json:"kinds"` |
| 37 | @@ -54,6 +66,7 @@ type AgentScope struct { |
| 38 | Repos []AgentRepo `json:"repos"` |
| 39 | Wiki string `json:"wiki,omitempty"` |
| 40 | Jobs string `json:"jobs,omitempty"` |
| 41 | + Sites []AgentSite `json:"sites,omitempty"` |
| 42 | Rate int `json:"rate"` |
| 43 | } |
| 44 | |
| 45 | @@ -103,6 +116,9 @@ func (g AgentGrant) AllowsKind(kind int) bool { |
| 46 | if (event.IsJobResult(kind) || kind == event.KIND_JOB_FEEDBACK) && g.ServesJobs() { |
| 47 | return true |
| 48 | } |
| 49 | + if siteManifestKind(kind) && len(g.Scope.Sites) > 0 { |
| 50 | + return true |
| 51 | + } |
| 52 | // Wiki access carries its kinds: proposing means publishing a version and |
| 53 | // asking for a merge; editing adds redirects. |
| 54 | switch g.Scope.Wiki { |
| 55 | @@ -114,6 +130,53 @@ func (g AgentGrant) AllowsKind(kind int) bool { |
| 56 | return false |
| 57 | } |
| 58 | |
| 59 | +// siteManifestKind reports whether a kind is a site manifest a grant's sites |
| 60 | +// tag admits: the key's own site and named sites under it. Snapshots are |
| 61 | +// left out; they are copies of a site rather than a site the agent publishes. |
| 62 | +func siteManifestKind(kind int) bool { |
| 63 | + return kind == sites.KindSite || kind == sites.KindNamedSite |
| 64 | +} |
| 65 | + |
| 66 | +// SiteEntries lists the grant's site entries that cover one label. |
| 67 | +func (g AgentGrant) SiteEntries(label string) []AgentSite { |
| 68 | + var out []AgentSite |
| 69 | + for _, site := range g.Scope.Sites { |
| 70 | + if site.Label == "*" || (label != "" && site.Label == label) { |
| 71 | + out = append(out, site) |
| 72 | + } |
| 73 | + } |
| 74 | + return out |
| 75 | +} |
| 76 | + |
| 77 | +// UploadTTLDays is the number of days the agent's uploads live: the longest |
| 78 | +// ttl among its site entries, or 0 when there is none or an entry sets none. |
| 79 | +func (g AgentGrant) UploadTTLDays() int { return longestTTL(g.Scope.Sites) } |
| 80 | + |
| 81 | +// UploadsEncrypted reports whether the agent's uploads must be encrypted. |
| 82 | +// One entry with the flag applies it to every upload the agent makes, since |
| 83 | +// an upload does not say which site it is for. |
| 84 | +func (g AgentGrant) UploadsEncrypted() bool { |
| 85 | + for _, site := range g.Scope.Sites { |
| 86 | + if site.Encrypted { |
| 87 | + return true |
| 88 | + } |
| 89 | + } |
| 90 | + return false |
| 91 | +} |
| 92 | + |
| 93 | +func longestTTL(entries []AgentSite) int { |
| 94 | + longest := 0 |
| 95 | + for _, site := range entries { |
| 96 | + if site.TTLDays == 0 { |
| 97 | + return 0 |
| 98 | + } |
| 99 | + if site.TTLDays > longest { |
| 100 | + longest = site.TTLDays |
| 101 | + } |
| 102 | + } |
| 103 | + return longest |
| 104 | +} |
| 105 | + |
| 106 | // RequestsJobs reports whether the jobs tag lets the agent publish job |
| 107 | // requests. |
| 108 | func (g AgentGrant) RequestsJobs() bool { |
| 109 | @@ -164,6 +227,9 @@ func (g AgentGrant) Check(e event.Event, now int64) error { |
| 110 | if room := event.Tag(e, "h"); room != "" && !g.AllowsRoom(room) { |
| 111 | return errors.New("restricted: agent grant does not allow room " + room) |
| 112 | } |
| 113 | + if siteManifestKind(e.Kind) { |
| 114 | + return g.checkSite(e, now) |
| 115 | + } |
| 116 | if !gitKind(e.Kind) { |
| 117 | return nil |
| 118 | } |
| 119 | @@ -186,6 +252,30 @@ func (g AgentGrant) Check(e event.Event, now int64) error { |
| 120 | return nil |
| 121 | } |
| 122 | |
| 123 | +// checkSite applies the sites entries to a manifest: the label must be |
| 124 | +// covered, and when the covering entries carry a ttl the manifest must |
| 125 | +// expire within it so NIP-40 retires the site on time. |
| 126 | +func (g AgentGrant) checkSite(e event.Event, now int64) error { |
| 127 | + label := sites.SiteLabel(e) |
| 128 | + entries := g.SiteEntries(label) |
| 129 | + if len(entries) == 0 { |
| 130 | + return fmt.Errorf("restricted: agent grant does not allow site %s", label) |
| 131 | + } |
| 132 | + ttl := longestTTL(entries) |
| 133 | + if ttl == 0 { |
| 134 | + return nil |
| 135 | + } |
| 136 | + latest := now + int64(ttl)*86400 |
| 137 | + expires := event.Expiration(e) |
| 138 | + if expires == 0 { |
| 139 | + return fmt.Errorf("invalid: agent grant allows site %s for %d days: add an expiration tag no later than %d", label, ttl, latest) |
| 140 | + } |
| 141 | + if expires > latest { |
| 142 | + return fmt.Errorf("invalid: agent grant allows site %s for %d days: set the expiration tag no later than %d", label, ttl, latest) |
| 143 | + } |
| 144 | + return nil |
| 145 | +} |
| 146 | + |
| 147 | func gitKind(kind int) bool { |
| 148 | return kind == event.KIND_GIT_PATCH || kind == event.KIND_GIT_PR || kind == event.KIND_GIT_PR_UPDATE || kind == event.KIND_GIT_ISSUE || kind == 1111 || gitStatusKind(kind) |
| 149 | } |
| 150 | @@ -286,6 +376,21 @@ func ParseAgentGrant(e event.Event, now int64) (AgentGrant, error) { |
| 151 | default: |
| 152 | return AgentGrant{}, errors.New("invalid: agent grant jobs tag must be request, serve or both") |
| 153 | } |
| 154 | + for _, tag := range e.Tags { |
| 155 | + if len(tag) == 0 || tag[0] != "sites" { |
| 156 | + continue |
| 157 | + } |
| 158 | + site, err := parseAgentSite(tag[1:], agent) |
| 159 | + if err != nil { |
| 160 | + return AgentGrant{}, err |
| 161 | + } |
| 162 | + if !siteListed(grant.Scope.Sites, site.Label) { |
| 163 | + grant.Scope.Sites = append(grant.Scope.Sites, site) |
| 164 | + } |
| 165 | + } |
| 166 | + if len(grant.Scope.Sites) > agentListMax { |
| 167 | + return AgentGrant{}, fmt.Errorf("invalid: agent grant lists more than %d entries", agentListMax) |
| 168 | + } |
| 169 | if value := event.Tag(e, "rate"); value != "" { |
| 170 | rate, err := strconv.Atoi(strings.TrimSpace(value)) |
| 171 | if err != nil || rate < 1 || rate > AgentRateMax { |
| 172 | @@ -313,6 +418,49 @@ func parseAgentRepo(value string) (AgentRepo, bool) { |
| 173 | return AgentRepo{Owner: parts[0], Identifier: parts[1], Level: level}, true |
| 174 | } |
| 175 | |
| 176 | +// parseAgentSite reads one sites tag: a label, then any of ttl=<days> and |
| 177 | +// encrypted. The label is "*" or a site label under the agent's own key. |
| 178 | +func parseAgentSite(values []string, agent string) (AgentSite, error) { |
| 179 | + if len(values) == 0 { |
| 180 | + return AgentSite{}, errors.New("invalid: agent grant sites tag needs a site label or *") |
| 181 | + } |
| 182 | + site := AgentSite{Label: strings.TrimSpace(values[0])} |
| 183 | + if site.Label != "*" { |
| 184 | + parsed, ok := sites.ParseSite(site.Label) |
| 185 | + if !ok || parsed.Kind == sites.KindSiteSnapshot { |
| 186 | + return AgentSite{}, errors.New("invalid: agent grant sites tag needs a site label or *") |
| 187 | + } |
| 188 | + if parsed.PubKey != agent { |
| 189 | + return AgentSite{}, errors.New("invalid: agent grant sites label must be a site under the agent's own key") |
| 190 | + } |
| 191 | + } |
| 192 | + for _, flag := range values[1:] { |
| 193 | + flag = strings.TrimSpace(flag) |
| 194 | + switch { |
| 195 | + case flag == "encrypted": |
| 196 | + site.Encrypted = true |
| 197 | + case strings.HasPrefix(flag, "ttl="): |
| 198 | + days, err := strconv.Atoi(strings.TrimPrefix(flag, "ttl=")) |
| 199 | + if err != nil || days < 1 || days > AgentSiteTTLMax { |
| 200 | + return AgentSite{}, fmt.Errorf("invalid: agent grant sites ttl must be between 1 and %d days", AgentSiteTTLMax) |
| 201 | + } |
| 202 | + site.TTLDays = days |
| 203 | + default: |
| 204 | + return AgentSite{}, errors.New("invalid: agent grant sites tag allows only ttl=<days> and encrypted after the label") |
| 205 | + } |
| 206 | + } |
| 207 | + return site, nil |
| 208 | +} |
| 209 | + |
| 210 | +func siteListed(entries []AgentSite, label string) bool { |
| 211 | + for _, site := range entries { |
| 212 | + if site.Label == label { |
| 213 | + return true |
| 214 | + } |
| 215 | + } |
| 216 | + return false |
| 217 | +} |
| 218 | + |
| 219 | // CanGrantAgents reports whether a role may sign agent grants. |
| 220 | func CanGrantAgents(role string) bool { return role == "owner" || role == "moderator" } |
| 221 | |
| 222 | diff --git a/internal/gates/agents_test.go b/internal/gates/agents_test.go |
| 223 | index ce68153..00d5b9a 100644 |
| 224 | --- a/internal/gates/agents_test.go |
| 225 | +++ b/internal/gates/agents_test.go |
| 226 | @@ -12,6 +12,7 @@ import ( |
| 227 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 228 | "github.com/FelineStateMachine/tinyrelay/internal/policy" |
| 229 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 230 | + "github.com/FelineStateMachine/tinyrelay/internal/sites" |
| 231 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 232 | ) |
| 233 | |
| 234 | @@ -255,3 +256,115 @@ func TestAgentGrantShapeAndSigner(t *testing.T) { |
| 235 | t.Fatalf("parsed grant %+v %v", grant, err) |
| 236 | } |
| 237 | } |
| 238 | + |
| 239 | +func TestAgentGrantSitesCoverLabelsAndRequireExpiry(t *testing.T) { |
| 240 | + f := newAgentFixture(t) |
| 241 | + ctx := context.Background() |
| 242 | + session := relay.Session{PubKeys: []string{f.agent}} |
| 243 | + hash := strings.Repeat("a", 64) |
| 244 | + paths := [][]string{{"path", "/index.html", hash}} |
| 245 | + own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: f.agent}) |
| 246 | + base, _ := sites.Base36(f.agent) |
| 247 | + named := base + "docs" |
| 248 | + manifest := func(kind int, tags ...[]string) event.Event { |
| 249 | + return signed(t, agentSecret, kind, agentNow, append(append([][]string{}, paths...), tags...), "") |
| 250 | + } |
| 251 | + // Without a sites tag the manifest kinds stay closed. |
| 252 | + f.grant(t, agentNow, grantTags(f.agent, agentNow+3600, []string{"k", "1"})) |
| 253 | + expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow), "kind 15128") |
| 254 | + // One label without a ttl: that site only, no expiration needed. |
| 255 | + f.grant(t, agentNow+1, grantTags(f.agent, agentNow+3600, []string{"sites", named})) |
| 256 | + if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}), session, agentNow); err != nil { |
| 257 | + t.Fatalf("covered named site rejected: %v", err) |
| 258 | + } |
| 259 | + expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "blog"}), session, agentNow), "site "+base+"blog") |
| 260 | + expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow), "site "+own) |
| 261 | + // A ttl requires an expiration tag within it; the reason says what to add. |
| 262 | + f.grant(t, agentNow+2, grantTags(f.agent, agentNow+3600, []string{"sites", own, "ttl=2"}, []string{"sites", named, "ttl=1", "encrypted"})) |
| 263 | + latest := strconv.FormatInt(agentNow+2*86400, 10) |
| 264 | + err := f.gate.Write(ctx, manifest(sites.KindSite), session, agentNow) |
| 265 | + if err == nil || !strings.HasPrefix(err.Error(), "invalid: agent grant allows site "+own+" for 2 days: add an expiration tag no later than "+latest) { |
| 266 | + t.Fatalf("manifest without expiration: %v", err) |
| 267 | + } |
| 268 | + err = f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", strconv.FormatInt(agentNow+3*86400, 10)}), session, agentNow) |
| 269 | + if err == nil || !strings.Contains(err.Error(), "set the expiration tag no later than "+latest) { |
| 270 | + t.Fatalf("manifest expiring too late: %v", err) |
| 271 | + } |
| 272 | + if err := f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow); err != nil { |
| 273 | + t.Fatalf("manifest within the ttl rejected: %v", err) |
| 274 | + } |
| 275 | + if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", strconv.FormatInt(agentNow+86400, 10)}), session, agentNow); err != nil { |
| 276 | + t.Fatalf("named manifest within its own ttl rejected: %v", err) |
| 277 | + } |
| 278 | + if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", latest}), session, agentNow); err == nil { |
| 279 | + t.Fatal("named manifest beyond its own ttl accepted") |
| 280 | + } |
| 281 | + // A star covers every label under the key and the longest ttl applies. |
| 282 | + grant := f.grant(t, agentNow+3, grantTags(f.agent, agentNow+3600, []string{"sites", "*", "ttl=5"}, []string{"sites", named, "ttl=1"})) |
| 283 | + if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "blog"}, []string{"expiration", strconv.FormatInt(agentNow+5*86400, 10)}), session, agentNow); err != nil { |
| 284 | + t.Fatalf("star label rejected: %v", err) |
| 285 | + } |
| 286 | + if err := f.gate.Write(ctx, manifest(sites.KindNamedSite, []string{"d", "docs"}, []string{"expiration", strconv.FormatInt(agentNow+5*86400, 10)}), session, agentNow); err != nil { |
| 287 | + t.Fatalf("longest ttl not applied: %v", err) |
| 288 | + } |
| 289 | + parsed, err := community.ParseAgentGrant(grant, agentNow) |
| 290 | + if err != nil || parsed.UploadTTLDays() != 5 || parsed.UploadsEncrypted() { |
| 291 | + t.Fatalf("parsed sites %+v %v", parsed.Scope.Sites, err) |
| 292 | + } |
| 293 | + // Snapshots are not admitted by the sites tag. |
| 294 | + expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSiteSnapshot), session, agentNow), "kind 5128") |
| 295 | + // Paused and revoked grants stop manifests like every other event. |
| 296 | + if _, err := f.community.SetAgentPaused(ctx, f.owner, f.agent, true); err != nil { |
| 297 | + t.Fatal(err) |
| 298 | + } |
| 299 | + expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow), "paused") |
| 300 | + if _, err := f.community.RevokeAgent(ctx, f.owner, f.agent, agentNow); err != nil { |
| 301 | + t.Fatal(err) |
| 302 | + } |
| 303 | + expectRestricted(t, f.gate.Write(ctx, manifest(sites.KindSite, []string{"expiration", latest}), session, agentNow), "revoked") |
| 304 | +} |
| 305 | + |
| 306 | +func TestAgentGrantSitesShape(t *testing.T) { |
| 307 | + f := newAgentFixture(t) |
| 308 | + ctx := context.Background() |
| 309 | + other, _ := event.PublicKey(agentOtherSecret) |
| 310 | + own := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: f.agent}) |
| 311 | + foreign := sites.SiteLabel(event.Event{Kind: sites.KindSite, PubKey: other}) |
| 312 | + for _, tc := range []struct { |
| 313 | + name string |
| 314 | + tag []string |
| 315 | + want string |
| 316 | + }{ |
| 317 | + {"empty", []string{"sites"}, "invalid: agent grant sites tag needs a site label"}, |
| 318 | + {"bad label", []string{"sites", "not-a-site"}, "invalid: agent grant sites tag needs a site label"}, |
| 319 | + {"other key", []string{"sites", foreign}, "invalid: agent grant sites label must be a site under the agent's own key"}, |
| 320 | + {"snapshot", []string{"sites", "v" + strings.Repeat("0", 50)}, "invalid: agent grant sites tag needs a site label"}, |
| 321 | + {"ttl zero", []string{"sites", "*", "ttl=0"}, "invalid: agent grant sites ttl must be between 1 and 365"}, |
| 322 | + {"ttl long", []string{"sites", "*", "ttl=366"}, "invalid: agent grant sites ttl must be between 1 and 365"}, |
| 323 | + {"ttl text", []string{"sites", "*", "ttl=soon"}, "invalid: agent grant sites ttl must be between 1 and 365"}, |
| 324 | + {"unknown flag", []string{"sites", own, "public"}, "invalid: agent grant sites tag allows only ttl=<days> and encrypted"}, |
| 325 | + } { |
| 326 | + t.Run(tc.name, func(t *testing.T) { |
| 327 | + e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, tc.tag), "") |
| 328 | + err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow) |
| 329 | + if err == nil || !strings.HasPrefix(err.Error(), tc.want) { |
| 330 | + t.Fatalf("want %q, got %v", tc.want, err) |
| 331 | + } |
| 332 | + }) |
| 333 | + } |
| 334 | + e := signed(t, agentOwnerSecret, event.KIND_AGENT_GRANT, agentNow, grantTags(f.agent, agentNow+60, []string{"sites", own, "ttl=30", "encrypted"}, []string{"sites", own, "ttl=7"}, []string{"sites", "*"}), "") |
| 335 | + if err := f.gate.Write(ctx, e, relay.Session{PubKeys: []string{f.owner}}, agentNow); err != nil { |
| 336 | + t.Fatalf("valid sites grant rejected: %v", err) |
| 337 | + } |
| 338 | + grant, err := community.ParseAgentGrant(e, agentNow) |
| 339 | + if err != nil { |
| 340 | + t.Fatal(err) |
| 341 | + } |
| 342 | + want := []community.AgentSite{{Label: own, TTLDays: 30, Encrypted: true}, {Label: "*"}} |
| 343 | + if len(grant.Scope.Sites) != len(want) || grant.Scope.Sites[0] != want[0] || grant.Scope.Sites[1] != want[1] { |
| 344 | + t.Fatalf("sites = %+v, want %+v", grant.Scope.Sites, want) |
| 345 | + } |
| 346 | + if !grant.UploadsEncrypted() || grant.UploadTTLDays() != 0 { |
| 347 | + t.Fatalf("an entry without a ttl should leave uploads unlimited: ttl=%d encrypted=%v", grant.UploadTTLDays(), grant.UploadsEncrypted()) |
| 348 | + } |
| 349 | +} |