fix scanner bugs and firehose bugs

This commit is contained in:
Evan Jarrett
2026-02-15 15:48:40 -06:00
parent abefcfd1ed
commit 10b35642a5
10 changed files with 112 additions and 31 deletions
+6 -6
View File
@@ -105,7 +105,7 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
COALESCE(rs.last_push, m.created_at),
MAX(rs.last_push, m.created_at),
COALESCE(rp.avatar_cid, '')
FROM matching_repos mr
JOIN manifests m ON mr.latest_id = m.id
@@ -113,7 +113,7 @@ func SearchRepositories(db DBTX, query string, limit, offset int, currentUserDID
JOIN repo_stats ON m.did = repo_stats.did AND m.repository = repo_stats.repository
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
ORDER BY COALESCE(rs.last_push, m.created_at) DESC
ORDER BY MAX(rs.last_push, m.created_at) DESC
LIMIT ? OFFSET ?
`
@@ -1743,7 +1743,7 @@ func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardS
var orderBy string
switch sortOrder {
case SortByLastUpdate:
orderBy = "COALESCE(rs.last_push, m.created_at) DESC"
orderBy = "MAX(rs.last_push, m.created_at) DESC"
default: // SortByScore
orderBy = "(COALESCE(rs.pull_count, 0) + COALESCE((SELECT COUNT(*) FROM stars WHERE owner_did = m.did AND repository = m.repository), 0) * 10) DESC, m.created_at DESC"
}
@@ -1768,7 +1768,7 @@ func GetRepoCards(db DBTX, limit int, currentUserDID string, sortOrder RepoCardS
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
COALESCE(rs.last_push, m.created_at),
MAX(rs.last_push, m.created_at),
COALESCE(rp.avatar_cid, '')
FROM latest_manifests lm
JOIN manifests m ON lm.latest_id = m.id
@@ -1841,14 +1841,14 @@ func GetUserRepoCards(db DBTX, userDID string, currentUserDID string) ([]RepoCar
COALESCE(m.artifact_type, 'container-image'),
COALESCE((SELECT tag FROM tags WHERE did = m.did AND repository = m.repository ORDER BY created_at DESC LIMIT 1), ''),
COALESCE(m.digest, ''),
COALESCE(rs.last_push, m.created_at),
MAX(rs.last_push, m.created_at),
COALESCE(rp.avatar_cid, '')
FROM latest_manifests lm
JOIN manifests m ON lm.latest_id = m.id
JOIN users u ON m.did = u.did
LEFT JOIN repository_stats rs ON m.did = rs.did AND m.repository = rs.repository
LEFT JOIN repo_pages rp ON m.did = rp.did AND m.repository = rp.repository
ORDER BY COALESCE(rs.last_push, m.created_at) DESC
ORDER BY MAX(rs.last_push, m.created_at) DESC
`
rows, err := db.Query(query, userDID, currentUserDID)
+5 -2
View File
@@ -647,8 +647,11 @@ func DomainRoutingMiddleware(registryDomains []string, uiBaseURL string) func(ht
next.ServeHTTP(w, r)
case regDomains[host]:
// Registry domain: allow /v2/*, redirect everything else
if isV2 {
// Registry domain: allow /v2/*, /auth/token, /auth/device/*, redirect everything else
// Auth endpoints must be served directly to avoid 307 redirects that strip
// the Authorization header on cross-host redirects (Go http.Client behavior).
isAuth := path == "/auth/token" || strings.HasPrefix(path, "/auth/device/")
if isV2 || isAuth {
next.ServeHTTP(w, r)
return
}
+6 -1
View File
@@ -355,7 +355,8 @@ func ValidateBlobWriteAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClien
// If captain.public = false: Requires valid DPoP + OAuth and (captain OR crew with blob:read or blob:write permission).
// Note: blob:write implicitly grants blob:read access.
// The httpClient parameter is optional and defaults to http.DefaultClient if nil.
func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient) (*ValidatedUser, error) {
// If scannerSecret is non-empty, a Bearer token matching it grants full read access (for scanner blob fetches).
func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient, scannerSecret string) (*ValidatedUser, error) {
// Get captain record to check public setting
_, captain, err := pds.GetCaptainRecord(r.Context())
if err != nil {
@@ -372,6 +373,10 @@ func ValidateBlobReadAccess(r *http.Request, pds *HoldPDS, httpClient HTTPClient
var user *ValidatedUser
if strings.HasPrefix(authHeader, "Bearer ") {
// Check if this is a scanner using the shared secret
if scannerSecret != "" && strings.TrimPrefix(authHeader, "Bearer ") == scannerSecret {
return &ValidatedUser{DID: "scanner"}, nil
}
// Service token authentication (from AppView via getServiceAuth)
user, err = ValidateServiceToken(r, pds.did, httpClient)
if err != nil {
+5 -5
View File
@@ -724,7 +724,7 @@ func TestValidateBlobReadAccess_PublicHold(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// This should return nil (public access allowed) for public holds
user, err := ValidateBlobReadAccess(req, pds, nil)
user, err := ValidateBlobReadAccess(req, pds, nil, "")
if err != nil {
t.Errorf("Expected public access for public hold, got error: %v", err)
}
@@ -768,7 +768,7 @@ func TestValidateBlobReadAccess_PrivateHold(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// This should return error (auth required) for private holds
user, err := ValidateBlobReadAccess(req, pds, nil)
user, err := ValidateBlobReadAccess(req, pds, nil, "")
if err == nil {
t.Error("Expected error for private hold without auth")
}
@@ -816,7 +816,7 @@ func TestValidateBlobReadAccess_BlobWriteImpliesRead(t *testing.T) {
}
// This should SUCCEED because blob:write implies blob:read
user, err := ValidateBlobReadAccess(req, pds, mockClient)
user, err := ValidateBlobReadAccess(req, pds, mockClient, "")
if err != nil {
t.Errorf("Expected blob:write to grant read access, got error: %v", err)
}
@@ -846,7 +846,7 @@ func TestValidateBlobReadAccess_BlobWriteImpliesRead(t *testing.T) {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
user, err := ValidateBlobReadAccess(req, pds, mockClient)
user, err := ValidateBlobReadAccess(req, pds, mockClient, "")
if err != nil {
t.Errorf("Expected blob:read to grant read access, got error: %v", err)
}
@@ -876,7 +876,7 @@ func TestValidateBlobReadAccess_BlobWriteImpliesRead(t *testing.T) {
t.Fatalf("Failed to add DPoP to request: %v", err)
}
_, err = ValidateBlobReadAccess(req, pds, mockClient)
_, err = ValidateBlobReadAccess(req, pds, mockClient, "")
if err == nil {
t.Error("Expected error for crew without read or write permission")
}
+25
View File
@@ -415,6 +415,11 @@ func (b *EventBroadcaster) Subscribe(conn *websocket.Conn, cursor int64, userAge
// else cursor == currentSeq: relay is caught up, just stream new events
}
// Start read pump to handle pings/pongs and detect disconnects.
// gorilla/websocket requires an active reader to process control frames;
// without one, pings go unanswered and the relay times out the connection.
go b.readPump(sub)
// Start goroutine to handle sending events to this subscriber
go b.handleSubscriber(sub)
@@ -686,6 +691,26 @@ func (b *EventBroadcaster) backfillFromMemory(sub *Subscriber, cursor int64) {
}
}
// readPump reads from the WebSocket to process control frames (ping/pong/close).
// gorilla/websocket automatically responds to pings with pongs when there is an
// active reader. Without this, relays time out the connection.
func (b *EventBroadcaster) readPump(sub *Subscriber) {
defer func() {
b.Unsubscribe(sub)
sub.conn.Close()
}()
for {
_, _, err := sub.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
slog.Warn("Firehose subscriber disconnected", "remote", sub.conn.RemoteAddr(), "error", err)
}
return
}
}
}
// handleSubscriber handles sending events to a subscriber over WebSocket
func (b *EventBroadcaster) handleSubscriber(sub *Subscriber) {
defer func() {
+34 -7
View File
@@ -94,6 +94,18 @@ func NewScanBroadcaster(holdDID, holdEndpoint, secret, dbPath string, s3svc *s3.
return nil, fmt.Errorf("failed to ping scan jobs database: %w", err)
}
// Set WAL mode and busy timeout (libsql PRAGMAs return rows)
var journalMode string
if err := db.QueryRow("PRAGMA journal_mode = WAL").Scan(&journalMode); err != nil {
db.Close()
return nil, fmt.Errorf("failed to set journal mode: %w", err)
}
var busyTimeout int
if err := db.QueryRow("PRAGMA busy_timeout = 5000").Scan(&busyTimeout); err != nil {
db.Close()
return nil, fmt.Errorf("failed to set busy_timeout: %w", err)
}
sb := &ScanBroadcaster{
subscribers: make([]*ScanSubscriber, 0),
db: db,
@@ -509,7 +521,9 @@ func (sb *ScanBroadcaster) handleError(sub *ScanSubscriber, msg ScannerMessage)
"error", msg.Error)
}
// drainPendingJobs sends pending/timed-out jobs to a newly connected scanner
// drainPendingJobs sends pending/timed-out jobs to a newly connected scanner.
// Collects all pending rows first, closes cursor, then assigns and dispatches
// to avoid holding a SELECT cursor open during UPDATEs (prevents SQLite BUSY).
func (sb *ScanBroadcaster) drainPendingJobs(sub *ScanSubscriber, cursor int64) {
rows, err := sb.db.Query(`
SELECT seq, manifest_digest, repository, tag, user_did, user_handle, hold_did, hold_endpoint, tier, config_json, layers_json
@@ -521,9 +535,8 @@ func (sb *ScanBroadcaster) drainPendingJobs(sub *ScanSubscriber, cursor int64) {
slog.Error("Failed to drain pending scan jobs", "error", err)
return
}
defer rows.Close()
count := 0
var jobs []*ScanJobEvent
for rows.Next() {
job := &ScanJobEvent{Type: "job"}
var configJSON, layersJSON string
@@ -540,8 +553,12 @@ func (sb *ScanBroadcaster) drainPendingJobs(sub *ScanSubscriber, cursor int64) {
job.Config = json.RawMessage(configJSON)
job.Layers = json.RawMessage(layersJSON)
jobs = append(jobs, job)
}
rows.Close()
// Assign and dispatch
count := 0
for _, job := range jobs {
_, err = sb.db.Exec(`
UPDATE scan_jobs SET status = 'assigned', assigned_to = ?, assigned_at = ?
WHERE seq = ? AND status = 'pending'
@@ -578,7 +595,9 @@ func (sb *ScanBroadcaster) reDispatchLoop() {
}
}
// reDispatchTimedOut finds jobs that were assigned but not acked/completed within timeout
// reDispatchTimedOut finds jobs that were assigned but not acked/completed within timeout.
// Collects timed-out rows first, closes cursor, then resets and re-dispatches
// to avoid holding a SELECT cursor open during UPDATEs (prevents SQLite BUSY).
func (sb *ScanBroadcaster) reDispatchTimedOut() {
timeout := time.Now().Add(-sb.ackTimeout)
@@ -592,8 +611,8 @@ func (sb *ScanBroadcaster) reDispatchTimedOut() {
slog.Error("Failed to query timed-out scan jobs", "error", err)
return
}
defer rows.Close()
var jobs []*ScanJobEvent
for rows.Next() {
job := &ScanJobEvent{Type: "job"}
var configJSON, layersJSON string
@@ -609,8 +628,11 @@ func (sb *ScanBroadcaster) reDispatchTimedOut() {
job.Config = json.RawMessage(configJSON)
job.Layers = json.RawMessage(layersJSON)
jobs = append(jobs, job)
}
rows.Close()
// Reset to pending and re-dispatch
for _, job := range jobs {
_, err = sb.db.Exec(`
UPDATE scan_jobs SET status = 'pending', assigned_to = NULL, assigned_at = NULL
WHERE seq = ?
@@ -635,6 +657,11 @@ func (sb *ScanBroadcaster) Close() error {
return nil
}
// Secret returns the scanner shared secret for use in blob read authorization
func (sb *ScanBroadcaster) Secret() string {
return sb.secret
}
// ValidateScannerSecret checks if the provided secret matches
func (sb *ScanBroadcaster) ValidateScannerSecret(secret string) bool {
return sb.secret != "" && secret == sb.secret
+5 -1
View File
@@ -1108,7 +1108,11 @@ func (h *XRPCHandler) handleGetOCIBlob(w http.ResponseWriter, r *http.Request, d
// Validate blob read access (hold access control)
// If captain.public = true, returns nil (public access allowed)
// If captain.public = false, validates auth and checks for blob:read permission
_, err := ValidateBlobReadAccess(r, h.pds, h.httpClient)
scannerSecret := ""
if h.scanBroadcaster != nil {
scannerSecret = h.scanBroadcaster.Secret()
}
_, err := ValidateBlobReadAccess(r, h.pds, h.httpClient, scannerSecret)
if err != nil {
slog.Warn("OCI blob authorization failed", "error", err, "digest", digest)
http.Error(w, fmt.Sprintf("authorization failed: %v", err), http.StatusForbidden)
+12 -3
View File
@@ -213,14 +213,23 @@ func (c *HoldClient) Close() {
c.mu.Unlock()
}
// GetBlobPresignedURL gets a presigned download URL from the hold service
func GetBlobPresignedURL(holdEndpoint, holdDID, digest string) (string, error) {
// GetBlobPresignedURL gets a presigned download URL from the hold service.
// If secret is non-empty, it is sent as a Bearer token for private hold access.
func GetBlobPresignedURL(holdEndpoint, holdDID, digest, secret string) (string, error) {
reqURL := fmt.Sprintf("%s/xrpc/com.atproto.sync.getBlob?did=%s&cid=%s&method=GET",
holdEndpoint,
url.QueryEscape(holdDID),
url.QueryEscape(digest))
resp, err := http.Get(reqURL)
req, err := http.NewRequest("GET", reqURL, nil)
if err != nil {
return "", fmt.Errorf("failed to create request: %w", err)
}
if secret != "" {
req.Header.Set("Authorization", "Bearer "+secret)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", fmt.Errorf("failed to get presigned URL: %w", err)
}
+13 -5
View File
@@ -17,7 +17,7 @@ import (
// extractLayers downloads and extracts all image layers via presigned URLs
// Returns the rootfs directory path and a cleanup function
func extractLayers(job *scanner.ScanJob, tmpDir string) (string, func(), error) {
func extractLayers(job *scanner.ScanJob, tmpDir, secret string) (string, func(), error) {
scanDir, err := os.MkdirTemp(tmpDir, "scan-*")
if err != nil {
return "", nil, fmt.Errorf("failed to create temp directory: %w", err)
@@ -41,9 +41,13 @@ func extractLayers(job *scanner.ScanJob, tmpDir string) (string, func(), error)
}
// Download and validate config blob
if job.Config.Digest == "" {
cleanup()
return "", nil, fmt.Errorf("config blob has empty digest, cannot download")
}
slog.Info("Downloading config blob", "digest", job.Config.Digest)
configPath := filepath.Join(imageDir, "config.json")
if err := downloadBlobViaPresignedURL(job.HoldEndpoint, job.HoldDID, job.Config.Digest, configPath); err != nil {
if err := downloadBlobViaPresignedURL(job.HoldEndpoint, job.HoldDID, job.Config.Digest, configPath, secret); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to download config blob: %w", err)
}
@@ -61,10 +65,14 @@ func extractLayers(job *scanner.ScanJob, tmpDir string) (string, func(), error)
// Download and extract each layer
for i, layer := range job.Layers {
if layer.Digest == "" {
slog.Warn("Skipping layer with empty digest", "index", i)
continue
}
slog.Info("Extracting layer", "index", i, "digest", layer.Digest, "size", layer.Size)
layerPath := filepath.Join(layersDir, fmt.Sprintf("layer-%d.tar.gz", i))
if err := downloadBlobViaPresignedURL(job.HoldEndpoint, job.HoldDID, layer.Digest, layerPath); err != nil {
if err := downloadBlobViaPresignedURL(job.HoldEndpoint, job.HoldDID, layer.Digest, layerPath, secret); err != nil {
cleanup()
return "", nil, fmt.Errorf("failed to download layer %d: %w", i, err)
}
@@ -91,8 +99,8 @@ func extractLayers(job *scanner.ScanJob, tmpDir string) (string, func(), error)
}
// downloadBlobViaPresignedURL gets a presigned URL from the hold and downloads the blob
func downloadBlobViaPresignedURL(holdEndpoint, holdDID, digest, destPath string) error {
presignedURL, err := client.GetBlobPresignedURL(holdEndpoint, holdDID, digest)
func downloadBlobViaPresignedURL(holdEndpoint, holdDID, digest, destPath, secret string) error {
presignedURL, err := client.GetBlobPresignedURL(holdEndpoint, holdDID, digest, secret)
if err != nil {
return fmt.Errorf("failed to get presigned URL for %s: %w", digest, err)
}
+1 -1
View File
@@ -106,7 +106,7 @@ func (wp *WorkerPool) processJob(ctx context.Context, job *scanner.ScanJob) (*sc
// Step 1: Extract image layers from hold via presigned URLs
slog.Info("Extracting image layers", "repository", job.Repository)
imageDir, cleanup, err := extractLayers(job, wp.cfg.Vuln.TmpDir)
imageDir, cleanup, err := extractLayers(job, wp.cfg.Vuln.TmpDir, wp.cfg.Hold.Secret)
if err != nil {
return nil, fmt.Errorf("failed to extract layers: %w", err)
}