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