clone

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

feat/standalone-relay
tinyrelay / internal / configport / configport.go
configport.go 17.6K
1package configport23import (4 "context"5 "database/sql"6 "encoding/hex"7 "encoding/json"8 "errors"9 "fmt"10 "sort"11 "strings"12 "time"1314 "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)2021const Format = "bind.ws/relay-config/2"2223// 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}3031// 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}3637// Config is the JSON representation accepted by Parse and produced by38// Export. Sections records which keys were present in the input so Apply can39// 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}6061// 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}6970// ConfigStore coordinates configuration with the tenant store, community71// service and policy callbacks.72type ConfigStore struct {73 Store *storage.Store74 Community *community.Service75 Policy func() policy.Policy76 // OnApplied runs only after the durable transaction commits. It is the77 // integration point for replacing an in-memory policy and closing stale78 // subscriptions.79 OnApplied func(policy.Policy)80 // ValidatePolicy runs before a config transaction is committed.81 ValidatePolicy func(policy.Policy) error82}8384// ApplyOptions controls an import or preset application.85type ApplyOptions struct {86 DryRun bool87 MigrationOwner string88}8990func 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 &c97}9899func Parse(raw []byte) (Config, error) {100 var obj map[string]json.RawMessage101 if err := json.Unmarshal(raw, &obj); err != nil {102 return Config{}, fmt.Errorf("invalid: config JSON: %w", err)103 }104 var f string105 _ = 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 Config110 c.Format = Format111 _ = 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{}, err146 }147 c.Sections = append(c.Sections, "kinds")148 case "retention":149 if err := json.Unmarshal(value, &c.Retention); err != nil {150 return Config{}, err151 }152 c.Sections = append(c.Sections, "retention")153 case "connections":154 if err := json.Unmarshal(value, &c.Connections); err != nil {155 return Config{}, err156 }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, nil169}170171func 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}182183func (s *ConfigStore) Export(ctx context.Context) (Config, error) {184 p := s.Policy()185 raw, _ := json.Marshal(p)186 var pm map[string]json.RawMessage187 _ = 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, err198 }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 string226 if err := rows.Scan(&raw); err != nil {227 return c, err228 }229 var conn map[string]any230 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 string240 if err := rows.Scan(&id, &recipe); err != nil {241 return c, err242 }243 var job map[string]any244 if json.Unmarshal([]byte(recipe), &job) != nil {245 job = map[string]any{"recipe": recipe}246 }247 job["id"] = id248 c.Jobs = append(c.Jobs, job)249 }250 if err := rows.Err(); err != nil {251 return c, err252 }253 if len(c.Jobs) > 0 {254 c.Sections = append(c.Sections, "jobs")255 }256 }257 sort.Strings(c.Sections)258 return c, nil259}260261func (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 = next272 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) == 0279 ch.Members = append(ch.Members, "replace members")280 }281 return ch, nil282}283284func 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 an291 // 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 = migrationOwner303 }304 return next, nil305}306307func marshalRaw(p policy.Policy, key string) []byte {308 b, _ := json.Marshal(p)309 var m map[string]json.RawMessage310 _ = 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 true317 }318 }319 return false320}321322func (s *ConfigStore) Apply(ctx context.Context, c Config, dryRun bool) (Changes, error) {323 return s.ApplyWithOptions(ctx, c, ApplyOptions{DryRun: dryRun})324}325326func (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{}, err330 }331 cur := s.Policy()332 ch.FinalPolicy, err = finalPolicy(cur, c, opts.MigrationOwner)333 if err != nil {334 return Changes{}, err335 }336 if s.ValidatePolicy != nil {337 if err := s.ValidatePolicy(ch.FinalPolicy); err != nil {338 return Changes{}, err339 }340 }341 if opts.DryRun {342 return ch, nil343 }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 writes349 // remain in this transaction so a later validation/constraint failure350 // 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 err354 }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 err386 }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 err390 }391 }392 if has(c, "connections") {393 if _, err := tx.ExecContext(ctx, `DELETE FROM config_connections`); err != nil {394 return err395 }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 err400 }401 }402 }403 if has(c, "jobs") {404 if _, err := tx.ExecContext(ctx, `DELETE FROM config_jobs`); err != nil {405 return err406 }407 var replicationJobs []replication.ConfigJob408 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] = v417 }418 }419 recipe, err := json.Marshal(copy)420 if err != nil {421 return err422 }423 if _, err := tx.ExecContext(ctx, `INSERT INTO config_jobs(id,recipe) VALUES(?,?)`, id, string(recipe)); err != nil {424 return err425 }426 copy["id"] = id427 payload, err := json.Marshal(copy)428 if err != nil {429 return err430 }431 replicationJobs = append(replicationJobs, replication.ConfigJob{ID: id, Payload: payload})432 }433 if err := replication.ReplaceJobsTx(ctx, tx, replicationJobs); err != nil {434 return err435 }436 }437 return nil438 })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{}, err445 }446 }447 if (len(c.Policy) > 0 || ch.FinalPolicy.Owner != cur.Owner) && s.OnApplied != nil {448 s.OnApplied(ch.FinalPolicy)449 }450 return ch, nil451}452453func Presets() []string { return templates.Names() }454455func (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, err467 }468 dry := false469 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.DryRun477 migrationOwner = o.MigrationOwner478 }479 return s.ApplyWithOptions(ctx, c, ApplyOptions{DryRun: dry, MigrationOwner: migrationOwner})480 case "applypreset":481 var name string482 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, nil494 default:495 return nil, fmt.Errorf("unsupported: unknown config method %q", method)496 }497}498499func (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 _ = p506 raw, _ := json.Marshal(t.Policy)507 var pm map[string]json.RawMessage508 _ = 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.AllowKinds513 c.Kinds.Block = t.BlockKinds514 }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}