diff options
Diffstat (limited to 'cmd/axon/main.go')
| -rw-r--r-- | cmd/axon/main.go | 483 |
1 files changed, 483 insertions, 0 deletions
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. | ||
| 11 | package main | ||
| 12 | |||
| 13 | import ( | ||
| 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 | |||
| 35 | const ( | ||
| 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 | |||
| 50 | type challengePayload struct { | ||
| 51 | Nonce []byte `msgpack:"nonce"` | ||
| 52 | } | ||
| 53 | |||
| 54 | type authPayload struct { | ||
| 55 | PubKey []byte `msgpack:"pubkey"` | ||
| 56 | Sig []byte `msgpack:"sig"` | ||
| 57 | } | ||
| 58 | |||
| 59 | type okPayload struct { | ||
| 60 | Message string `msgpack:"message"` | ||
| 61 | } | ||
| 62 | |||
| 63 | type errorPayload struct { | ||
| 64 | Code uint16 `msgpack:"code"` | ||
| 65 | Message string `msgpack:"message"` | ||
| 66 | } | ||
| 67 | |||
| 68 | type publishPayload struct { | ||
| 69 | Event axon.Event `msgpack:"event"` | ||
| 70 | } | ||
| 71 | |||
| 72 | type subscribePayload struct { | ||
| 73 | SubID string `msgpack:"sub_id"` | ||
| 74 | Filter axon.Filter `msgpack:"filter"` | ||
| 75 | } | ||
| 76 | |||
| 77 | type eventPayload struct { | ||
| 78 | SubID string `msgpack:"sub_id"` | ||
| 79 | Event axon.Event `msgpack:"event"` | ||
| 80 | } | ||
| 81 | |||
| 82 | type eosePayload struct { | ||
| 83 | SubID string `msgpack:"sub_id"` | ||
| 84 | } | ||
| 85 | |||
| 86 | // ── Transport helpers ──────────────────────────────────────────────────────── | ||
| 87 | |||
| 88 | func 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 | |||
| 96 | func 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. | ||
| 116 | func 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. | ||
| 174 | type 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 | |||
| 184 | func 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 | |||
| 196 | func 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. | ||
| 204 | type tagFlag []axon.Tag | ||
| 205 | |||
| 206 | func (f *tagFlag) String() string { return fmt.Sprint([]axon.Tag(*f)) } | ||
| 207 | func (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. | ||
| 218 | type kindFlag []uint16 | ||
| 219 | |||
| 220 | func (f *kindFlag) String() string { return fmt.Sprint([]uint16(*f)) } | ||
| 221 | func (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. | ||
| 231 | type authorFlag [][]byte | ||
| 232 | |||
| 233 | func (f *authorFlag) String() string { return fmt.Sprint([][]byte(*f)) } | ||
| 234 | func (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 | |||
| 248 | func 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 | |||
| 267 | func 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 | |||
| 278 | func 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 | |||
| 347 | func 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 | |||
| 450 | func 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 | |||
| 461 | func 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 | } | ||
