clone

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

feat/ui-updates
tinyrelay / cmd / tiny / main.go
main.go 11.7K
1package main23import (4 "context"5 "encoding/json"6 "errors"7 "flag"8 "fmt"9 "io"10 "net"11 "net/http"12 "net/url"13 "os"14 "os/signal"15 "path/filepath"16 "strings"17 "syscall"18 "time"1920 "github.com/FelineStateMachine/tinyrelay/internal/daemon"21 "github.com/FelineStateMachine/tinyrelay/internal/relaycmd"22 "github.com/FelineStateMachine/tinyrelay/internal/templates"23)2425const version = "0.1.0-dev"2627func main() {28 ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)29 err := run(ctx, os.Args[1:], os.Stdout)30 stop()31 if err != nil {32 fmt.Fprintln(os.Stderr, "tiny:", err)33 os.Exit(1)34 }35}3637// run dispatches arguments after the executable name and writes command output38// to out. Returning errors to main keeps process exit at the command boundary.39func run(ctx context.Context, args []string, out io.Writer) error {40 if len(args) == 0 || args[0] == "help" || args[0] == "--help" || args[0] == "-h" {41 _, err := fmt.Fprintln(out, "tiny serve [--data-dir PATH] [--listen :7447]\ntiny relay [options]\ntiny agent --relay URL --room ROOM --command PROGRAM [--protocol acp|command]\ntiny tenant create --name NAME --owner PUBKEY [--template default]\ntiny tenant list|enable|disable|host [options]\ntiny git-token --repo URL [--key-env TINY_AGENT_KEY] [--format header|value|git]\ntiny templates\ntiny version")42 return err43 }44 switch args[0] {45 case "version":46 _, err := fmt.Fprintln(out, "tiny", version)47 return err48 case "templates":49 return json.NewEncoder(out).Encode(templates.Names())50 case "serve":51 return serve(ctx, args[1:], out)52 case "relay":53 return relaycmd.Run(ctx, args[1:], out)54 case "agent":55 return agent(ctx, args[1:], out)56 case "tenant":57 return tenantCommand(ctx, args[1:], out)58 case "git-token":59 return gitToken(args[1:], out)60 default:61 return fmt.Errorf("unknown command %q; run tiny help", args[0])62 }63}6465func defaultDataDir() string {66 if value := os.Getenv("TINY_DATA_DIR"); value != "" {67 return value68 }69 if value := os.Getenv("XDG_DATA_HOME"); value != "" {70 return filepath.Join(value, "tiny")71 }72 return "data"73}7475func tenantCommand(ctx context.Context, args []string, out io.Writer) error {76 if len(args) == 0 {77 return errors.New("tenant subcommand required")78 }79 fs := flag.NewFlagSet("tiny tenant "+args[0], flag.ContinueOnError)80 dataDir := fs.String("data-dir", defaultDataDir(), "local data directory")81 name := fs.String("name", "", "tenant name")82 owner := fs.String("owner", "", "owner public key (64 lowercase hex characters)")83 templateName := fs.String("template", "default", "relay template")84 source := fs.String("source", "", "upstream relay for a replica template")85 host := fs.String("host", "", "custom hostname")86 if err := fs.Parse(args[1:]); err != nil {87 return err88 }89 app, err := daemon.New(ctx, daemon.Config{DataDir: *dataDir, Version: version})90 if err != nil {91 return err92 }93 defer func() {94 if err := app.Close(ctx); err != nil {95 fmt.Fprintln(os.Stderr, "close tenant catalog:", err)96 }97 }()98 if args[0] == "create" {99 tenant, err := app.Create(ctx, daemon.CreateOptions{Name: *name, Owner: *owner, Template: *templateName, Source: *source})100 if err != nil {101 return err102 }103 return json.NewEncoder(out).Encode(tenant)104 }105 if args[0] == "list" {106 tenants, err := app.Catalog().List(ctx)107 if err != nil {108 return err109 }110 return json.NewEncoder(out).Encode(tenants)111 }112 meta, err := app.Catalog().GetByName(ctx, *name)113 if err != nil {114 return err115 }116 switch args[0] {117 case "enable":118 err = app.Catalog().Enable(ctx, meta.ID)119 case "disable":120 err = app.Catalog().Disable(ctx, meta.ID)121 case "host":122 if *host == "" {123 err = app.Catalog().ClearHost(ctx, meta.ID)124 } else {125 err = app.Catalog().SetHost(ctx, meta.ID, *host)126 }127 default:128 return fmt.Errorf("unknown tenant command %q", args[0])129 }130 if err != nil {131 return err132 }133 meta, err = app.Catalog().GetByID(ctx, meta.ID)134 if err != nil {135 return err136 }137 return json.NewEncoder(out).Encode(meta)138}139140type serveOptions struct {141 cfg daemon.Config142 listen string143 diagnostics string144 peers []string145 peerInterval time.Duration146 peerTimeout time.Duration147 peerPublish bool148 peerPublishTenant string149}150151func parseServe(args []string) (serveOptions, error) {152 var opts serveOptions153 fs := flag.NewFlagSet("tiny serve", flag.ContinueOnError)154 fs.StringVar(&opts.cfg.DataDir, "data-dir", defaultDataDir(), "local data directory")155 fs.StringVar(&opts.listen, "listen", ":7447", "public HTTP listener")156 fs.StringVar(&opts.cfg.PublicURL, "public-url", os.Getenv("TINY_PUBLIC_URL"), "external https URL (reverse proxy)")157 fs.StringVar(&opts.cfg.DefaultTenant, "default-tenant", "main", "tenant served on the root path")158 fs.StringVar(&opts.diagnostics, "diagnostics", "", "separate diagnostics listener, e.g.127.0.0.1:9090")159 fs.StringVar(&opts.cfg.Telemetry.DebugToken, "diagnostics-token", os.Getenv("TINY_DIAGNOSTICS_TOKEN"), "diagnostics bearer token")160 fs.StringVar(&opts.cfg.Telemetry.OTLPEndpoint, "otlp-endpoint", os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT"), "optional OTLP HTTP trace export")161 fs.Float64Var(&opts.cfg.Telemetry.TraceSampleRatio, "trace-sample", 0.01, "trace sampling ratio")162 fs.Int64Var(&opts.cfg.MaxMessageBytes, "max-message-bytes", 0, "optional host input byte guard; 0 unlimited")163 fs.BoolVar(&opts.cfg.AllowPrivateRelays, "allow-private-relays", false, "allow outbound relay connections to localhost, LAN, and Tailscale addresses")164 fs.Func("provision-owner", "operator-approved pubkey allowed to create tenants through the browser (repeatable)", func(value string) error {165 value = strings.TrimSpace(value)166 if len(value) != 64 || value != strings.ToLower(value) || !hexOwner(value) {167 return errors.New("provision owner must be a lowercase 64-character hex pubkey")168 }169 opts.cfg.ProvisionOwners = append(opts.cfg.ProvisionOwners, value)170 return nil171 })172 fs.Func("discovery-relay", "relay used to discover NIP-65 and inbox lists (repeatable)", func(value string) error {173 if !strings.HasPrefix(value, "ws://") && !strings.HasPrefix(value, "wss://") {174 return errors.New("discovery relay must use ws:// or wss://")175 }176 opts.cfg.DiscoveryRelays = append(opts.cfg.DiscoveryRelays, value)177 return nil178 })179 fs.Func("push-callback-origin", "operator-approved HTTPS callback origin (repeatable)", func(value string) error {180 u, err := url.Parse(value)181 if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || (u.Path != "" && u.Path != "/") {182 return errors.New("callback origin must be https://hostname[:port]")183 }184 opts.cfg.PushCallbackOrigins = append(opts.cfg.PushCallbackOrigins, "https://"+strings.ToLower(u.Host))185 return nil186 })187 fs.IntVar(&opts.cfg.MaxPendingBytes, "max-pending-bytes", 4<<20, "connection output backpressure guard")188 fs.Func("peer", "configured NIP-66 peer URL to probe (repeatable)", func(value string) error {189 opts.peers = append(opts.peers, value)190 return nil191 })192 fs.DurationVar(&opts.peerInterval, "peer-interval", time.Minute, "NIP-66 peer probe interval")193 fs.DurationVar(&opts.peerTimeout, "peer-timeout", 5*time.Second, "NIP-66 peer probe timeout")194 fs.BoolVar(&opts.peerPublish, "peer-publish", false, "publish signed NIP-66 status for the explicitly selected tenant")195 fs.StringVar(&opts.peerPublishTenant, "peer-publish-tenant", "", "tenant whose relay identity publishes NIP-66 status (required with --peer-publish)")196 opts.cfg.Version = version197 if err := fs.Parse(args); err != nil {198 return opts, err199 }200 if opts.diagnostics != "" && strings.TrimSpace(opts.cfg.Telemetry.DebugToken) == "" {201 return opts, errors.New("diagnostics listener requires --diagnostics-token or TINY_DIAGNOSTICS_TOKEN")202 }203 if opts.peerPublish && strings.TrimSpace(opts.peerPublishTenant) == "" {204 return opts, errors.New("--peer-publish requires --peer-publish-tenant")205 }206 if opts.peerPublish && len(opts.peers) == 0 {207 return opts, errors.New("--peer-publish requires at least one --peer peer URL")208 }209 if len(opts.peers) > 0 {210 opts.cfg.PeerMonitor = &daemon.PeerMonitorConfig{Peers: opts.peers, Interval: opts.peerInterval, Timeout: opts.peerTimeout, Publish: opts.peerPublish}211 }212 return opts, nil213}214215func hexOwner(value string) bool {216 for _, r := range value {217 if r >= '0' && r <= '9' || r >= 'a' && r <= 'f' {218 continue219 }220 return false221 }222 return true223}224225// serve owns listeners and peer monitoring around the App lifecycle. Shutdown226// drains the servers and joins peer monitoring before deferred App cleanup.227func serve(ctx context.Context, args []string, out io.Writer) error {228 opts, err := parseServe(args)229 if err != nil {230 return err231 }232 app, err := daemon.New(ctx, opts.cfg)233 if err != nil {234 return err235 }236 defer func() {237 closeCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)238 defer cancel()239 if err := app.Close(closeCtx); err != nil {240 fmt.Fprintln(os.Stderr, "close daemon:", err)241 }242 }()243 listener, err := net.Listen("tcp", opts.listen)244 if err != nil {245 return fmt.Errorf("listen: %w", err)246 }247 baseURL := opts.cfg.PublicURL248 if baseURL == "" {249 host, port, err := net.SplitHostPort(listener.Addr().String())250 if err != nil {251 return errors.Join(err, listener.Close())252 }253 if ip := net.ParseIP(host); host == "" || (ip != nil && ip.IsUnspecified()) {254 host = "127.0.0.1"255 }256 baseURL = "http://" + net.JoinHostPort(host, port)257 }258 if err := app.Start(ctx, baseURL); err != nil {259 return errors.Join(err, listener.Close())260 }261 if opts.peerPublish {262 if err := app.ConfigurePeerPublication(ctx, opts.peerPublishTenant); err != nil {263 return errors.Join(err, listener.Close())264 }265 }266 peerCtx, stopPeer := context.WithCancel(ctx)267 defer stopPeer()268 peerDone := make(chan error, 1)269 if app.PeerMonitor() != nil {270 go func() { peerDone <- app.PeerMonitorRun(peerCtx) }()271 } else {272 close(peerDone)273 }274 server := &http.Server{Handler: app, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 90 * time.Second}275 servers := []*http.Server{server}276 results := make(chan error, 1)277 go func() { results <- server.Serve(listener) }()278 if opts.diagnostics != "" {279 diag, err := net.Listen("tcp", opts.diagnostics)280 if err != nil {281 closeErr := server.Close()282 return errors.Join(err, closeErr)283 }284 debugServer := &http.Server{Handler: app.Diagnostics(), ReadHeaderTimeout: 10 * time.Second}285 servers = append(servers, debugServer)286 go func() { results <- debugServer.Serve(diag) }()287 }288 if _, err := fmt.Fprintf(out, "tiny listening on %s; data in %s\n", listener.Addr(), opts.cfg.DataDir); err != nil {289 for _, srv := range servers {290 if closeErr := srv.Close(); closeErr != nil {291 err = errors.Join(err, closeErr)292 }293 }294 for range servers {295 <-results296 }297 return err298 }299 serverErr := awaitServers(ctx, servers, results)300 stopPeer()301 if app.PeerMonitor() != nil {302 if err := <-peerDone; err != nil && !errors.Is(err, context.Canceled) {303 serverErr = errors.Join(serverErr, err)304 }305 }306 return serverErr307}308309// awaitServers starts shutdown when ctx ends or a server returns. Each server310// must report once to results; all reports are drained before returning the311// joined shutdown and serving errors.312func awaitServers(ctx context.Context, servers []*http.Server, results <-chan error) error {313 var first error314 received := 0315 select {316 case <-ctx.Done():317 case first = <-results:318 received = 1319 }320 if errors.Is(first, http.ErrServerClosed) {321 first = nil322 }323 shutdown, cancel := context.WithTimeout(context.Background(), 15*time.Second)324 defer cancel()325 for _, server := range servers {326 if err := server.Shutdown(shutdown); err != nil {327 first = errors.Join(first, err, server.Close())328 }329 }330 for received < len(servers) {331 err := <-results332 received++333 if !errors.Is(err, http.ErrServerClosed) {334 first = errors.Join(first, err)335 }336 }337 return first338}