A container registry that uses the AT Protocol for manifest storage and S3 for blob storage. atcr.io
docker container atproto go
81
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix credential helper to read insecure-registries. fix device registration flow

+165 -42
+137 -21
cmd/credential-helper/main.go
··· 5 5 "encoding/json" 6 6 "fmt" 7 7 "io" 8 + "net" 8 9 "net/http" 9 10 "os" 10 11 "os/exec" 11 12 "path/filepath" 12 13 "runtime" 14 + "strings" 13 15 "time" 14 16 ) 15 17 16 - const ( 17 - // Default AppView URL - can be overridden via environment variable 18 - defaultAppViewURL = "http://127.0.0.1:5000" 19 - ) 20 - 21 18 // DeviceConfig represents the stored device configuration 22 19 type DeviceConfig struct { 23 20 Handle string `json:"handle"` ··· 25 22 AppViewURL string `json:"appview_url"` 26 23 } 27 24 25 + // DockerDaemonConfig represents Docker's daemon.json configuration 26 + type DockerDaemonConfig struct { 27 + InsecureRegistries []string `json:"insecure-registries"` 28 + } 29 + 28 30 // Docker credential helper protocol 29 31 // https://github.com/docker/docker-credential-helpers 30 32 ··· 97 99 // First time - trigger device authorization 98 100 fmt.Fprintf(os.Stderr, "No device configuration found. Starting device authorization...\n") 99 101 100 - deviceConfig, err = authorizeDevice() 102 + deviceConfig, err = authorizeDevice(serverURL) 101 103 if err != nil { 102 104 fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err) 103 - fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login atcr.io' with your ATProto app-password\n") 105 + fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login %s' with your ATProto app-password\n", serverURL) 104 106 os.Exit(1) 105 107 } 106 108 ··· 157 159 } 158 160 159 161 // authorizeDevice performs the device authorization flow 160 - func authorizeDevice() (*DeviceConfig, error) { 161 - // Get AppView URL 162 - appViewURL := os.Getenv("ATCR_APPVIEW_URL") 163 - if appViewURL == "" { 164 - appViewURL = defaultAppViewURL 165 - } 162 + func authorizeDevice(serverURL string) (*DeviceConfig, error) { 163 + appViewURL := buildAppViewURL(serverURL) 166 164 167 165 // Get device name (hostname) 168 166 deviceName, err := os.Hostname() ··· 190 188 return nil, fmt.Errorf("failed to decode device code response: %w", err) 191 189 } 192 190 193 - // 2. Open browser for user to approve 191 + // 2. Display authorization URL and user code 194 192 verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode 195 193 196 - fmt.Fprintf(os.Stderr, "\nOpening browser for device authorization...\n") 197 - fmt.Fprintf(os.Stderr, "User code: %s\n", codeResp.UserCode) 198 - fmt.Fprintf(os.Stderr, "\nIf browser doesn't open, visit: %s\n\n", verificationURL) 194 + fmt.Fprintf(os.Stderr, "\n╔════════════════════════════════════════════════════════════════╗\n") 195 + fmt.Fprintf(os.Stderr, "║ Device Authorization Required ║\n") 196 + fmt.Fprintf(os.Stderr, "╚════════════════════════════════════════════════════════════════╝\n\n") 197 + fmt.Fprintf(os.Stderr, "Visit this URL in your browser:\n") 198 + fmt.Fprintf(os.Stderr, " %s\n\n", verificationURL) 199 + fmt.Fprintf(os.Stderr, "Your code: %s\n\n", codeResp.UserCode) 199 200 200 - if err := openBrowser(verificationURL); err != nil { 201 - fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err) 201 + // Try to open browser (may fail on headless systems) 202 + if err := openBrowser(verificationURL); err == nil { 203 + fmt.Fprintf(os.Stderr, "Opening browser...\n\n") 204 + } else { 205 + fmt.Fprintf(os.Stderr, "Could not open browser automatically (%v)\n", err) 206 + fmt.Fprintf(os.Stderr, "Please open the URL above manually.\n\n") 202 207 } 203 208 204 - fmt.Fprintf(os.Stderr, "Waiting for authorization...\n") 209 + fmt.Fprintf(os.Stderr, "Waiting for authorization") 205 210 206 211 // 3. Poll for authorization completion 207 212 pollInterval := time.Duration(codeResp.Interval) * time.Second 208 213 timeout := time.Duration(codeResp.ExpiresIn) * time.Second 209 214 deadline := time.Now().Add(timeout) 210 215 216 + dots := 0 211 217 for time.Now().Before(deadline) { 212 218 time.Sleep(pollInterval) 213 219 220 + // Show progress dots 221 + dots = (dots + 1) % 4 222 + fmt.Fprintf(os.Stderr, "\rWaiting for authorization%s ", strings.Repeat(".", dots)) 223 + 214 224 // Poll token endpoint 215 225 tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode}) 216 226 tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody)) 217 227 if err != nil { 218 - fmt.Fprintf(os.Stderr, "Poll failed: %v\n", err) 228 + fmt.Fprintf(os.Stderr, "\nPoll failed: %v\n", err) 219 229 continue 220 230 } 221 231 ··· 229 239 } 230 240 231 241 if tokenResult.Error != "" { 242 + fmt.Fprintf(os.Stderr, "\n") 232 243 return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error) 233 244 } 234 245 235 246 // Success! 247 + fmt.Fprintf(os.Stderr, "\n") 236 248 return &DeviceConfig{ 237 249 Handle: tokenResult.Handle, 238 250 DeviceSecret: tokenResult.DeviceSecret, ··· 240 252 }, nil 241 253 } 242 254 255 + fmt.Fprintf(os.Stderr, "\n") 243 256 return nil, fmt.Errorf("authorization timeout") 244 257 } 245 258 ··· 302 315 303 316 return cmd.Start() 304 317 } 318 + 319 + // buildAppViewURL constructs the AppView URL with the appropriate protocol 320 + func buildAppViewURL(serverURL string) string { 321 + // If serverURL already has a scheme, use it as-is 322 + if strings.HasPrefix(serverURL, "http://") || strings.HasPrefix(serverURL, "https://") { 323 + return serverURL 324 + } 325 + 326 + // Determine protocol based on Docker configuration and heuristics 327 + if isInsecureRegistry(serverURL) { 328 + return "http://" + serverURL 329 + } 330 + 331 + // Default to HTTPS (mirrors Docker's default behavior) 332 + return "https://" + serverURL 333 + } 334 + 335 + // isInsecureRegistry checks if a registry should use HTTP instead of HTTPS 336 + func isInsecureRegistry(serverURL string) bool { 337 + // Check Docker's insecure-registries configuration 338 + insecureRegistries := getDockerInsecureRegistries() 339 + for _, reg := range insecureRegistries { 340 + // Match exact serverURL or just the host part 341 + if reg == serverURL || reg == stripPort(serverURL) { 342 + return true 343 + } 344 + } 345 + 346 + // Fallback heuristics: localhost and private IPs 347 + host := stripPort(serverURL) 348 + 349 + // Check for localhost variants 350 + if host == "localhost" || host == "127.0.0.1" || host == "::1" { 351 + return true 352 + } 353 + 354 + // Check if it's a private IP address 355 + if ip := net.ParseIP(host); ip != nil { 356 + if ip.IsLoopback() || ip.IsPrivate() { 357 + return true 358 + } 359 + } 360 + 361 + return false 362 + } 363 + 364 + // getDockerInsecureRegistries reads Docker's insecure-registries configuration 365 + func getDockerInsecureRegistries() []string { 366 + var paths []string 367 + 368 + // Common Docker daemon.json locations 369 + switch runtime.GOOS { 370 + case "windows": 371 + programData := os.Getenv("ProgramData") 372 + if programData != "" { 373 + paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json")) 374 + } 375 + default: 376 + // Linux and macOS 377 + paths = append(paths, "/etc/docker/daemon.json") 378 + if homeDir, err := os.UserHomeDir(); err == nil { 379 + // Rootless Docker location 380 + paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json")) 381 + } 382 + } 383 + 384 + // Try each path 385 + for _, path := range paths { 386 + if config := readDockerDaemonConfig(path); config != nil && len(config.InsecureRegistries) > 0 { 387 + return config.InsecureRegistries 388 + } 389 + } 390 + 391 + return nil 392 + } 393 + 394 + // readDockerDaemonConfig reads and parses a Docker daemon.json file 395 + func readDockerDaemonConfig(path string) *DockerDaemonConfig { 396 + data, err := os.ReadFile(path) 397 + if err != nil { 398 + return nil 399 + } 400 + 401 + var config DockerDaemonConfig 402 + if err := json.Unmarshal(data, &config); err != nil { 403 + return nil 404 + } 405 + 406 + return &config 407 + } 408 + 409 + // stripPort removes the port from a host:port string 410 + func stripPort(hostPort string) string { 411 + if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 { 412 + // Check if this is IPv6 (has multiple colons) 413 + if strings.Count(hostPort, ":") > 1 { 414 + // IPv6 address, don't strip 415 + return hostPort 416 + } 417 + return hostPort[:colonIdx] 418 + } 419 + return hostPort 420 + }
+20 -13
pkg/appview/db/device_store.go
··· 28 28 29 29 // PendingAuthorization represents a device awaiting user approval 30 30 type PendingAuthorization struct { 31 - DeviceCode string `json:"device_code"` 32 - UserCode string `json:"user_code"` 33 - DeviceName string `json:"device_name"` 34 - IPAddress string `json:"ip_address"` 35 - UserAgent string `json:"user_agent"` 36 - ExpiresAt time.Time `json:"expires_at"` 37 - ApprovedDID string `json:"approved_did"` 38 - ApprovedAt time.Time `json:"approved_at"` 39 - DeviceSecret string `json:"device_secret"` 31 + DeviceCode string `json:"device_code"` 32 + UserCode string `json:"user_code"` 33 + DeviceName string `json:"device_name"` 34 + IPAddress string `json:"ip_address"` 35 + UserAgent string `json:"user_agent"` 36 + ExpiresAt time.Time `json:"expires_at"` 37 + ApprovedDID *string `json:"approved_did"` 38 + ApprovedAt *time.Time `json:"approved_at"` 39 + DeviceSecret *string `json:"device_secret"` 40 40 } 41 41 42 42 // DeviceStore manages devices and pending authorizations with SQLite persistence ··· 194 194 } 195 195 196 196 // Check if already approved 197 - if pending.ApprovedDID != "" { 197 + if pending.ApprovedDID != nil && *pending.ApprovedDID != "" { 198 198 return "", fmt.Errorf("already approved") 199 199 } 200 200 ··· 259 259 for rows.Next() { 260 260 var device Device 261 261 var lastUsed sql.NullTime 262 + var location sql.NullString 262 263 263 264 err := rows.Scan( 264 265 &device.ID, ··· 267 268 &device.Name, 268 269 &device.SecretHash, 269 270 &device.IPAddress, 270 - &device.Location, 271 + &location, 271 272 &device.UserAgent, 272 273 &device.CreatedAt, 273 274 &lastUsed, ··· 278 279 279 280 if lastUsed.Valid { 280 281 device.LastUsed = lastUsed.Time 282 + } 283 + if location.Valid { 284 + device.Location = location.String 281 285 } 282 286 283 287 // Check if this device's hash matches the secret ··· 302 306 `, did) 303 307 304 308 if err != nil { 305 - fmt.Printf("Warning: Failed to list devices: %v\n", err) 306 309 return []*Device{} 307 310 } 308 311 defer rows.Close() ··· 311 314 for rows.Next() { 312 315 var device Device 313 316 var lastUsed sql.NullTime 317 + var location sql.NullString 314 318 315 319 err := rows.Scan( 316 320 &device.ID, ··· 318 322 &device.Handle, 319 323 &device.Name, 320 324 &device.IPAddress, 321 - &device.Location, 325 + &location, 322 326 &device.UserAgent, 323 327 &device.CreatedAt, 324 328 &lastUsed, ··· 329 333 330 334 if lastUsed.Valid { 331 335 device.LastUsed = lastUsed.Time 336 + } 337 + if location.Valid { 338 + device.Location = location.String 332 339 } 333 340 334 341 devices = append(devices, &device)
+8 -7
pkg/appview/handlers/device.go
··· 117 117 } 118 118 119 119 // Check if approved 120 - if pending.ApprovedDID == "" { 120 + if pending.ApprovedDID == nil || *pending.ApprovedDID == "" { 121 121 // Still pending 122 122 resp := DeviceTokenResponse{ 123 123 Error: "authorization_pending", ··· 128 128 } 129 129 130 130 // Approved! Get device from store to find handle 131 - devices := h.Store.ListDevices(pending.ApprovedDID) 131 + devices := h.Store.ListDevices(*pending.ApprovedDID) 132 + 132 133 var handle string 133 134 for _, d := range devices { 134 - if d.DID == pending.ApprovedDID { 135 + if d.DID == *pending.ApprovedDID { 135 136 handle = d.Handle 136 137 break 137 138 } ··· 139 140 140 141 // Return device secret 141 142 resp := DeviceTokenResponse{ 142 - DeviceSecret: pending.DeviceSecret, 143 + DeviceSecret: *pending.DeviceSecret, 143 144 Handle: handle, 144 - DID: pending.ApprovedDID, 145 + DID: *pending.ApprovedDID, 145 146 } 146 147 147 148 w.Header().Set("Content-Type", "application/json") ··· 204 205 } 205 206 206 207 // Check if already approved 207 - if pending.ApprovedDID != "" { 208 + if pending.ApprovedDID != nil && *pending.ApprovedDID != "" { 208 209 h.renderSuccess(w, pending.DeviceName) 209 210 return 210 211 } ··· 260 261 // Approve the device 261 262 _, err := h.Store.ApprovePending(req.UserCode, sess.DID, sess.Handle) 262 263 if err != nil { 264 + fmt.Printf("ERROR [device/approve]: Failed to approve: %v\n", err) 263 265 http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError) 264 266 return 265 267 } 266 - 267 268 w.Header().Set("Content-Type", "application/json") 268 269 json.NewEncoder(w).Encode(map[string]string{"status": "approved"}) 269 270 }
-1
pkg/appview/jetstream/worker.go
··· 602 602 Time string `json:"time"` 603 603 Status string `json:"status,omitempty"` 604 604 } 605 -