sugarproxy.gno
14.37 Kb · 494 lines
1// Package sugarproxy holds the rules of sugar proxy (sugar repo, PROXY.md): which sites sharers' home connections
2// may carry, for which customers and why; which app releases they may run; which hubs they may connect to; who pays
3// them. Sharers' apps read these rules through a light client, from one parameter this realm writes on every change
4// (params "doc": the canonical JSON of everything below), so no hub can change what a sharer's connection does.
5//
6// Who decides:
7// - Holders of the SUGAR token, through kourt's governor (govern.gno): they add and remove anything.
8// - A delegate realm, if they name one (gno.security's, say): it adds and removes sites, nothing else.
9// - GovDAO (govdao.gno), above both: it adds and removes anything at once, vetoes, pauses, and names the delegate
10// and the guardian. A guardian it names (the deployer, at first) may veto and pause, never approve.
11//
12// Anything added by the token or the delegate waits out a veto window before it counts; GovDAO's own additions
13// count at once. A veto or a removal counts at once, whoever makes it.
14package sugarproxy
15
16import (
17 "chain"
18 "chain/params"
19 "crypto/sha256"
20 "encoding/hex"
21 "strconv"
22 "strings"
23 "time"
24
25 "gno.land/p/nt/ufmt/v0"
26)
27
28func errorf(format string, args ...any) error { return ufmt.Errorf(format, args...) }
29
30// Item is one entry of the rules: a site, a release, a hub, a customer or a payer.
31type Item struct {
32 ID int
33 Kind string // KindSite, KindRelease, KindHub, KindCustomer, KindPayer
34 Fields []string
35 State string // StateApproved, StateVetoed, StateRevoked
36 By string // who added it: "init", "token", "delegate", "govdao"
37 Approved int64 // unix seconds
38 After int64 // unix seconds: when its veto window ends and it counts
39 Note string // who vetoed or removed it, and when
40}
41
42const (
43 KindSite = "site" // fields: pattern, customer, purpose
44 KindRelease = "release" // fields: version, platform, sha256, url
45 KindHub = "hub" // fields: host:port, pin (hex sha256 of its certificate's public key)
46 KindCustomer = "customer" // fields: name, purpose
47 KindPayer = "payer" // fields: address (may call Pay)
48
49 StateApproved = "approved"
50 StateVetoed = "vetoed"
51 StateRevoked = "revoked"
52)
53
54var (
55 items []*Item
56 nextID = 1
57 paused bool
58 delegate string // the realm path that may add and remove sites; "" is none
59 guardian address // may veto and pause; GovDAO names it
60 vetoWin = map[string]int64{KindSite: 3 * 86400, KindRelease: 7 * 86400, KindHub: 3 * 86400, KindCustomer: 3 * 86400, KindPayer: 3 * 86400}
61 doc string // the canonical JSON the apps read, as params "doc"
62 digest string // its hex sha256
63 docSeq int // how many times it has changed
64 requests []*Request
65 nextReqID = 1
66 launching bool // launch.gno: while the first install's items go in
67)
68
69// Request is anyone's suggestion of an item, for the token holders or the delegate to consider. It changes nothing.
70type Request struct {
71 ID int
72 By address
73 Payload string
74 Reason string
75 Time int64
76 Done bool
77}
78
79func init(cur realm) {
80 setupGovernor()
81 // The owner (launch.gno; the deployer when unset) is the first guardian (GovDAO can replace it) and holds the
82 // whole first supply of SUGAR (govern.gno).
83 who := owner
84 if who == "" {
85 who = cur.Previous().Address()
86 }
87 if who != "" {
88 guardian = who
89 mintInitial(who)
90 }
91 launch()
92 publish()
93}
94
95// --- what an item may say ---
96
97// parse turns "kind|field|field..." into an item's kind and fields, checking each field.
98func parse(payload string) (string, []string, error) {
99 parts := strings.Split(payload, "|")
100 kind, f := parts[0], parts[1:]
101 want := map[string]int{KindSite: 3, KindRelease: 4, KindHub: 2, KindCustomer: 2, KindPayer: 1}[kind]
102 if want == 0 {
103 return "", nil, errorf("unknown kind %q: site, release, hub, customer or payer", kind)
104 }
105 if len(f) != want {
106 return "", nil, errorf("a %s has %d fields, separated by |", kind, want)
107 }
108 for _, x := range f {
109 if len(x) == 0 || len(x) > 300 || strings.ContainsAny(x, "\n\r\t") {
110 return "", nil, errorf("each field is 1 to 300 characters on one line")
111 }
112 }
113 switch kind {
114 case KindSite:
115 if !sitePattern(f[0]) {
116 return "", nil, errorf("%q is not a site: a lowercase DNS name, or *. and one", f[0])
117 }
118 if !customerActive(f[1]) {
119 return "", nil, errorf("no customer %q is approved", f[1])
120 }
121 case KindRelease:
122 if !word(f[0], 1, 32) || !word(f[1], 3, 32) || !hexOf(f[2], 64) || !strings.HasPrefix(f[3], "https://") {
123 return "", nil, errorf("a release is version|platform|sha256 (64 hex)|https://url")
124 }
125 case KindHub:
126 host, port, ok := strings.Cut(f[0], ":")
127 n, err := strconv.Atoi(port)
128 if !ok || !sitePattern(host) || strings.HasPrefix(host, "*.") || err != nil || n < 1 || n > 65535 || !hexOf(f[1], 64) {
129 return "", nil, errorf("a hub is host:port|pin (64 hex)")
130 }
131 case KindCustomer:
132 if !word(f[0], 2, 32) {
133 return "", nil, errorf("a customer's name is 2 to 32 of a-z, 0-9 and -")
134 }
135 case KindPayer:
136 if !address(f[0]).IsValid() {
137 return "", nil, errorf("%q is not an address", f[0])
138 }
139 }
140 return kind, f, nil
141}
142
143func word(s string, min, max int) bool {
144 if len(s) < min || len(s) > max {
145 return false
146 }
147 for i := 0; i < len(s); i++ {
148 c := s[i]
149 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-' || c == '.' || c == '_') {
150 return false
151 }
152 }
153 return true
154}
155
156func hexOf(s string, n int) bool {
157 if len(s) != n {
158 return false
159 }
160 _, err := hex.DecodeString(s)
161 return err == nil && strings.ToLower(s) == s
162}
163
164// sitePattern is a lowercase DNS name of two or more labels, or "*." and one: what an app matches a host against.
165func sitePattern(s string) bool {
166 s = strings.TrimPrefix(s, "*.")
167 if len(s) > 253 {
168 return false
169 }
170 labels := strings.Split(s, ".")
171 if len(labels) < 2 {
172 return false
173 }
174 for _, l := range labels {
175 if len(l) == 0 || len(l) > 63 || l[0] == '-' || l[len(l)-1] == '-' {
176 return false
177 }
178 for i := 0; i < len(l); i++ {
179 c := l[i]
180 if !(c >= 'a' && c <= 'z' || c >= '0' && c <= '9' || c == '-') {
181 return false
182 }
183 }
184 }
185 return strings.Trim(labels[len(labels)-1], "0123456789") != ""
186}
187
188func customerActive(name string) bool {
189 now := time.Now().Unix()
190 for _, it := range items {
191 if it.Kind == KindCustomer && it.Fields[0] == name && it.State == StateApproved && now >= it.After {
192 return true
193 }
194 }
195 return false
196}
197
198// --- changes: every one goes through here and republishes the doc ---
199
200// add puts an item in, approved. by says who: its veto window applies unless GovDAO added it.
201func add(payload, by string) (int, error) {
202 if paused && by != "govdao" {
203 return 0, errorf("sugar proxy is paused")
204 }
205 kind, fields, err := parse(payload)
206 if err != nil {
207 return 0, err
208 }
209 if by == "delegate" && kind != KindSite {
210 return 0, errorf("the delegate adds sites only")
211 }
212 for _, it := range items {
213 if it.Kind == kind && strings.Join(it.Fields, "|") == strings.Join(fields, "|") && it.State == StateApproved {
214 return 0, errorf("item %d already says that", it.ID)
215 }
216 }
217 now := time.Now().Unix()
218 after := now + vetoWin[kind]
219 if by == "govdao" || by == "init" || by == "launcher" {
220 after = now // GovDAO is the veto; the first install is signed by Jae; the launcher's sites are removable at once
221 }
222 it := &Item{ID: nextID, Kind: kind, Fields: fields, State: StateApproved, By: by, Approved: now, After: after}
223 nextID++
224 items = append(items, it)
225 for _, r := range requests {
226 if !r.Done && r.Payload == payload {
227 r.Done = true
228 }
229 }
230 chain.Emit("Added", "id", strconv.Itoa(it.ID), "kind", kind, "by", by, "after", strconv.FormatInt(after, 10))
231 publish()
232 return it.ID, nil
233}
234
235// end vetoes or removes an item: a veto only while its window is open, a removal any time. It counts at once.
236func end(id int, veto bool, by string) error {
237 it := find(id)
238 if it == nil {
239 return errorf("no item %d", id)
240 }
241 if it.State != StateApproved {
242 return errorf("item %d is already %s", id, it.State)
243 }
244 now := time.Now().Unix()
245 if veto {
246 if now >= it.After {
247 return errorf("item %d's veto window is over; remove it instead", id)
248 }
249 it.State = StateVetoed
250 } else {
251 it.State = StateRevoked
252 }
253 it.Note = it.State + " by " + by + " at " + strconv.FormatInt(now, 10)
254 chain.Emit("Ended", "id", strconv.Itoa(id), "state", it.State, "by", by)
255 publish()
256 return nil
257}
258
259func setPaused(on bool, by string) {
260 if paused == on {
261 return
262 }
263 paused = on
264 chain.Emit("Paused", "on", strconv.FormatBool(on), "by", by)
265 publish()
266}
267
268func find(id int) *Item {
269 for _, it := range items {
270 if it.ID == id {
271 return it
272 }
273 }
274 return nil
275}
276
277// publish writes the doc the apps read: params "doc", at gno.land's main store key
278// "/pv/vm:<this realm's path>:doc", which a light client proves against a block's app hash.
279func publish() {
280 if launching {
281 return
282 }
283 doc = render()
284 sum := sha256.Sum256([]byte(doc))
285 digest = hex.EncodeToString(sum[:])
286 docSeq++
287 params.SetBytes("doc", []byte(doc))
288}
289
290// render is the canonical JSON of the rules: every item ever approved, in order, with its state, so an app can
291// tell what counts at any time (approved, and its After passed) without trusting anyone's clock but its own.
292func render() string {
293 var b strings.Builder
294 b.WriteString(`{"v":1,"seq":`)
295 b.WriteString(strconv.Itoa(docSeq + 1))
296 b.WriteString(`,"paused":`)
297 b.WriteString(strconv.FormatBool(paused))
298 b.WriteString(`,"items":[`)
299 for i, it := range items {
300 if i > 0 {
301 b.WriteByte(',')
302 }
303 b.WriteString(`{"id":`)
304 b.WriteString(strconv.Itoa(it.ID))
305 b.WriteString(`,"kind":`)
306 b.WriteString(jstr(it.Kind))
307 b.WriteString(`,"fields":[`)
308 for j, f := range it.Fields {
309 if j > 0 {
310 b.WriteByte(',')
311 }
312 b.WriteString(jstr(f))
313 }
314 b.WriteString(`],"state":`)
315 b.WriteString(jstr(it.State))
316 b.WriteString(`,"by":`)
317 b.WriteString(jstr(it.By))
318 b.WriteString(`,"approved":`)
319 b.WriteString(strconv.FormatInt(it.Approved, 10))
320 b.WriteString(`,"after":`)
321 b.WriteString(strconv.FormatInt(it.After, 10))
322 b.WriteByte('}')
323 }
324 b.WriteString(`]}`)
325 return b.String()
326}
327
328// jstr quotes s as a JSON string.
329func jstr(s string) string {
330 const hexd = "0123456789abcdef"
331 var b strings.Builder
332 b.WriteByte('"')
333 for i := 0; i < len(s); i++ {
334 c := s[i]
335 switch {
336 case c == '"':
337 b.WriteString(`\"`)
338 case c == '\\':
339 b.WriteString(`\\`)
340 case c < 0x20:
341 b.WriteString(`\u00`)
342 b.WriteByte(hexd[c>>4])
343 b.WriteByte(hexd[c&15])
344 default:
345 b.WriteByte(c)
346 }
347 }
348 b.WriteByte('"')
349 return b.String()
350}
351
352// --- reading ---
353
354// Doc is the canonical JSON the apps read (the same bytes as params "doc"); Digest is its hex sha256.
355func Doc() string { return doc }
356func Digest() string { return digest }
357
358// ItemID is the number of the approved item that says exactly payload ("kind|field|..."), or 0.
359func ItemID(payload string) int {
360 for _, it := range items {
361 if it.State == StateApproved && it.Kind+"|"+joinFields(it.Fields) == payload {
362 return it.ID
363 }
364 }
365 return 0
366}
367
368// Paused reports whether sugar proxy is paused: apps carry nothing while it is.
369func Paused() bool { return paused }
370
371// Delegate is the realm that may add and remove sites ("" is none); Guardian may veto and pause.
372func Delegate() string { return delegate }
373func Guardian() address { return guardian }
374
375// VetoWindow is how long an added item of a kind waits before it counts, in seconds.
376func VetoWindow(kind string) int64 { return vetoWin[kind] }
377
378// --- anyone ---
379
380// Suggest records a suggestion of an item ("kind|field|..."), for the token holders or the delegate to consider. It
381// changes nothing. Each address may have three open at a time.
382func Suggest(cur realm, payload, reason string) int {
383 by := mustUser(cur)
384 if _, _, err := parse(payload); err != nil {
385 panic(err.Error())
386 }
387 if len(reason) > 500 {
388 panic("the reason is at most 500 characters")
389 }
390 open := 0
391 for _, r := range requests {
392 if r.By == by && !r.Done {
393 open++
394 }
395 }
396 if open >= 3 {
397 panic("you have three suggestions open already")
398 }
399 r := &Request{ID: nextReqID, By: by, Payload: payload, Reason: reason, Time: time.Now().Unix()}
400 nextReqID++
401 requests = append(requests, r)
402 chain.Emit("Suggested", "id", strconv.Itoa(r.ID), "by", by.String())
403 return r.ID
404}
405
406// Withdraw closes your own suggestion.
407func Withdraw(cur realm, id int) {
408 by := mustUser(cur)
409 for _, r := range requests {
410 if r.ID == id && r.By == by {
411 r.Done = true
412 return
413 }
414 }
415 panic("no suggestion of yours has that id")
416}
417
418// --- the delegate realm ---
419
420// DelegateAdd adds a site ("site|pattern|customer|purpose"), from the delegate realm only; it waits out the
421// site veto window.
422func DelegateAdd(cur realm, payload string) int {
423 mustDelegate(cur)
424 id, err := add(payload, "delegate")
425 if err != nil {
426 panic(err.Error())
427 }
428 return id
429}
430
431// DelegateRemove removes a site, from the delegate realm only.
432func DelegateRemove(cur realm, id int) {
433 mustDelegate(cur)
434 if it := find(id); it == nil || it.Kind != KindSite {
435 panic("the delegate removes sites only")
436 }
437 if err := end(id, false, "delegate"); err != nil {
438 panic(err.Error())
439 }
440}
441
442// mustDelegate refuses any caller but the delegate realm itself, the way r/sys/params checks its delegates: none
443// named refuses everyone, and the caller's realm path must match exactly.
444func mustDelegate(cur realm) {
445 if delegate == "" {
446 panic("no delegate is named")
447 }
448 if cur.Previous().PkgPath() != delegate {
449 panic("only the delegate realm " + delegate + " may do that")
450 }
451}
452
453// --- the guardian ---
454
455// GuardianVeto vetoes an item still in its veto window.
456func GuardianVeto(cur realm, id int) {
457 mustGuardian(cur)
458 if err := end(id, true, "guardian"); err != nil {
459 panic(err.Error())
460 }
461}
462
463// GuardianRemove removes a site, at once: the safe direction, so the guardian may (the launcher's sites count at
464// once, so there is no window to veto them in).
465func GuardianRemove(cur realm, id int) {
466 mustGuardian(cur)
467 if it := find(id); it == nil || it.Kind != KindSite {
468 panic("the guardian removes sites only")
469 }
470 if err := end(id, false, "guardian"); err != nil {
471 panic(err.Error())
472 }
473}
474
475// GuardianPause pauses or resumes sugar proxy.
476func GuardianPause(cur realm, on bool) {
477 mustGuardian(cur)
478 setPaused(on, "guardian")
479}
480
481func mustGuardian(cur realm) {
482 if guardian == "" || mustUser(cur) != guardian {
483 panic("only the guardian may do that")
484 }
485}
486
487// mustUser returns the caller, who must be a user, not another realm's code.
488func mustUser(cur realm) address {
489 prev := cur.Previous()
490 if !prev.IsUser() {
491 panic("only a user may call this")
492 }
493 return prev.Address()
494}