Initial commit
diff --git a/skabandclient/skabandclient.go b/skabandclient/skabandclient.go
new file mode 100644
index 0000000..1f1e92b
--- /dev/null
+++ b/skabandclient/skabandclient.go
@@ -0,0 +1,255 @@
+package skabandclient
+
+import (
+	"bufio"
+	"context"
+	"crypto/ed25519"
+	crand "crypto/rand"
+	"crypto/tls"
+	"crypto/x509"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"io"
+	"log/slog"
+	"net"
+	"net/http"
+	"net/url"
+	"os"
+	"path/filepath"
+	"strings"
+	"sync/atomic"
+	"time"
+
+	"golang.org/x/net/http2"
+)
+
+// DialAndServeLoop is a redial loop around DialAndServe.
+func DialAndServeLoop(ctx context.Context, skabandAddr, sessionID, clientPubKey string, srv http.Handler, connectFn func(connected bool)) {
+	if _, err := os.Stat("/.dockerenv"); err == nil { // inDocker
+		if addr, err := LocalhostToDockerInternal(skabandAddr); err == nil {
+			skabandAddr = addr
+		}
+	}
+
+	var skabandConnected atomic.Bool
+	skabandHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+		if r.URL.Path == "/skabandinit" {
+			b, err := io.ReadAll(r.Body)
+			if err != nil {
+				fmt.Printf("skabandinit failed: %v\n", err)
+				return
+			}
+			m := map[string]string{}
+			if err := json.Unmarshal(b, &m); err != nil {
+				fmt.Printf("skabandinit failed: %v\n", err)
+				return
+			}
+			skabandConnected.Store(true)
+			if connectFn != nil {
+				connectFn(true)
+			}
+			return
+		}
+		srv.ServeHTTP(w, r)
+	})
+
+	var lastErrLog time.Time
+	for {
+		if err := DialAndServe(ctx, skabandAddr, sessionID, clientPubKey, skabandHandler); err != nil {
+			// NOTE: *just* backoff the logging. Backing off dialing
+			// is bad UX. Doing so saves negligble CPU and doing so
+			// without huring UX requires interrupting the backoff with
+			// wake-from-sleep and network-up events from the OS,
+			// which are a pain to plumb.
+			if time.Since(lastErrLog) > 1*time.Minute {
+				slog.DebugContext(ctx, "skaband connection failed", "err", err)
+				lastErrLog = time.Now()
+			}
+		}
+		if skabandConnected.CompareAndSwap(true, false) {
+			if connectFn != nil {
+				connectFn(false)
+			}
+		}
+		time.Sleep(200 * time.Millisecond)
+	}
+}
+
+func DialAndServe(ctx context.Context, hostURL, sessionID, clientPubKey string, h http.Handler) (err error) {
+	// Connect to the server.
+	var conn net.Conn
+	if strings.HasPrefix(hostURL, "https://") {
+		u, err := url.Parse(hostURL)
+		if err != nil {
+			return err
+		}
+		port := u.Port()
+		if port == "" {
+			port = "443"
+		}
+		dialer := tls.Dialer{}
+		conn, err = dialer.DialContext(ctx, "tcp4", u.Host+":"+port)
+	} else if strings.HasPrefix(hostURL, "http://") {
+		dialer := net.Dialer{}
+		conn, err = dialer.DialContext(ctx, "tcp4", strings.TrimPrefix(hostURL, "http://"))
+	} else {
+		return fmt.Errorf("skabandclient.Dial: bad url, needs to be http or https: %s", hostURL)
+	}
+	if err != nil {
+		return fmt.Errorf("skabandclient: %w", err)
+	}
+	defer conn.Close()
+
+	// "Upgrade" our connection, like a WebSocket does.
+	req, err := http.NewRequest("POST", hostURL+"/attach", nil)
+	if err != nil {
+		return fmt.Errorf("skabandclient.Dial: /attach: %w", err)
+	}
+	req.Header.Set("Connection", "Upgrade")
+	req.Header.Set("Upgrade", "ska")
+	req.Header.Set("Session-ID", sessionID)
+	req.Header.Set("Public-Key", clientPubKey)
+
+	if err := req.Write(conn); err != nil {
+		return fmt.Errorf("skabandclient.Dial: write upgrade request: %w", err)
+	}
+	reader := bufio.NewReader(conn)
+	resp, err := http.ReadResponse(reader, req)
+	if err != nil {
+		b, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("skabandclient.Dial: read upgrade response: %w: %s", err, b)
+	}
+	defer resp.Body.Close()
+	if resp.StatusCode != http.StatusSwitchingProtocols {
+		b, _ := io.ReadAll(resp.Body)
+		return fmt.Errorf("skabandclient.Dial: unexpected status code: %d: %s", resp.StatusCode, b)
+	}
+	if !strings.Contains(resp.Header.Get("Upgrade"), "ska") {
+		return errors.New("skabandclient.Dial: server did not upgrade to ska protocol")
+	}
+	if buf := reader.Buffered(); buf > 0 {
+		peek, _ := reader.Peek(buf)
+		return fmt.Errorf("skabandclient.Dial: buffered read after upgrade response: %d: %q", buf, string(peek))
+	}
+
+	// Send Magic.
+	const magic = "skaband\n"
+	if _, err := conn.Write([]byte(magic)); err != nil {
+		return fmt.Errorf("skabandclient.Dial: failed to send upgrade init message: %w", err)
+	}
+
+	// We have a TCP connection to the server and have been through the upgrade dance.
+	// Now we can run an HTTP server over that connection ("inverting" the HTTP flow).
+	server := &http2.Server{}
+	server.ServeConn(conn, &http2.ServeConnOpts{
+		Handler: h,
+	})
+
+	return nil
+}
+
+func decodePrivKey(privData []byte) (ed25519.PrivateKey, error) {
+	privBlock, _ := pem.Decode(privData)
+	if privBlock == nil || privBlock.Type != "PRIVATE KEY" {
+		return nil, fmt.Errorf("no valid private key block found")
+	}
+	parsedPriv, err := x509.ParsePKCS8PrivateKey(privBlock.Bytes)
+	if err != nil {
+		return nil, err
+	}
+	return parsedPriv.(ed25519.PrivateKey), nil
+}
+
+func encodePrivateKey(privKey ed25519.PrivateKey) ([]byte, error) {
+	privBytes, err := x509.MarshalPKCS8PrivateKey(privKey)
+	if err != nil {
+		return nil, err
+	}
+	return pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privBytes}), nil
+}
+
+func LoadOrCreatePrivateKey(path string) (ed25519.PrivateKey, error) {
+	privData, err := os.ReadFile(path)
+	if os.IsNotExist(err) {
+		_, privKey, err := ed25519.GenerateKey(crand.Reader)
+		if err != nil {
+			return nil, err
+		}
+		b, err := encodePrivateKey(privKey)
+		if err := os.WriteFile(path, b, 0o600); err != nil {
+			return nil, err
+		}
+		return privKey, nil
+	} else if err != nil {
+		return nil, fmt.Errorf("read key failed: %w", err)
+	}
+	key, err := decodePrivKey(privData)
+	if err != nil {
+		return nil, fmt.Errorf("%s: %w", path, err)
+	}
+	return key, nil
+}
+
+func Login(stdout io.Writer, privKey ed25519.PrivateKey, skabandAddr, sessionID string) (pubKey, apiURL, apiKey string, err error) {
+	sig := ed25519.Sign(privKey, []byte(sessionID))
+
+	req, err := http.NewRequest("POST", skabandAddr+"/authclient", nil)
+	if err != nil {
+		return "", "", "", err
+	}
+	pubKey = hex.EncodeToString(privKey.Public().(ed25519.PublicKey))
+	req.Header.Set("Public-Key", pubKey)
+	req.Header.Set("Session-ID", sessionID)
+	req.Header.Set("Session-ID-Sig", hex.EncodeToString(sig))
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		return "", "", "", fmt.Errorf("skaband login: %w", err)
+	}
+	apiURL = resp.Header.Get("X-API-URL")
+	apiKey = resp.Header.Get("X-API-Key")
+	defer resp.Body.Close()
+	_, err = io.Copy(stdout, resp.Body)
+	if err != nil {
+		return "", "", "", fmt.Errorf("skaband login: %w", err)
+	}
+	if resp.StatusCode != 200 {
+		return "", "", "", fmt.Errorf("skaband login failed: %d", resp.StatusCode)
+	}
+	if apiURL == "" {
+		return "", "", "", fmt.Errorf("skaband returned no api url")
+	}
+	if apiKey == "" {
+		return "", "", "", fmt.Errorf("skaband returned no api key")
+	}
+	return pubKey, apiURL, apiKey, nil
+}
+
+func DefaultKeyPath() string {
+	homeDir, err := os.UserHomeDir()
+	if err != nil {
+		panic(err)
+	}
+	cacheDir := filepath.Join(homeDir, ".cache", "sketch")
+	os.MkdirAll(cacheDir, 0o777)
+	return filepath.Join(cacheDir, "sketch.ed25519")
+}
+
+func LocalhostToDockerInternal(skabandURL string) (string, error) {
+	u, err := url.Parse(skabandURL)
+	if err != nil {
+		return "", fmt.Errorf("localhostToDockerInternal: %w", err)
+	}
+	switch u.Hostname() {
+	case "localhost", "127.0.0.1":
+		host := "host.docker.internal"
+		if port := u.Port(); port != "" {
+			host += ":" + port
+		}
+		u.Host = host
+		return u.String(), nil
+	}
+	return skabandURL, nil
+}