tinyrelayfile
tinyrelay/internal/configport/configport.go/

internal/configport/configport.go raw
1package configport
2
3import (
4 "context"
5 "database/sql"
6 "encoding/hex"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "sort"
11 "strings"
12 "time"
13
14 "github.com/FelineStateMachine/tinyrelay/internal/community"
15 "github.com/FelineStateMachine/tinyrelay/internal/policy"
16 "github.com/FelineStateMachine/tinyrelay/internal/replication"
17 "github.com/FelineStateMachine/tinyrelay/internal/storage"
18 "github.com/FelineStateMachine/tinyrelay/internal/templates"
19)
20
21const Format = "bind.ws/relay-config/2"
22
23// Member is a community member in an exported configuration.
24type Member struct {
25 PubKey string `json:"pubkey"`
26 Name string `json:"name,omitempty"`
27 Note string `json:"note,omitempty"`
28 Role string `json:"role"`
29}
30
31// Rule is a retention rule. A nil Kind applies to the tenant default.
32type Rule struct {
33 Kind *int `json:"kind"`
34 Days int `json:"days"`
35}
36
37// Config is the JSON representation accepted by Parse and produced by
38// Export. Sections records which keys were present in the input so Apply can
39// distinguish omission from an explicit empty replacement.
40type Config struct {
41 Format string `json:"format"`
42 ExportedAt int64 `json:"exported_at,omitempty"`
43 Name string `json:"name,omitempty"`
44 Template *templates.Template `json:"template,omitempty"`
45 Policy map[string]json.RawMessage `json:"policy,omitempty"`
46 Members []Member `json:"members,omitempty"`
47 Bans []map[string]any `json:"bans,omitempty"`
48 Addresses []map[string]any `json:"addresses,omitempty"`
49 BannedEvents []map[string]any `json:"banned_events,omitempty"`
50 Kinds struct {
51 Allow []int `json:"allow"`
52 Block []int `json:"block"`
53 } `json:"kinds,omitempty"`
54 Retention []Rule `json:"retention,omitempty"`
55 Connections []map[string]any `json:"connections,omitempty"`
56 Jobs []map[string]any `json:"jobs,omitempty"`
57 Sections []string `json:"-"`
58 Warnings []string `json:"-"`
59}
60
61// Changes describes the policy and community sections changed by an apply.
62type Changes struct {
63 Policy []string `json:"policy,omitempty"`
64 Members []string `json:"members,omitempty"`
65 MembersCleared bool `json:"membersCleared,omitempty"`
66 Warnings []string `json:"warnings,omitempty"`
67 FinalPolicy policy.Policy `json:"-"`
68}
69
70// ConfigStore coordinates configuration with the tenant store, community
71// service and policy callbacks.
72type ConfigStore struct {
73 Store *storage.Store
74 Community *community.Service
75 Policy func() policy.Policy
76 // OnApplied runs only after the durable transaction commits. It is the
77 // integration point for replacing an in-memory policy and closing stale
78 // subscriptions.
79 OnApplied func(policy.Policy)
80 // ValidatePolicy runs before a config transaction is committed.
81 ValidatePolicy func(policy.Policy) error
82}
83
84// ApplyOptions controls an import or preset application.
85type ApplyOptions struct {
86 DryRun bool
87 MigrationOwner string
88}
89
90func New(c ConfigStore) *ConfigStore {
91 if c.Store != nil {
92 _, _ = c.Store.DB().Exec(`CREATE TABLE IF NOT EXISTS config_connections(position INTEGER PRIMARY KEY, value TEXT NOT NULL)`)
93 _, _ = c.Store.DB().Exec(`CREATE TABLE IF NOT EXISTS config_jobs(id TEXT PRIMARY KEY, recipe TEXT NOT NULL)`)
94 _ = replication.EnsureJobSchema(c.Store.DB())
95 }
96 return &c
97}
98
99func Parse(raw []byte) (Config, error) {
100 var obj map[string]json.RawMessage
101 if err := json.Unmarshal(raw, &obj); err != nil {
102 return Config{}, fmt.Errorf("invalid: config JSON: %w", err)
103 }
104 var f string
105 _ = json.Unmarshal(obj["format"], &f)
106 if f != "bind.ws/relay-config/1" && f != Format {
107 return Config{}, errors.New("invalid: format must be bind.ws/relay-config/2")
108 }
109 var c Config
110 c.Format = Format
111 _ = json.Unmarshal(obj["name"], &c.Name)
112 if b := obj["exported_at"]; b != nil {
113 _ = json.Unmarshal(b, &c.ExportedAt)
114 }
115 for key, value := range obj {
116 switch key {
117 case "format", "name", "exported_at":
118 case "policy":
119 if err := json.Unmarshal(value, &c.Policy); err != nil {
120 return Config{}, errors.New("invalid: policy must be an object")
121 }
122 c.Sections = append(c.Sections, "policy")
123 warnPolicy(&c)
124 case "members":
125 if err := json.Unmarshal(value, &c.Members); err != nil {
126 return Config{}, errors.New("invalid: members must be a list")
127 }
128 for _, member := range c.Members {
129 if len(member.PubKey) != 64 {
130 return Config{}, errors.New("invalid: member pubkey must be 64 hex chars")
131 }
132 }
133 c.Sections = append(c.Sections, "members")
134 case "bans":
135 _ = json.Unmarshal(value, &c.Bans)
136 c.Sections = append(c.Sections, "bans")
137 case "addresses":
138 _ = json.Unmarshal(value, &c.Addresses)
139 c.Sections = append(c.Sections, "addresses")
140 case "banned_events":
141 _ = json.Unmarshal(value, &c.BannedEvents)
142 c.Sections = append(c.Sections, "banned_events")
143 case "kinds":
144 if err := json.Unmarshal(value, &c.Kinds); err != nil {
145 return Config{}, err
146 }
147 c.Sections = append(c.Sections, "kinds")
148 case "retention":
149 if err := json.Unmarshal(value, &c.Retention); err != nil {
150 return Config{}, err
151 }
152 c.Sections = append(c.Sections, "retention")
153 case "connections":
154 if err := json.Unmarshal(value, &c.Connections); err != nil {
155 return Config{}, err
156 }
157 c.Sections = append(c.Sections, "connections")
158 case "jobs":
159 if err := json.Unmarshal(value, &c.Jobs); err != nil {
160 return Config{}, errors.New("invalid: jobs must be a list")
161 }
162 c.Sections = append(c.Sections, "jobs")
163 default:
164 c.Warnings = append(c.Warnings, key+": not a setting")
165 }
166 }
167 sort.Strings(c.Sections)
168 return c, nil
169}
170
171func warnPolicy(c *Config) {
172 for key := range c.Policy {
173 if key == "lease" {
174 c.Warnings = append(c.Warnings, "policy.lease: legacy lease imported disabled; explicit owner mapping required")
175 } else if key == "owner" {
176 c.Warnings = append(c.Warnings, "policy.owner: never imported; tenant owner must be assigned by the operator")
177 } else if key != "fileLimits" && (key == "succession" || key == "customHosts" || key == "fuel" || strings.Contains(strings.ToLower(key), "limit") || strings.HasPrefix(strings.ToLower(key), "free") || strings.HasPrefix(strings.ToLower(key), "sats")) {
178 c.Warnings = append(c.Warnings, "policy."+key+": removed from self-hosted runtime")
179 }
180 }
181}
182
183func (s *ConfigStore) Export(ctx context.Context) (Config, error) {
184 p := s.Policy()
185 raw, _ := json.Marshal(p)
186 var pm map[string]json.RawMessage
187 _ = json.Unmarshal(raw, &pm)
188 for _, key := range []string{"owner", "succession", "customHosts", "fuel", "lease"} {
189 delete(pm, key)
190 }
191 c := Config{Format: Format, ExportedAt: time.Now().Unix(), Policy: pm, Sections: []string{"policy", "members", "bans", "addresses", "banned_events", "kinds", "retention", "connections"}, Warnings: []string{"identity private material is never exported"}}
192 if s.Store == nil {
193 return c, errors.New("config: missing store")
194 }
195 communityConfig, err := community.ExportConfig(ctx, s.Store)
196 if err != nil {
197 return c, err
198 }
199 for _, m := range communityConfig.Members {
200 c.Members = append(c.Members, Member{PubKey: m.PubKey, Name: m.Name, Note: m.Note, Role: m.Role})
201 }
202 for _, item := range communityConfig.Bans {
203 c.Bans = append(c.Bans, map[string]any{"pubkey": item.PubKey, "reason": item.Reason})
204 }
205 for _, item := range communityConfig.IPBlocks {
206 c.Addresses = append(c.Addresses, map[string]any{"ip": item.IP, "reason": item.Reason})
207 }
208 for _, item := range communityConfig.EventBans {
209 c.BannedEvents = append(c.BannedEvents, map[string]any{"id": item.ID, "reason": item.Reason})
210 }
211 for _, item := range communityConfig.KindRules {
212 if item.Rule == "allow" {
213 c.Kinds.Allow = append(c.Kinds.Allow, item.Kind)
214 } else if item.Rule == "block" {
215 c.Kinds.Block = append(c.Kinds.Block, item.Kind)
216 }
217 }
218 for _, item := range communityConfig.Retention {
219 c.Retention = append(c.Retention, Rule{Kind: item.Kind, Days: item.Days})
220 }
221 rows, err := s.Store.DB().QueryContext(ctx, `SELECT value FROM config_connections ORDER BY position`)
222 if err == nil {
223 defer rows.Close()
224 for rows.Next() {
225 var raw string
226 if err := rows.Scan(&raw); err != nil {
227 return c, err
228 }
229 var conn map[string]any
230 if json.Unmarshal([]byte(raw), &conn) == nil {
231 c.Connections = append(c.Connections, conn)
232 }
233 }
234 }
235 rows, err = s.Store.DB().QueryContext(ctx, `SELECT id,recipe FROM config_jobs ORDER BY id`)
236 if err == nil {
237 defer rows.Close()
238 for rows.Next() {
239 var id, recipe string
240 if err := rows.Scan(&id, &recipe); err != nil {
241 return c, err
242 }
243 var job map[string]any
244 if json.Unmarshal([]byte(recipe), &job) != nil {
245 job = map[string]any{"recipe": recipe}
246 }
247 job["id"] = id
248 c.Jobs = append(c.Jobs, job)
249 }
250 if err := rows.Err(); err != nil {
251 return c, err
252 }
253 if len(c.Jobs) > 0 {
254 c.Sections = append(c.Sections, "jobs")
255 }
256 }
257 sort.Strings(c.Sections)
258 return c, nil
259}
260
261func (s *ConfigStore) Plan(ctx context.Context, c Config) (Changes, error) {
262 if s.Policy == nil {
263 return Changes{}, errors.New("config: missing policy accessor")
264 }
265 cur := s.Policy()
266 ch := Changes{Warnings: append([]string(nil), c.Warnings...)}
267 next, err := policy.Patch(cur, c.Policy)
268 if err != nil {
269 return Changes{}, fmt.Errorf("config policy: %w", err)
270 }
271 ch.FinalPolicy = next
272 for k := range c.Policy {
273 if fmt.Sprint(marshalRaw(cur, k)) != fmt.Sprint(marshalRaw(next, k)) {
274 ch.Policy = append(ch.Policy, k)
275 }
276 }
277 if has(c, "members") {
278 ch.MembersCleared = len(c.Members) == 0
279 ch.Members = append(ch.Members, "replace members")
280 }
281 return ch, nil
282}
283
284func finalPolicy(cur policy.Policy, c Config, migrationOwner string) (policy.Policy, error) {
285 next, err := policy.Patch(cur, c.Policy)
286 if err != nil {
287 return policy.Policy{}, fmt.Errorf("config policy: %w", err)
288 }
289 // A legacy lease is evidence only. It can never silently become ownership.
290 // Existing tenants retain their explicit owner; unowned imports require an
291 // operator supplied mapping.
292 if _, leased := c.Policy["lease"]; leased && cur.Owner == "" {
293 if migrationOwner == "" {
294 return policy.Policy{}, errors.New("config migration: leased relay has no owner; supply migrationOwner")
295 }
296 if len(migrationOwner) != 64 {
297 return policy.Policy{}, errors.New("config migration: migrationOwner must be a 64-hex pubkey")
298 }
299 if _, err := hex.DecodeString(migrationOwner); err != nil {
300 return policy.Policy{}, errors.New("config migration: migrationOwner must be a 64-hex pubkey")
301 }
302 next.Owner = migrationOwner
303 }
304 return next, nil
305}
306
307func marshalRaw(p policy.Policy, key string) []byte {
308 b, _ := json.Marshal(p)
309 var m map[string]json.RawMessage
310 _ = json.Unmarshal(b, &m)
311 return m[key]
312}
313func has(c Config, key string) bool {
314 for _, x := range c.Sections {
315 if x == key {
316 return true
317 }
318 }
319 return false
320}
321
322func (s *ConfigStore) Apply(ctx context.Context, c Config, dryRun bool) (Changes, error) {
323 return s.ApplyWithOptions(ctx, c, ApplyOptions{DryRun: dryRun})
324}
325
326func (s *ConfigStore) ApplyWithOptions(ctx context.Context, c Config, opts ApplyOptions) (Changes, error) {
327 ch, err := s.Plan(ctx, c)
328 if err != nil {
329 return Changes{}, err
330 }
331 cur := s.Policy()
332 ch.FinalPolicy, err = finalPolicy(cur, c, opts.MigrationOwner)
333 if err != nil {
334 return Changes{}, err
335 }
336 if s.ValidatePolicy != nil {
337 if err := s.ValidatePolicy(ch.FinalPolicy); err != nil {
338 return Changes{}, err
339 }
340 }
341 if opts.DryRun {
342 return ch, nil
343 }
344 if s.Store == nil {
345 return Changes{}, errors.New("config: missing store")
346 }
347 err = s.Store.WithTx(ctx, func(tx *sql.Tx) error {
348 // Persist the fully merged policy before touching metadata. All writes
349 // remain in this transaction so a later validation/constraint failure
350 // cannot leave the relay half-imported.
351 if has(c, "policy") || (ch.FinalPolicy.Owner != "" && s.Policy().Owner == "") {
352 if err := storage.PutSetting(ctx, tx, "policy", ch.FinalPolicy); err != nil {
353 return err
354 }
355 }
356 communityConfig := community.ConfigSnapshot{}
357 for _, m := range c.Members {
358 communityConfig.Members = append(communityConfig.Members, community.Member{PubKey: m.PubKey, Name: m.Name, Note: m.Note, Role: m.Role})
359 }
360 for _, b := range c.Bans {
361 pk, _ := b["pubkey"].(string)
362 reason, _ := b["reason"].(string)
363 communityConfig.Bans = append(communityConfig.Bans, community.ConfigBan{PubKey: pk, Reason: reason})
364 }
365 for _, b := range c.Addresses {
366 ip, _ := b["ip"].(string)
367 reason, _ := b["reason"].(string)
368 communityConfig.IPBlocks = append(communityConfig.IPBlocks, community.ConfigIPBlock{IP: ip, Reason: reason})
369 }
370 for _, b := range c.BannedEvents {
371 id, _ := b["id"].(string)
372 reason, _ := b["reason"].(string)
373 communityConfig.EventBans = append(communityConfig.EventBans, community.ConfigEventBan{ID: id, Reason: reason})
374 }
375 for _, k := range c.Kinds.Allow {
376 communityConfig.KindRules = append(communityConfig.KindRules, community.ConfigKindRule{Kind: k, Rule: "allow"})
377 }
378 for _, k := range c.Kinds.Block {
379 communityConfig.KindRules = append(communityConfig.KindRules, community.ConfigKindRule{Kind: k, Rule: "block"})
380 }
381 for _, r := range c.Retention {
382 communityConfig.Retention = append(communityConfig.Retention, community.ConfigRetention{Kind: r.Kind, Days: r.Days})
383 }
384 if err := community.ApplyConfigTx(ctx, tx, communityConfig, has(c, "members"), has(c, "bans"), has(c, "addresses"), has(c, "banned_events"), has(c, "kinds"), has(c, "retention")); err != nil {
385 return err
386 }
387 if s.Community != nil && ch.FinalPolicy.Owner != "" && ch.FinalPolicy.Owner != cur.Owner {
388 if err := s.Community.ApplyOwnerTx(ctx, tx, cur.Owner, ch.FinalPolicy.Owner); err != nil {
389 return err
390 }
391 }
392 if has(c, "connections") {
393 if _, err := tx.ExecContext(ctx, `DELETE FROM config_connections`); err != nil {
394 return err
395 }
396 for i, conn := range c.Connections {
397 b, _ := json.Marshal(conn)
398 if _, err := tx.ExecContext(ctx, `INSERT INTO config_connections(position,value) VALUES(?,?)`, i, string(b)); err != nil {
399 return err
400 }
401 }
402 }
403 if has(c, "jobs") {
404 if _, err := tx.ExecContext(ctx, `DELETE FROM config_jobs`); err != nil {
405 return err
406 }
407 var replicationJobs []replication.ConfigJob
408 for _, job := range c.Jobs {
409 id, _ := job["id"].(string)
410 if id == "" {
411 return errors.New("config jobs: missing id")
412 }
413 copy := make(map[string]any, len(job))
414 for k, v := range job {
415 if k != "id" {
416 copy[k] = v
417 }
418 }
419 recipe, err := json.Marshal(copy)
420 if err != nil {
421 return err
422 }
423 if _, err := tx.ExecContext(ctx, `INSERT INTO config_jobs(id,recipe) VALUES(?,?)`, id, string(recipe)); err != nil {
424 return err
425 }
426 copy["id"] = id
427 payload, err := json.Marshal(copy)
428 if err != nil {
429 return err
430 }
431 replicationJobs = append(replicationJobs, replication.ConfigJob{ID: id, Payload: payload})
432 }
433 if err := replication.ReplaceJobsTx(ctx, tx, replicationJobs); err != nil {
434 return err
435 }
436 }
437 return nil
438 })
439 if err != nil {
440 return Changes{}, fmt.Errorf("config apply: %w", err)
441 }
442 if ch.FinalPolicy.Owner != cur.Owner && s.Community != nil {
443 if err := s.Community.SetOwner(ch.FinalPolicy.Owner); err != nil {
444 return Changes{}, err
445 }
446 }
447 if (len(c.Policy) > 0 || ch.FinalPolicy.Owner != cur.Owner) && s.OnApplied != nil {
448 s.OnApplied(ch.FinalPolicy)
449 }
450 return ch, nil
451}
452
453func Presets() []string { return templates.Names() }
454
455func (s *ConfigStore) Execute(ctx context.Context, method string, params []json.RawMessage) (any, error) {
456 switch method {
457 case "exportconfig":
458 return s.Export(ctx)
459 case "importconfig":
460 if len(params) == 0 {
461 return nil, errors.New("invalid: missing configuration")
462 }
463 var raw json.RawMessage = params[0]
464 c, err := Parse(raw)
465 if err != nil {
466 return nil, err
467 }
468 dry := false
469 migrationOwner := ""
470 if len(params) > 1 {
471 var o struct {
472 DryRun bool `json:"dryRun"`
473 MigrationOwner string `json:"migrationOwner"`
474 }
475 _ = json.Unmarshal(params[1], &o)
476 dry = o.DryRun
477 migrationOwner = o.MigrationOwner
478 }
479 return s.ApplyWithOptions(ctx, c, ApplyOptions{DryRun: dry, MigrationOwner: migrationOwner})
480 case "applypreset":
481 var name string
482 if len(params) == 0 || json.Unmarshal(params[0], &name) != nil {
483 return nil, errors.New("invalid: preset name required")
484 }
485 return s.ApplyPreset(ctx, name, false)
486 case "listpresets":
487 out := []map[string]any{}
488 for _, name := range templates.Names() {
489 if t, ok := templates.Find(name); ok {
490 out = append(out, map[string]any{"name": t.Name, "title": t.Title, "about": t.About, "source": t.Source, "every": t.EveryHours, "connections": t.Connections})
491 }
492 }
493 return out, nil
494 default:
495 return nil, fmt.Errorf("unsupported: unknown config method %q", method)
496 }
497}
498
499func (s *ConfigStore) ApplyPreset(ctx context.Context, name string, dryRun bool) (Changes, error) {
500 t, ok := templates.Find(name)
501 if !ok {
502 return Changes{}, fmt.Errorf("invalid: no preset named %s", name)
503 }
504 p := policy.Defaults(s.Policy().Owner)
505 _ = p
506 raw, _ := json.Marshal(t.Policy)
507 var pm map[string]json.RawMessage
508 _ = json.Unmarshal(raw, &pm)
509 c := Config{Format: Format, Policy: pm, Sections: []string{"policy"}}
510 if len(t.AllowKinds) > 0 || len(t.BlockKinds) > 0 {
511 c.Sections = append(c.Sections, "kinds")
512 c.Kinds.Allow = t.AllowKinds
513 c.Kinds.Block = t.BlockKinds
514 }
515 if t.RetentionDays > 0 {
516 c.Sections = append(c.Sections, "retention")
517 c.Retention = []Rule{{Days: t.RetentionDays}}
518 }
519 if len(t.Connections) > 0 {
520 c.Sections = append(c.Sections, "connections")
521 for _, name := range t.Connections {
522 c.Connections = append(c.Connections, map[string]any{"template": name})
523 }
524 }
525 return s.Apply(ctx, c, dryRun)
526}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs