diff options
| author | bndw <ben@bdw.to> | 2026-03-09 12:48:39 -0700 |
|---|---|---|
| committer | bndw <ben@bdw.to> | 2026-03-09 12:48:39 -0700 |
| commit | 0bd392e076e36c80a152abe00cbcd0bc9efedd9c (patch) | |
| tree | 2727cf8a030e675fa86082b56faf9fee4ddc45b1 | |
| parent | 2c1ec8f9e964c2e89eee900299db667d9a58db25 (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
| -rwxr-xr-x | cmd/axon/cli | bin | 0 -> 7995047 bytes | |||
| -rw-r--r-- | cmd/axon/go.mod | 20 | ||||
| -rw-r--r-- | cmd/axon/go.sum | 16 | ||||
| -rw-r--r-- | cmd/axon/main.go | 483 | ||||
| -rw-r--r-- | go.work | 1 | ||||
| -rw-r--r-- | go.work.sum | 4 | ||||
| -rw-r--r-- | relay/server.go | 5 | ||||
| -rw-r--r-- | relay/websocket/websocket.go | 75 |
8 files changed, 603 insertions, 1 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 @@ | |||
| 1 | module axon/cli | ||
| 2 | |||
| 3 | go 1.25.5 | ||
| 4 | |||
| 5 | require ( | ||
| 6 | axon v0.0.0 | ||
| 7 | axon/relay v0.0.0 | ||
| 8 | github.com/vmihailenco/msgpack/v5 v5.4.1 | ||
| 9 | ) | ||
| 10 | |||
| 11 | require ( | ||
| 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 | |||
| 17 | replace ( | ||
| 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 @@ | |||
| 1 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= | ||
| 2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= | ||
| 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= | ||
| 5 | github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= | ||
| 6 | github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | ||
| 7 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= | ||
| 8 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= | ||
| 9 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= | ||
| 10 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= | ||
| 11 | golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= | ||
| 12 | golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= | ||
| 13 | golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= | ||
| 14 | golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= | ||
| 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 16 | gopkg.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. | ||
| 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 | } | ||
| @@ -3,4 +3,5 @@ go 1.25.5 | |||
| 3 | use ( | 3 | use ( |
| 4 | . | 4 | . |
| 5 | ./relay | 5 | ./relay |
| 6 | ./cmd/axon | ||
| 6 | ) | 7 | ) |
diff --git a/go.work.sum b/go.work.sum new file mode 100644 index 0000000..08345b3 --- /dev/null +++ b/go.work.sum | |||
| @@ -0,0 +1,4 @@ | |||
| 1 | golang.org/x/exp v0.0.0-20231108232855-2478ac86f678/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= | ||
| 2 | golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= | ||
| 3 | golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= | ||
| 4 | golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= | ||
diff --git a/relay/server.go b/relay/server.go index 085929c..d4a1edd 100644 --- a/relay/server.go +++ b/relay/server.go | |||
| @@ -98,7 +98,10 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { | |||
| 98 | s.mu.Add(1) | 98 | s.mu.Add(1) |
| 99 | go func() { | 99 | go func() { |
| 100 | defer s.mu.Done() | 100 | defer s.mu.Done() |
| 101 | ctx := r.Context() | 101 | // r.Context() is cancelled by the HTTP server when Hijack is called, |
| 102 | // so we use a fresh context. The connection manages its own lifecycle | ||
| 103 | // via the ping loop and WebSocket close frames. | ||
| 104 | ctx := context.Background() | ||
| 102 | h.serve(ctx) | 105 | h.serve(ctx) |
| 103 | if err := c.CloseConn(); err != nil { | 106 | if err := c.CloseConn(); err != nil { |
| 104 | // Ignore close errors — connection may already be gone. | 107 | // Ignore close errors — connection may already be gone. |
diff --git a/relay/websocket/websocket.go b/relay/websocket/websocket.go index cfc3289..2ae0dec 100644 --- a/relay/websocket/websocket.go +++ b/relay/websocket/websocket.go | |||
| @@ -7,12 +7,14 @@ import ( | |||
| 7 | "context" | 7 | "context" |
| 8 | "crypto/rand" | 8 | "crypto/rand" |
| 9 | "crypto/sha1" | 9 | "crypto/sha1" |
| 10 | "crypto/tls" | ||
| 10 | "encoding/base64" | 11 | "encoding/base64" |
| 11 | "encoding/binary" | 12 | "encoding/binary" |
| 12 | "fmt" | 13 | "fmt" |
| 13 | "io" | 14 | "io" |
| 14 | "net" | 15 | "net" |
| 15 | "net/http" | 16 | "net/http" |
| 17 | "net/url" | ||
| 16 | "strings" | 18 | "strings" |
| 17 | "sync" | 19 | "sync" |
| 18 | "time" | 20 | "time" |
| @@ -207,6 +209,79 @@ func acceptKey(key string) string { | |||
| 207 | return base64.StdEncoding.EncodeToString(h.Sum(nil)) | 209 | return base64.StdEncoding.EncodeToString(h.Sum(nil)) |
| 208 | } | 210 | } |
| 209 | 211 | ||
| 212 | // Dial connects to a WebSocket server at rawURL and performs the client-side | ||
| 213 | // RFC 6455 handshake. Supports ws:// and wss:// schemes. | ||
| 214 | func Dial(rawURL string) (*Conn, error) { | ||
| 215 | u, err := url.Parse(rawURL) | ||
| 216 | if err != nil { | ||
| 217 | return nil, fmt.Errorf("websocket: parse url: %w", err) | ||
| 218 | } | ||
| 219 | |||
| 220 | host := u.Host | ||
| 221 | var netConn net.Conn | ||
| 222 | switch u.Scheme { | ||
| 223 | case "ws": | ||
| 224 | if !strings.Contains(host, ":") { | ||
| 225 | host += ":80" | ||
| 226 | } | ||
| 227 | netConn, err = net.Dial("tcp", host) | ||
| 228 | case "wss": | ||
| 229 | if !strings.Contains(host, ":") { | ||
| 230 | host += ":443" | ||
| 231 | } | ||
| 232 | netConn, err = tls.Dial("tcp", host, &tls.Config{ServerName: u.Hostname()}) | ||
| 233 | default: | ||
| 234 | return nil, fmt.Errorf("websocket: unsupported scheme %q (use ws:// or wss://)", u.Scheme) | ||
| 235 | } | ||
| 236 | if err != nil { | ||
| 237 | return nil, fmt.Errorf("websocket: dial %s: %w", host, err) | ||
| 238 | } | ||
| 239 | |||
| 240 | // Generate a random 16-byte key and base64-encode it. | ||
| 241 | var keyBytes [16]byte | ||
| 242 | if _, err := rand.Read(keyBytes[:]); err != nil { | ||
| 243 | netConn.Close() | ||
| 244 | return nil, fmt.Errorf("websocket: generate key: %w", err) | ||
| 245 | } | ||
| 246 | key := base64.StdEncoding.EncodeToString(keyBytes[:]) | ||
| 247 | |||
| 248 | path := u.RequestURI() | ||
| 249 | if path == "" { | ||
| 250 | path = "/" | ||
| 251 | } | ||
| 252 | |||
| 253 | req := "GET " + path + " HTTP/1.1\r\n" + | ||
| 254 | "Host: " + u.Host + "\r\n" + | ||
| 255 | "Upgrade: websocket\r\n" + | ||
| 256 | "Connection: Upgrade\r\n" + | ||
| 257 | "Sec-WebSocket-Key: " + key + "\r\n" + | ||
| 258 | "Sec-WebSocket-Version: 13\r\n\r\n" | ||
| 259 | |||
| 260 | if _, err := netConn.Write([]byte(req)); err != nil { | ||
| 261 | netConn.Close() | ||
| 262 | return nil, fmt.Errorf("websocket: send handshake: %w", err) | ||
| 263 | } | ||
| 264 | |||
| 265 | br := bufio.NewReader(netConn) | ||
| 266 | resp, err := http.ReadResponse(br, nil) | ||
| 267 | if err != nil { | ||
| 268 | netConn.Close() | ||
| 269 | return nil, fmt.Errorf("websocket: read handshake response: %w", err) | ||
| 270 | } | ||
| 271 | resp.Body.Close() | ||
| 272 | |||
| 273 | if resp.StatusCode != 101 { | ||
| 274 | netConn.Close() | ||
| 275 | return nil, fmt.Errorf("websocket: server returned status %d, want 101", resp.StatusCode) | ||
| 276 | } | ||
| 277 | if resp.Header.Get("Sec-WebSocket-Accept") != acceptKey(key) { | ||
| 278 | netConn.Close() | ||
| 279 | return nil, fmt.Errorf("websocket: bad Sec-WebSocket-Accept header") | ||
| 280 | } | ||
| 281 | |||
| 282 | return &Conn{rwc: netConn, br: br, client: true}, nil | ||
| 283 | } | ||
| 284 | |||
| 210 | // Accept performs the server-side WebSocket handshake, hijacking the HTTP | 285 | // Accept performs the server-side WebSocket handshake, hijacking the HTTP |
| 211 | // connection and returning a Conn ready for framed I/O. | 286 | // connection and returning a Conn ready for framed I/O. |
| 212 | func Accept(w http.ResponseWriter, r *http.Request) (*Conn, error) { | 287 | func Accept(w http.ResponseWriter, r *http.Request) (*Conn, error) { |
