aboutsummaryrefslogtreecommitdiffstats
path: root/cmd
diff options
context:
space:
mode:
authorbndw <ben@bdw.to>2026-03-09 12:48:39 -0700
committerbndw <ben@bdw.to>2026-03-09 12:48:39 -0700
commit0bd392e076e36c80a152abe00cbcd0bc9efedd9c (patch)
tree2727cf8a030e675fa86082b56faf9fee4ddc45b1 /cmd
parent2c1ec8f9e964c2e89eee900299db667d9a58db25 (diff)
feat: add axon CLI for pub/sub; fix relay hijack context bug
- cmd/axon: new CLI module with keygen, pub, and req subcommands - keygen: generate Ed25519 keypair, print hex seed and pubkey - pub: sign and publish an event; accepts --kind, --content, --tag - req: query/stream events as JSON lines; accepts --kind, --author, --tag, --since, --until, --limit, --stream - key loaded from --key flag or AXON_KEY env var - relay/websocket: add Dial() for client-side WebSocket handshake (ws:// and wss://, RFC 6455 masking via client:true) - relay/server: fix broken-pipe on auth by switching hijacked conn goroutine from r.Context() to context.Background(); r.Context() is cancelled by net/http immediately after Hijack is called
Diffstat (limited to 'cmd')
-rwxr-xr-xcmd/axon/clibin0 -> 7995047 bytes
-rw-r--r--cmd/axon/go.mod20
-rw-r--r--cmd/axon/go.sum16
-rw-r--r--cmd/axon/main.go483
4 files changed, 519 insertions, 0 deletions
diff --git a/cmd/axon/cli b/cmd/axon/cli
new file mode 100755
index 0000000..93f7706
--- /dev/null
+++ b/cmd/axon/cli
Binary files differ
diff --git a/cmd/axon/go.mod b/cmd/axon/go.mod
new file mode 100644
index 0000000..2f26fca
--- /dev/null
+++ b/cmd/axon/go.mod
@@ -0,0 +1,20 @@
1module axon/cli
2
3go 1.25.5
4
5require (
6 axon v0.0.0
7 axon/relay v0.0.0
8 github.com/vmihailenco/msgpack/v5 v5.4.1
9)
10
11require (
12 github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
13 golang.org/x/crypto v0.48.0 // indirect
14 golang.org/x/sys v0.41.0 // indirect
15)
16
17replace (
18 axon => ../../
19 axon/relay => ../../relay
20)
diff --git a/cmd/axon/go.sum b/cmd/axon/go.sum
new file mode 100644
index 0000000..7f2743c
--- /dev/null
+++ b/cmd/axon/go.sum
@@ -0,0 +1,16 @@
1github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8=
2github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
4github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
5github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
6github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
7github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8=
8github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok=
9github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g=
10github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds=
11golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
12golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
13golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
14golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
15gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
16gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/cmd/axon/main.go b/cmd/axon/main.go
new file mode 100644
index 0000000..f7aaf63
--- /dev/null
+++ b/cmd/axon/main.go
@@ -0,0 +1,483 @@
1// axon is a CLI for publishing and querying events on an Axon relay.
2//
3// Usage:
4//
5// axon keygen
6// axon pub [flags] <relay-url>
7// axon req [flags] <relay-url>
8//
9// The private key seed is read from --key or the AXON_KEY environment variable.
10// All binary values (IDs, pubkeys, signatures) are printed as lowercase hex.
11package main
12
13import (
14 "context"
15 "encoding/hex"
16 "encoding/json"
17 "flag"
18 "fmt"
19 "log"
20 "os"
21 "os/signal"
22 "strconv"
23 "strings"
24 "syscall"
25 "time"
26
27 "axon"
28 ws "axon/relay/websocket"
29
30 "github.com/vmihailenco/msgpack/v5"
31)
32
33// ── Wire protocol constants (mirrors relay/handler.go) ──────────────────────
34
35const (
36 msgTypeAuth uint16 = 1
37 msgTypeSubscribe uint16 = 2
38 msgTypeUnsubscribe uint16 = 3
39 msgTypePublish uint16 = 4
40
41 msgTypeChallenge uint16 = 10
42 msgTypeEvent uint16 = 11
43 msgTypeEose uint16 = 12
44 msgTypeOk uint16 = 13
45 msgTypeError uint16 = 14
46)
47
48// ── Payload structs ──────────────────────────────────────────────────────────
49
50type challengePayload struct {
51 Nonce []byte `msgpack:"nonce"`
52}
53
54type authPayload struct {
55 PubKey []byte `msgpack:"pubkey"`
56 Sig []byte `msgpack:"sig"`
57}
58
59type okPayload struct {
60 Message string `msgpack:"message"`
61}
62
63type errorPayload struct {
64 Code uint16 `msgpack:"code"`
65 Message string `msgpack:"message"`
66}
67
68type publishPayload struct {
69 Event axon.Event `msgpack:"event"`
70}
71
72type subscribePayload struct {
73 SubID string `msgpack:"sub_id"`
74 Filter axon.Filter `msgpack:"filter"`
75}
76
77type eventPayload struct {
78 SubID string `msgpack:"sub_id"`
79 Event axon.Event `msgpack:"event"`
80}
81
82type eosePayload struct {
83 SubID string `msgpack:"sub_id"`
84}
85
86// ── Transport helpers ────────────────────────────────────────────────────────
87
88func send(conn *ws.Conn, msgType uint16, payload interface{}) error {
89 b, err := msgpack.Marshal([]interface{}{msgType, payload})
90 if err != nil {
91 return err
92 }
93 return conn.Write(b)
94}
95
96func recv(conn *ws.Conn, ctx context.Context) (uint16, msgpack.RawMessage, error) {
97 data, err := conn.Read(ctx)
98 if err != nil {
99 return 0, nil, err
100 }
101 var arr []msgpack.RawMessage
102 if err := msgpack.Unmarshal(data, &arr); err != nil {
103 return 0, nil, fmt.Errorf("decode message: %w", err)
104 }
105 if len(arr) < 2 {
106 return 0, nil, fmt.Errorf("message too short (%d elements)", len(arr))
107 }
108 var t uint16
109 if err := msgpack.Unmarshal(arr[0], &t); err != nil {
110 return 0, nil, fmt.Errorf("decode message type: %w", err)
111 }
112 return t, arr[1], nil
113}
114
115// dial connects and performs the auth handshake. Returns an authenticated conn.
116func dial(relayURL string, kp axon.KeyPair) (*ws.Conn, error) {
117 conn, err := ws.Dial(relayURL)
118 if err != nil {
119 return nil, err
120 }
121
122 ctx := context.Background()
123
124 // Receive Challenge.
125 t, raw, err := recv(conn, ctx)
126 if err != nil {
127 conn.CloseConn()
128 return nil, fmt.Errorf("recv challenge: %w", err)
129 }
130 if t != msgTypeChallenge {
131 conn.CloseConn()
132 return nil, fmt.Errorf("expected challenge (10), got %d", t)
133 }
134 var cp challengePayload
135 if err := msgpack.Unmarshal(raw, &cp); err != nil {
136 conn.CloseConn()
137 return nil, fmt.Errorf("decode challenge: %w", err)
138 }
139
140 // Send Auth.
141 sig := axon.SignChallenge(kp, cp.Nonce, relayURL)
142 if err := send(conn, msgTypeAuth, authPayload{
143 PubKey: []byte(kp.PubKey),
144 Sig: sig,
145 }); err != nil {
146 conn.CloseConn()
147 return nil, fmt.Errorf("send auth: %w", err)
148 }
149
150 // Receive Ok / Error.
151 t, raw, err = recv(conn, ctx)
152 if err != nil {
153 conn.CloseConn()
154 return nil, fmt.Errorf("recv auth response: %w", err)
155 }
156 switch t {
157 case msgTypeOk:
158 return conn, nil
159 case msgTypeError:
160 var ep errorPayload
161 msgpack.Unmarshal(raw, &ep)
162 conn.CloseConn()
163 return nil, fmt.Errorf("auth rejected (%d): %s", ep.Code, ep.Message)
164 default:
165 conn.CloseConn()
166 return nil, fmt.Errorf("unexpected message %d after auth", t)
167 }
168}
169
170// ── Output helpers ───────────────────────────────────────────────────────────
171
172// eventJSON is a JSON-serialisable view of an axon.Event with hex-encoded
173// binary fields and content treated as a UTF-8 string.
174type eventJSON struct {
175 ID string `json:"id"`
176 PubKey string `json:"pubkey"`
177 CreatedAt int64 `json:"created_at"`
178 Kind uint16 `json:"kind"`
179 Content string `json:"content"`
180 Tags []axon.Tag `json:"tags"`
181 Sig string `json:"sig"`
182}
183
184func toEventJSON(e *axon.Event) eventJSON {
185 return eventJSON{
186 ID: hex.EncodeToString(e.ID),
187 PubKey: hex.EncodeToString(e.PubKey),
188 CreatedAt: e.CreatedAt,
189 Kind: e.Kind,
190 Content: string(e.Content),
191 Tags: e.Tags,
192 Sig: hex.EncodeToString(e.Sig),
193 }
194}
195
196func printEvent(e *axon.Event) {
197 b, _ := json.Marshal(toEventJSON(e))
198 fmt.Println(string(b))
199}
200
201// ── Custom flag types ────────────────────────────────────────────────────────
202
203// tagFlag accumulates --tag name=value or --tag name=v1,v2 flags.
204type tagFlag []axon.Tag
205
206func (f *tagFlag) String() string { return fmt.Sprint([]axon.Tag(*f)) }
207func (f *tagFlag) Set(s string) error {
208 parts := strings.SplitN(s, "=", 2)
209 if len(parts) != 2 || parts[0] == "" {
210 return fmt.Errorf("expected name=value, got %q", s)
211 }
212 values := strings.Split(parts[1], ",")
213 *f = append(*f, axon.Tag{Name: parts[0], Values: values})
214 return nil
215}
216
217// kindFlag accumulates --kind flags as uint16.
218type kindFlag []uint16
219
220func (f *kindFlag) String() string { return fmt.Sprint([]uint16(*f)) }
221func (f *kindFlag) Set(s string) error {
222 v, err := strconv.ParseUint(s, 10, 16)
223 if err != nil {
224 return err
225 }
226 *f = append(*f, uint16(v))
227 return nil
228}
229
230// authorFlag accumulates --author hex-pubkey flags.
231type authorFlag [][]byte
232
233func (f *authorFlag) String() string { return fmt.Sprint([][]byte(*f)) }
234func (f *authorFlag) Set(s string) error {
235 b, err := hex.DecodeString(s)
236 if err != nil {
237 return fmt.Errorf("not valid hex: %w", err)
238 }
239 if len(b) != 32 {
240 return fmt.Errorf("pubkey must be 32 bytes (64 hex chars), got %d", len(b))
241 }
242 *f = append(*f, b)
243 return nil
244}
245
246// ── Key loading ──────────────────────────────────────────────────────────────
247
248func loadKey(hexSeed string) (axon.KeyPair, error) {
249 if hexSeed == "" {
250 hexSeed = os.Getenv("AXON_KEY")
251 }
252 if hexSeed == "" {
253 return axon.KeyPair{}, fmt.Errorf("no key: supply --key or set AXON_KEY")
254 }
255 seed, err := hex.DecodeString(hexSeed)
256 if err != nil {
257 return axon.KeyPair{}, fmt.Errorf("decode key: %w", err)
258 }
259 if len(seed) != 32 {
260 return axon.KeyPair{}, fmt.Errorf("key must be 32 bytes (64 hex chars), got %d", len(seed))
261 }
262 return axon.NewKeyPairFromSeed(seed), nil
263}
264
265// ── keygen ───────────────────────────────────────────────────────────────────
266
267func cmdKeygen(_ []string) {
268 kp, err := axon.NewKeyPair()
269 if err != nil {
270 log.Fatalf("keygen: %v", err)
271 }
272 fmt.Printf("private-key %s\n", hex.EncodeToString(kp.PrivKey.Seed()))
273 fmt.Printf("public-key %s\n", hex.EncodeToString(kp.PubKey))
274}
275
276// ── pub ──────────────────────────────────────────────────────────────────────
277
278func cmdPub(args []string) {
279 fs := flag.NewFlagSet("pub", flag.ExitOnError)
280 fs.Usage = func() {
281 fmt.Fprintln(os.Stderr, "usage: axon pub [flags] <relay-url>")
282 fmt.Fprintln(os.Stderr, "\nFlags:")
283 fs.PrintDefaults()
284 }
285 keyHex := fs.String("key", "", "private key seed (hex, 32 bytes). Falls back to AXON_KEY env var.")
286 kind := fs.Uint("kind", 1000, "event kind")
287 content := fs.String("content", "", "event content (string)")
288 var tags tagFlag
289 fs.Var(&tags, "tag", "add a tag: name=value or name=v1,v2 (repeatable)")
290 _ = fs.Parse(args)
291
292 relayURL := fs.Arg(0)
293 if relayURL == "" {
294 fs.Usage()
295 os.Exit(1)
296 }
297
298 kp, err := loadKey(*keyHex)
299 if err != nil {
300 log.Fatal(err)
301 }
302
303 event := axon.Event{
304 CreatedAt: time.Now().Unix(),
305 Kind: uint16(*kind),
306 Content: []byte(*content),
307 Tags: []axon.Tag(tags),
308 }
309 if event.Tags == nil {
310 event.Tags = []axon.Tag{}
311 }
312 if err := axon.Sign(&event, kp); err != nil {
313 log.Fatalf("sign: %v", err)
314 }
315
316 conn, err := dial(relayURL, kp)
317 if err != nil {
318 log.Fatalf("connect: %v", err)
319 }
320 defer conn.CloseConn()
321
322 if err := send(conn, msgTypePublish, publishPayload{Event: event}); err != nil {
323 log.Fatalf("publish: %v", err)
324 }
325
326 ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
327 defer cancel()
328
329 t, raw, err := recv(conn, ctx)
330 if err != nil {
331 log.Fatalf("recv: %v", err)
332 }
333 switch t {
334 case msgTypeOk:
335 fmt.Printf("published %s\n", hex.EncodeToString(event.ID))
336 case msgTypeError:
337 var ep errorPayload
338 msgpack.Unmarshal(raw, &ep)
339 log.Fatalf("error %d: %s", ep.Code, ep.Message)
340 default:
341 log.Fatalf("unexpected message type %d", t)
342 }
343}
344
345// ── req ──────────────────────────────────────────────────────────────────────
346
347func cmdReq(args []string) {
348 fs := flag.NewFlagSet("req", flag.ExitOnError)
349 fs.Usage = func() {
350 fmt.Fprintln(os.Stderr, "usage: axon req [flags] <relay-url>")
351 fmt.Fprintln(os.Stderr, "\nPrints one JSON event per line. Exits after EOSE unless --stream is set.")
352 fmt.Fprintln(os.Stderr, "\nFlags:")
353 fs.PrintDefaults()
354 }
355 keyHex := fs.String("key", "", "private key seed (hex). Falls back to AXON_KEY env var.")
356 var kinds kindFlag
357 fs.Var(&kinds, "kind", "filter by event kind (repeatable)")
358 var authors authorFlag
359 fs.Var(&authors, "author", "filter by author pubkey hex (repeatable)")
360 var filterTags tagFlag
361 fs.Var(&filterTags, "tag", "filter by tag: name=value (repeatable)")
362 since := fs.Int64("since", 0, "only events with created_at >= this unix timestamp")
363 until := fs.Int64("until", 0, "only events with created_at <= this unix timestamp")
364 limit := fs.Int("limit", 0, "max events to return (0 = no limit)")
365 stream := fs.Bool("stream", false, "keep streaming live events after EOSE (Ctrl-C to exit)")
366 _ = fs.Parse(args)
367
368 relayURL := fs.Arg(0)
369 if relayURL == "" {
370 fs.Usage()
371 os.Exit(1)
372 }
373
374 kp, err := loadKey(*keyHex)
375 if err != nil {
376 log.Fatal(err)
377 }
378
379 filter := axon.Filter{
380 Kinds: []uint16(kinds),
381 Authors: [][]byte(authors),
382 Since: *since,
383 Until: *until,
384 Limit: int32(*limit),
385 }
386 for _, t := range filterTags {
387 filter.Tags = append(filter.Tags, axon.TagFilter{Name: t.Name, Values: t.Values})
388 }
389
390 conn, err := dial(relayURL, kp)
391 if err != nil {
392 log.Fatalf("connect: %v", err)
393 }
394 defer conn.CloseConn()
395
396 subID := "req-" + strconv.FormatInt(time.Now().UnixNano(), 36)
397 if err := send(conn, msgTypeSubscribe, subscribePayload{SubID: subID, Filter: filter}); err != nil {
398 log.Fatalf("subscribe: %v", err)
399 }
400
401 ctx, cancel := context.WithCancel(context.Background())
402 defer cancel()
403
404 // Cancel on Ctrl-C when streaming.
405 if *stream {
406 sigCh := make(chan os.Signal, 1)
407 signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
408 go func() {
409 <-sigCh
410 cancel()
411 }()
412 }
413
414 for {
415 t, raw, err := recv(conn, ctx)
416 if err != nil {
417 if ctx.Err() != nil {
418 return // clean cancellation
419 }
420 log.Fatalf("recv: %v", err)
421 }
422 switch t {
423 case msgTypeEvent:
424 var ep eventPayload
425 if err := msgpack.Unmarshal(raw, &ep); err != nil {
426 log.Printf("decode event: %v", err)
427 continue
428 }
429 printEvent(&ep.Event)
430
431 case msgTypeEose:
432 if !*stream {
433 return
434 }
435 // Keep looping for live events.
436
437 case msgTypeError:
438 var ep errorPayload
439 msgpack.Unmarshal(raw, &ep)
440 log.Fatalf("error %d: %s", ep.Code, ep.Message)
441
442 default:
443 log.Printf("unexpected message type %d", t)
444 }
445 }
446}
447
448// ── main ─────────────────────────────────────────────────────────────────────
449
450func usage() {
451 fmt.Fprintln(os.Stderr, "usage: axon <command> [flags]")
452 fmt.Fprintln(os.Stderr, "")
453 fmt.Fprintln(os.Stderr, "Commands:")
454 fmt.Fprintln(os.Stderr, " keygen Generate a new Ed25519 keypair")
455 fmt.Fprintln(os.Stderr, " pub Publish an event to a relay")
456 fmt.Fprintln(os.Stderr, " req Query or stream events from a relay")
457 fmt.Fprintln(os.Stderr, "")
458 fmt.Fprintln(os.Stderr, "Run 'axon <command> -h' for command-specific help.")
459}
460
461func main() {
462 log.SetFlags(0)
463 log.SetPrefix("axon: ")
464
465 if len(os.Args) < 2 {
466 usage()
467 os.Exit(1)
468 }
469
470 cmd, rest := os.Args[1], os.Args[2:]
471 switch cmd {
472 case "keygen":
473 cmdKeygen(rest)
474 case "pub":
475 cmdPub(rest)
476 case "req":
477 cmdReq(rest)
478 default:
479 fmt.Fprintf(os.Stderr, "axon: unknown command %q\n\n", cmd)
480 usage()
481 os.Exit(1)
482 }
483}