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