1package daemon23import (4 "context"5 "database/sql"6 "encoding/json"7 "errors"8 "fmt"9 "net"10 "net/http"11 "net/url"12 "strings"13 "sync"1415 "github.com/FelineStateMachine/tinyrelay/internal/catalog"16 "github.com/FelineStateMachine/tinyrelay/internal/community"17 "github.com/FelineStateMachine/tinyrelay/internal/policy"18 "github.com/FelineStateMachine/tinyrelay/internal/storage"19 "github.com/FelineStateMachine/tinyrelay/internal/telemetry"20 "github.com/FelineStateMachine/tinyrelay/internal/templates"21)2223// Config supplies process-level paths, public addresses, transport limits and24// operator permissions. Tenant policy supplies each relay's access rules.25// DataDir is required; DefaultTenant defaults to "main". PublicURL, when set,26// is an absolute HTTP or HTTPS address used for externally visible URLs.27type Config struct {28 DataDir string29 PublicURL string30 DefaultTenant string31 Version string32 Revision string33 Telemetry telemetry.Config34 MaxMessageBytes int6435 MaxPendingBytes int36 AllowPrivateRelays bool37 // ProvisionOwners controls browser-created tenants. An empty list closes38 // public provisioning while direct operator creation remains available.39 ProvisionOwners []string40 DiscoveryRelays []string41 // PushCallbackOrigins is the operator-approved HTTPS origin allowlist for42 // host-wide push registrations. Tenant policy PushCallbacks is a separate43 // owner-controlled allowlist; both approvals are required.44 PushCallbackOrigins []string45 // PeerMonitor configures NIP-66 liveness probes for its listed peers.46 PeerMonitor *PeerMonitorConfig47}4849type CreateOptions = catalog.CreateOptions5051// App owns the catalog, active tenants and process-wide service lifecycle.52// New constructs it, Start starts serving work, and Close releases resources.53// An HTTP server uses App as its handler.54type App struct {55 cfg Config56 catalog *catalog.Catalog57 telemetry *telemetry.Telemetry58 peerMonitor *PeerMonitor59 mu sync.Mutex60 tenants map[string]*Tenant61 closed bool62 lifecycle lifecycleState63}6465// New opens catalog and telemetry resources and attempts to resume incomplete66// tenant creation. The caller owns the returned App and calls Close to release67// it. Start begins the serving lifecycle.68func New(ctx context.Context, cfg Config) (*App, error) {69 if cfg.DataDir == "" {70 return nil, errors.New("data directory is required")71 }72 if cfg.DefaultTenant == "" {73 cfg.DefaultTenant = "main"74 }75 if cfg.PublicURL != "" {76 u, err := url.Parse(cfg.PublicURL)77 if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {78 return nil, errors.New("public URL must be an absolute http or https URL")79 }80 }81 cat, err := catalog.Open(ctx, cfg.DataDir)82 if err != nil {83 return nil, err84 }85 cfg.Telemetry.Version = cfg.Version86 cfg.Telemetry.Revision = cfg.Revision87 metrics, err := telemetry.New(ctx, cfg.Telemetry)88 if err != nil {89 return nil, errors.Join(err, cat.Close())90 }91 var peerMonitor *PeerMonitor92 if cfg.PeerMonitor != nil {93 peerMonitor, err = NewPeerMonitor(*cfg.PeerMonitor)94 if err != nil {95 return nil, errors.Join(err, cat.Close(), metrics.Close(ctx))96 }97 }98 app := &App{cfg: cfg, catalog: cat, telemetry: metrics, peerMonitor: peerMonitor, tenants: make(map[string]*Tenant)}99 if err := metrics.Register(app.QueueCollector()); err != nil {100 return nil, errors.Join(err, cat.Close(), metrics.Close(ctx))101 }102 if err := app.RecoverCreating(ctx); err != nil {103 metrics.Logger().Error("recover unfinished tenant creation", "error", err)104 }105 return app, nil106}107108func (a *App) Catalog() *catalog.Catalog { return a.catalog }109func (a *App) Diagnostics() http.Handler { return a.telemetry.Handler() }110func (a *App) PeerMonitor() *PeerMonitor { return a.peerMonitor }111112// ConfigurePeerPublication binds the explicitly selected tenant to the113// opt-in NIP-66 publisher. The CLI operator is the authorization boundary;114// tenant selection is required so a private tenant is never implicit.115func (a *App) ConfigurePeerPublication(ctx context.Context, tenantName string) error {116 if a.peerMonitor == nil {117 return errors.New("daemon: peer monitor is not configured")118 }119 if strings.TrimSpace(tenantName) == "" {120 return errors.New("daemon: peer publication tenant is required")121 }122 meta, err := a.catalog.GetByName(ctx, tenantName)123 if err != nil {124 return err125 }126 a.lifecycle.mu.Lock()127 base, started := a.lifecycle.baseURL, a.lifecycle.started128 a.lifecycle.mu.Unlock()129 if !started {130 return errors.New("daemon: start the app before configuring peer publication")131 }132 publicURL := base133 if meta.Name != a.cfg.DefaultTenant {134 publicURL += "/r/" + meta.Name135 }136 t, err := a.tenant(ctx, meta, publicURL)137 if err != nil {138 return err139 }140 a.peerMonitor.SetPublisher(t.PublishPeerStatus)141 return nil142}143144// PeerMonitorRun runs configured NIP-66 probes under the caller's lifecycle.145// Publication remains opt-in in PeerMonitorConfig and is only a capability146// status until an operator supplies a signed publication path.147func (a *App) PeerMonitorRun(ctx context.Context) error {148 if a.peerMonitor == nil {149 return errors.New("daemon: peer monitor is not configured")150 }151 return a.peerMonitor.Run(ctx)152}153154// Create provisions a tenant from a template, initializes its durable state155// and marks it ready. A running App also starts its services. The returned156// metadata identifies the creation record even if a later setup step fails;157// RecoverCreating resumes incomplete creation from that durable record.158func (a *App) Create(ctx context.Context, opts CreateOptions) (catalog.Tenant, error) {159 if template, ok := templates.Find(opts.Template); ok && template.Source == "required" && strings.TrimSpace(opts.Source) == "" {160 return catalog.Tenant{}, fmt.Errorf("template %q requires a source relay", opts.Template)161 }162 p, err := templates.ApplyTemplate(opts.Template, opts.Owner)163 if err != nil {164 return catalog.Tenant{}, err165 }166 meta, err := a.catalog.Create(ctx, opts)167 if err != nil {168 return meta, err169 }170 store, err := storage.Open(ctx, meta.Paths.Database)171 if err != nil {172 return meta, err173 }174 if err := initializeTenant(ctx, store, p, opts.Template); err != nil {175 return meta, errors.Join(err, store.Close())176 }177 if err := store.Close(); err != nil {178 return meta, err179 }180 if err := a.AfterCreate(ctx, meta, opts); err != nil {181 return meta, err182 }183 if err := a.catalog.MarkReady(ctx, meta.ID); err != nil {184 return meta, err185 }186 meta.Status = catalog.StatusReady187 if err := a.startCreatedTenant(ctx, meta); err != nil {188 return meta, fmt.Errorf("relay created but services could not start: %w", err)189 }190 return meta, nil191}192193func initializeTenant(ctx context.Context, s *storage.Store, p policy.Policy, name string) error {194 if _, err := community.New(ctx, s, p.Owner); err != nil {195 return err196 }197 return s.WithTx(ctx, func(tx *sql.Tx) error {198 if err := storage.PutSetting(ctx, tx, "policy", p); err != nil {199 return err200 }201 communityConfig := community.ConfigSnapshot{}202 for _, rule := range []struct {203 kinds []int204 name string205 }{{p.AllowedKinds, "allow"}, {p.BlockedKinds, "block"}} {206 for _, kind := range rule.kinds {207 communityConfig.KindRules = append(communityConfig.KindRules, community.ConfigKindRule{Kind: kind, Rule: rule.name})208 }209 }210 for _, r := range p.Retention {211 kind := r.Kind212 communityConfig.Retention = append(communityConfig.Retention, community.ConfigRetention{Kind: &kind, Days: r.Days})213 }214 if err := community.ApplyConfigTx(ctx, tx, communityConfig, false, false, false, false, true, true); err != nil {215 return err216 }217 if _, err := tx.ExecContext(ctx, "CREATE TABLE IF NOT EXISTS config_connections(position INTEGER PRIMARY KEY,value TEXT NOT NULL)"); err != nil {218 return err219 }220 template, ok := templates.Find(name)221 if !ok {222 return fmt.Errorf("template %q disappeared", name)223 }224 connections := template.Connections225 if connections == nil {226 connections = []string{"notes", "find-me", "group"}227 }228 for i, name := range connections {229 visibility := "public"230 for _, connection := range templates.Connections() {231 if connection.Name == name {232 visibility = connection.Visibility233 break234 }235 }236 b, err := json.Marshal(map[string]string{"template": name, "visibility": visibility})237 if err != nil {238 return err239 }240 if _, err := tx.ExecContext(ctx, "INSERT INTO config_connections(position,value) VALUES(?,?)", i, string(b)); err != nil {241 return err242 }243 }244 return nil245 })246}247248func (a *App) tenant(ctx context.Context, meta catalog.Tenant, publicURL string) (*Tenant, error) {249 a.mu.Lock()250 defer a.mu.Unlock()251 if a.closed {252 return nil, errors.New("daemon is shutting down")253 }254 current, err := a.catalog.GetByID(ctx, meta.ID)255 if err != nil {256 return nil, err257 }258 if current.Status != catalog.StatusReady {259 return nil, errors.New("relay is not ready")260 }261 meta = current262 if t := a.tenants[meta.ID]; t != nil {263 return t, nil264 }265 s, err := storage.Open(ctx, meta.Paths.Database)266 if err != nil {267 return nil, err268 }269 s.SetObserver(a.telemetry)270 var p policy.Policy271 err = s.GetSetting(ctx, "policy", &p)272 if errors.Is(err, sql.ErrNoRows) {273 p, err = templates.ApplyTemplate(meta.Template, meta.Owner)274 }275 if err != nil {276 return nil, errors.Join(err, s.Close())277 }278 t, err := newTenant(ctx, tenantConfig{app: a, meta: meta, store: s, policy: p, publicURL: publicURL})279 if err != nil {280 return nil, errors.Join(err, s.Close())281 }282 a.tenants[meta.ID] = t283 return t, nil284}285286// Close stops catalog reconciliation and tenant services, then closes shared287// resources and releases the process lock. It joins cleanup errors and returns288// nil on subsequent calls. The caller shuts down its HTTP servers separately.289func (a *App) Close(ctx context.Context) error {290 a.mu.Lock()291 if a.closed {292 a.mu.Unlock()293 return nil294 }295 a.closed = true296 a.mu.Unlock()297 lifecycleErr := a.stopLifecycle()298 a.mu.Lock()299 var tenants []*Tenant300 for _, t := range a.tenants {301 tenants = append(tenants, t)302 }303 a.mu.Unlock()304 errs := []error{lifecycleErr}305 for _, t := range tenants {306 errs = append(errs, t.Close(ctx))307 }308 errs = append(errs, a.catalog.Close(), a.telemetry.Close(ctx), a.releaseProcessLock())309 return errors.Join(errs...)310}311312// ServeHTTP resolves the request's tenant or hosted site and delegates to its313// handler. Health and relay discovery routes are served at the process level.314func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) {315 if r.URL.Path == "/relays" && a.ServeLanding(w, r) {316 return317 }318 if r.URL.Path == "/healthz" || r.URL.Path == "/readyz" {319 a.healthHTTP(w, r)320 return321 }322 ctx, finish := a.telemetry.Start(r.Context(), "http")323 r = r.WithContext(ctx)324 if meta, label, isSite, siteErr := a.resolveHostedSite(r); isSite {325 if siteErr != nil {326 finish("error")327 http.NotFound(w, r)328 return329 }330 if meta.Status != catalog.StatusReady {331 finish("unavailable")332 http.Error(w, "relay is "+string(meta.Status), http.StatusServiceUnavailable)333 return334 }335 t, err := a.tenant(ctx, meta, a.publicURL(r, ""))336 if err != nil {337 finish("error")338 http.Error(w, "relay could not be opened", http.StatusServiceUnavailable)339 return340 }341 if t.sites == nil {342 finish("error")343 http.NotFound(w, r)344 return345 }346 ctx, done, err := t.beginOperation(r.Context())347 if err != nil {348 finish("unavailable")349 http.Error(w, err.Error(), http.StatusServiceUnavailable)350 return351 }352 defer done()353 r = r.WithContext(ctx)354 t.sites.HandlerForLabel(label).ServeHTTP(w, r)355 finish("ok")356 return357 }358 meta, prefix, err := a.resolve(r)359 if err != nil {360 finish("error")361 if errors.Is(err, catalog.ErrNotFound) && r.Method == http.MethodGet && r.URL.Path == "/" && !strings.Contains(r.Header.Get("Accept"), "application/nostr+json") && !strings.EqualFold(r.Header.Get("Upgrade"), "websocket") && a.ServeLanding(w, r) {362 return363 }364 http.Error(w, "relay not found", http.StatusNotFound)365 return366 }367 if meta.Status != catalog.StatusReady {368 finish("unavailable")369 http.Error(w, "relay is "+string(meta.Status), http.StatusServiceUnavailable)370 return371 }372 publicURL := a.publicURL(r, prefix)373 t, err := a.tenant(ctx, meta, publicURL)374 if err != nil {375 finish("error")376 a.telemetry.Logger().Error("open tenant", "error", err)377 http.Error(w, "relay could not be opened", http.StatusServiceUnavailable)378 return379 }380 if prefix != "" {381 r = r.Clone(ctx)382 u := *r.URL383 r.URL = &u384 r.URL.Path = strings.TrimPrefix(r.URL.Path, prefix)385 if r.URL.Path == "" {386 r.URL.Path = "/"387 }388 }389 t.ServeHTTP(w, r)390 finish("ok")391}392393func (a *App) resolve(r *http.Request) (catalog.Tenant, string, error) {394 if strings.HasPrefix(r.URL.Path, "/r/") {395 parts := strings.SplitN(strings.TrimPrefix(r.URL.Path, "/r/"), "/", 2)396 meta, err := a.catalog.GetByName(r.Context(), parts[0])397 return meta, "/r/" + parts[0], err398 }399 host := r.Host400 if hostname, _, err := net.SplitHostPort(host); err == nil {401 host = hostname402 }403 meta, err := a.catalog.ResolveHost(r.Context(), host)404 if err == nil {405 return meta, "", nil406 }407 if !errors.Is(err, catalog.ErrNotFound) {408 return meta, "", err409 }410 meta, err = a.catalog.GetByName(r.Context(), a.cfg.DefaultTenant)411 return meta, "", err412}413414func (a *App) publicURL(r *http.Request, prefix string) string {415 if a.cfg.PublicURL != "" {416 return strings.TrimSuffix(a.cfg.PublicURL, "/") + prefix417 }418 scheme := "http"419 if r.TLS != nil {420 scheme = "https"421 }422 return fmt.Sprintf("%s://%s%s", scheme, r.Host, prefix)423}