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