tinyrelayfile
tinyrelay/cmd/tiny/main.go/

cmd/tiny/main.go raw
1package main
2
3import (
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
24const version = "0.1.0-dev"
25
26func 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.
38func 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
62func 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
72func 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
137type 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
148func 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
212func 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.
224func 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.
309func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs