mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-18 16:24:16 +00:00
289 lines
7.0 KiB
Go
289 lines
7.0 KiB
Go
// Package client implements the bidirectional WebSocket client for communicating
|
|
// with the hold service, plus HTTP helpers for downloading blobs.
|
|
package client
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
|
|
scanner "atcr.io/scanner"
|
|
"atcr.io/scanner/internal/queue"
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// httpClient is used for blob downloads and presigned URL requests
|
|
// with a timeout to prevent stalled connections from leaking memory.
|
|
var httpClient = &http.Client{Timeout: 5 * time.Minute}
|
|
|
|
// HoldClient manages the WebSocket connection to a hold service
|
|
type HoldClient struct {
|
|
holdURL string
|
|
secret string
|
|
queue *queue.JobQueue
|
|
conn *websocket.Conn
|
|
mu sync.Mutex // protects conn writes
|
|
done chan struct{}
|
|
}
|
|
|
|
// NewHoldClient creates a new hold client
|
|
func NewHoldClient(holdURL, secret string, q *queue.JobQueue) *HoldClient {
|
|
return &HoldClient{
|
|
holdURL: holdURL,
|
|
secret: secret,
|
|
queue: q,
|
|
done: make(chan struct{}),
|
|
}
|
|
}
|
|
|
|
// Connect establishes the WebSocket connection with auto-reconnect
|
|
func (c *HoldClient) Connect() {
|
|
var cursor int64 = -1
|
|
|
|
for {
|
|
select {
|
|
case <-c.done:
|
|
return
|
|
default:
|
|
}
|
|
|
|
err := c.connectOnce(cursor)
|
|
if err != nil {
|
|
slog.Error("WebSocket connection failed, reconnecting",
|
|
"error", err)
|
|
}
|
|
|
|
// Exponential backoff with max 30s
|
|
select {
|
|
case <-c.done:
|
|
return
|
|
case <-time.After(5 * time.Second):
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *HoldClient) connectOnce(cursor int64) error {
|
|
// Build WebSocket URL
|
|
u, err := url.Parse(c.holdURL)
|
|
if err != nil {
|
|
return fmt.Errorf("invalid hold URL: %w", err)
|
|
}
|
|
|
|
// Convert http(s) to ws(s)
|
|
switch u.Scheme {
|
|
case "http":
|
|
u.Scheme = "ws"
|
|
case "https":
|
|
u.Scheme = "wss"
|
|
case "ws", "wss":
|
|
// Already correct
|
|
}
|
|
|
|
u.Path = "/xrpc/io.atcr.hold.subscribeScanJobs"
|
|
q := u.Query()
|
|
q.Set("secret", c.secret)
|
|
if cursor >= 0 {
|
|
q.Set("cursor", fmt.Sprintf("%d", cursor))
|
|
}
|
|
u.RawQuery = q.Encode()
|
|
|
|
slog.Info("Connecting to hold service", "url", u.Host)
|
|
|
|
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
|
|
if err != nil {
|
|
return fmt.Errorf("dial failed: %w", err)
|
|
}
|
|
defer conn.Close()
|
|
|
|
c.mu.Lock()
|
|
c.conn = conn
|
|
c.mu.Unlock()
|
|
|
|
slog.Info("Connected to hold service")
|
|
|
|
// Read messages from hold
|
|
for {
|
|
_, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseNormalClosure) {
|
|
slog.Error("WebSocket read error", "error", err)
|
|
}
|
|
return err
|
|
}
|
|
|
|
var raw scanner.ScanJobRaw
|
|
if err := json.Unmarshal(data, &raw); err != nil {
|
|
slog.Error("Failed to unmarshal message", "error", err)
|
|
continue
|
|
}
|
|
|
|
if raw.Type != "job" {
|
|
slog.Warn("Unknown message type from hold", "type", raw.Type)
|
|
continue
|
|
}
|
|
|
|
// Parse config and layers from raw JSON
|
|
var config scanner.BlobDescriptor
|
|
if err := json.Unmarshal(raw.Config, &config); err != nil {
|
|
slog.Error("Failed to unmarshal config", "seq", raw.Seq, "error", err)
|
|
continue
|
|
}
|
|
|
|
var layers []scanner.BlobDescriptor
|
|
if err := json.Unmarshal(raw.Layers, &layers); err != nil {
|
|
slog.Error("Failed to unmarshal layers", "seq", raw.Seq, "error", err)
|
|
continue
|
|
}
|
|
|
|
job := &scanner.ScanJob{
|
|
Seq: raw.Seq,
|
|
ManifestDigest: raw.ManifestDigest,
|
|
Repository: raw.Repository,
|
|
Tag: raw.Tag,
|
|
UserDID: raw.UserDID,
|
|
UserHandle: raw.UserHandle,
|
|
HoldDID: raw.HoldDID,
|
|
HoldEndpoint: raw.HoldEndpoint,
|
|
Tier: raw.Tier,
|
|
Config: config,
|
|
Layers: layers,
|
|
}
|
|
|
|
// Send ack immediately
|
|
c.SendAck(job.Seq)
|
|
|
|
// Enqueue into priority queue
|
|
if !c.queue.Enqueue(job) {
|
|
slog.Warn("Queue full, sending error",
|
|
"seq", job.Seq,
|
|
"repository", job.Repository)
|
|
c.SendError(job.Seq, "scanner queue full")
|
|
}
|
|
}
|
|
}
|
|
|
|
// SendAck sends an acknowledgement for a received job
|
|
func (c *HoldClient) SendAck(seq int64) {
|
|
c.sendJSON(scanner.AckMessage{Type: "ack", Seq: seq})
|
|
}
|
|
|
|
// SendResult sends scan results back to the hold
|
|
func (c *HoldClient) SendResult(seq int64, result *scanner.ScanResult) {
|
|
msg := scanner.ResultMessage{
|
|
Type: "result",
|
|
Seq: seq,
|
|
Summary: result.Summary,
|
|
}
|
|
if result.SBOM != nil {
|
|
msg.SBOM = string(result.SBOM)
|
|
}
|
|
if result.VulnReport != nil {
|
|
msg.VulnReport = string(result.VulnReport)
|
|
}
|
|
c.sendJSON(msg)
|
|
}
|
|
|
|
// SendError sends an error message for a failed scan
|
|
func (c *HoldClient) SendError(seq int64, errMsg string) {
|
|
c.sendJSON(scanner.ErrorMessage{Type: "error", Seq: seq, Error: errMsg})
|
|
}
|
|
|
|
// SendSkipped sends a skipped message for an artifact the scanner intentionally
|
|
// won't process (e.g., helm charts). Distinct from SendError so the hold can
|
|
// distinguish a permanent skip from a retryable failure.
|
|
func (c *HoldClient) SendSkipped(seq int64, reason string) {
|
|
c.sendJSON(scanner.SkippedMessage{Type: "skipped", Seq: seq, Reason: reason})
|
|
}
|
|
|
|
func (c *HoldClient) sendJSON(v any) {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
|
|
if c.conn == nil {
|
|
slog.Warn("Cannot send, no connection")
|
|
return
|
|
}
|
|
|
|
if err := c.conn.WriteJSON(v); err != nil {
|
|
slog.Error("Failed to send WebSocket message", "error", err)
|
|
}
|
|
}
|
|
|
|
// Close shuts down the client
|
|
func (c *HoldClient) Close() {
|
|
close(c.done)
|
|
c.mu.Lock()
|
|
if c.conn != nil {
|
|
c.conn.Close()
|
|
}
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// 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))
|
|
|
|
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 := httpClient.Do(req)
|
|
if err != nil {
|
|
return "", fmt.Errorf("failed to get presigned URL: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(resp.Body)
|
|
return "", fmt.Errorf("hold returned status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
var result struct {
|
|
URL string `json:"url"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
|
return "", fmt.Errorf("failed to decode response: %w", err)
|
|
}
|
|
|
|
return result.URL, nil
|
|
}
|
|
|
|
// DownloadBlob downloads a blob from a presigned URL to a local file
|
|
func DownloadBlob(presignedURL, destPath string) error {
|
|
resp, err := httpClient.Get(presignedURL)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to download blob: %w", err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return fmt.Errorf("download returned status %d", resp.StatusCode)
|
|
}
|
|
|
|
out, err := os.Create(destPath)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create file: %w", err)
|
|
}
|
|
defer out.Close()
|
|
|
|
if _, err := io.Copy(out, resp.Body); err != nil {
|
|
return fmt.Errorf("failed to write blob: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|