clone

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

feat/standalone-relay
tinyrelay / internal / relaycmd / runner.go
runner.go 5.3K
1// Package relaycmd contains the shared standalone relay command runner.2package relaycmd34import (5 "context"6 "errors"7 "flag"8 "fmt"9 "io"10 "net"11 "net/http"12 "net/url"13 "os"14 "strings"15 "time"1617 "github.com/FelineStateMachine/tinyrelay/tinyrelay"18)1920const (21 defaultMessageBytes = 1 << 2022 defaultPendingBytes = 4 << 2023)2425type options struct {26 dataDir, listen, publicURL string27 name, description, contact string28 owner string29 authRequired bool30 maxMessageBytes int6431 maxPendingBytes int32}3334func parse(args []string) (options, error) {35 return parseArgs(args, io.Discard)36}3738func parseArgs(args []string, output io.Writer) (options, error) {39 opts := options{dataDir: defaultDataDir(), listen: "127.0.0.1:7447", maxMessageBytes: defaultMessageBytes, maxPendingBytes: defaultPendingBytes}40 fs := flag.NewFlagSet("tinyrelay", flag.ContinueOnError)41 fs.SetOutput(output)42 fs.StringVar(&opts.dataDir, "data-dir", opts.dataDir, "relay data directory")43 fs.StringVar(&opts.listen, "listen", opts.listen, "HTTP/WebSocket listen address")44 fs.StringVar(&opts.publicURL, "public-url", "", "public HTTP(S) or WS(S) relay URL")45 fs.StringVar(&opts.name, "name", "", "relay name")46 fs.StringVar(&opts.description, "description", "", "relay description")47 fs.StringVar(&opts.contact, "contact", "", "relay operator contact")48 fs.StringVar(&opts.owner, "owner", "", "relay owner lowercase hex public key")49 fs.BoolVar(&opts.authRequired, "auth-required", false, "require AUTH before reading and publishing as the authenticated author")50 fs.Int64Var(&opts.maxMessageBytes, "max-message-bytes", opts.maxMessageBytes, "maximum websocket message size")51 fs.IntVar(&opts.maxPendingBytes, "max-pending-bytes", opts.maxPendingBytes, "maximum queued output per connection")52 if err := fs.Parse(args); err != nil {53 return opts, err54 }55 if fs.NArg() != 0 {56 return opts, errors.New("tinyrelay accepts flags only")57 }58 var err error59 if opts.publicURL, err = normalizeRelayURL(opts.publicURL); err != nil {60 return opts, err61 }62 if opts.maxMessageBytes <= 0 || opts.maxPendingBytes <= 0 {63 return opts, errors.New("message and pending byte limits must be positive")64 }65 if opts.owner != "" && !validOwner(opts.owner) {66 return opts, errors.New("owner must be a lowercase 64-character hex public key")67 }68 return opts, nil69}7071func defaultDataDir() string {72 if value := strings.TrimSpace(os.Getenv("TINY_RELAY_DATA_DIR")); value != "" {73 return value74 }75 return "tinyrelay-data"76}7778func normalizeRelayURL(value string) (string, error) {79 if value == "" {80 return "", nil81 }82 u, err := url.Parse(value)83 if err != nil || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {84 return "", errors.New("public URL must be an HTTP(S) or WS(S) URL without query or fragment")85 }86 switch u.Scheme {87 case "http":88 u.Scheme = "ws"89 case "https":90 u.Scheme = "wss"91 case "ws", "wss":92 default:93 return "", errors.New("public URL must use http, https, ws or wss")94 }95 u.Path = strings.TrimRight(u.Path, "/")96 return u.String(), nil97}9899func validOwner(value string) bool {100 if len(value) != 64 || value != strings.ToLower(value) {101 return false102 }103 for _, char := range value {104 if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) {105 return false106 }107 }108 return true109}110111// Run serves a standalone, protocol-compatible relay until ctx is canceled.112func Run(ctx context.Context, args []string, output io.Writer) error {113 opts, err := parseArgs(args, output)114 if errors.Is(err, flag.ErrHelp) {115 return nil116 }117 if err != nil {118 return err119 }120 listener, err := net.Listen("tcp", opts.listen)121 if err != nil {122 return fmt.Errorf("listen on %s: %w", opts.listen, err)123 }124 defer listener.Close()125 publicURL := opts.publicURL126 if publicURL == "" {127 publicURL = "ws://" + listener.Addr().String()128 }129 app, err := tinyrelay.OpenServer(ctx, tinyrelay.ServerConfig{130 DataDir: opts.dataDir, PublicURL: publicURL, Name: opts.name,131 Description: opts.description, Contact: opts.contact, Owner: opts.owner,132 AuthRequired: opts.authRequired, MaxMessageBytes: opts.maxMessageBytes,133 MaxPendingBytes: opts.maxPendingBytes,134 })135 if err != nil {136 return err137 }138 server := &http.Server{Handler: app, ReadHeaderTimeout: 10 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20, BaseContext: func(net.Listener) context.Context { return ctx }}139 done := make(chan error, 1)140 go func() { done <- server.Serve(listener) }()141 if _, err := fmt.Fprintf(output, "tinyrelay listening on %s\n", listener.Addr()); err != nil {142 _ = server.Close()143 serveErr := <-done144 return errors.Join(err, joinServeErrors(serveErr, app.Close()))145 }146 select {147 case serveErr := <-done:148 closeErr := app.Close()149 return joinServeErrors(serveErr, closeErr)150 case <-ctx.Done():151 return stopServer(server, app, done)152 }153}154155func stopServer(server *http.Server, app *tinyrelay.Server, done <-chan error) error {156 shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)157 defer cancel()158 shutdownErr := server.Shutdown(shutdownCtx)159 if shutdownErr != nil {160 _ = server.Close()161 }162 serveErr := <-done163 closeErr := app.Close()164 return errors.Join(shutdownErr, joinServeErrors(serveErr, closeErr))165}166167func joinServeErrors(serveErr, closeErr error) error {168 if errors.Is(serveErr, http.ErrServerClosed) {169 serveErr = nil170 }171 return errors.Join(serveErr, closeErr)172}