Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 12 additions & 10 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,8 @@
# Database (Required)
# =============================================================================

# PostgreSQL connection string
# For local development with Docker:
# docker run -d -p 5432:5432 -e POSTGRES_DB=counterspell -e POSTGRES_PASSWORD=dev postgres:16
DATABASE_URL=postgres://postgres:dev@localhost:5432/counterspell?sslmode=disable
# SQLite database path (local-first)
DATABASE_PATH=./data/counterspell.db

# =============================================================================
# GitHub OAuth (Required for repo access)
Expand All @@ -26,14 +24,18 @@ GITHUB_CLIENT_SECRET=your_github_client_secret_here
GITHUB_REDIRECT_URI=http://localhost:8710/github/callback

# =============================================================================
# Supabase Auth (Optional - for multi-user mode)
# Control Plane (Required for tunneling and multi-user features)
# =============================================================================
# Leave these empty for single-user mode (all requests use "default" user)
# Get these from: https://supabase.com/dashboard
# The control plane handles auth, subdomain assignment, and tunnel routing.
# This is the hosted service at counterspell.io
# Leave empty for offline mode (no tunneling, no auth)

# SUPABASE_URL=https://xxx.supabase.co
# SUPABASE_ANON_KEY=eyJ...
# SUPABASE_JWT_SECRET=your-jwt-secret
# Control plane API URL (use https://api.counterspell.io in production)
CONTROL_PLANE_URL=http://localhost:3001

# Pre-configured auth token (optional - if set, skip interactive auth)
# Leave empty to trigger interactive auth flow on first run
AUTH_TOKEN=

# =============================================================================
# Worker Pool Configuration
Expand Down
126 changes: 126 additions & 0 deletions TUNNELING_PLAN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
# Counterspell Tunneling Architecture & Plan

## 1. The Vision: "Local First, Globally Accessible"

We are building a **Split-Brain Architecture** where:
- **The Brain (Logic/Agents)** runs locally on the user's machine (Data Plane).
- **The Face (UI/Auth)** runs on the Edge (Control Plane).
- **The Connection** is a secure tunnel linking them.

**Goal:** Allow users to access their local coding agent from any device (phone, tablet, laptop) via a secure `username.counterspell.app` URL, without complex networking setup.

---

## 2. Architecture Overview

```mermaid
graph TD
User[User's Browser] -->|https://alice.counterspell.app| Edge[Control Plane (Cloudflare/Vercel)]

subgraph "Control Plane (counterspell.io)"
Edge -->|Serving Static UI| UI_Bucket[UI Assets]
Edge -->|Auth & Routing| Supabase[Auth / DB]
Edge -->|Tunnel Request| TunnelServer[Tunnel Ingress]
end

subgraph "Data Plane (User's Machine)"
TunnelClient[Binary Tunnel Client] -->|Secure WebSocket| TunnelServer
TunnelClient -->|Local API Request| GoServer[Go Backend :8710]
GoServer -->|SQL| SQLite[Local DB]
GoServer -->|Exec| Agents[Claude/Native Agents]
end
```

### Key Components

| Component | Responsibility | Location | Status |
|-----------|----------------|----------|--------|
| **Control Plane** | Auth, Billing, Tunnel Routing, Subdomain Management | `counterspell.io` | Spec Defined |
| **Data Plane** | Agent Logic, File Access, Local DB, Tunnel Client | User's Machine | **In Progress** |
| **The Tunnel** | Secure pipe between Control & Data Plane | Cloudflare / Ngrok | Next Step |

---

## 3. Implementation Status: Authentication (Completed)

We have implemented a secure **OAuth 2.0 Authorization Code Flow with PKCE**.

### Why this approach?
- **Security:** The JWT *never* appears in the URL or browser history.
- **UX:** No copy-pasting codes. The browser creates a magical hand-off to the CLI.
- **Standards:** Compliant with OAuth 2.0 best practices for public clients.

### The Flow
1. **CLI Start:** Generates PKCE `code_verifier` & `code_challenge`.
2. **Request:** CLI asks Control Plane for a login URL (sending `code_challenge`).
3. **Login:** User logs in at `counterspell.io` (Supabase handled internally).
4. **Redirect:** Control Plane redirects browser to `http://localhost:8711/callback?code=abc...`.
5. **Exchange:** CLI (listening on :8711) takes `code`, validates `state`, and POSTs to Control Plane to get JWT.
6. **Token:** JWT is stored locally in `counterspell.db` (sqlite).

**Files Implemented:**
- `internal/services/auth.go`: PKCE generation, Token storage.
- `internal/services/controlplane.go`: API client for Auth & Machine Registration.
- `internal/cli/callback.go`: Local HTTP server for zero-friction handoff.
- `cmd/app/main.go`: Integration into startup flow.

---

## 4. Next Step: The Tunneling Implementation

### 4.1. Registration
Once authenticated, the binary calls `POST /api/v1/machines/register`.
- **Input:** Machine info (OS, Arch, CPU).
- **Output:** Authorized Subdomain (e.g., `alice`) and Tunnel Configuration.

### 4.2. Tunnel Establishment
We will use **Cloudflare Tunnels (cloudflared)** via the Go SDK or wrapper.

**Command:**
```bash
# Concept
cloudflared tunnel run --token <token_from_control_plane>
```

**Implementation Plan:**
1. **Tunnel Service:** Create `internal/services/tunnel.go`.
2. **Auto-Download:** If `cloudflared` isn't found, download the binary for the OS.
3. **Configuration:** Configure ingress rules to forward `https://alice.counterspell.app` -> `http://localhost:8710`.
4. **Lifecycle:** Start tunnel on app launch, stop on shutdown.

---

## 5. Control Plane API Specification

The Data Plane (Binary) expects these endpoints to exist on the Control Plane:

### Auth
- `POST /api/v1/auth/url`
- Body: `{ "machine_name": "...", "redirect_url": "...", "code_challenge": "...", "state": "..." }`
- Returns: `{ "auth_url": "..." }`

- `POST /api/v1/auth/exchange`
- Body: `{ "code": "...", "code_verifier": "...", "state": "..." }`
- Returns: `{ "jwt": "...", "user_id": "...", "email": "..." }`

### Machines & Tunnel
- `POST /api/v1/machines/register` (Protected)
- Headers: `Authorization: Bearer <jwt>`
- Body: `{ "machine_id": "...", "capabilities": {...} }`
- Returns: `{ "subdomain": "alice", "tunnel_token": "..." }`

---

## 6. Subdomain Routing (The "Split Brain" Logic)

The Frontend (Svelte) is a **Single Page App** served from the Edge, but it needs to talk to the *correct* backend.

**Logic:**
1. User visits `alice.counterspell.app`.
2. Frontend loads assets from CDN.
3. Frontend API Client (`api.ts`) detects hostname `alice.counterspell.app`.
4. Requests sent to `/api/*` are routed by the Control Plane's Edge Worker.
5. Edge Worker looks up `alice` -> Finds Tunnel ID -> Proxies request down the tunnel.
6. Local Binary receives request -> Executes Agent -> Returns JSON.

This architecture allows for **infinite scaling** of users without running infinite servers in the cloud. We only pay for the "pipe" (tunneling), while users pay for the "compute" (running the agent locally).
74 changes: 72 additions & 2 deletions cmd/app/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,18 @@ import (
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
"github.com/revrost/code/counterspell/internal/cli"
"github.com/revrost/code/counterspell/internal/config"
"github.com/revrost/code/counterspell/internal/db"
"github.com/revrost/code/counterspell/internal/handlers"
"github.com/revrost/code/counterspell/internal/services"
"github.com/revrost/code/counterspell/ui"
)

type contextKey string

const subdomainKey contextKey = "subdomain"

func main() {
// Parse flags
addr := flag.String("addr", ":8710", "Server address")
Expand Down Expand Up @@ -79,6 +84,71 @@ func main() {
os.Exit(1)
}

// Initialize control plane client (if configured)
var controlPlane *services.ControlPlaneClient
if cfg.ControlPlaneURL != "" {
controlPlane = services.NewControlPlaneClient(cfg.ControlPlaneURL)
logger.Info("Control plane configured", "url", cfg.ControlPlaneURL)
} else {
logger.Info("No control plane configured, running in offline mode")
}

// Create auth service
authService := services.NewAuthService(database, controlPlane, cfg.AuthToken)

// Ensure machine exists in database
machineID, err := authService.EnsureMachine(ctx)
if err != nil {
logger.Error("Failed to ensure machine", "error", err)
os.Exit(1)
}

// Check authentication and prompt if needed
if controlPlane != nil {
// Check if already authenticated
authenticated, err := authService.IsAuthenticated(ctx, machineID)
if err != nil {
logger.Error("Failed to check auth status", "error", err)
os.Exit(1)
}

var userID string
var exchangeResp *services.ExchangeCodeResponse

if !authenticated {
// Not authenticated - start auth flow with callback
authCallback := cli.NewAuthWithCallback(authService, controlPlane, 8711)
userID, exchangeResp, err = authCallback.Authenticate(ctx, machineID)
if err != nil {
logger.Error("Authentication failed", "error", err)
os.Exit(1)
}

// Store auth token
if err := authService.StoreAuth(ctx, machineID, exchangeResp.JWT, exchangeResp.UserID, exchangeResp.Email, exchangeResp.ExpiresAt); err != nil {
logger.Error("Failed to store auth", "error", err)
os.Exit(1)
}

// Register machine and get subdomain
subdomain, err := authService.RegisterMachine(ctx, exchangeResp.JWT, machineID)
if err != nil {
logger.Error("Failed to register machine", "error", err)
os.Exit(1)
}
logger.Info("Machine registered", "subdomain", subdomain)
} else {
// Already authenticated
auth, err := authService.GetStoredAuth(ctx, machineID)
if err != nil {
logger.Error("Failed to get stored auth", "error", err)
os.Exit(1)
}
userID = auth.UserID
logger.Info("Already authenticated", "user_id", userID, "machine_id", machineID)
}
}

// Create event bus
eventBus := services.NewEventBus()

Expand Down Expand Up @@ -114,7 +184,7 @@ func main() {
subdomain = parts[0]
}

ctx := context.WithValue(r.Context(), "subdomain", subdomain)
ctx := context.WithValue(r.Context(), subdomainKey, subdomain)
next.ServeHTTP(w, r.WithContext(ctx))
})
})
Expand Down Expand Up @@ -258,7 +328,7 @@ func spaHandler(fsys fs.FS) http.HandlerFunc {
// SubdomainFromContext extracts the subdomain from the request context.
// Returns empty string if no subdomain is present.
func SubdomainFromContext(ctx context.Context) string {
if subdomain, ok := ctx.Value("subdomain").(string); ok {
if subdomain, ok := ctx.Value(subdomainKey).(string); ok {
return subdomain
}
return ""
Expand Down
124 changes: 124 additions & 0 deletions internal/cli/auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package cli

import (
"bufio"
"context"
"fmt"
"log/slog"
"os"

"github.com/revrost/code/counterspell/internal/services"
)

// AuthCLI handles authentication from CLI.
type AuthCLI struct {
authService *services.AuthService
}

// NewAuthCLI creates a new auth CLI.
func NewAuthCLI(authService *services.AuthService) *AuthCLI {
return &AuthCLI{
authService: authService,
}
}

// CheckAuth checks if user is authenticated, prompts if not.
func (a *AuthCLI) CheckAuth(ctx context.Context, machineID string) (string, *services.ExchangeCodeResponse, error) {
// Check if already authenticated
authenticated, err := a.authService.IsAuthenticated(ctx, machineID)
if err != nil {
return "", nil, fmt.Errorf("failed to check auth status: %w", err)
}

if authenticated {
auth, err := a.authService.GetStoredAuth(ctx, machineID)
if err != nil {
return "", nil, fmt.Errorf("failed to get stored auth: %w", err)
}
slog.Info("Already authenticated", "user", auth.Email, "user_id", auth.UserID)
return auth.UserID, nil, nil
}

// Not authenticated - start auth flow
return a.startAuthFlow(ctx, machineID)
}

// startAuthFlow starts the interactive auth flow.
func (a *AuthCLI) startAuthFlow(ctx context.Context, machineID string) (string, *services.ExchangeCodeResponse, error) {
fmt.Println("\n" + "============================================================")
fmt.Println("Welcome to Counterspell!")
fmt.Println("============================================================")
fmt.Println("\nYou need to authenticate to use Counterspell.")
fmt.Println("This allows us to:")
fmt.Println(" - Provide your personal subdomain (e.g., username.counterspell.app)")
fmt.Println(" - Create a secure tunnel to your machine")
fmt.Println(" - Manage your cloud deployments")
fmt.Println()

// Get auth URL
authURL, state, err := a.authService.StartAuthFlow(ctx)
if err != nil {
return "", nil, fmt.Errorf("failed to get auth URL: %w", err)
}

fmt.Printf("\n1. Visit this URL in your browser:\n\n")
fmt.Printf(" %s\n\n", authURL)
fmt.Printf("2. Log in or create an account\n")
fmt.Printf("3. After logging in, you'll receive a code\n\n")

// Prompt for code
fmt.Print("Enter the code from the browser: ")
reader := bufio.NewReader(os.Stdin)
code, err := reader.ReadString('\n')
if err != nil {
return "", nil, fmt.Errorf("failed to read code: %w", err)
}
code = code[:len(code)-1] // Remove newline

if code == "" {
return "", nil, fmt.Errorf("code cannot be empty")
}

fmt.Println("\nAuthenticating...")

// Exchange code for JWT
resp, err := a.authService.CompleteAuthFlow(ctx, code, state)
if err != nil {
return "", nil, fmt.Errorf("failed to authenticate: %w", err)
}

// Store auth
if err := a.authService.StoreAuth(ctx, machineID, resp.JWT, resp.UserID, resp.Email, resp.ExpiresAt); err != nil {
return "", nil, fmt.Errorf("failed to store auth: %w", err)
}

fmt.Printf("\n✓ Authenticated as %s\n", resp.Email)
fmt.Printf("✓ Your user ID: %s\n\n", resp.UserID)

return resp.UserID, resp, nil
}

// RegisterMachine registers the machine and returns the subdomain.
func (a *AuthCLI) RegisterMachine(ctx context.Context, machineID, userID string) (string, error) {
// Get stored auth
auth, err := a.authService.GetStoredAuth(ctx, machineID)
if err != nil {
return "", fmt.Errorf("failed to get auth: %w", err)
}

fmt.Println("Registering your machine...")

subdomain, err := a.authService.RegisterMachine(ctx, auth.JwtToken, machineID)
if err != nil {
return "", fmt.Errorf("failed to register machine: %w", err)
}

fmt.Printf("✓ Machine registered!\n")
fmt.Printf("✓ Your subdomain: %s.counterspell.app\n\n", subdomain)

fmt.Println("You can now access Counterspell from:")
fmt.Printf(" https://%s.counterspell.app\n\n", subdomain)
fmt.Println("This is a secure tunnel to your local machine.")

return subdomain, nil
}
Loading
Loading