blob: 7295a7b2dc9f36050889ff0683b7de5393dd226e [file] [log] [blame]
Earl Lee2e463fb2025-04-17 11:22:22 -07001package skabandclient
2
3import (
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 Crawshaw0ead54d2025-05-16 13:58:36 -070017 "math/rand/v2"
Earl Lee2e463fb2025-04-17 11:22:22 -070018 "net"
19 "net/http"
20 "net/url"
21 "os"
22 "path/filepath"
23 "strings"
Philip Zeyligere9eaf6c2025-05-19 16:14:39 -070024 "sync"
Earl Lee2e463fb2025-04-17 11:22:22 -070025 "sync/atomic"
26 "time"
27
David Crawshaw0ead54d2025-05-16 13:58:36 -070028 "github.com/richardlehane/crock32"
Earl Lee2e463fb2025-04-17 11:22:22 -070029 "golang.org/x/net/http2"
30)
31
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070032// SketchSession represents a sketch session with metadata
33type 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 Lee2e463fb2025-04-17 11:22:22 -070041
Philip Zeyligerc17ffe32025-06-05 19:49:13 -070042// SkabandClient provides HTTP client functionality for skaband server
43type SkabandClient struct {
44 addr string
45 publicKey string
46 client *http.Client
Earl Lee2e463fb2025-04-17 11:22:22 -070047}
48
49func 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 Zeyligerfe3e9f72025-04-24 09:02:05 -070072 if conn == nil {
73 return fmt.Errorf("skabandclient: nil connection")
74 }
Earl Lee2e463fb2025-04-17 11:22:22 -070075 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 Zeyligerfe3e9f72025-04-24 09:02:05 -070093 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 Lee2e463fb2025-04-17 11:22:22 -070099 }
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 Zeyligere9eaf6c2025-05-19 16:14:39 -0700121 // 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 Lee2e463fb2025-04-17 11:22:22 -0700137 server := &http2.Server{}
Philip Zeyligere9eaf6c2025-05-19 16:14:39 -0700138 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 Lee2e463fb2025-04-17 11:22:22 -0700147 server.ServeConn(conn, &http2.ServeConnOpts{
Philip Zeyligere9eaf6c2025-05-19 16:14:39 -0700148 Handler: h2,
Earl Lee2e463fb2025-04-17 11:22:22 -0700149 })
150
151 return nil
152}
153
154func 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
166func 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
174func 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 Crawshaw961cc9e2025-05-05 14:33:33 -0700182 if err != nil {
183 return nil, err
184 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700185 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 Crawshaw961cc9e2025-05-05 14:33:33 -0700199func Login(stdout io.Writer, privKey ed25519.PrivateKey, skabandAddr, sessionID, model string) (pubKey, apiURL, apiKey string, err error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700200 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 Crawshaw961cc9e2025-05-05 14:33:33 -0700210 req.Header.Set("X-Model", model)
Earl Lee2e463fb2025-04-17 11:22:22 -0700211 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
234func 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
244func 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 Crawshaw0ead54d2025-05-16 13:58:36 -0700260
261// NewSessionID generates a new 10-byte random Session ID.
262func 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 Zeyligerc17ffe32025-06-05 19:49:13 -0700270
Philip Zeyliger0113be52025-06-07 23:53:41 +0000271// Addr returns the skaband server address
272func (c *SkabandClient) Addr() string {
273 if c == nil {
274 return ""
275 }
276 return c.addr
277}
278
Philip Zeyligerc17ffe32025-06-05 19:49:13 -0700279// NewSkabandClient creates a new skaband client
280func 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
296func (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
336func (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.
371func (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}