| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 1 | package skabandclient |
| 2 | |
| 3 | import ( |
| 4 | "bufio" |
| 5 | "context" |
| 6 | "crypto/ed25519" |
| 7 | crand "crypto/rand" |
| 8 | "crypto/tls" |
| 9 | "crypto/x509" |
| 10 | "encoding/hex" |
| 11 | "encoding/json" |
| 12 | "encoding/pem" |
| 13 | "errors" |
| 14 | "fmt" |
| 15 | "io" |
| 16 | "log/slog" |
| David Crawshaw | 0ead54d | 2025-05-16 13:58:36 -0700 | [diff] [blame] | 17 | "math/rand/v2" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 18 | "net" |
| 19 | "net/http" |
| 20 | "net/url" |
| 21 | "os" |
| 22 | "path/filepath" |
| 23 | "strings" |
| Philip Zeyliger | e9eaf6c | 2025-05-19 16:14:39 -0700 | [diff] [blame] | 24 | "sync" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 25 | "sync/atomic" |
| 26 | "time" |
| 27 | |
| David Crawshaw | 0ead54d | 2025-05-16 13:58:36 -0700 | [diff] [blame] | 28 | "github.com/richardlehane/crock32" |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 29 | "golang.org/x/net/http2" |
| 30 | ) |
| 31 | |
| Philip Zeyliger | c17ffe3 | 2025-06-05 19:49:13 -0700 | [diff] [blame] | 32 | // SketchSession represents a sketch session with metadata |
| 33 | type SketchSession struct { |
| 34 | ID string `json:"id"` |
| 35 | Title string `json:"title"` |
| 36 | FirstMessage string `json:"first_message"` |
| 37 | LastMessage string `json:"last_message"` |
| 38 | FirstMessageDate time.Time `json:"first_message_date"` |
| 39 | LastMessageDate time.Time `json:"last_message_date"` |
| 40 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 41 | |
| Philip Zeyliger | c17ffe3 | 2025-06-05 19:49:13 -0700 | [diff] [blame] | 42 | // SkabandClient provides HTTP client functionality for skaband server |
| 43 | type SkabandClient struct { |
| 44 | addr string |
| 45 | publicKey string |
| 46 | client *http.Client |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 47 | } |
| 48 | |
| 49 | func DialAndServe(ctx context.Context, hostURL, sessionID, clientPubKey string, h http.Handler) (err error) { |
| 50 | // Connect to the server. |
| 51 | var conn net.Conn |
| 52 | if strings.HasPrefix(hostURL, "https://") { |
| 53 | u, err := url.Parse(hostURL) |
| 54 | if err != nil { |
| 55 | return err |
| 56 | } |
| 57 | port := u.Port() |
| 58 | if port == "" { |
| 59 | port = "443" |
| 60 | } |
| 61 | dialer := tls.Dialer{} |
| 62 | conn, err = dialer.DialContext(ctx, "tcp4", u.Host+":"+port) |
| 63 | } else if strings.HasPrefix(hostURL, "http://") { |
| 64 | dialer := net.Dialer{} |
| 65 | conn, err = dialer.DialContext(ctx, "tcp4", strings.TrimPrefix(hostURL, "http://")) |
| 66 | } else { |
| 67 | return fmt.Errorf("skabandclient.Dial: bad url, needs to be http or https: %s", hostURL) |
| 68 | } |
| 69 | if err != nil { |
| 70 | return fmt.Errorf("skabandclient: %w", err) |
| 71 | } |
| Philip Zeyliger | fe3e9f7 | 2025-04-24 09:02:05 -0700 | [diff] [blame] | 72 | if conn == nil { |
| 73 | return fmt.Errorf("skabandclient: nil connection") |
| 74 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 75 | defer conn.Close() |
| 76 | |
| 77 | // "Upgrade" our connection, like a WebSocket does. |
| 78 | req, err := http.NewRequest("POST", hostURL+"/attach", nil) |
| 79 | if err != nil { |
| 80 | return fmt.Errorf("skabandclient.Dial: /attach: %w", err) |
| 81 | } |
| 82 | req.Header.Set("Connection", "Upgrade") |
| 83 | req.Header.Set("Upgrade", "ska") |
| 84 | req.Header.Set("Session-ID", sessionID) |
| 85 | req.Header.Set("Public-Key", clientPubKey) |
| 86 | |
| 87 | if err := req.Write(conn); err != nil { |
| 88 | return fmt.Errorf("skabandclient.Dial: write upgrade request: %w", err) |
| 89 | } |
| 90 | reader := bufio.NewReader(conn) |
| 91 | resp, err := http.ReadResponse(reader, req) |
| 92 | if err != nil { |
| Philip Zeyliger | fe3e9f7 | 2025-04-24 09:02:05 -0700 | [diff] [blame] | 93 | if resp != nil { |
| 94 | b, _ := io.ReadAll(resp.Body) |
| 95 | return fmt.Errorf("skabandclient.Dial: read upgrade response: %w: %s", err, b) |
| 96 | } else { |
| 97 | return fmt.Errorf("skabandclient.Dial: read upgrade response: %w", err) |
| 98 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 99 | } |
| 100 | defer resp.Body.Close() |
| 101 | if resp.StatusCode != http.StatusSwitchingProtocols { |
| 102 | b, _ := io.ReadAll(resp.Body) |
| 103 | return fmt.Errorf("skabandclient.Dial: unexpected status code: %d: %s", resp.StatusCode, b) |
| 104 | } |
| 105 | if !strings.Contains(resp.Header.Get("Upgrade"), "ska") { |
| 106 | return errors.New("skabandclient.Dial: server did not upgrade to ska protocol") |
| 107 | } |
| 108 | if buf := reader.Buffered(); buf > 0 { |
| 109 | peek, _ := reader.Peek(buf) |
| 110 | return fmt.Errorf("skabandclient.Dial: buffered read after upgrade response: %d: %q", buf, string(peek)) |
| 111 | } |
| 112 | |
| 113 | // Send Magic. |
| 114 | const magic = "skaband\n" |
| 115 | if _, err := conn.Write([]byte(magic)); err != nil { |
| 116 | return fmt.Errorf("skabandclient.Dial: failed to send upgrade init message: %w", err) |
| 117 | } |
| 118 | |
| 119 | // We have a TCP connection to the server and have been through the upgrade dance. |
| 120 | // Now we can run an HTTP server over that connection ("inverting" the HTTP flow). |
| Philip Zeyliger | e9eaf6c | 2025-05-19 16:14:39 -0700 | [diff] [blame] | 121 | // Skaband is expected to heartbeat within 60 seconds. |
| 122 | lastHeartbeat := time.Now() |
| 123 | mu := sync.Mutex{} |
| 124 | go func() { |
| 125 | for { |
| 126 | time.Sleep(5 * time.Second) |
| 127 | mu.Lock() |
| 128 | if time.Since(lastHeartbeat) > 60*time.Second { |
| 129 | mu.Unlock() |
| 130 | conn.Close() |
| 131 | slog.Info("skaband heartbeat timeout") |
| 132 | return |
| 133 | } |
| 134 | mu.Unlock() |
| 135 | } |
| 136 | }() |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 137 | server := &http2.Server{} |
| Philip Zeyliger | e9eaf6c | 2025-05-19 16:14:39 -0700 | [diff] [blame] | 138 | h2 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 139 | if r.URL.Path == "/skabandheartbeat" { |
| 140 | w.WriteHeader(http.StatusOK) |
| 141 | mu.Lock() |
| 142 | defer mu.Unlock() |
| 143 | lastHeartbeat = time.Now() |
| 144 | } |
| 145 | h.ServeHTTP(w, r) |
| 146 | }) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 147 | server.ServeConn(conn, &http2.ServeConnOpts{ |
| Philip Zeyliger | e9eaf6c | 2025-05-19 16:14:39 -0700 | [diff] [blame] | 148 | Handler: h2, |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 149 | }) |
| 150 | |
| 151 | return nil |
| 152 | } |
| 153 | |
| 154 | func decodePrivKey(privData []byte) (ed25519.PrivateKey, error) { |
| 155 | privBlock, _ := pem.Decode(privData) |
| 156 | if privBlock == nil || privBlock.Type != "PRIVATE KEY" { |
| 157 | return nil, fmt.Errorf("no valid private key block found") |
| 158 | } |
| 159 | parsedPriv, err := x509.ParsePKCS8PrivateKey(privBlock.Bytes) |
| 160 | if err != nil { |
| 161 | return nil, err |
| 162 | } |
| 163 | return parsedPriv.(ed25519.PrivateKey), nil |
| 164 | } |
| 165 | |
| 166 | func encodePrivateKey(privKey ed25519.PrivateKey) ([]byte, error) { |
| 167 | privBytes, err := x509.MarshalPKCS8PrivateKey(privKey) |
| 168 | if err != nil { |
| 169 | return nil, err |
| 170 | } |
| 171 | return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}), nil |
| 172 | } |
| 173 | |
| 174 | func LoadOrCreatePrivateKey(path string) (ed25519.PrivateKey, error) { |
| 175 | privData, err := os.ReadFile(path) |
| 176 | if os.IsNotExist(err) { |
| 177 | _, privKey, err := ed25519.GenerateKey(crand.Reader) |
| 178 | if err != nil { |
| 179 | return nil, err |
| 180 | } |
| 181 | b, err := encodePrivateKey(privKey) |
| David Crawshaw | 961cc9e | 2025-05-05 14:33:33 -0700 | [diff] [blame] | 182 | if err != nil { |
| 183 | return nil, err |
| 184 | } |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 185 | if err := os.WriteFile(path, b, 0o600); err != nil { |
| 186 | return nil, err |
| 187 | } |
| 188 | return privKey, nil |
| 189 | } else if err != nil { |
| 190 | return nil, fmt.Errorf("read key failed: %w", err) |
| 191 | } |
| 192 | key, err := decodePrivKey(privData) |
| 193 | if err != nil { |
| 194 | return nil, fmt.Errorf("%s: %w", path, err) |
| 195 | } |
| 196 | return key, nil |
| 197 | } |
| 198 | |
| David Crawshaw | 961cc9e | 2025-05-05 14:33:33 -0700 | [diff] [blame] | 199 | func Login(stdout io.Writer, privKey ed25519.PrivateKey, skabandAddr, sessionID, model string) (pubKey, apiURL, apiKey string, err error) { |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 200 | sig := ed25519.Sign(privKey, []byte(sessionID)) |
| 201 | |
| 202 | req, err := http.NewRequest("POST", skabandAddr+"/authclient", nil) |
| 203 | if err != nil { |
| 204 | return "", "", "", err |
| 205 | } |
| 206 | pubKey = hex.EncodeToString(privKey.Public().(ed25519.PublicKey)) |
| 207 | req.Header.Set("Public-Key", pubKey) |
| 208 | req.Header.Set("Session-ID", sessionID) |
| 209 | req.Header.Set("Session-ID-Sig", hex.EncodeToString(sig)) |
| David Crawshaw | 961cc9e | 2025-05-05 14:33:33 -0700 | [diff] [blame] | 210 | req.Header.Set("X-Model", model) |
| Earl Lee | 2e463fb | 2025-04-17 11:22:22 -0700 | [diff] [blame] | 211 | resp, err := http.DefaultClient.Do(req) |
| 212 | if err != nil { |
| 213 | return "", "", "", fmt.Errorf("skaband login: %w", err) |
| 214 | } |
| 215 | apiURL = resp.Header.Get("X-API-URL") |
| 216 | apiKey = resp.Header.Get("X-API-Key") |
| 217 | defer resp.Body.Close() |
| 218 | _, err = io.Copy(stdout, resp.Body) |
| 219 | if err != nil { |
| 220 | return "", "", "", fmt.Errorf("skaband login: %w", err) |
| 221 | } |
| 222 | if resp.StatusCode != 200 { |
| 223 | return "", "", "", fmt.Errorf("skaband login failed: %d", resp.StatusCode) |
| 224 | } |
| 225 | if apiURL == "" { |
| 226 | return "", "", "", fmt.Errorf("skaband returned no api url") |
| 227 | } |
| 228 | if apiKey == "" { |
| 229 | return "", "", "", fmt.Errorf("skaband returned no api key") |
| 230 | } |
| 231 | return pubKey, apiURL, apiKey, nil |
| 232 | } |
| 233 | |
| 234 | func DefaultKeyPath() string { |
| 235 | homeDir, err := os.UserHomeDir() |
| 236 | if err != nil { |
| 237 | panic(err) |
| 238 | } |
| 239 | cacheDir := filepath.Join(homeDir, ".cache", "sketch") |
| 240 | os.MkdirAll(cacheDir, 0o777) |
| 241 | return filepath.Join(cacheDir, "sketch.ed25519") |
| 242 | } |
| 243 | |
| 244 | func LocalhostToDockerInternal(skabandURL string) (string, error) { |
| 245 | u, err := url.Parse(skabandURL) |
| 246 | if err != nil { |
| 247 | return "", fmt.Errorf("localhostToDockerInternal: %w", err) |
| 248 | } |
| 249 | switch u.Hostname() { |
| 250 | case "localhost", "127.0.0.1": |
| 251 | host := "host.docker.internal" |
| 252 | if port := u.Port(); port != "" { |
| 253 | host += ":" + port |
| 254 | } |
| 255 | u.Host = host |
| 256 | return u.String(), nil |
| 257 | } |
| 258 | return skabandURL, nil |
| 259 | } |
| David Crawshaw | 0ead54d | 2025-05-16 13:58:36 -0700 | [diff] [blame] | 260 | |
| 261 | // NewSessionID generates a new 10-byte random Session ID. |
| 262 | func NewSessionID() string { |
| 263 | u1, u2 := rand.Uint64(), rand.Uint64N(1<<16) |
| 264 | s := crock32.Encode(u1) + crock32.Encode(uint64(u2)) |
| 265 | if len(s) < 16 { |
| 266 | s += strings.Repeat("0", 16-len(s)) |
| 267 | } |
| 268 | return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16] |
| 269 | } |
| Philip Zeyliger | c17ffe3 | 2025-06-05 19:49:13 -0700 | [diff] [blame] | 270 | |
| Philip Zeyliger | 0113be5 | 2025-06-07 23:53:41 +0000 | [diff] [blame] | 271 | // Addr returns the skaband server address |
| 272 | func (c *SkabandClient) Addr() string { |
| 273 | if c == nil { |
| 274 | return "" |
| 275 | } |
| 276 | return c.addr |
| 277 | } |
| 278 | |
| Philip Zeyliger | c17ffe3 | 2025-06-05 19:49:13 -0700 | [diff] [blame] | 279 | // NewSkabandClient creates a new skaband client |
| 280 | func NewSkabandClient(addr, publicKey string) *SkabandClient { |
| 281 | // Apply localhost-to-docker-internal transformation if needed |
| 282 | if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker |
| 283 | if newAddr, err := LocalhostToDockerInternal(addr); err == nil { |
| 284 | addr = newAddr |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | return &SkabandClient{ |
| 289 | addr: addr, |
| 290 | publicKey: publicKey, |
| 291 | client: &http.Client{Timeout: 30 * time.Second}, |
| 292 | } |
| 293 | } |
| 294 | |
| 295 | // ListRecentSketchSessionsMarkdown returns recent sessions as a markdown table |
| 296 | func (c *SkabandClient) ListRecentSketchSessionsMarkdown(ctx context.Context, currentRepo, sessionID string) (string, error) { |
| 297 | if c == nil { |
| 298 | return "", fmt.Errorf("SkabandClient is nil") |
| 299 | } |
| 300 | |
| 301 | // Build URL with query parameters |
| 302 | baseURL := c.addr + "/api/sessions/recent" |
| 303 | if currentRepo != "" { |
| 304 | baseURL += "?repo=" + url.QueryEscape(currentRepo) |
| 305 | } |
| 306 | |
| 307 | req, err := http.NewRequestWithContext(ctx, "GET", baseURL, nil) |
| 308 | if err != nil { |
| 309 | return "", fmt.Errorf("failed to create request: %w", err) |
| 310 | } |
| 311 | |
| 312 | // Add headers |
| 313 | req.Header.Set("Public-Key", c.publicKey) |
| 314 | req.Header.Set("Session-ID", sessionID) |
| 315 | |
| 316 | resp, err := c.client.Do(req) |
| 317 | if err != nil { |
| 318 | return "", fmt.Errorf("failed to make request: %w", err) |
| 319 | } |
| 320 | defer resp.Body.Close() |
| 321 | |
| 322 | if resp.StatusCode != http.StatusOK { |
| 323 | body, _ := io.ReadAll(resp.Body) |
| 324 | return "", fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) |
| 325 | } |
| 326 | |
| 327 | body, err := io.ReadAll(resp.Body) |
| 328 | if err != nil { |
| 329 | return "", fmt.Errorf("failed to read response: %w", err) |
| 330 | } |
| 331 | |
| 332 | return string(body), nil |
| 333 | } |
| 334 | |
| 335 | // ReadSketchSession reads the full details of a specific session and returns formatted text |
| 336 | func (c *SkabandClient) ReadSketchSession(ctx context.Context, targetSessionID, originSessionID string) (*string, error) { |
| 337 | if c == nil { |
| 338 | return nil, fmt.Errorf("SkabandClient is nil") |
| 339 | } |
| 340 | |
| 341 | req, err := http.NewRequestWithContext(ctx, "GET", c.addr+"/api/sessions/"+targetSessionID, nil) |
| 342 | if err != nil { |
| 343 | return nil, fmt.Errorf("failed to create request: %w", err) |
| 344 | } |
| 345 | |
| 346 | // Add headers |
| 347 | req.Header.Set("Public-Key", c.publicKey) |
| 348 | req.Header.Set("Session-ID", originSessionID) |
| 349 | |
| 350 | resp, err := c.client.Do(req) |
| 351 | if err != nil { |
| 352 | return nil, fmt.Errorf("failed to make request: %w", err) |
| 353 | } |
| 354 | defer resp.Body.Close() |
| 355 | |
| 356 | if resp.StatusCode != http.StatusOK { |
| 357 | body, _ := io.ReadAll(resp.Body) |
| 358 | return nil, fmt.Errorf("request failed with status %d: %s", resp.StatusCode, string(body)) |
| 359 | } |
| 360 | |
| 361 | body, err := io.ReadAll(resp.Body) |
| 362 | if err != nil { |
| 363 | return nil, fmt.Errorf("failed to read response: %w", err) |
| 364 | } |
| 365 | |
| 366 | response := string(body) |
| 367 | return &response, nil |
| 368 | } |
| 369 | |
| 370 | // DialAndServeLoop is a redial loop around DialAndServe. |
| 371 | func (c *SkabandClient) DialAndServeLoop(ctx context.Context, sessionID string, srv http.Handler, connectFn func(connected bool)) { |
| 372 | skabandAddr := c.addr |
| 373 | clientPubKey := c.publicKey |
| 374 | |
| 375 | if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker |
| 376 | if addr, err := LocalhostToDockerInternal(skabandAddr); err == nil { |
| 377 | skabandAddr = addr |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | var skabandConnected atomic.Bool |
| 382 | skabandHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 383 | if r.URL.Path == "/skabandinit" { |
| 384 | b, err := io.ReadAll(r.Body) |
| 385 | if err != nil { |
| 386 | fmt.Printf("skabandinit failed: %v\n", err) |
| 387 | return |
| 388 | } |
| 389 | m := map[string]string{} |
| 390 | if err := json.Unmarshal(b, &m); err != nil { |
| 391 | fmt.Printf("skabandinit failed: %v\n", err) |
| 392 | return |
| 393 | } |
| 394 | skabandConnected.Store(true) |
| 395 | if connectFn != nil { |
| 396 | connectFn(true) |
| 397 | } |
| 398 | return |
| 399 | } |
| 400 | srv.ServeHTTP(w, r) |
| 401 | }) |
| 402 | |
| 403 | var lastErrLog time.Time |
| 404 | for { |
| 405 | if err := DialAndServe(ctx, skabandAddr, sessionID, clientPubKey, skabandHandler); err != nil { |
| 406 | // NOTE: *just* backoff the logging. Backing off dialing |
| 407 | // is bad UX. Doing so saves negligible CPU and doing so |
| 408 | // without hurting UX requires interrupting the backoff with |
| 409 | // wake-from-sleep and network-up events from the OS, |
| 410 | // which are a pain to plumb. |
| 411 | if time.Since(lastErrLog) > 1*time.Minute { |
| 412 | slog.DebugContext(ctx, "skaband connection failed", "err", err) |
| 413 | lastErrLog = time.Now() |
| 414 | } |
| 415 | } |
| 416 | if skabandConnected.CompareAndSwap(true, false) { |
| 417 | if connectFn != nil { |
| 418 | connectFn(false) |
| 419 | } |
| 420 | } |
| 421 | time.Sleep(200 * time.Millisecond) |
| 422 | } |
| 423 | } |