blob: 25608895b528d928d334dba873adfb2fd9c7e43a [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// SkabandClient provides HTTP client functionality for skaband server
35type SkabandClient struct {
36 addr string
37 publicKey string
38 client *http.Client
Earl Lee2e463fb2025-04-17 11:22:22 -070039}
40
Philip Zeyligerf2814ea2025-06-30 10:16:50 -070041func DialAndServe(ctx context.Context, hostURL, sessionID, clientPubKey string, sessionSecret string, h http.Handler) (err error) {
Earl Lee2e463fb2025-04-17 11:22:22 -070042 // Connect to the server.
43 var conn net.Conn
44 if strings.HasPrefix(hostURL, "https://") {
45 u, err := url.Parse(hostURL)
46 if err != nil {
47 return err
48 }
49 port := u.Port()
50 if port == "" {
51 port = "443"
52 }
53 dialer := tls.Dialer{}
54 conn, err = dialer.DialContext(ctx, "tcp4", u.Host+":"+port)
55 } else if strings.HasPrefix(hostURL, "http://") {
56 dialer := net.Dialer{}
57 conn, err = dialer.DialContext(ctx, "tcp4", strings.TrimPrefix(hostURL, "http://"))
58 } else {
59 return fmt.Errorf("skabandclient.Dial: bad url, needs to be http or https: %s", hostURL)
60 }
61 if err != nil {
62 return fmt.Errorf("skabandclient: %w", err)
63 }
Philip Zeyligerfe3e9f72025-04-24 09:02:05 -070064 if conn == nil {
65 return fmt.Errorf("skabandclient: nil connection")
66 }
Earl Lee2e463fb2025-04-17 11:22:22 -070067 defer conn.Close()
68
69 // "Upgrade" our connection, like a WebSocket does.
70 req, err := http.NewRequest("POST", hostURL+"/attach", nil)
71 if err != nil {
72 return fmt.Errorf("skabandclient.Dial: /attach: %w", err)
73 }
74 req.Header.Set("Connection", "Upgrade")
75 req.Header.Set("Upgrade", "ska")
76 req.Header.Set("Session-ID", sessionID)
77 req.Header.Set("Public-Key", clientPubKey)
Philip Zeyligerf2814ea2025-06-30 10:16:50 -070078 req.Header.Set("Session-Secret", sessionSecret)
Earl Lee2e463fb2025-04-17 11:22:22 -070079
80 if err := req.Write(conn); err != nil {
81 return fmt.Errorf("skabandclient.Dial: write upgrade request: %w", err)
82 }
83 reader := bufio.NewReader(conn)
84 resp, err := http.ReadResponse(reader, req)
85 if err != nil {
Philip Zeyligerfe3e9f72025-04-24 09:02:05 -070086 if resp != nil {
87 b, _ := io.ReadAll(resp.Body)
88 return fmt.Errorf("skabandclient.Dial: read upgrade response: %w: %s", err, b)
89 } else {
90 return fmt.Errorf("skabandclient.Dial: read upgrade response: %w", err)
91 }
Earl Lee2e463fb2025-04-17 11:22:22 -070092 }
93 defer resp.Body.Close()
94 if resp.StatusCode != http.StatusSwitchingProtocols {
95 b, _ := io.ReadAll(resp.Body)
96 return fmt.Errorf("skabandclient.Dial: unexpected status code: %d: %s", resp.StatusCode, b)
97 }
98 if !strings.Contains(resp.Header.Get("Upgrade"), "ska") {
99 return errors.New("skabandclient.Dial: server did not upgrade to ska protocol")
100 }
101 if buf := reader.Buffered(); buf > 0 {
102 peek, _ := reader.Peek(buf)
103 return fmt.Errorf("skabandclient.Dial: buffered read after upgrade response: %d: %q", buf, string(peek))
104 }
105
106 // Send Magic.
107 const magic = "skaband\n"
108 if _, err := conn.Write([]byte(magic)); err != nil {
109 return fmt.Errorf("skabandclient.Dial: failed to send upgrade init message: %w", err)
110 }
111
112 // We have a TCP connection to the server and have been through the upgrade dance.
113 // Now we can run an HTTP server over that connection ("inverting" the HTTP flow).
Philip Zeyligere9eaf6c2025-05-19 16:14:39 -0700114 // Skaband is expected to heartbeat within 60 seconds.
115 lastHeartbeat := time.Now()
116 mu := sync.Mutex{}
117 go func() {
118 for {
119 time.Sleep(5 * time.Second)
120 mu.Lock()
121 if time.Since(lastHeartbeat) > 60*time.Second {
122 mu.Unlock()
123 conn.Close()
124 slog.Info("skaband heartbeat timeout")
125 return
126 }
127 mu.Unlock()
128 }
129 }()
Earl Lee2e463fb2025-04-17 11:22:22 -0700130 server := &http2.Server{}
Philip Zeyligere9eaf6c2025-05-19 16:14:39 -0700131 h2 := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
132 if r.URL.Path == "/skabandheartbeat" {
133 w.WriteHeader(http.StatusOK)
134 mu.Lock()
135 defer mu.Unlock()
136 lastHeartbeat = time.Now()
137 }
138 h.ServeHTTP(w, r)
139 })
Earl Lee2e463fb2025-04-17 11:22:22 -0700140 server.ServeConn(conn, &http2.ServeConnOpts{
Philip Zeyligere9eaf6c2025-05-19 16:14:39 -0700141 Handler: h2,
Earl Lee2e463fb2025-04-17 11:22:22 -0700142 })
143
144 return nil
145}
146
147func decodePrivKey(privData []byte) (ed25519.PrivateKey, error) {
148 privBlock, _ := pem.Decode(privData)
149 if privBlock == nil || privBlock.Type != "PRIVATE KEY" {
150 return nil, fmt.Errorf("no valid private key block found")
151 }
152 parsedPriv, err := x509.ParsePKCS8PrivateKey(privBlock.Bytes)
153 if err != nil {
154 return nil, err
155 }
156 return parsedPriv.(ed25519.PrivateKey), nil
157}
158
159func encodePrivateKey(privKey ed25519.PrivateKey) ([]byte, error) {
160 privBytes, err := x509.MarshalPKCS8PrivateKey(privKey)
161 if err != nil {
162 return nil, err
163 }
164 return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}), nil
165}
166
167func LoadOrCreatePrivateKey(path string) (ed25519.PrivateKey, error) {
168 privData, err := os.ReadFile(path)
169 if os.IsNotExist(err) {
170 _, privKey, err := ed25519.GenerateKey(crand.Reader)
171 if err != nil {
172 return nil, err
173 }
174 b, err := encodePrivateKey(privKey)
David Crawshaw961cc9e2025-05-05 14:33:33 -0700175 if err != nil {
176 return nil, err
177 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700178 if err := os.WriteFile(path, b, 0o600); err != nil {
179 return nil, err
180 }
181 return privKey, nil
182 } else if err != nil {
183 return nil, fmt.Errorf("read key failed: %w", err)
184 }
185 key, err := decodePrivKey(privData)
186 if err != nil {
187 return nil, fmt.Errorf("%s: %w", path, err)
188 }
189 return key, nil
190}
191
Josh Bleecher Snyder75b45f52025-07-17 15:47:32 -0700192// Login connects to skaband and authenticates the user.
193// If skabandAddr is empty, it returns the public key without contacting a server.
194// It is the caller's responsibility to set the API URL and key in this case.
David Crawshaw961cc9e2025-05-05 14:33:33 -0700195func Login(stdout io.Writer, privKey ed25519.PrivateKey, skabandAddr, sessionID, model string) (pubKey, apiURL, apiKey string, err error) {
Earl Lee2e463fb2025-04-17 11:22:22 -0700196 sig := ed25519.Sign(privKey, []byte(sessionID))
Josh Bleecher Snyder75b45f52025-07-17 15:47:32 -0700197 pubKey = hex.EncodeToString(privKey.Public().(ed25519.PublicKey))
198 if skabandAddr == "" {
199 return pubKey, "", "", nil
200 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700201
202 req, err := http.NewRequest("POST", skabandAddr+"/authclient", nil)
203 if err != nil {
204 return "", "", "", err
205 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700206 req.Header.Set("Public-Key", pubKey)
207 req.Header.Set("Session-ID", sessionID)
208 req.Header.Set("Session-ID-Sig", hex.EncodeToString(sig))
David Crawshaw961cc9e2025-05-05 14:33:33 -0700209 req.Header.Set("X-Model", model)
Earl Lee2e463fb2025-04-17 11:22:22 -0700210 resp, err := http.DefaultClient.Do(req)
211 if err != nil {
212 return "", "", "", fmt.Errorf("skaband login: %w", err)
213 }
214 apiURL = resp.Header.Get("X-API-URL")
215 apiKey = resp.Header.Get("X-API-Key")
216 defer resp.Body.Close()
217 _, err = io.Copy(stdout, resp.Body)
218 if err != nil {
219 return "", "", "", fmt.Errorf("skaband login: %w", err)
220 }
221 if resp.StatusCode != 200 {
222 return "", "", "", fmt.Errorf("skaband login failed: %d", resp.StatusCode)
223 }
224 if apiURL == "" {
225 return "", "", "", fmt.Errorf("skaband returned no api url")
226 }
227 if apiKey == "" {
228 return "", "", "", fmt.Errorf("skaband returned no api key")
229 }
230 return pubKey, apiURL, apiKey, nil
231}
232
Josh Bleecher Snyder75b45f52025-07-17 15:47:32 -0700233func DefaultKeyPath(skabandAddr string) string {
Earl Lee2e463fb2025-04-17 11:22:22 -0700234 homeDir, err := os.UserHomeDir()
235 if err != nil {
236 panic(err)
237 }
238 cacheDir := filepath.Join(homeDir, ".cache", "sketch")
Josh Bleecher Snyder75b45f52025-07-17 15:47:32 -0700239 if skabandAddr != "https://sketch.dev" { // main server gets "root" cache dir, for backwards compatibility
240 h := sha256.Sum256([]byte(skabandAddr))
241 cacheDir = filepath.Join(cacheDir, hex.EncodeToString(h[:8]))
242 }
Earl Lee2e463fb2025-04-17 11:22:22 -0700243 os.MkdirAll(cacheDir, 0o777)
244 return filepath.Join(cacheDir, "sketch.ed25519")
245}
246
247func LocalhostToDockerInternal(skabandURL string) (string, error) {
248 u, err := url.Parse(skabandURL)
249 if err != nil {
250 return "", fmt.Errorf("localhostToDockerInternal: %w", err)
251 }
252 switch u.Hostname() {
253 case "localhost", "127.0.0.1":
254 host := "host.docker.internal"
255 if port := u.Port(); port != "" {
256 host += ":" + port
257 }
258 u.Host = host
259 return u.String(), nil
260 }
261 return skabandURL, nil
262}
David Crawshaw0ead54d2025-05-16 13:58:36 -0700263
264// NewSessionID generates a new 10-byte random Session ID.
265func NewSessionID() string {
266 u1, u2 := rand.Uint64(), rand.Uint64N(1<<16)
267 s := crock32.Encode(u1) + crock32.Encode(uint64(u2))
268 if len(s) < 16 {
269 s += strings.Repeat("0", 16-len(s))
270 }
271 return s[0:4] + "-" + s[4:8] + "-" + s[8:12] + "-" + s[12:16]
272}
Philip Zeyligerc17ffe32025-06-05 19:49:13 -0700273
Philip Zeyliger59789952025-06-28 20:02:23 -0700274// Regex pattern for SessionID format: xxxx-xxxx-xxxx-xxxx
275// Where x is a valid Crockford Base32 character (0-9, A-H, J-N, P-Z)
276// Case-insensitive match
277var sessionIdRegexp = regexp.MustCompile(
278 "^[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}")
279
280func ValidateSessionID(sessionID string) bool {
281 return sessionIdRegexp.MatchString(sessionID)
282}
283
Philip Zeyliger0113be52025-06-07 23:53:41 +0000284// Addr returns the skaband server address
285func (c *SkabandClient) Addr() string {
286 if c == nil {
287 return ""
288 }
289 return c.addr
290}
291
Philip Zeyligerc17ffe32025-06-05 19:49:13 -0700292// NewSkabandClient creates a new skaband client
293func NewSkabandClient(addr, publicKey string) *SkabandClient {
294 // Apply localhost-to-docker-internal transformation if needed
295 if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
296 if newAddr, err := LocalhostToDockerInternal(addr); err == nil {
297 addr = newAddr
298 }
299 }
300
301 return &SkabandClient{
302 addr: addr,
303 publicKey: publicKey,
304 client: &http.Client{Timeout: 30 * time.Second},
305 }
306}
307
Philip Zeyligerc17ffe32025-06-05 19:49:13 -0700308// DialAndServeLoop is a redial loop around DialAndServe.
Philip Zeyligerf2814ea2025-06-30 10:16:50 -0700309func (c *SkabandClient) DialAndServeLoop(ctx context.Context, sessionID string, sessionSecret string, srv http.Handler, connectFn func(connected bool)) {
Philip Zeyligerc17ffe32025-06-05 19:49:13 -0700310 skabandAddr := c.addr
311 clientPubKey := c.publicKey
312
313 if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
314 if addr, err := LocalhostToDockerInternal(skabandAddr); err == nil {
315 skabandAddr = addr
316 }
317 }
318
319 var skabandConnected atomic.Bool
320 skabandHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
321 if r.URL.Path == "/skabandinit" {
322 b, err := io.ReadAll(r.Body)
323 if err != nil {
324 fmt.Printf("skabandinit failed: %v\n", err)
325 return
326 }
327 m := map[string]string{}
328 if err := json.Unmarshal(b, &m); err != nil {
329 fmt.Printf("skabandinit failed: %v\n", err)
330 return
331 }
332 skabandConnected.Store(true)
333 if connectFn != nil {
334 connectFn(true)
335 }
336 return
337 }
338 srv.ServeHTTP(w, r)
339 })
340
341 var lastErrLog time.Time
342 for {
Philip Zeyligerf2814ea2025-06-30 10:16:50 -0700343 if err := DialAndServe(ctx, skabandAddr, sessionID, clientPubKey, sessionSecret, skabandHandler); err != nil {
Philip Zeyligerc17ffe32025-06-05 19:49:13 -0700344 // NOTE: *just* backoff the logging. Backing off dialing
345 // is bad UX. Doing so saves negligible CPU and doing so
346 // without hurting UX requires interrupting the backoff with
347 // wake-from-sleep and network-up events from the OS,
348 // which are a pain to plumb.
349 if time.Since(lastErrLog) > 1*time.Minute {
350 slog.DebugContext(ctx, "skaband connection failed", "err", err)
351 lastErrLog = time.Now()
352 }
353 }
354 if skabandConnected.CompareAndSwap(true, false) {
355 if connectFn != nil {
356 connectFn(false)
357 }
358 }
359 time.Sleep(200 * time.Millisecond)
360 }
361}