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