forked from wrenn/wrenn
Remove API key auth requirement for sandbox port proxy connections
Sandbox URLs ({port}-{sandbox_id}.{domain}) are now accessible without
authentication. The sandbox ID in the hostname is sufficient for routing.
This commit is contained in:
@ -15,7 +15,7 @@ SELECT * FROM sandboxes WHERE id = $1 AND team_id = $2;
|
|||||||
SELECT s.status, h.address AS host_address
|
SELECT s.status, h.address AS host_address
|
||||||
FROM sandboxes s
|
FROM sandboxes s
|
||||||
JOIN hosts h ON h.id = s.host_id
|
JOIN hosts h ON h.id = s.host_id
|
||||||
WHERE s.id = $1 AND s.team_id = $2;
|
WHERE s.id = $1;
|
||||||
|
|
||||||
-- name: ListSandboxes :many
|
-- name: ListSandboxes :many
|
||||||
SELECT * FROM sandboxes ORDER BY created_at DESC;
|
SELECT * FROM sandboxes ORDER BY created_at DESC;
|
||||||
|
|||||||
@ -17,7 +17,6 @@ import (
|
|||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/auth"
|
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/db"
|
"git.omukk.dev/wrenn/wrenn/internal/db"
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/id"
|
"git.omukk.dev/wrenn/wrenn/internal/id"
|
||||||
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
|
"git.omukk.dev/wrenn/wrenn/internal/lifecycle"
|
||||||
@ -44,7 +43,7 @@ func (e errProxySandboxNotRunning) Error() string {
|
|||||||
return fmt.Sprintf("sandbox is not running (status: %s)", e.status)
|
return fmt.Sprintf("sandbox is not running (status: %s)", e.status)
|
||||||
}
|
}
|
||||||
|
|
||||||
// proxyCacheEntry caches the resolved agent URL for a (sandbox, team) pair.
|
// proxyCacheEntry caches the resolved agent URL for a sandbox.
|
||||||
// The *httputil.ReverseProxy is built per-request (cheap) so the Director closure
|
// The *httputil.ReverseProxy is built per-request (cheap) so the Director closure
|
||||||
// can capture the correct port without the cache key needing to include it.
|
// can capture the correct port without the cache key needing to include it.
|
||||||
type proxyCacheEntry struct {
|
type proxyCacheEntry struct {
|
||||||
@ -52,23 +51,13 @@ type proxyCacheEntry struct {
|
|||||||
expiresAt time.Time
|
expiresAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// proxyCacheKey is a fixed-size key from two UUIDs, avoids string allocation.
|
|
||||||
type proxyCacheKey [32]byte
|
|
||||||
|
|
||||||
func makeProxyCacheKey(sandboxID, teamID pgtype.UUID) proxyCacheKey {
|
|
||||||
var k proxyCacheKey
|
|
||||||
copy(k[:16], sandboxID.Bytes[:])
|
|
||||||
copy(k[16:], teamID.Bytes[:])
|
|
||||||
return k
|
|
||||||
}
|
|
||||||
|
|
||||||
// SandboxProxyWrapper wraps an existing HTTP handler and intercepts requests
|
// SandboxProxyWrapper wraps an existing HTTP handler and intercepts requests
|
||||||
// whose Host header matches the {port}-{sandbox_id}.{domain} pattern. Matching
|
// whose Host header matches the {port}-{sandbox_id}.{domain} pattern. Matching
|
||||||
// requests are reverse-proxied through the host agent that owns the sandbox.
|
// requests are reverse-proxied through the host agent that owns the sandbox.
|
||||||
// All other requests are passed through to the inner handler.
|
// All other requests are passed through to the inner handler.
|
||||||
//
|
//
|
||||||
// Authentication is via X-API-Key header only (no JWT). The API key's team
|
// No authentication is required — sandbox URLs are unguessable and access is
|
||||||
// must own the sandbox.
|
// scoped to the sandbox ID embedded in the hostname.
|
||||||
type SandboxProxyWrapper struct {
|
type SandboxProxyWrapper struct {
|
||||||
inner http.Handler
|
inner http.Handler
|
||||||
db *db.Queries
|
db *db.Queries
|
||||||
@ -76,7 +65,7 @@ type SandboxProxyWrapper struct {
|
|||||||
transport http.RoundTripper
|
transport http.RoundTripper
|
||||||
|
|
||||||
cacheMu sync.Mutex
|
cacheMu sync.Mutex
|
||||||
cache map[proxyCacheKey]proxyCacheEntry
|
cache map[pgtype.UUID]proxyCacheEntry
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewSandboxProxyWrapper creates a new proxy wrapper.
|
// NewSandboxProxyWrapper creates a new proxy wrapper.
|
||||||
@ -86,19 +75,15 @@ func NewSandboxProxyWrapper(inner http.Handler, queries *db.Queries, pool *lifec
|
|||||||
db: queries,
|
db: queries,
|
||||||
pool: pool,
|
pool: pool,
|
||||||
transport: pool.Transport(),
|
transport: pool.Transport(),
|
||||||
cache: make(map[proxyCacheKey]proxyCacheEntry),
|
cache: make(map[pgtype.UUID]proxyCacheEntry),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// proxyTarget looks up the cached agent URL for (sandboxID, teamID).
|
// proxyTarget looks up the cached agent URL for sandboxID.
|
||||||
// On a miss it queries the DB, resolves the address, and populates the cache.
|
// On a miss it queries the DB, resolves the address, and populates the cache.
|
||||||
// The *httputil.ReverseProxy is built by the caller so the Director closure
|
func (h *SandboxProxyWrapper) proxyTarget(ctx context.Context, sandboxID pgtype.UUID) (*url.URL, error) {
|
||||||
// captures the correct port without the cache key needing to include it.
|
|
||||||
func (h *SandboxProxyWrapper) proxyTarget(ctx context.Context, sandboxID, teamID pgtype.UUID) (*url.URL, error) {
|
|
||||||
cacheKey := makeProxyCacheKey(sandboxID, teamID)
|
|
||||||
|
|
||||||
h.cacheMu.Lock()
|
h.cacheMu.Lock()
|
||||||
entry, ok := h.cache[cacheKey]
|
entry, ok := h.cache[sandboxID]
|
||||||
h.cacheMu.Unlock()
|
h.cacheMu.Unlock()
|
||||||
|
|
||||||
if ok && time.Now().Before(entry.expiresAt) {
|
if ok && time.Now().Before(entry.expiresAt) {
|
||||||
@ -106,10 +91,7 @@ func (h *SandboxProxyWrapper) proxyTarget(ctx context.Context, sandboxID, teamID
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache miss or expired — query DB.
|
// Cache miss or expired — query DB.
|
||||||
target, err := h.db.GetSandboxProxyTarget(ctx, db.GetSandboxProxyTargetParams{
|
target, err := h.db.GetSandboxProxyTarget(ctx, sandboxID)
|
||||||
ID: sandboxID,
|
|
||||||
TeamID: teamID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errProxySandboxNotFound
|
return nil, errProxySandboxNotFound
|
||||||
}
|
}
|
||||||
@ -126,7 +108,7 @@ func (h *SandboxProxyWrapper) proxyTarget(ctx context.Context, sandboxID, teamID
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.cacheMu.Lock()
|
h.cacheMu.Lock()
|
||||||
h.cache[cacheKey] = proxyCacheEntry{
|
h.cache[sandboxID] = proxyCacheEntry{
|
||||||
agentURL: agentURL,
|
agentURL: agentURL,
|
||||||
expiresAt: time.Now().Add(proxyCacheTTL),
|
expiresAt: time.Now().Add(proxyCacheTTL),
|
||||||
}
|
}
|
||||||
@ -135,11 +117,11 @@ func (h *SandboxProxyWrapper) proxyTarget(ctx context.Context, sandboxID, teamID
|
|||||||
return agentURL, nil
|
return agentURL, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// evictProxyCache removes the cached entry for a (sandbox, team) pair.
|
// evictProxyCache removes the cached entry for a sandbox.
|
||||||
// Called on 502 so a stopped/moved sandbox is re-resolved on the next request.
|
// Called on 502 so a stopped/moved sandbox is re-resolved on the next request.
|
||||||
func (h *SandboxProxyWrapper) evictProxyCache(sandboxID, teamID pgtype.UUID) {
|
func (h *SandboxProxyWrapper) evictProxyCache(sandboxID pgtype.UUID) {
|
||||||
h.cacheMu.Lock()
|
h.cacheMu.Lock()
|
||||||
delete(h.cache, makeProxyCacheKey(sandboxID, teamID))
|
delete(h.cache, sandboxID)
|
||||||
h.cacheMu.Unlock()
|
h.cacheMu.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,20 +148,13 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authenticate: require API key or JWT, extract team ID.
|
|
||||||
teamID, err := h.authenticateRequest(r)
|
|
||||||
if err != nil {
|
|
||||||
writeError(w, http.StatusUnauthorized, "unauthorized", err.Error())
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
sandboxID, err := id.ParseSandboxID(sandboxIDStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "invalid sandbox ID", http.StatusBadRequest)
|
http.Error(w, "invalid sandbox ID", http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
agentURL, err := h.proxyTarget(r.Context(), sandboxID, teamID)
|
agentURL, err := h.proxyTarget(r.Context(), sandboxID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, errProxySandboxNotFound):
|
case errors.Is(err, errProxySandboxNotFound):
|
||||||
@ -206,25 +181,9 @@ func (h *SandboxProxyWrapper) ServeHTTP(w http.ResponseWriter, r *http.Request)
|
|||||||
"port", port,
|
"port", port,
|
||||||
"error", err,
|
"error", err,
|
||||||
)
|
)
|
||||||
h.evictProxyCache(sandboxID, teamID)
|
h.evictProxyCache(sandboxID)
|
||||||
http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway)
|
http.Error(w, "proxy error: "+err.Error(), http.StatusBadGateway)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
proxy.ServeHTTP(w, r)
|
proxy.ServeHTTP(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// authenticateRequest validates the request's API key and returns the team ID.
|
|
||||||
// Only API key authentication is supported for sandbox proxy requests (not JWT).
|
|
||||||
func (h *SandboxProxyWrapper) authenticateRequest(r *http.Request) (pgtype.UUID, error) {
|
|
||||||
key := r.Header.Get("X-API-Key")
|
|
||||||
if key == "" {
|
|
||||||
return pgtype.UUID{}, fmt.Errorf("X-API-Key header required")
|
|
||||||
}
|
|
||||||
|
|
||||||
hash := auth.HashAPIKey(key)
|
|
||||||
row, err := h.db.GetAPIKeyByHash(r.Context(), hash)
|
|
||||||
if err != nil {
|
|
||||||
return pgtype.UUID{}, fmt.Errorf("invalid API key")
|
|
||||||
}
|
|
||||||
return row.TeamID, nil
|
|
||||||
}
|
|
||||||
|
|||||||
@ -109,14 +109,9 @@ const getSandboxProxyTarget = `-- name: GetSandboxProxyTarget :one
|
|||||||
SELECT s.status, h.address AS host_address
|
SELECT s.status, h.address AS host_address
|
||||||
FROM sandboxes s
|
FROM sandboxes s
|
||||||
JOIN hosts h ON h.id = s.host_id
|
JOIN hosts h ON h.id = s.host_id
|
||||||
WHERE s.id = $1 AND s.team_id = $2
|
WHERE s.id = $1
|
||||||
`
|
`
|
||||||
|
|
||||||
type GetSandboxProxyTargetParams struct {
|
|
||||||
ID pgtype.UUID `json:"id"`
|
|
||||||
TeamID pgtype.UUID `json:"team_id"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type GetSandboxProxyTargetRow struct {
|
type GetSandboxProxyTargetRow struct {
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
HostAddress string `json:"host_address"`
|
HostAddress string `json:"host_address"`
|
||||||
@ -124,8 +119,8 @@ type GetSandboxProxyTargetRow struct {
|
|||||||
|
|
||||||
// Returns the sandbox status and its host's address in one query.
|
// Returns the sandbox status and its host's address in one query.
|
||||||
// Used by SandboxProxyWrapper to avoid two round-trips.
|
// Used by SandboxProxyWrapper to avoid two round-trips.
|
||||||
func (q *Queries) GetSandboxProxyTarget(ctx context.Context, arg GetSandboxProxyTargetParams) (GetSandboxProxyTargetRow, error) {
|
func (q *Queries) GetSandboxProxyTarget(ctx context.Context, id pgtype.UUID) (GetSandboxProxyTargetRow, error) {
|
||||||
row := q.db.QueryRow(ctx, getSandboxProxyTarget, arg.ID, arg.TeamID)
|
row := q.db.QueryRow(ctx, getSandboxProxyTarget, id)
|
||||||
var i GetSandboxProxyTargetRow
|
var i GetSandboxProxyTargetRow
|
||||||
err := row.Scan(&i.Status, &i.HostAddress)
|
err := row.Scan(&i.Status, &i.HostAddress)
|
||||||
return i, err
|
return i, err
|
||||||
|
|||||||
Reference in New Issue
Block a user