mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-20 09:14:16 +00:00
clean up old docs add quotas prelim spec
This commit is contained in:
@@ -1,826 +0,0 @@
|
||||
# API Key Migration Plan
|
||||
|
||||
## Overview
|
||||
|
||||
Replace the session token system (used only by credential helper) with API keys that link to OAuth sessions. This simplifies authentication while maintaining all use cases.
|
||||
|
||||
## Current State
|
||||
|
||||
### Three Separate Auth Systems
|
||||
|
||||
1. **Session Tokens** (`pkg/auth/session/`)
|
||||
- JWT-like tokens: `<base64_claims>.<base64_signature>`
|
||||
- Created after OAuth callback, shown to user to copy
|
||||
- User manually pastes into credential helper config
|
||||
- Validated in `/auth/token` and `/auth/exchange`
|
||||
- 30-day TTL
|
||||
- **Problem:** Awkward UX, requires manual copy/paste
|
||||
|
||||
2. **UI Sessions** (`pkg/appview/session/`)
|
||||
- Cookie-based (`atcr_session`)
|
||||
- Random session ID, server-side store
|
||||
- 24-hour TTL
|
||||
- **Keep this - works well**
|
||||
|
||||
3. **App Password Auth** (via PDS)
|
||||
- Direct `com.atproto.server.createSession` call
|
||||
- No AppView involvement until token request
|
||||
- **Keep this - essential for non-UI users**
|
||||
|
||||
## Target State
|
||||
|
||||
### Two Auth Methods
|
||||
|
||||
1. **API Keys** (NEW - replaces session tokens)
|
||||
- Generated in UI after OAuth login
|
||||
- Format: `atcr_<32_bytes_base64>`
|
||||
- Linked to server-side OAuth refresh token
|
||||
- Multiple keys per user (laptop, CI/CD, etc.)
|
||||
- Revocable without re-auth
|
||||
|
||||
2. **App Passwords** (KEEP)
|
||||
- Direct PDS authentication
|
||||
- Works without UI/OAuth
|
||||
|
||||
### UI Sessions (UNCHANGED)
|
||||
- Cookie-based for web UI
|
||||
- Separate system, no changes needed
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: API Key System
|
||||
|
||||
#### 1.1 Create API Key Store (`pkg/appview/apikey/store.go`)
|
||||
|
||||
```go
|
||||
package apikey
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
"time"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
// APIKey represents a user's API key
|
||||
type APIKey struct {
|
||||
ID string `json:"id"` // UUID
|
||||
KeyHash string `json:"key_hash"` // bcrypt hash
|
||||
DID string `json:"did"` // Owner's DID
|
||||
Handle string `json:"handle"` // Owner's handle
|
||||
Name string `json:"name"` // User-provided name
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastUsed time.Time `json:"last_used"`
|
||||
}
|
||||
|
||||
// Store manages API keys
|
||||
type Store struct {
|
||||
mu sync.RWMutex
|
||||
keys map[string]*APIKey // keyHash -> APIKey
|
||||
byDID map[string][]string // DID -> []keyHash
|
||||
filePath string // /var/lib/atcr/api-keys.json
|
||||
}
|
||||
|
||||
// NewStore creates a new API key store
|
||||
func NewStore(filePath string) (*Store, error)
|
||||
|
||||
// Generate creates a new API key and returns the plaintext key (shown once)
|
||||
func (s *Store) Generate(did, handle, name string) (key string, keyID string, err error)
|
||||
|
||||
// Validate checks if an API key is valid and returns the associated data
|
||||
func (s *Store) Validate(key string) (*APIKey, error)
|
||||
|
||||
// List returns all API keys for a DID (without plaintext keys)
|
||||
func (s *Store) List(did string) []*APIKey
|
||||
|
||||
// Delete removes an API key
|
||||
func (s *Store) Delete(did, keyID string) error
|
||||
|
||||
// UpdateLastUsed updates the last used timestamp
|
||||
func (s *Store) UpdateLastUsed(keyHash string) error
|
||||
```
|
||||
|
||||
**Key Generation:**
|
||||
```go
|
||||
func (s *Store) Generate(did, handle, name string) (string, string, error) {
|
||||
// Generate 32 random bytes
|
||||
b := make([]byte, 32)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Format: atcr_<base64>
|
||||
key := "atcr_" + base64.RawURLEncoding.EncodeToString(b)
|
||||
|
||||
// Hash for storage
|
||||
keyHash, err := bcrypt.GenerateFromPassword([]byte(key), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
// Generate ID
|
||||
keyID := generateUUID()
|
||||
|
||||
apiKey := &APIKey{
|
||||
ID: keyID,
|
||||
KeyHash: string(keyHash),
|
||||
DID: did,
|
||||
Handle: handle,
|
||||
Name: name,
|
||||
CreatedAt: time.Now(),
|
||||
LastUsed: time.Time{}, // Never used yet
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.keys[string(keyHash)] = apiKey
|
||||
s.byDID[did] = append(s.byDID[did], string(keyHash))
|
||||
s.mu.Unlock()
|
||||
|
||||
s.save()
|
||||
|
||||
// Return plaintext key (only time it's available)
|
||||
return key, keyID, nil
|
||||
}
|
||||
```
|
||||
|
||||
**Key Validation:**
|
||||
```go
|
||||
func (s *Store) Validate(key string) (*APIKey, error) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
|
||||
// Try to match against all stored hashes
|
||||
for hash, apiKey := range s.keys {
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(hash), []byte(key)); err == nil {
|
||||
// Update last used asynchronously
|
||||
go s.UpdateLastUsed(hash)
|
||||
return apiKey, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("invalid API key")
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 Add API Key Handlers (`pkg/appview/handlers/apikeys.go`)
|
||||
|
||||
```go
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"github.com/gorilla/mux"
|
||||
"atcr.io/pkg/appview/apikey"
|
||||
"atcr.io/pkg/appview/middleware"
|
||||
)
|
||||
|
||||
// GenerateAPIKeyHandler handles POST /api/keys
|
||||
type GenerateAPIKeyHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *GenerateAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
name := r.FormValue("name")
|
||||
if name == "" {
|
||||
name = "Unnamed Key"
|
||||
}
|
||||
|
||||
key, keyID, err := h.Store.Generate(user.DID, user.Handle, name)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Return key (shown once!)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"id": keyID,
|
||||
"key": key,
|
||||
})
|
||||
}
|
||||
|
||||
// ListAPIKeysHandler handles GET /api/keys
|
||||
type ListAPIKeysHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *ListAPIKeysHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
keys := h.Store.List(user.DID)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(keys)
|
||||
}
|
||||
|
||||
// DeleteAPIKeyHandler handles DELETE /api/keys/{id}
|
||||
type DeleteAPIKeyHandler struct {
|
||||
Store *apikey.Store
|
||||
}
|
||||
|
||||
func (h *DeleteAPIKeyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
user := middleware.GetUser(r)
|
||||
if user == nil {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
vars := mux.Vars(r)
|
||||
keyID := vars["id"]
|
||||
|
||||
if err := h.Store.Delete(user.DID, keyID); err != nil {
|
||||
http.Error(w, "Failed to delete key", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: Update Token Handler
|
||||
|
||||
#### 2.1 Modify `/auth/token` Handler (`pkg/auth/token/handler.go`)
|
||||
|
||||
```go
|
||||
type Handler struct {
|
||||
issuer *Issuer
|
||||
validator *atproto.SessionValidator
|
||||
apiKeyStore *apikey.Store // NEW
|
||||
defaultHoldEndpoint string
|
||||
}
|
||||
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
username, password, ok := r.BasicAuth()
|
||||
if !ok {
|
||||
return unauthorized
|
||||
}
|
||||
|
||||
var did, handle, accessToken string
|
||||
|
||||
// 1. Check if it's an API key (NEW)
|
||||
if strings.HasPrefix(password, "atcr_") {
|
||||
apiKey, err := h.apiKeyStore.Validate(password)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [token/handler]: API key validation failed: %v\n", err)
|
||||
return unauthorized
|
||||
}
|
||||
|
||||
did = apiKey.DID
|
||||
handle = apiKey.Handle
|
||||
fmt.Printf("DEBUG [token/handler]: API key validated for DID=%s, handle=%s\n", did, handle)
|
||||
|
||||
// API key is linked to OAuth session
|
||||
// OAuth refresher will provide access token when needed via middleware
|
||||
}
|
||||
// 2. Try app password (direct PDS)
|
||||
else {
|
||||
did, handle, accessToken, err = h.validator.CreateSessionAndGetToken(r.Context(), username, password)
|
||||
if err != nil {
|
||||
fmt.Printf("DEBUG [token/handler]: App password validation failed: %v\n", err)
|
||||
return unauthorized
|
||||
}
|
||||
|
||||
fmt.Printf("DEBUG [token/handler]: App password validated, DID=%s\n", did)
|
||||
|
||||
// Cache access token for manifest operations
|
||||
auth.GetGlobalTokenCache().Set(did, accessToken, 2*time.Hour)
|
||||
|
||||
// Ensure profile exists
|
||||
// ... existing code ...
|
||||
}
|
||||
|
||||
// Rest of handler: validate access, issue JWT, etc.
|
||||
// ... existing code ...
|
||||
}
|
||||
```
|
||||
|
||||
**Key Changes:**
|
||||
- Remove session token validation (`sessionManager.Validate()`)
|
||||
- Add API key check as first priority
|
||||
- Keep app password as fallback
|
||||
- API keys use OAuth refresher (server-side), app passwords use token cache (client-side)
|
||||
|
||||
#### 2.2 Remove `/auth/exchange` Endpoint
|
||||
|
||||
The `/auth/exchange` endpoint was only used for exchanging session tokens for registry JWTs. With API keys, this is no longer needed.
|
||||
|
||||
**Files to delete:**
|
||||
- `pkg/auth/exchange/handler.go`
|
||||
|
||||
**Files to update:**
|
||||
- `cmd/appview/serve.go` - Remove exchange handler registration
|
||||
|
||||
### Phase 3: Update UI
|
||||
|
||||
#### 3.1 Add API Keys Section to Settings Page
|
||||
|
||||
**Template** (`pkg/appview/templates/settings.html`):
|
||||
|
||||
```html
|
||||
<!-- Add after existing profile settings -->
|
||||
<section class="api-keys">
|
||||
<h2>API Keys</h2>
|
||||
<p>Generate API keys for Docker CLI and CI/CD. Each key is linked to your OAuth session.</p>
|
||||
|
||||
<!-- Generate New Key -->
|
||||
<div class="generate-key">
|
||||
<h3>Generate New API Key</h3>
|
||||
<form id="generate-key-form">
|
||||
<input type="text" id="key-name" placeholder="Key name (e.g., My Laptop)" required>
|
||||
<button type="submit">Generate Key</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Key Generated Modal (shown once) -->
|
||||
<div id="key-modal" class="modal hidden">
|
||||
<div class="modal-content">
|
||||
<h3>✓ API Key Generated!</h3>
|
||||
<p><strong>Copy this key now - it won't be shown again:</strong></p>
|
||||
<div class="key-display">
|
||||
<code id="generated-key"></code>
|
||||
<button onclick="copyKey()">Copy to Clipboard</button>
|
||||
</div>
|
||||
<div class="usage-instructions">
|
||||
<h4>Using with Docker:</h4>
|
||||
<pre>docker login atcr.io -u <span class="handle">{{.Profile.Handle}}</span> -p <span class="key-placeholder">[paste key here]</span></pre>
|
||||
</div>
|
||||
<button onclick="closeModal()">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Existing Keys List -->
|
||||
<div class="keys-list">
|
||||
<h3>Your API Keys</h3>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Created</th>
|
||||
<th>Last Used</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="keys-table">
|
||||
<!-- Populated via JavaScript -->
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
// Generate key
|
||||
document.getElementById('generate-key-form').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const name = document.getElementById('key-name').value;
|
||||
|
||||
const resp = await fetch('/api/keys', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
|
||||
body: `name=${encodeURIComponent(name)}`
|
||||
});
|
||||
|
||||
const data = await resp.json();
|
||||
|
||||
// Show key in modal (only time it's available)
|
||||
document.getElementById('generated-key').textContent = data.key;
|
||||
document.getElementById('key-modal').classList.remove('hidden');
|
||||
|
||||
// Refresh keys list
|
||||
loadKeys();
|
||||
});
|
||||
|
||||
// Copy key to clipboard
|
||||
function copyKey() {
|
||||
const key = document.getElementById('generated-key').textContent;
|
||||
navigator.clipboard.writeText(key);
|
||||
alert('Copied to clipboard!');
|
||||
}
|
||||
|
||||
// Load existing keys
|
||||
async function loadKeys() {
|
||||
const resp = await fetch('/api/keys');
|
||||
const keys = await resp.json();
|
||||
|
||||
const tbody = document.getElementById('keys-table');
|
||||
tbody.innerHTML = keys.map(key => `
|
||||
<tr>
|
||||
<td>${key.name}</td>
|
||||
<td>${new Date(key.created_at).toLocaleDateString()}</td>
|
||||
<td>${key.last_used ? new Date(key.last_used).toLocaleDateString() : 'Never'}</td>
|
||||
<td><button onclick="deleteKey('${key.id}')">Revoke</button></td>
|
||||
</tr>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
// Delete key
|
||||
async function deleteKey(id) {
|
||||
if (!confirm('Are you sure you want to revoke this key?')) return;
|
||||
|
||||
await fetch(`/api/keys/${id}`, { method: 'DELETE' });
|
||||
loadKeys();
|
||||
}
|
||||
|
||||
// Load keys on page load
|
||||
loadKeys();
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.modal.hidden { display: none; }
|
||||
.modal {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.modal-content {
|
||||
background: white;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
max-width: 600px;
|
||||
}
|
||||
.key-display {
|
||||
background: #f5f5f5;
|
||||
padding: 1rem;
|
||||
margin: 1rem 0;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.key-display code {
|
||||
word-break: break-all;
|
||||
font-size: 14px;
|
||||
}
|
||||
.usage-instructions {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background: #e3f2fd;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.usage-instructions pre {
|
||||
background: #263238;
|
||||
color: #aed581;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
.handle { color: #ffab40; }
|
||||
.key-placeholder { color: #64b5f6; }
|
||||
</style>
|
||||
```
|
||||
|
||||
#### 3.2 Register API Key Routes (`cmd/appview/serve.go`)
|
||||
|
||||
```go
|
||||
// In initializeUI() function, add:
|
||||
|
||||
// API key management routes (authenticated)
|
||||
authRouter.Handle("/api/keys", &uihandlers.GenerateAPIKeyHandler{
|
||||
Store: apiKeyStore,
|
||||
}).Methods("POST")
|
||||
|
||||
authRouter.Handle("/api/keys", &uihandlers.ListAPIKeysHandler{
|
||||
Store: apiKeyStore,
|
||||
}).Methods("GET")
|
||||
|
||||
authRouter.Handle("/api/keys/{id}", &uihandlers.DeleteAPIKeyHandler{
|
||||
Store: apiKeyStore,
|
||||
}).Methods("DELETE")
|
||||
```
|
||||
|
||||
### Phase 4: Update Credential Helper
|
||||
|
||||
#### 4.1 Simplify Configuration (`cmd/credential-helper/main.go`)
|
||||
|
||||
```go
|
||||
// SessionStore becomes CredentialStore
|
||||
type CredentialStore struct {
|
||||
Handle string `json:"handle"`
|
||||
APIKey string `json:"api_key"`
|
||||
AppViewURL string `json:"appview_url"`
|
||||
}
|
||||
|
||||
func handleConfigure(handle string) {
|
||||
fmt.Println("ATCR Credential Helper Configuration")
|
||||
fmt.Println("=====================================")
|
||||
fmt.Println()
|
||||
fmt.Println("You need an API key from the ATCR web UI.")
|
||||
fmt.Println()
|
||||
|
||||
appViewURL := os.Getenv("ATCR_APPVIEW_URL")
|
||||
if appViewURL == "" {
|
||||
appViewURL = defaultAppViewURL
|
||||
}
|
||||
|
||||
// Auto-open settings page
|
||||
settingsURL := appViewURL + "/settings"
|
||||
fmt.Printf("Opening settings page: %s\n", settingsURL)
|
||||
fmt.Println("Log in and generate an API key if you haven't already.")
|
||||
fmt.Println()
|
||||
|
||||
if err := oauth.OpenBrowser(settingsURL); err != nil {
|
||||
fmt.Printf("Could not open browser. Please visit: %s\n\n", settingsURL)
|
||||
}
|
||||
|
||||
// Prompt for credentials
|
||||
if handle == "" {
|
||||
fmt.Print("Enter your ATProto handle (e.g., alice.bsky.social): ")
|
||||
fmt.Scanln(&handle)
|
||||
} else {
|
||||
fmt.Printf("Using handle: %s\n", handle)
|
||||
}
|
||||
|
||||
fmt.Print("Enter your API key (from settings page): ")
|
||||
var apiKey string
|
||||
fmt.Scanln(&apiKey)
|
||||
|
||||
// Validate key format
|
||||
if !strings.HasPrefix(apiKey, "atcr_") {
|
||||
fmt.Fprintf(os.Stderr, "Invalid API key format. Key should start with 'atcr_'\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save credentials
|
||||
creds := &CredentialStore{
|
||||
Handle: handle,
|
||||
APIKey: apiKey,
|
||||
AppViewURL: appViewURL,
|
||||
}
|
||||
|
||||
if err := saveCredentials(getCredentialsPath(), creds); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error saving credentials: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("✓ Configuration complete!")
|
||||
fmt.Println("You can now use docker push/pull with atcr.io")
|
||||
}
|
||||
|
||||
func handleGet() {
|
||||
var serverURL string
|
||||
fmt.Fscanln(os.Stdin, &serverURL)
|
||||
|
||||
// Load credentials
|
||||
creds, err := loadCredentials(getCredentialsPath())
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading credentials: %v\n", err)
|
||||
fmt.Fprintf(os.Stderr, "Please run: docker-credential-atcr configure\n")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Return credentials for Docker
|
||||
// Docker will send these as Basic Auth to /auth/token
|
||||
response := Credentials{
|
||||
ServerURL: serverURL,
|
||||
Username: creds.Handle,
|
||||
Secret: creds.APIKey, // API key as password
|
||||
}
|
||||
|
||||
json.NewEncoder(os.Stdout).Encode(response)
|
||||
}
|
||||
```
|
||||
|
||||
**File Rename:**
|
||||
- `~/.atcr/session.json` → `~/.atcr/credentials.json`
|
||||
|
||||
### Phase 5: Remove Session Token System
|
||||
|
||||
#### 5.1 Delete Session Token Files
|
||||
|
||||
**Files to delete:**
|
||||
- `pkg/auth/session/handler.go`
|
||||
- `pkg/auth/exchange/handler.go`
|
||||
|
||||
#### 5.2 Update OAuth Server (`pkg/auth/oauth/server.go`)
|
||||
|
||||
**Remove session token creation:**
|
||||
```go
|
||||
// OLD (delete this):
|
||||
sessionToken, err := s.sessionManager.Create(did, handle)
|
||||
if err != nil {
|
||||
s.renderError(w, fmt.Sprintf("Failed to create session token: %v", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Check if this is a UI login...
|
||||
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
|
||||
// UI flow...
|
||||
} else {
|
||||
// Render success page with session token (for credential helper)
|
||||
s.renderSuccess(w, sessionToken, handle)
|
||||
}
|
||||
```
|
||||
|
||||
**NEW (replace with):**
|
||||
```go
|
||||
// Check if this is a UI login
|
||||
if cookie, err := r.Cookie("oauth_return_to"); err == nil && s.uiSessionStore != nil {
|
||||
// Create UI session
|
||||
uiSessionID, err := s.uiSessionStore.Create(did, handle, sessionData.HostURL, 24*time.Hour)
|
||||
// ... set cookie, redirect ...
|
||||
} else {
|
||||
// Non-UI flow: redirect to settings to get API key
|
||||
s.renderRedirectToSettings(w, handle)
|
||||
}
|
||||
```
|
||||
|
||||
**Add redirect to settings template:**
|
||||
```go
|
||||
func (s *Server) renderRedirectToSettings(w http.ResponseWriter, handle string) {
|
||||
tmpl := template.Must(template.New("redirect").Parse(`
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Authorization Successful - ATCR</title>
|
||||
<meta http-equiv="refresh" content="3;url=/settings">
|
||||
</head>
|
||||
<body>
|
||||
<h1>✓ Authorization Successful!</h1>
|
||||
<p>Redirecting to settings page to generate your API key...</p>
|
||||
<p>If not redirected, <a href="/settings">click here</a>.</p>
|
||||
</body>
|
||||
</html>
|
||||
`))
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
tmpl.Execute(w, nil)
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3 Update Server Constructor
|
||||
|
||||
```go
|
||||
// Remove sessionManager parameter
|
||||
func NewServer(app *App) *Server {
|
||||
return &Server{
|
||||
app: app,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.4 Update Registry Initialization (`cmd/appview/serve.go`)
|
||||
|
||||
```go
|
||||
// REMOVE session manager creation:
|
||||
// sessionManager, err := session.NewManagerWithPersistentSecret(secretPath, 30*24*time.Hour)
|
||||
|
||||
// Create API key store
|
||||
apiKeyStorePath := filepath.Join(filepath.Dir(storagePath), "api-keys.json")
|
||||
apiKeyStore, err := apikey.NewStore(apiKeyStorePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create API key store: %w", err)
|
||||
}
|
||||
|
||||
// OAuth server doesn't need session manager anymore
|
||||
oauthServer := oauth.NewServer(oauthApp)
|
||||
oauthServer.SetRefresher(refresher)
|
||||
if uiSessionStore != nil {
|
||||
oauthServer.SetUISessionStore(uiSessionStore)
|
||||
}
|
||||
|
||||
// Token handler gets API key store instead of session manager
|
||||
if issuer != nil {
|
||||
tokenHandler := token.NewHandler(issuer, apiKeyStore, defaultHoldEndpoint)
|
||||
tokenHandler.RegisterRoutes(mux)
|
||||
|
||||
// Remove exchange handler registration (no longer needed)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Migration Path
|
||||
|
||||
### For Existing Users
|
||||
|
||||
**Option 1: Smooth Migration (Recommended)**
|
||||
1. Keep session token validation temporarily with deprecation warning
|
||||
2. When session token is used, log warning and return special response header
|
||||
3. Docker client shows warning: "Session tokens deprecated, please regenerate API key"
|
||||
4. Remove session token support in next major version
|
||||
|
||||
**Option 2: Hard Cutover**
|
||||
1. Deploy new version with API keys
|
||||
2. Session tokens stop working immediately
|
||||
3. Users must reconfigure: `docker-credential-atcr configure`
|
||||
4. Cleaner but disruptive
|
||||
|
||||
### Rollout Plan
|
||||
|
||||
**Week 1: Deploy API Keys**
|
||||
- Add API key system
|
||||
- Keep session token validation
|
||||
- Add deprecation notice to OAuth callback
|
||||
|
||||
**Week 2-4: Migration Period**
|
||||
- Monitor API key adoption
|
||||
- Email users about migration
|
||||
- Provide migration guide
|
||||
|
||||
**Week 5: Remove Session Tokens**
|
||||
- Delete session token code
|
||||
- Force users to API keys
|
||||
|
||||
---
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Unit Tests
|
||||
|
||||
1. **API Key Store**
|
||||
- Test key generation (format, uniqueness)
|
||||
- Test key validation (correct/incorrect keys)
|
||||
- Test bcrypt hashing
|
||||
- Test key listing/deletion
|
||||
|
||||
2. **Token Handler**
|
||||
- Test API key authentication
|
||||
- Test app password authentication
|
||||
- Test invalid credentials
|
||||
- Test key format validation
|
||||
|
||||
### Integration Tests
|
||||
|
||||
1. **Full Auth Flow**
|
||||
- UI login → OAuth → API key generation
|
||||
- Credential helper → API key → registry JWT
|
||||
- App password → registry JWT
|
||||
|
||||
2. **Docker Client Tests**
|
||||
- `docker login -u handle -p api_key`
|
||||
- `docker login -u handle -p app_password`
|
||||
- `docker push` with API key
|
||||
- `docker pull` with API key
|
||||
|
||||
### Security Tests
|
||||
|
||||
1. **Key Security**
|
||||
- Verify bcrypt hashing (not plaintext storage)
|
||||
- Test key shown only once
|
||||
- Test key revocation
|
||||
- Test unauthorized key access
|
||||
|
||||
2. **OAuth Security**
|
||||
- Verify API key links to correct OAuth session
|
||||
- Test expired refresh token handling
|
||||
- Test multiple keys for same user
|
||||
|
||||
---
|
||||
|
||||
## Files Changed
|
||||
|
||||
### New Files
|
||||
- `pkg/appview/apikey/store.go` - API key storage and validation
|
||||
- `pkg/appview/handlers/apikeys.go` - API key HTTP handlers
|
||||
- `docs/API_KEY_MIGRATION.md` - This document
|
||||
|
||||
### Modified Files
|
||||
- `pkg/auth/token/handler.go` - Add API key validation, remove session token
|
||||
- `pkg/auth/oauth/server.go` - Remove session token creation, redirect to settings
|
||||
- `pkg/appview/handlers/settings.go` - Add API key management UI
|
||||
- `pkg/appview/templates/settings.html` - Add API key section
|
||||
- `cmd/credential-helper/main.go` - Simplify to use API keys
|
||||
- `cmd/appview/serve.go` - Initialize API key store, remove session manager
|
||||
|
||||
### Deleted Files
|
||||
- `pkg/auth/session/handler.go` - Session token system
|
||||
- `pkg/auth/exchange/handler.go` - Exchange endpoint (no longer needed)
|
||||
|
||||
---
|
||||
|
||||
## Advantages
|
||||
|
||||
✅ **Simpler Auth:** Two methods instead of three (API keys + app passwords)
|
||||
✅ **Better UX:** No manual copy/paste of session tokens
|
||||
✅ **Multiple Keys:** Users can have laptop key, CI key, etc.
|
||||
✅ **Revocable:** Revoke individual keys without re-auth
|
||||
✅ **Server-Side OAuth:** Refresh tokens stay on server, not in client files
|
||||
✅ **Familiar Pattern:** Matches AWS ECR, GitHub tokens, etc.
|
||||
|
||||
## Backward Compatibility
|
||||
|
||||
⚠️ **Breaking Change:** Session tokens will stop working
|
||||
✅ **App passwords:** Still work (no changes)
|
||||
✅ **UI sessions:** Still work (separate system)
|
||||
|
||||
**Migration Required:** Users with session tokens must run `docker-credential-atcr configure` again to get API keys.
|
||||
-281
@@ -1,281 +0,0 @@
|
||||
# ATCR OAuth Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
ATCR now supports ATProto OAuth authentication via Docker credential helpers. This allows users to authenticate with their ATProto identity (Bluesky account) and use Docker push/pull commands seamlessly.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
1. **OAuth Client** (`pkg/auth/oauth/`)
|
||||
- Full ATProto OAuth implementation with DPoP support
|
||||
- Uses `authelia.com/client/oauth2` for OAuth + PAR
|
||||
- Uses `github.com/AxisCommunications/go-dpop` for DPoP proof generation
|
||||
- Automatic authorization server discovery
|
||||
- PKCE support for security
|
||||
|
||||
2. **Credential Helper** (`cmd/credential-helper/`)
|
||||
- Standalone binary: `docker-credential-atcr`
|
||||
- Implements Docker credential helper protocol
|
||||
- Manages OAuth flow with browser
|
||||
- Stores tokens securely in `~/.atcr/oauth-token.json`
|
||||
|
||||
3. **Registry Integration**
|
||||
- `/auth/exchange` endpoint exchanges OAuth tokens for registry JWTs
|
||||
- Existing `/auth/token` endpoint for standard Docker auth
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `authelia.com/client/oauth2` - OAuth client with PAR support (2⭐, Authelia-backed)
|
||||
- `github.com/AxisCommunications/go-dpop` - DPoP implementation (10⭐, RFC 9449 compliant)
|
||||
- `github.com/golang-jwt/jwt/v5` - JWT library (transitive, 11k+⭐)
|
||||
|
||||
## Usage
|
||||
|
||||
### Setup
|
||||
|
||||
1. Build the credential helper:
|
||||
```bash
|
||||
go build -o docker-credential-atcr ./cmd/credential-helper
|
||||
```
|
||||
|
||||
2. Install it in your PATH:
|
||||
```bash
|
||||
sudo mv docker-credential-atcr /usr/local/bin/
|
||||
```
|
||||
|
||||
3. Configure Docker to use it by editing `~/.docker/config.json`:
|
||||
```json
|
||||
{
|
||||
"credsStore": "atcr"
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Run the OAuth flow:
|
||||
```bash
|
||||
docker-credential-atcr configure
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Prompt for your ATProto handle (e.g., `alice.bsky.social`)
|
||||
2. Open your browser for OAuth authorization
|
||||
3. Store the OAuth token and DPoP key in `~/.atcr/oauth-token.json`
|
||||
|
||||
### Using with Docker
|
||||
|
||||
Once configured, use Docker normally:
|
||||
|
||||
```bash
|
||||
# Push an image
|
||||
docker push atcr.io/alice/myapp:latest
|
||||
|
||||
# Pull an image
|
||||
docker pull atcr.io/alice/myapp:latest
|
||||
```
|
||||
|
||||
The credential helper automatically:
|
||||
1. Loads your stored OAuth token
|
||||
2. Refreshes it if expired
|
||||
3. Exchanges it for a registry JWT
|
||||
4. Provides the JWT to Docker
|
||||
|
||||
## How It Works
|
||||
|
||||
### OAuth Flow
|
||||
|
||||
1. **User runs** `docker-credential-atcr configure`
|
||||
2. **Resolve identity**: alice.bsky.social → DID → PDS endpoint
|
||||
3. **Discover auth server**: GET `{pds}/.well-known/oauth-authorization-server`
|
||||
4. **Generate DPoP key**: ECDSA P-256 key pair
|
||||
5. **PAR request**: POST to PAR endpoint with DPoP header + PKCE challenge
|
||||
6. **Open browser**: User authorizes on their PDS
|
||||
7. **Receive code**: Callback to `localhost:8888/callback`
|
||||
8. **Exchange code**: POST to token endpoint with DPoP header + PKCE verifier
|
||||
9. **Save tokens**: Store OAuth token + DPoP key + DID/handle
|
||||
|
||||
### Docker Push/Pull Flow
|
||||
|
||||
1. **Docker needs credentials** for `atcr.io`
|
||||
2. **Calls credential helper**: `docker-credential-atcr get`
|
||||
3. **Helper loads token** from `~/.atcr/oauth-token.json`
|
||||
4. **Refresh if needed**: Uses refresh token + DPoP if expired
|
||||
5. **Exchange for registry JWT**: POST to `/auth/exchange` with OAuth token + handle
|
||||
6. **Registry validates token**: Calls `getSession` on PDS to validate token
|
||||
7. **Registry issues JWT**: Creates registry JWT with validated DID/handle
|
||||
8. **Return to Docker**: `{"Username": "oauth2", "Secret": "<jwt>"}`
|
||||
9. **Docker uses JWT**: For authentication to registry API
|
||||
|
||||
## Security
|
||||
|
||||
### DPoP (Demonstrating Proof-of-Possession)
|
||||
|
||||
Every OAuth request includes a DPoP proof:
|
||||
- Unique JWT signed with ECDSA private key
|
||||
- Contains HTTP method, URL, timestamp, nonce
|
||||
- Public key (JWK) included in JWT header
|
||||
- Binds the token to the specific client
|
||||
|
||||
### PKCE (Proof Key for Code Exchange)
|
||||
|
||||
- Code verifier generated locally
|
||||
- Code challenge sent in authorization request
|
||||
- Verifier sent in token exchange
|
||||
- Prevents authorization code interception
|
||||
|
||||
### Token Storage
|
||||
|
||||
- Tokens stored in `~/.atcr/oauth-token.json`
|
||||
- File permissions: 0600 (owner read/write only)
|
||||
- DPoP key stored in PEM format
|
||||
- Refresh tokens for long-term access
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Code Structure
|
||||
|
||||
```
|
||||
pkg/auth/oauth/
|
||||
├── client.go # OAuth client with DPoP
|
||||
├── discovery.go # Authorization server discovery
|
||||
├── metadata.go # Client metadata document
|
||||
├── storage.go # Token persistence
|
||||
└── transport.go # DPoP HTTP transport
|
||||
|
||||
pkg/auth/atproto/
|
||||
├── session.go # ATProto session validation (Basic auth)
|
||||
└── validator.go # OAuth token validation via getSession
|
||||
|
||||
cmd/credential-helper/
|
||||
├── main.go # Docker credential helper protocol
|
||||
├── oauth.go # OAuth flow orchestration
|
||||
└── token.go # Token management
|
||||
|
||||
pkg/auth/exchange/
|
||||
└── handler.go # OAuth → Registry JWT exchange
|
||||
```
|
||||
|
||||
### Key Classes
|
||||
|
||||
**OAuth Client** (`pkg/auth/oauth/client.go`)
|
||||
- `NewClient()` - Create client with DPoP key
|
||||
- `InitializeForHandle()` - Discover auth server
|
||||
- `AuthorizeURL()` - Generate authorization URL with PAR + PKCE
|
||||
- `Exchange()` - Exchange code for token with DPoP
|
||||
- `RefreshToken()` - Refresh expired token with DPoP
|
||||
|
||||
**DPoP Transport** (`pkg/auth/oauth/transport.go`)
|
||||
- Implements `http.RoundTripper`
|
||||
- Automatically adds DPoP header to all requests
|
||||
- Handles nonce management and retries
|
||||
- Used by OAuth client for all HTTP requests
|
||||
|
||||
**Token Store** (`pkg/auth/oauth/storage.go`)
|
||||
- Persists OAuth tokens and DPoP key
|
||||
- PEM encoding for private key
|
||||
- Expiration checking
|
||||
- Secure file permissions
|
||||
|
||||
**Token Validator** (`pkg/auth/atproto/validator.go`)
|
||||
- `ValidateToken()` - Validate token via PDS getSession
|
||||
- `ValidateTokenWithResolver()` - Auto-resolve PDS from handle
|
||||
- Returns validated DID and handle
|
||||
- Used by registry to verify OAuth tokens
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. Configure the helper:
|
||||
```bash
|
||||
./docker-credential-atcr configure
|
||||
# Enter handle: alice.bsky.social
|
||||
# Browser opens for authorization
|
||||
# Token saved to ~/.atcr/oauth-token.json
|
||||
```
|
||||
|
||||
2. Test credential retrieval:
|
||||
```bash
|
||||
echo '{"ServerURL": "atcr.io"}' | ./docker-credential-atcr get
|
||||
# Should return: {"Username":"oauth2","Secret":"<jwt>"}
|
||||
```
|
||||
|
||||
3. Test with Docker:
|
||||
```bash
|
||||
docker push atcr.io/alice/test:latest
|
||||
```
|
||||
|
||||
### Integration Testing
|
||||
|
||||
TODO: Add automated tests for:
|
||||
- OAuth flow with mock PDS
|
||||
- DPoP proof generation
|
||||
- Token exchange
|
||||
- Credential helper protocol
|
||||
|
||||
## Security Features
|
||||
|
||||
### OAuth Token Validation
|
||||
|
||||
The registry validates ATProto OAuth tokens by calling `com.atproto.server.getSession` on the user's PDS. This ensures:
|
||||
- Token is valid and not expired
|
||||
- Token belongs to the claimed user
|
||||
- User's DID and handle are extracted from the PDS response
|
||||
- No trust in client-provided identity information
|
||||
|
||||
**Flow:**
|
||||
1. Client sends OAuth token + handle to `/auth/exchange`
|
||||
2. Registry resolves handle → PDS endpoint
|
||||
3. Registry calls `{pds}/xrpc/com.atproto.server.getSession` with token
|
||||
4. PDS validates token and returns session info (DID, handle)
|
||||
5. Registry uses validated DID/handle to issue registry JWT
|
||||
|
||||
## Future Improvements
|
||||
|
||||
1. **Token refresh in background**
|
||||
- Proactively refresh before expiry
|
||||
- Reduce latency on Docker commands
|
||||
|
||||
3. **Multiple account support**
|
||||
- Store tokens for multiple handles
|
||||
- Allow selecting which account to use
|
||||
|
||||
4. **Revocation support**
|
||||
- Implement token revocation
|
||||
- Clean up on logout
|
||||
|
||||
5. **Better error messages**
|
||||
- User-friendly OAuth error handling
|
||||
- Guide users through common issues
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to resolve identity"
|
||||
- Check internet connection
|
||||
- Verify handle is correct (e.g., `alice.bsky.social`)
|
||||
- Ensure PDS is accessible
|
||||
|
||||
### "Authorization timed out"
|
||||
- Complete authorization within 5 minutes
|
||||
- Check if browser opened correctly
|
||||
- Try running `configure` again
|
||||
|
||||
### "Token expired"
|
||||
- Credential helper should auto-refresh
|
||||
- If persistent, run `configure` again
|
||||
- Check `~/.atcr/oauth-token.json` permissions
|
||||
|
||||
### "Failed to exchange token"
|
||||
- Ensure registry is running
|
||||
- Check `/auth/exchange` endpoint is accessible
|
||||
- Verify token hasn't been revoked
|
||||
|
||||
## References
|
||||
|
||||
- [ATProto OAuth Specification](https://atproto.com/specs/oauth)
|
||||
- [RFC 9449: DPoP](https://datatracker.ietf.org/doc/html/rfc9449)
|
||||
- [RFC 9126: PAR](https://datatracker.ietf.org/doc/html/rfc9126)
|
||||
- [RFC 7636: PKCE](https://datatracker.ietf.org/doc/html/rfc7636)
|
||||
- [Docker Credential Helpers](https://github.com/docker/docker-credential-helpers)
|
||||
+1289
File diff suppressed because it is too large
Load Diff
-460
@@ -1,460 +0,0 @@
|
||||
ATProto Container Registry (atcr.io) Implementation Plan
|
||||
|
||||
Project Structure
|
||||
|
||||
/home/data/atcr.io/
|
||||
├── cmd/
|
||||
│ └── registry/
|
||||
│ └── main.go # Entrypoint that imports distribution
|
||||
├── pkg/
|
||||
│ ├── atproto/
|
||||
│ │ ├── client.go # ATProto client wrapper (using indigo)
|
||||
│ │ ├── manifest_store.go # Implements distribution.ManifestService
|
||||
│ │ ├── resolver.go # DID/handle resolution (alice → did:plc:...)
|
||||
│ │ └── lexicon.go # ATProto record schemas for manifests
|
||||
│ ├── storage/
|
||||
│ │ ├── s3_blob_store.go # Wraps distribution's S3 driver for blobs
|
||||
│ │ └── routing_repository.go # Routes manifests→ATProto, blobs→S3
|
||||
│ ├── middleware/
|
||||
│ │ ├── repository.go # Repository middleware registration
|
||||
│ │ └── registry.go # Registry middleware for name resolution
|
||||
│ └── server/
|
||||
│ └── handler.go # HTTP wrapper for custom name resolution
|
||||
├── config/
|
||||
│ └── config.yml # Registry configuration
|
||||
├── go.mod
|
||||
├── go.sum
|
||||
├── Dockerfile
|
||||
├── README.md
|
||||
└── CLAUDE.md # Updated with architecture docs
|
||||
|
||||
|
||||
Implementation Steps
|
||||
|
||||
Phase 1: Project Setup
|
||||
|
||||
1. Initialize Go module with github.com/distribution/distribution/v3 and github.com/bluesky-social/indigo
|
||||
2. Create basic project structure
|
||||
3. Set up cmd/appview/main.go that imports distribution and registers middleware
|
||||
|
||||
Phase 2: Core ATProto Integration
|
||||
|
||||
4. Implement DID/handle resolver (pkg/atproto/resolver.go)
|
||||
- Resolve handles to DIDs (alice.bsky.social → did:plc:xyz)
|
||||
- Discover PDS endpoints from DID documents
|
||||
5. Create ATProto client wrapper (pkg/atproto/client.go)
|
||||
- Wrap indigo SDK for manifest storage
|
||||
- Handle authentication with PDS
|
||||
6. Design ATProto lexicon for manifest records (pkg/atproto/lexicon.go)
|
||||
- Define schema for storing OCI manifests as ATProto records
|
||||
|
||||
Phase 3: Storage Layer
|
||||
|
||||
7. Implement ATProto manifest store (pkg/atproto/manifest_store.go)
|
||||
- Implements distribution.ManifestService
|
||||
- Stores/retrieves manifests from PDS
|
||||
8. Implement S3 blob store wrapper (pkg/storage/s3_blob_store.go)
|
||||
- Wraps distribution's built-in S3 driver
|
||||
9. Create routing repository (pkg/storage/routing_repository.go)
|
||||
- Returns ATProto store for Manifests()
|
||||
- Returns S3 store for Blobs()
|
||||
|
||||
Phase 4: Middleware Layer
|
||||
|
||||
10. Implement repository middleware (pkg/middleware/repository.go)
|
||||
- Registers routing repository
|
||||
- Configurable via YAML
|
||||
11. Implement registry/namespace middleware (pkg/middleware/registry.go)
|
||||
- Intercepts Repository(name) calls
|
||||
- Performs name resolution before repository creation
|
||||
|
||||
Phase 5: HTTP Layer (if needed)
|
||||
|
||||
12. Create custom HTTP handler (pkg/server/handler.go)
|
||||
- Wraps distribution's HTTP handlers
|
||||
- Performs early name resolution: atcr.io/alice/myimage → resolve alice
|
||||
- Delegates to distribution handlers
|
||||
|
||||
Phase 6: Configuration & Deployment
|
||||
|
||||
13. Create registry configuration (config/config.yml)
|
||||
14. Create Dockerfile for building atcr-appview binary
|
||||
16. Write README.md with usage instructions
|
||||
|
||||
Phase 7: Documentation
|
||||
|
||||
17. Update CLAUDE.md with:
|
||||
- Architecture overview (ATProto for manifests, S3 for blobs)
|
||||
- Build/run/test commands
|
||||
- How name resolution works
|
||||
- Middleware registration flow
|
||||
- Key design decisions
|
||||
|
||||
Key Technical Decisions
|
||||
|
||||
ATProto Storage Design:
|
||||
- Manifests stored as ATProto records in user's PDS
|
||||
- Each image manifest is a record in a io.atcr.manifest collection
|
||||
- Record key = image digest (sha256:...)
|
||||
|
||||
Name Resolution:
|
||||
- atcr.io/alice/myimage → resolve alice to DID → discover PDS
|
||||
- Support both handles (alice.bsky.social) and DIDs (did:plc:xyz)
|
||||
|
||||
Blob Storage:
|
||||
- All layers/blobs in S3 (content-addressable by digest)
|
||||
- Manifests reference S3-stored blobs by digest
|
||||
- S3 provides cheap, durable blob storage
|
||||
|
||||
Middleware Strategy:
|
||||
- Repository middleware for storage routing
|
||||
- Registry middleware (or HTTP wrapper) for name resolution
|
||||
- No fork of distribution core needed
|
||||
|
||||
Perfect. To match Docker Hub/ghcr.io/gcr.io, here's what we need:
|
||||
|
||||
● Implementation Plan (Drop-in replacement for Docker Hub/ghcr.io)
|
||||
|
||||
Flow 1: Standard Token Auth (Like Docker Hub) - PRIMARY
|
||||
|
||||
# User experience
|
||||
docker login atcr.io -u alice.bsky.social -p <atproto-app-password>
|
||||
docker push atcr.io/alice/myapp:latest
|
||||
|
||||
# Behind the scenes
|
||||
1. docker login stores credentials locally
|
||||
2. docker push → Registry returns 401 with WWW-Authenticate: Bearer realm="https://atcr.io/auth/token"...
|
||||
3. Docker auto-calls /auth/token with Basic auth (alice.bsky.social:app-password)
|
||||
4. Auth service validates against ATProto createSession
|
||||
5. Returns JWT token with scope for alice/myapp
|
||||
6. Docker uses JWT for manifest/blob uploads
|
||||
7. Registry validates JWT signature and scope
|
||||
|
||||
Components:
|
||||
- /auth/token endpoint (standalone service or embedded)
|
||||
- ATProto session validator (username/password → validate via PDS)
|
||||
- JWT issuer/signer
|
||||
- JWT validator middleware for registry
|
||||
|
||||
Flow 2: Credential Helper (Like gcr.io) - ADVANCED
|
||||
|
||||
# User experience
|
||||
docker-credential-atcr configure
|
||||
# Opens browser for ATProto OAuth
|
||||
docker push atcr.io/alice/myapp:latest
|
||||
# No manual login needed
|
||||
|
||||
# Behind the scenes
|
||||
1. Helper does OAuth flow → gets ATProto access token
|
||||
2. Caches token securely
|
||||
3. When Docker needs credentials, calls helper via stdin/stdout
|
||||
4. Helper exchanges ATProto token for registry JWT at /auth/exchange
|
||||
5. Returns JWT to Docker
|
||||
6. Docker uses JWT for requests
|
||||
|
||||
Components:
|
||||
- cmd/credential-helper/main.go - Standalone binary
|
||||
- ATProto OAuth client
|
||||
- Token exchange endpoint (/auth/exchange)
|
||||
- Secure token cache
|
||||
|
||||
Architecture:
|
||||
|
||||
pkg/auth/
|
||||
├── token/
|
||||
│ ├── service.go # HTTP handler for /auth/token
|
||||
│ ├── claims.go # JWT claims structure
|
||||
│ ├── issuer.go # Signs JWTs
|
||||
│ └── validator.go # Validates JWTs (middleware for registry)
|
||||
├── atproto/
|
||||
│ ├── session.go # Validates username/password via ATProto
|
||||
│ └── oauth.go # OAuth flow implementation
|
||||
├── exchange/
|
||||
│ └── handler.go # /auth/exchange endpoint (OAuth → JWT)
|
||||
└── scope.go # Parses/validates Docker scopes
|
||||
|
||||
cmd/
|
||||
├── registry/main.go # Registry server (existing)
|
||||
├── auth/main.go # Standalone auth service (optional)
|
||||
└── credential-helper/
|
||||
└── main.go # docker-credential-atcr binary
|
||||
|
||||
Config:
|
||||
|
||||
auth:
|
||||
token:
|
||||
realm: https://atcr.io/auth/token # Where Docker gets tokens
|
||||
service: atcr.io
|
||||
issuer: atcr.io
|
||||
rootcertbundle: /etc/atcr/token-signing.crt
|
||||
privatekey: /etc/atcr/token-signing.pem
|
||||
expiration: 300
|
||||
|
||||
atproto:
|
||||
# Used by auth service to validate credentials
|
||||
pds_endpoint: https://bsky.social
|
||||
client_id: atcr-appview
|
||||
oauth_redirect: http://localhost:8888/callback
|
||||
|
||||
ATProto OAuth Implementation Plan
|
||||
|
||||
Architecture
|
||||
|
||||
Dependencies:
|
||||
- authelia.com/client/oauth2 - OAuth + PAR support
|
||||
- github.com/AxisCommunications/go-dpop - DPoP proof generation (handles JWK automatically)
|
||||
- github.com/golang-jwt/jwt/v5 - JWT library (transitive via go-dpop)
|
||||
- Our existing pkg/atproto/resolver.go - ATProto identity resolution
|
||||
|
||||
Implementation Components
|
||||
|
||||
1. OAuth Client (pkg/auth/oauth/client.go) - ~100 lines
|
||||
|
||||
type Client struct {
|
||||
config *oauth2.Config
|
||||
dpopKey *ecdsa.PrivateKey
|
||||
resolver *atproto.Resolver
|
||||
clientID string // URL to our metadata document
|
||||
redirectURI string
|
||||
dpopNonce string // Server-provided nonce
|
||||
}
|
||||
|
||||
func NewClient(clientID, redirectURI string) (*Client, error)
|
||||
func (c *Client) AuthorizeURL(handle string, scopes []string) (string, error)
|
||||
func (c *Client) Exchange(code string) (*Token, error)
|
||||
func (c *Client) addDPoPHeader(req *http.Request, method, url string) error
|
||||
|
||||
Flow:
|
||||
1. Generate ECDSA P-256 key for DPoP
|
||||
2. Discover authorization server from handle/DID
|
||||
3. Use authelia's PushedAuth() for PAR with DPoP header
|
||||
4. Exchange code for token with DPoP proof
|
||||
|
||||
2. Authorization Server Discovery (pkg/auth/oauth/discovery.go) - ~30 lines
|
||||
|
||||
type AuthServerMetadata struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthorizationEndpoint string `json:"authorization_endpoint"`
|
||||
TokenEndpoint string `json:"token_endpoint"`
|
||||
PushedAuthorizationRequestEndpoint string `json:"pushed_authorization_request_endpoint"`
|
||||
DPoPSigningAlgValuesSupported []string `json:"dpop_signing_alg_values_supported"`
|
||||
}
|
||||
|
||||
func DiscoverAuthServer(pdsEndpoint string) (*AuthServerMetadata, error)
|
||||
|
||||
Implementation:
|
||||
- GET {pds}/.well-known/oauth-authorization-server
|
||||
- Parse JSON metadata
|
||||
- Validate required endpoints exist
|
||||
|
||||
3. Client Metadata Server (pkg/auth/oauth/metadata.go) - ~40 lines
|
||||
|
||||
type ClientMetadata struct {
|
||||
ClientID string `json:"client_id"`
|
||||
RedirectURIs []string `json:"redirect_uris"`
|
||||
GrantTypes []string `json:"grant_types"`
|
||||
ResponseTypes []string `json:"response_types"`
|
||||
Scope string `json:"scope"`
|
||||
DPoPBoundAccessTokens bool `json:"dpop_bound_access_tokens"`
|
||||
}
|
||||
|
||||
func ServeMetadata(clientID string, redirectURIs []string) http.Handler
|
||||
|
||||
Serves: https://atcr.io/oauth/client-metadata.json
|
||||
|
||||
4. Token Storage (pkg/auth/oauth/storage.go) - ~50 lines
|
||||
|
||||
type TokenStore struct {
|
||||
AccessToken string
|
||||
RefreshToken string
|
||||
DPoPKey *ecdsa.PrivateKey // Persist for refresh
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func (s *TokenStore) Save(path string) error
|
||||
func LoadTokenStore(path string) (*TokenStore, error)
|
||||
|
||||
Storage location: ~/.atcr/oauth-tokens.json
|
||||
|
||||
5. Credential Helper (cmd/credential-helper/main.go) - ~80 lines
|
||||
|
||||
// Docker credential helper protocol
|
||||
// Reads JSON from stdin, writes to stdout
|
||||
|
||||
func main() {
|
||||
if len(os.Args) < 2 {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
switch os.Args[1] {
|
||||
case "get":
|
||||
handleGet() // Return credentials for registry
|
||||
case "store":
|
||||
handleStore() // Store credentials
|
||||
case "erase":
|
||||
handleErase() // Remove credentials
|
||||
}
|
||||
}
|
||||
|
||||
func handleGet() {
|
||||
var request struct {
|
||||
ServerURL string `json:"ServerURL"`
|
||||
}
|
||||
json.NewDecoder(os.Stdin).Decode(&request)
|
||||
|
||||
// Load token from storage
|
||||
// Exchange for registry JWT if needed
|
||||
// Output: {"Username": "oauth2", "Secret": "<jwt>"}
|
||||
}
|
||||
|
||||
6. OAuth Flow (cmd/credential-helper/oauth.go) - ~60 lines
|
||||
|
||||
func RunOAuthFlow(handle string) (*TokenStore, error) {
|
||||
// 1. Start local HTTP server on :8888
|
||||
// 2. Open browser to authorization URL
|
||||
// 3. Wait for callback with code
|
||||
// 4. Exchange code for token
|
||||
// 5. Save token store
|
||||
// 6. Return token
|
||||
}
|
||||
|
||||
func startCallbackServer() (chan string, *http.Server)
|
||||
|
||||
Complete Flow Example
|
||||
|
||||
User runs:
|
||||
docker-credential-atcr configure
|
||||
|
||||
What happens:
|
||||
|
||||
1. Generate DPoP key (client.go)
|
||||
dpopKey, _ := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
|
||||
2. Resolve handle → DID → PDS (using our resolver)
|
||||
did, pds, _ := resolver.ResolveIdentity(ctx, "alice.bsky.social")
|
||||
|
||||
3. Discover auth server (discovery.go)
|
||||
metadata, _ := DiscoverAuthServer(pds)
|
||||
// Returns: PAR endpoint, token endpoint, etc.
|
||||
|
||||
4. Create PAR request with DPoP (client.go + go-dpop)
|
||||
// Generate DPoP proof for PAR endpoint
|
||||
claims := &dpop.ProofTokenClaims{
|
||||
Method: dpop.POST,
|
||||
URL: metadata.PushedAuthorizationRequestEndpoint,
|
||||
RegisteredClaims: &jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey)
|
||||
|
||||
// Use authelia for PAR
|
||||
config := &oauth2.Config{
|
||||
ClientID: "https://atcr.io/oauth/client-metadata.json",
|
||||
Endpoint: oauth2.Endpoint{
|
||||
AuthURL: metadata.AuthorizationEndpoint,
|
||||
TokenURL: metadata.TokenEndpoint,
|
||||
},
|
||||
}
|
||||
|
||||
// Create custom HTTP client that adds DPoP header
|
||||
client := &http.Client{
|
||||
Transport: &dpopTransport{
|
||||
base: http.DefaultTransport,
|
||||
dpopKey: dpopKey,
|
||||
},
|
||||
}
|
||||
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client)
|
||||
|
||||
// PAR request (authelia handles this)
|
||||
authURL, parResp, _ := config.PushedAuth(ctx, state,
|
||||
oauth2.SetAuthURLParam("code_challenge", pkceChallenge),
|
||||
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
|
||||
)
|
||||
|
||||
5. Open browser, get code (oauth.go)
|
||||
exec.Command("open", authURL).Run()
|
||||
// User authorizes
|
||||
// Callback: http://localhost:8888?code=xyz&state=abc
|
||||
|
||||
6. Exchange code for token with DPoP (client.go + go-dpop)
|
||||
// Generate DPoP proof for token endpoint
|
||||
claims := &dpop.ProofTokenClaims{
|
||||
Method: dpop.POST,
|
||||
URL: metadata.TokenEndpoint,
|
||||
RegisteredClaims: &jwt.RegisteredClaims{
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
dpopProof, _ := dpop.Create(jwt.SigningMethodES256, claims, dpopKey)
|
||||
|
||||
// Exchange (with DPoP header added by our transport)
|
||||
token, _ := config.Exchange(ctx, code,
|
||||
oauth2.SetAuthURLParam("code_verifier", pkceVerifier),
|
||||
)
|
||||
|
||||
7. Save token + DPoP key (storage.go)
|
||||
store := &TokenStore{
|
||||
AccessToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken,
|
||||
DPoPKey: dpopKey,
|
||||
ExpiresAt: token.Expiry,
|
||||
}
|
||||
store.Save("~/.atcr/oauth-tokens.json")
|
||||
|
||||
Later, when docker push happens:
|
||||
docker push atcr.io/alice/myapp:latest
|
||||
|
||||
1. Docker calls credential helper: docker-credential-atcr get
|
||||
2. Helper loads stored token
|
||||
3. Helper calls /auth/exchange with OAuth token → gets registry JWT
|
||||
4. Returns JWT to Docker
|
||||
5. Docker uses JWT for push
|
||||
|
||||
Directory Structure
|
||||
|
||||
pkg/auth/oauth/
|
||||
├── client.go # OAuth client with DPoP integration
|
||||
├── discovery.go # Authorization server discovery
|
||||
├── metadata.go # Client metadata server
|
||||
├── storage.go # Token persistence
|
||||
└── transport.go # HTTP transport that adds DPoP headers
|
||||
|
||||
cmd/credential-helper/
|
||||
├── main.go # Docker credential helper protocol
|
||||
├── oauth.go # OAuth flow (browser, callback)
|
||||
└── config.go # Configuration
|
||||
|
||||
go.mod additions:
|
||||
authelia.com/client/oauth2 v0.25.0
|
||||
github.com/AxisCommunications/go-dpop v1.1.2
|
||||
|
||||
Unified Model
|
||||
|
||||
Every hold service requires HOLD_OWNER:
|
||||
- Owner's PDS has the io.atcr.hold record
|
||||
- Owner's PDS has all io.atcr.hold.crew records
|
||||
- Authorization is always governed by PDS records
|
||||
|
||||
For "public" hold (like Tangled's public knot):
|
||||
- Owner creates hold with public: true
|
||||
- Anyone can push/pull without being crew
|
||||
- Owner can add crew records for special privileges/tracking if desired
|
||||
|
||||
Config has emergency override:
|
||||
auth:
|
||||
# Emergency freeze: ignore public setting, restrict to crew only
|
||||
# Use this to stop abuse without changing PDS records
|
||||
freeze: false
|
||||
|
||||
Authorization logic:
|
||||
1. Check freeze in config → if true, skip to crew check
|
||||
2. Query owner's PDS for io.atcr.hold record
|
||||
3. If public: true → allow all operations (unless frozen)
|
||||
4. If public: false OR frozen → query io.atcr.hold.crew records, check membership
|
||||
|
||||
Remove from config:
|
||||
- allow_all (replaced by public: true in PDS)
|
||||
- allowed_dids (replaced by crew records in PDS)
|
||||
|
||||
This way the hold owner at atcr.io can run a public hold at hold1.atcr.io that anyone can use, but can freeze it instantly if needed without touching PDS records.
|
||||
-334
@@ -1,334 +0,0 @@
|
||||
# Local Testing Guide
|
||||
|
||||
## Quick Start
|
||||
|
||||
```bash
|
||||
./test-local.sh
|
||||
```
|
||||
|
||||
This automated script will:
|
||||
1. Create storage directories
|
||||
2. Build all binaries
|
||||
3. Start both services
|
||||
4. Show test commands
|
||||
|
||||
## Manual Testing Steps
|
||||
|
||||
### 1. Setup Directories
|
||||
|
||||
```bash
|
||||
sudo mkdir -p /var/lib/atcr/{blobs,hold,auth}
|
||||
sudo chown -R $USER:$USER /var/lib/atcr
|
||||
```
|
||||
|
||||
### 2. Build Binaries
|
||||
|
||||
```bash
|
||||
go build -o atcr-appview ./cmd/appview
|
||||
go build -o atcr-hold ./cmd/hold
|
||||
go build -o docker-credential-atcr ./cmd/credential-helper
|
||||
```
|
||||
|
||||
### 3. Configure Environment
|
||||
|
||||
Create a `.env` file in the project root:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
```
|
||||
|
||||
Edit `.env` with your credentials:
|
||||
|
||||
```env
|
||||
# Your ATProto handle
|
||||
ATPROTO_HANDLE=your-handle.bsky.social
|
||||
|
||||
# Hold service public URL (hostname becomes the hold name)
|
||||
HOLD_PUBLIC_URL=http://127.0.0.1:8080
|
||||
|
||||
# Enable OAuth registration on startup
|
||||
HOLD_AUTO_REGISTER=true
|
||||
```
|
||||
|
||||
**Notes:**
|
||||
- Use your Bluesky handle (e.g., `alice.bsky.social`)
|
||||
- For localhost, use `127.0.0.1` instead of `localhost` for OAuth
|
||||
- The hostname from the URL becomes the hold name (e.g., `127.0.0.1` or `hold1.atcr.io`)
|
||||
|
||||
**Load environment:**
|
||||
```bash
|
||||
export $(cat .env | xargs)
|
||||
```
|
||||
|
||||
### 4. Start Services
|
||||
|
||||
**Terminal 1 - AppView:**
|
||||
```bash
|
||||
./atcr-appview serve config/config.yml
|
||||
```
|
||||
|
||||
**Terminal 2 - Hold:**
|
||||
```bash
|
||||
./atcr-hold config/hold.yml
|
||||
```
|
||||
|
||||
### 5. Start Services and OAuth Registration
|
||||
|
||||
**Terminal 1 - AppView:**
|
||||
```bash
|
||||
./atcr-appview serve config/config.yml
|
||||
```
|
||||
|
||||
**Terminal 2 - Hold (OAuth registration):**
|
||||
```bash
|
||||
./atcr-hold config/hold.yml
|
||||
```
|
||||
|
||||
The hold service will start an OAuth flow. You'll see output like:
|
||||
|
||||
```
|
||||
================================================================================
|
||||
OAUTH AUTHORIZATION REQUIRED
|
||||
================================================================================
|
||||
|
||||
Please visit this URL to authorize the hold service:
|
||||
|
||||
https://bsky.social/oauth/authorize?...
|
||||
|
||||
Waiting for authorization...
|
||||
================================================================================
|
||||
```
|
||||
|
||||
**Steps:**
|
||||
1. Copy the OAuth URL from the logs
|
||||
2. Open it in your browser
|
||||
3. Sign in to Bluesky and authorize
|
||||
4. The callback will complete automatically
|
||||
5. Hold service registers in your PDS
|
||||
|
||||
After successful OAuth, you'll see:
|
||||
```
|
||||
✓ Created hold record: at://did:plc:.../io.atcr.hold/127.0.0.1
|
||||
✓ Created crew record: at://did:plc:.../io.atcr.hold.crew/127.0.0.1-did:plc:...
|
||||
================================================================================
|
||||
REGISTRATION COMPLETE
|
||||
================================================================================
|
||||
Hold service is now registered and ready to use!
|
||||
```
|
||||
|
||||
This creates two records in your PDS:
|
||||
- `io.atcr.hold` - Defines the storage endpoint URL
|
||||
- `io.atcr.hold.crew` - Grants you admin access
|
||||
|
||||
### 6. Test Docker Push/Pull
|
||||
|
||||
**Test 1: Basic Push**
|
||||
```bash
|
||||
# Tag an image
|
||||
docker tag alpine:latest localhost:5000/alice/alpine:test
|
||||
|
||||
# Push to local registry
|
||||
docker push localhost:5000/alice/alpine:test
|
||||
```
|
||||
|
||||
**Test 2: Pull**
|
||||
```bash
|
||||
# Remove local image
|
||||
docker rmi localhost:5000/alice/alpine:test
|
||||
|
||||
# Pull from registry
|
||||
docker pull localhost:5000/alice/alpine:test
|
||||
```
|
||||
|
||||
**Test 3: Verify Storage**
|
||||
```bash
|
||||
# Check manifests were stored in ATProto
|
||||
# (Check your PDS for io.atcr.manifest records)
|
||||
|
||||
# Check blobs were stored locally
|
||||
ls -lh /var/lib/atcr/blobs/docker/registry/v2/
|
||||
```
|
||||
|
||||
## OAuth Testing (Optional)
|
||||
|
||||
### Setup Credential Helper
|
||||
|
||||
```bash
|
||||
# Configure OAuth
|
||||
./docker-credential-atcr configure
|
||||
|
||||
# Follow the browser flow to authorize
|
||||
|
||||
# Verify token was saved
|
||||
ls -la ~/.atcr/oauth-token.json
|
||||
```
|
||||
|
||||
### Configure Docker to Use Helper
|
||||
|
||||
Edit `~/.docker/config.json`:
|
||||
```json
|
||||
{
|
||||
"credHelpers": {
|
||||
"localhost:5000": "atcr"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Test with OAuth
|
||||
|
||||
```bash
|
||||
# Push should now use OAuth automatically
|
||||
docker push localhost:5000/alice/myapp:latest
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Registry won't start
|
||||
|
||||
**Error:** `failed to create storage driver`
|
||||
```bash
|
||||
# Check directory permissions
|
||||
ls -ld /var/lib/atcr/blobs
|
||||
# Should be owned by your user
|
||||
|
||||
# Fix permissions
|
||||
sudo chown -R $USER:$USER /var/lib/atcr
|
||||
```
|
||||
|
||||
**Error:** `address already in use`
|
||||
```bash
|
||||
# Check what's using port 5000
|
||||
lsof -i :5000
|
||||
|
||||
# Kill existing process
|
||||
kill $(lsof -t -i :5000)
|
||||
```
|
||||
|
||||
### Hold service won't start
|
||||
|
||||
**Error:** `failed to create storage driver`
|
||||
```bash
|
||||
# Check hold directory
|
||||
ls -ld /var/lib/atcr/hold
|
||||
sudo chown -R $USER:$USER /var/lib/atcr/hold
|
||||
```
|
||||
|
||||
**Error:** `address already in use`
|
||||
```bash
|
||||
# Check port 8080
|
||||
lsof -i :8080
|
||||
kill $(lsof -t -i :8080)
|
||||
```
|
||||
|
||||
### Docker push fails
|
||||
|
||||
**Error:** `unauthorized: authentication required`
|
||||
- Check `ATPROTO_DID` and `ATPROTO_ACCESS_TOKEN` are set
|
||||
- Verify token is valid (not expired)
|
||||
- Check registry logs for auth errors
|
||||
|
||||
**Error:** `denied: requested access to the resource is denied`
|
||||
- Check the identity in the image name matches your DID
|
||||
- Example: If your handle is `alice.bsky.social`, use:
|
||||
```bash
|
||||
docker push localhost:5000/alice/myapp:test
|
||||
# NOT localhost:5000/bob/myapp:test
|
||||
```
|
||||
|
||||
**Error:** `failed to resolve identity`
|
||||
- Check internet connection (needs to resolve DIDs)
|
||||
- Verify handle is correct
|
||||
- Try using DID directly instead of handle
|
||||
|
||||
### OAuth issues
|
||||
|
||||
**Error:** `Failed to exchange token`
|
||||
- Ensure registry is running and accessible
|
||||
- Check `/auth/exchange` endpoint is responding
|
||||
- Verify OAuth token hasn't expired
|
||||
|
||||
**Error:** `Token validation failed`
|
||||
- Token might be expired
|
||||
- Run `./docker-credential-atcr configure` again
|
||||
- Check PDS is accessible
|
||||
|
||||
## Verifying the Flow
|
||||
|
||||
### Check Registry is Running
|
||||
```bash
|
||||
curl http://localhost:5000/v2/
|
||||
# Should return: {}
|
||||
```
|
||||
|
||||
### Check Hold is Running
|
||||
```bash
|
||||
curl http://localhost:8080/health
|
||||
# Should return: {"status":"ok"}
|
||||
```
|
||||
|
||||
### Check Auth Endpoint
|
||||
```bash
|
||||
curl -v http://localhost:5000/v2/
|
||||
# Should return 401 with WWW-Authenticate header
|
||||
```
|
||||
|
||||
### Inspect Stored Data
|
||||
|
||||
**Manifests (in ATProto):**
|
||||
- Check your PDS web interface
|
||||
- Look for `io.atcr.manifest` collection records
|
||||
|
||||
**Blobs (local filesystem):**
|
||||
```bash
|
||||
# List blobs
|
||||
find /var/lib/atcr/blobs -type f
|
||||
|
||||
# Check blob content (should be binary)
|
||||
ls -lh /var/lib/atcr/blobs/docker/registry/v2/blobs/sha256/
|
||||
```
|
||||
|
||||
## Clean Up
|
||||
|
||||
### Stop Services
|
||||
```bash
|
||||
# If using test script
|
||||
kill $(cat .atcr-pids)
|
||||
|
||||
# Or manually
|
||||
pkill atcr-appview
|
||||
pkill atcr-hold
|
||||
```
|
||||
|
||||
### Remove Test Data
|
||||
```bash
|
||||
# Remove all stored data
|
||||
sudo rm -rf /var/lib/atcr/*
|
||||
|
||||
# Remove OAuth tokens
|
||||
rm -rf ~/.atcr/
|
||||
```
|
||||
|
||||
### Reset Docker Config
|
||||
```bash
|
||||
# Remove credential helper config
|
||||
# Edit ~/.docker/config.json and remove "credHelpers" section
|
||||
```
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once local testing works:
|
||||
|
||||
1. **Deploy to production:**
|
||||
- Use S3/Storj for blob storage
|
||||
- Deploy registry and hold to separate hosts
|
||||
- Configure DNS for `atcr.io`
|
||||
|
||||
2. **Enable BYOS:**
|
||||
- Users create `io.atcr.hold` records
|
||||
- Deploy their own hold service
|
||||
- AppView automatically routes to their storage
|
||||
|
||||
3. **Add monitoring:**
|
||||
- Registry metrics
|
||||
- Hold service metrics
|
||||
- Storage usage tracking
|
||||
Reference in New Issue
Block a user