fix realip

This commit is contained in:
Evan Jarrett
2025-10-22 22:49:42 -05:00
parent 30ea5256f3
commit 97d1b3cdd5
2 changed files with 876 additions and 0 deletions
+3
View File
@@ -97,6 +97,9 @@ func main() {
// Setup HTTP routes with chi router
r := chi.NewRouter()
// Add RealIP middleware to extract real client IP from proxy headers
r.Use(middleware.RealIP)
// Add logging middleware to log all HTTP requests
r.Use(middleware.Logger)
+873
View File
@@ -0,0 +1,873 @@
# Bluesky Manifest Posts
## Overview
This document describes the feature for posting to Bluesky when OCI manifests are uploaded to ATCR holds. When a user pushes an image to the registry, the hold's embedded PDS will:
1. Create `io.atcr.hold.layer` records for structured metadata tracking
2. Post to Bluesky announcing the push (similar to the "what's new" feed on the AppView web UI)
## Architecture
### High-Level Flow
```
User pushes image
AppView receives manifest PUT request
AppView stores manifest in user's PDS
AppView notifies hold via XRPC
Hold creates layer records in embedded PDS
Hold creates Bluesky post
Post appears in Bluesky feed
```
### Component Interactions
**AppView** (`pkg/appview/storage/manifest_store.go`):
- After successfully uploading manifest to user's PDS
- Extracts manifest metadata (repository, tag, user info, layers)
- Calls hold's `io.atcr.hold.notifyManifest` XRPC endpoint
- Uses service token from user's PDS for authentication
- Gracefully handles notification failures (doesn't fail manifest upload)
**Hold** (`pkg/hold/oci/xrpc.go`):
- Receives manifest notification via new XRPC endpoint
- Validates service token and extracts user DID
- Creates layer records for each blob reference in manifest
- Creates Bluesky post announcing the push
- Returns success/failure status
**Hold's Embedded PDS** (`pkg/hold/pds/`):
- Stores layer records in `io.atcr.hold.layer` collection
- Stores Bluesky posts in `app.bsky.feed.post` collection
- Both are ATProto records with auto-generated TID rkeys
- Queryable via standard ATProto sync endpoints
## Implementation Details
### 1. Layer Record Schema
**File**: `pkg/atproto/lexicon.go`
**Collection**: `io.atcr.hold.layer`
**Purpose**: Structured metadata about container layers stored in the hold
**Schema**:
```go
type LayerRecord struct {
// Type identifier (always "io.atcr.hold.layer")
Type string `json:"$type" cborgen:"$type"`
// Digest of the layer (e.g., "sha256:abc123...")
Digest string `json:"digest" cborgen:"digest"`
// Size in bytes
Size int64 `json:"size" cborgen:"size"`
// MediaType of the layer
MediaType string `json:"mediaType" cborgen:"mediaType"`
// Repository this layer belongs to (e.g., "alice/myapp")
Repository string `json:"repository" cborgen:"repository"`
// User DID who uploaded this layer
UserDID string `json:"userDid" cborgen:"userDid"`
// User handle (for display purposes)
UserHandle string `json:"userHandle,omitempty" cborgen:"userHandle,omitempty"`
// Timestamp
CreatedAt time.Time `json:"createdAt" cborgen:"createdAt"`
}
```
**Constructor**:
```go
func NewLayerRecord(digest string, size int64, mediaType, repository, userDID, userHandle string) *LayerRecord {
return &LayerRecord{
Type: LayerCollection,
Digest: digest,
Size: size,
MediaType: mediaType,
Repository: repository,
UserDID: userDID,
UserHandle: userHandle,
CreatedAt: time.Now(),
}
}
```
**Why CBOR tags**: The hold's embedded PDS uses CBOR encoding for efficient storage in the SQLite-backed carstore. All records stored in the hold must have `cborgen:` tags.
### 2. XRPC Manifest Notification Endpoint
**File**: `pkg/hold/oci/xrpc.go`
**Endpoint**: `POST /xrpc/io.atcr.hold.notifyManifest`
**Authentication**: Service token from user's PDS (same pattern as blob upload endpoints)
**Request Schema**:
```go
type NotifyManifestRequest struct {
// Repository name (e.g., "alice/myapp")
Repository string `json:"repository"`
// Tag (e.g., "latest", "v1.0.0")
Tag string `json:"tag"`
// User DID (e.g., "did:plc:abc123")
UserDID string `json:"userDid"`
// User handle (e.g., "alice.bsky.social")
UserHandle string `json:"userHandle"`
// Manifest content (parsed from uploaded manifest)
Manifest struct {
MediaType string `json:"mediaType"`
Config struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
} `json:"config"`
Layers []struct {
Digest string `json:"digest"`
Size int64 `json:"size"`
MediaType string `json:"mediaType"`
} `json:"layers"`
} `json:"manifest"`
}
```
**Response Schema**:
```go
type NotifyManifestResponse struct {
Success bool `json:"success"`
LayersCreated int `json:"layersCreated"`
PostCreated bool `json:"postCreated"`
PostURI string `json:"postUri,omitempty"` // ATProto URI if post created
Error string `json:"error,omitempty"`
}
```
**Handler Implementation**:
```go
func (h *XRPCHandler) HandleNotifyManifest(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
// 1. Validate service token (reuse existing auth middleware pattern)
userDID, err := h.validateServiceToken(ctx, r)
if err != nil {
writeXRPCError(w, "InvalidToken", err.Error())
return
}
// 2. Parse request
var req NotifyManifestRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeXRPCError(w, "InvalidRequest", err.Error())
return
}
// 3. Verify user DID matches token
if req.UserDID != userDID {
writeXRPCError(w, "Unauthorized", "user DID mismatch")
return
}
// 4. Create layer records for each blob
layersCreated := 0
for _, layer := range req.Manifest.Layers {
record := atproto.NewLayerRecord(
layer.Digest,
layer.Size,
layer.MediaType,
req.Repository,
req.UserDID,
req.UserHandle,
)
_, _, err := h.pds.CreateLayerRecord(ctx, record)
if err != nil {
log.Printf("Failed to create layer record: %v", err)
// Continue creating other records
} else {
layersCreated++
}
}
// 5. Create Bluesky post
postURI, err := h.pds.CreateManifestPost(ctx, req.Repository, req.Tag, req.UserHandle)
// 6. Return response
resp := NotifyManifestResponse{
Success: layersCreated > 0 || err == nil,
LayersCreated: layersCreated,
PostCreated: err == nil,
PostURI: postURI,
}
if err != nil && layersCreated == 0 {
resp.Error = err.Error()
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(resp)
}
```
### 3. Hold PDS Layer Record Methods
**File**: `pkg/hold/pds/layer.go` (new file)
**Methods**:
```go
// CreateLayerRecord creates a new layer record in the hold's PDS
func (p *HoldPDS) CreateLayerRecord(ctx context.Context, record *atproto.LayerRecord) (string, string, error) {
// Validate record
if record.Type != atproto.LayerCollection {
return "", "", fmt.Errorf("invalid record type: %s", record.Type)
}
if record.Digest == "" {
return "", "", fmt.Errorf("digest is required")
}
// Create record with auto-generated TID rkey
rkey, recordCID, err := p.repomgr.CreateRecord(
ctx,
p.uid,
atproto.LayerCollection,
record,
)
if err != nil {
return "", "", fmt.Errorf("failed to create layer record: %w", err)
}
log.Printf("Created layer record at %s/%s (digest: %s, size: %d)",
atproto.LayerCollection, rkey, record.Digest, record.Size)
return rkey, recordCID.String(), nil
}
// ListLayerRecords lists layer records with optional filtering
func (p *HoldPDS) ListLayerRecords(ctx context.Context, limit int, cursor string) ([]*atproto.LayerRecord, string, error) {
// Implementation using repomgr.GetRecord for pagination
// This would query the carstore and unmarshal layer records
// Return records + next cursor for pagination
}
// GetLayerRecord retrieves a specific layer record by rkey
func (p *HoldPDS) GetLayerRecord(ctx context.Context, rkey string) (*atproto.LayerRecord, error) {
// Implementation using repomgr.GetRecord
}
```
### 4. Bluesky Post Creation
**File**: `pkg/hold/pds/manifest_post.go` (new file)
**Pattern**: Reuse existing `status.go` pattern
```go
// CreateManifestPost creates a Bluesky post announcing a manifest upload
func (p *HoldPDS) CreateManifestPost(ctx context.Context, repository, tag, userHandle string) (string, error) {
now := time.Now()
// Format post text (similar to "what's new" feed)
text := formatManifestPostText(repository, tag, userHandle)
// Create post struct
post := &bsky.FeedPost{
LexiconTypeID: "app.bsky.feed.post",
Text: text,
CreatedAt: now.Format(time.RFC3339),
// Optional: Add embed with link to AppView
// Embed: &bsky.FeedPost_Embed{...}
}
// Create record with auto-generated TID
rkey, recordCID, err := p.repomgr.CreateRecord(
ctx,
p.uid,
"app.bsky.feed.post",
post,
)
if err != nil {
return "", fmt.Errorf("failed to create manifest post: %w", err)
}
// Build ATProto URI for the post
postURI := fmt.Sprintf("at://%s/app.bsky.feed.post/%s", p.did, rkey)
log.Printf("Created manifest post: %s (cid: %s)", postURI, recordCID)
return postURI, nil
}
// formatManifestPostText generates the post text
func formatManifestPostText(repository, tag, userHandle string) string {
// Example formats:
// "@alice.bsky.social pushed alice/myapp:latest to ATCR"
// "New image pushed: alice/myapp:v1.0.0 by @alice.bsky.social"
// "📦 alice/myapp:latest pushed by @alice.bsky.social"
return fmt.Sprintf("📦 %s:%s pushed by @%s", repository, tag, userHandle)
}
```
**Advanced Post Options**:
```go
// Example with embedded link to AppView
post := &bsky.FeedPost{
LexiconTypeID: "app.bsky.feed.post",
Text: text,
CreatedAt: now.Format(time.RFC3339),
Embed: &bsky.FeedPost_Embed{
FeedPost_External: &bsky.EmbedExternal{
External: &bsky.EmbedExternal_External{
Uri: fmt.Sprintf("https://atcr.io/%s", repository),
Title: fmt.Sprintf("%s:%s", repository, tag),
Description: "View on ATCR",
},
},
},
}
// Example with facets (mentions)
// This would require parsing the text and creating facet structs
// for @mentions to be clickable in Bluesky
```
### 5. AppView Integration
**File**: `pkg/appview/storage/manifest_store.go`
**Integration Point**: After `client.PutRecord()` succeeds (around line 130-140)
```go
// Existing code:
recordURI, recordCID, err := ms.client.PutRecord(ctx, atproto.ManifestCollection, rkey, manifestRecord)
if err != nil {
return "", fmt.Errorf("failed to store manifest in PDS: %w", err)
}
// NEW: Notify hold about manifest upload
if err := ms.notifyHoldAboutManifest(ctx, desc, manifestRecord, tag); err != nil {
// Log error but don't fail the manifest upload
log.Printf("Failed to notify hold about manifest: %v", err)
}
return desc.Digest.String(), nil
```
**Implementation**:
```go
// notifyHoldAboutManifest sends manifest metadata to the hold
func (ms *ManifestStore) notifyHoldAboutManifest(
ctx context.Context,
desc distribution.Descriptor,
manifestRecord *atproto.ManifestRecord,
tag string,
) error {
// 1. Get registry context
regCtx, err := storage.GetRegistryContext(ctx)
if err != nil {
return fmt.Errorf("failed to get registry context: %w", err)
}
// 2. Resolve hold DID to endpoint
holdEndpoint, err := ms.resolver.ResolveDIDToHTTPEndpoint(ctx, manifestRecord.HoldDID)
if err != nil {
return fmt.Errorf("failed to resolve hold DID: %w", err)
}
// 3. Get service token from user's PDS
serviceToken, err := regCtx.Refresher.GetServiceToken(ctx, regCtx.DID, manifestRecord.HoldDID)
if err != nil {
return fmt.Errorf("failed to get service token: %w", err)
}
// 4. Parse manifest to extract layer info
var parsedManifest struct {
MediaType string `json:"mediaType"`
Config distribution.Descriptor `json:"config"`
Layers []distribution.Descriptor `json:"layers"`
}
if err := json.Unmarshal(manifestRecord.ManifestBlob.Data, &parsedManifest); err != nil {
return fmt.Errorf("failed to parse manifest: %w", err)
}
// 5. Build notification request
notifyReq := map[string]interface{}{
"repository": ms.repository,
"tag": tag,
"userDid": regCtx.DID,
"userHandle": regCtx.Handle, // Need to add this to RegistryContext
"manifest": map[string]interface{}{
"mediaType": parsedManifest.MediaType,
"config": map[string]interface{}{
"digest": parsedManifest.Config.Digest.String(),
"size": parsedManifest.Config.Size,
},
"layers": func() []map[string]interface{} {
layers := make([]map[string]interface{}, len(parsedManifest.Layers))
for i, layer := range parsedManifest.Layers {
layers[i] = map[string]interface{}{
"digest": layer.Digest.String(),
"size": layer.Size,
"mediaType": layer.MediaType,
}
}
return layers
}(),
},
}
// 6. Call hold's XRPC endpoint
reqBody, _ := json.Marshal(notifyReq)
req, err := http.NewRequestWithContext(
ctx,
"POST",
holdEndpoint+"/xrpc/io.atcr.hold.notifyManifest",
bytes.NewReader(reqBody),
)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+serviceToken)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("hold notification failed: %s (status: %d)", body, resp.StatusCode)
}
// 7. Parse response (optional logging)
var notifyResp map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&notifyResp); err == nil {
log.Printf("Hold notification successful: %+v", notifyResp)
}
return nil
}
```
### 6. Record Type Registration
**File**: `pkg/hold/pds/server.go`
**In `init()` function** (around line 30):
```go
func init() {
// Existing registrations
lexutil.RegisterType(atproto.CaptainCollection, &atproto.CaptainRecord{})
lexutil.RegisterType(atproto.CrewCollection, &atproto.CrewRecord{})
lexutil.RegisterType(atproto.TangledProfileCollection, &atproto.TangledProfileRecord{})
// NEW: Register layer record type
lexutil.RegisterType(atproto.LayerCollection, &atproto.LayerRecord{})
}
```
**Why needed**: ATProto's CBOR unmarshaling requires type registration to automatically deserialize records when reading from the carstore.
## Testing Strategy
### Unit Tests
**Test Layer Record Creation** (`pkg/hold/pds/layer_test.go`):
```go
func TestCreateLayerRecord(t *testing.T) {
pds := setupTestPDS(t)
ctx := context.Background()
record := atproto.NewLayerRecord(
"sha256:abc123",
1024,
"application/vnd.docker.image.rootfs.diff.tar.gzip",
"alice/myapp",
"did:plc:alice123",
"alice.bsky.social",
)
rkey, cid, err := pds.CreateLayerRecord(ctx, record)
assert.NoError(t, err)
assert.NotEmpty(t, rkey)
assert.NotEmpty(t, cid)
// Verify record was stored
retrieved, err := pds.GetLayerRecord(ctx, rkey)
assert.NoError(t, err)
assert.Equal(t, record.Digest, retrieved.Digest)
}
```
**Test Manifest Post Creation** (`pkg/hold/pds/manifest_post_test.go`):
```go
func TestCreateManifestPost(t *testing.T) {
pds := setupTestPDS(t)
ctx := context.Background()
postURI, err := pds.CreateManifestPost(ctx, "alice/myapp", "latest", "alice.bsky.social")
assert.NoError(t, err)
assert.Contains(t, postURI, "app.bsky.feed.post")
// Parse URI and verify post exists
// at://did:web:hold01.atcr.io/app.bsky.feed.post/{rkey}
}
```
**Test XRPC Endpoint** (`pkg/hold/oci/xrpc_test.go`):
```go
func TestHandleNotifyManifest(t *testing.T) {
handler := setupTestHandler(t)
req := NotifyManifestRequest{
Repository: "alice/myapp",
Tag: "latest",
UserDID: "did:plc:alice123",
UserHandle: "alice.bsky.social",
Manifest: /* ... */,
}
// Make HTTP request with service token
resp := makeRequest(t, handler, req, validServiceToken)
assert.Equal(t, http.StatusOK, resp.StatusCode)
var result NotifyManifestResponse
json.NewDecoder(resp.Body).Decode(&result)
assert.True(t, result.Success)
assert.Equal(t, 3, result.LayersCreated) // if manifest has 3 layers
assert.True(t, result.PostCreated)
}
```
### Integration Tests
**End-to-End Test**:
1. Push a test image to ATCR
2. Verify manifest is stored in user's PDS
3. Verify layer records are created in hold's PDS
4. Verify Bluesky post is created in hold's PDS
5. Query ATProto endpoints to retrieve records
## Error Handling
### AppView Side
**Notification failures should NOT break manifest uploads**:
- If hold is unreachable: Log error, continue
- If service token fails: Log error, continue
- If hold returns error: Log error, continue
**Rationale**: Bluesky posts are a "nice to have" feature, not critical infrastructure. Image pushes must succeed even if social features fail.
### Hold Side
**Partial failures are acceptable**:
- If some layer records fail: Create what we can, return partial success
- If Bluesky post fails but layers succeed: Return success with `postCreated: false`
- If all operations fail: Return error response
**Logging**:
- Log all errors for debugging
- Include user DID, repository, and error details
- Use structured logging for easy querying
## Configuration
### Environment Variables
**Hold Service** (`.env.hold.example`):
```bash
# Enable/disable Bluesky posting
HOLD_BLUESKY_POSTS_ENABLED=true
# Enable/disable layer record creation
HOLD_LAYER_RECORDS_ENABLED=true
```
**AppView** (`.env.appview.example`):
```bash
# Enable/disable manifest notifications to holds
ATCR_NOTIFY_HOLDS_ENABLED=true
```
### Feature Flags
Consider making this feature opt-in initially:
- Add flag to captain record: `enableSocialPosts bool`
- Check flag before creating posts
- Allow hold owners to disable social features
## Performance Considerations
### Database Impact
**Layer records**: Each manifest upload creates N records (where N = number of layers)
- Typical image: 5-10 layers
- Large image: 50+ layers
- Storage: ~500 bytes per record (CBOR compressed)
**Bluesky posts**: One post per manifest
- Storage: ~200 bytes per post
- Indexed by creation time for feed queries
**Carstore growth**: Estimate ~5KB per manifest upload (records + post)
### Network Impact
**AppView → Hold notification**:
- One HTTP POST per manifest upload
- Payload size: ~2-10KB (depends on layer count)
- Should complete in <100ms on local network
**Service token requests**:
- Tokens cached for 50 seconds
- Minimal overhead if pushing multiple manifests quickly
### Optimization Opportunities
1. **Batch layer record creation**: Use `BatchWrite` for multiple records
2. **Async processing**: Queue notifications and process in background
3. **Rate limiting**: Limit posts per user/hold to prevent spam
4. **Deduplication**: Skip layer records for already-seen digests
## Future Enhancements
### Phase 2: Enhanced Posts
**Rich embeds**:
- Link preview to AppView repository page
- Thumbnail image from first layer
- Metadata badges (image size, layer count, tags)
**Mentions**:
- Parse user handle and create Bluesky facets for @mentions
- Enable clickable mentions in posts
**Tags/hashtags**:
- Add `#container`, `#docker`, repository tags
- Improve discoverability in Bluesky
### Phase 3: Feed Customization
**Hold-specific feeds**:
- Query layer records by repository
- Filter by user DID
- Time-based queries
**ATProto feed generator**:
- Implement `app.bsky.feed.getFeedSkeleton` XRPC endpoint
- Publish hold's feed to Bluesky
- Users can subscribe to hold activity feeds
### Phase 4: Analytics
**Track metrics**:
- Posts per day/week/month
- Most active users
- Most popular repositories
- Storage growth over time
**Dashboards**:
- Visualize activity on AppView UI
- Show trending images
- Leaderboards for most pushed repositories
## Security Considerations
### Authentication
**Service tokens**:
- Validate tokens against user's PDS
- Verify DID matches in token claims
- Check token expiration (60s from PDS)
**Authorization**:
- Only authenticated users can trigger posts
- Posts created under hold's DID (not user's DID)
- User information is metadata in post text
### Privacy
**User handles**:
- Posts include user handle (`@alice.bsky.social`)
- Consider opt-out mechanism for privacy-conscious users
**Repository names**:
- Public information (already visible in AppView)
- Consider private repository flags in future
### Rate Limiting
**Prevent spam**:
- Limit posts per user per hour
- Detect rapid-fire pushes (CI/CD)
- Consider aggregating multiple pushes into single post
**Resource protection**:
- Limit layer record creation to prevent storage exhaustion
- Cap manifest notification payload size
- Timeout long-running operations
## Monitoring and Observability
### Metrics to Track
**AppView**:
- `atcr_hold_notifications_total` - Counter of notifications sent
- `atcr_hold_notifications_errors` - Counter of failures
- `atcr_hold_notification_duration_ms` - Histogram of latency
**Hold**:
- `hold_layer_records_created_total` - Counter of layer records
- `hold_bluesky_posts_created_total` - Counter of posts
- `hold_manifest_notifications_received_total` - Counter of incoming notifications
- `hold_notification_errors_total` - Counter of errors by type
### Logging
**Structured logs**:
```json
{
"level": "info",
"msg": "manifest notification received",
"repository": "alice/myapp",
"tag": "latest",
"userDid": "did:plc:alice123",
"layerCount": 5,
"layersCreated": 5,
"postCreated": true,
"duration_ms": 45
}
```
### Alerts
**Critical issues**:
- High error rate (>10% failures)
- Service token failures (auth issues)
- PDS carstore errors (database problems)
**Warning issues**:
- Slow notifications (>1s latency)
- Partial failures (some layers not created)
- Missing user handle in context
## Migration Strategy
### Rollout Plan
**Phase 1: Development**
- Implement core functionality
- Add comprehensive tests
- Deploy to staging environment
**Phase 2: Beta**
- Enable for test holds only
- Gather feedback from early users
- Monitor performance and errors
**Phase 3: Opt-in**
- Add configuration flags
- Allow hold owners to enable feature
- Document setup process
**Phase 4: Default On**
- Enable by default for new holds
- Migrate existing holds (opt-out available)
- Announce feature publicly
### Backward Compatibility
**No breaking changes**:
- New XRPC endpoint (doesn't affect existing endpoints)
- New record types (isolated collections)
- Optional feature (can be disabled)
**Existing holds**:
- Work without changes
- Can opt-in by updating hold service
- No data migration required
## Example Post Formats
### Simple Format
```
📦 alice/myapp:latest pushed by @alice.bsky.social
```
### Detailed Format
```
📦 New container image pushed!
alice/myapp:v1.2.3
Pushed by @alice.bsky.social
5 layers, 125 MB total
View: https://atcr.io/alice/myapp
```
### With Emoji/Styling
```
🚀 alice/myapp:latest
✅ 5 layers
📦 125.4 MB
👤 @alice.bsky.social
🔗 atcr.io/alice/myapp
```
### With Tags
```
📦 alice/myapp:latest pushed by @alice.bsky.social
#container #docker #atcr
```
## References
### Related Code
- Existing Bluesky post implementation: `pkg/hold/pds/status.go`
- XRPC endpoint pattern: `pkg/hold/oci/xrpc.go`
- Record type definitions: `pkg/atproto/lexicon.go`
- Manifest storage: `pkg/appview/storage/manifest_store.go`
- Service token handling: `pkg/auth/oauth/refresher.go`
### External Documentation
- ATProto Record Schema: https://atproto.com/specs/record-key
- Bluesky Post Lexicon: https://atproto.com/lexicons/app-bsky-feed#appbskyfeedpost
- CBOR Encoding: https://cbor.io/
- Bluesky Facets (mentions/links): https://atproto.com/specs/richtext
### Tools
- CBOR code generation: `github.com/whyrusleeping/cbor-gen`
- ATProto libraries: `github.com/bluesky-social/indigo`
- Testing: Standard Go testing + `testify/assert`