diff --git a/cmd/credential-helper/main.go b/cmd/credential-helper/main.go index 0436eed..6d41247 100644 --- a/cmd/credential-helper/main.go +++ b/cmd/credential-helper/main.go @@ -5,19 +5,16 @@ import ( "encoding/json" "fmt" "io" + "net" "net/http" "os" "os/exec" "path/filepath" "runtime" + "strings" "time" ) -const ( - // Default AppView URL - can be overridden via environment variable - defaultAppViewURL = "http://127.0.0.1:5000" -) - // DeviceConfig represents the stored device configuration type DeviceConfig struct { Handle string `json:"handle"` @@ -25,6 +22,11 @@ type DeviceConfig struct { AppViewURL string `json:"appview_url"` } +// DockerDaemonConfig represents Docker's daemon.json configuration +type DockerDaemonConfig struct { + InsecureRegistries []string `json:"insecure-registries"` +} + // Docker credential helper protocol // https://github.com/docker/docker-credential-helpers @@ -97,10 +99,10 @@ func handleGet() { // First time - trigger device authorization fmt.Fprintf(os.Stderr, "No device configuration found. Starting device authorization...\n") - deviceConfig, err = authorizeDevice() + deviceConfig, err = authorizeDevice(serverURL) if err != nil { fmt.Fprintf(os.Stderr, "Device authorization failed: %v\n", err) - fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login atcr.io' with your ATProto app-password\n") + fmt.Fprintf(os.Stderr, "\nFallback: Use 'docker login %s' with your ATProto app-password\n", serverURL) os.Exit(1) } @@ -157,12 +159,8 @@ func handleErase() { } // authorizeDevice performs the device authorization flow -func authorizeDevice() (*DeviceConfig, error) { - // Get AppView URL - appViewURL := os.Getenv("ATCR_APPVIEW_URL") - if appViewURL == "" { - appViewURL = defaultAppViewURL - } +func authorizeDevice(serverURL string) (*DeviceConfig, error) { + appViewURL := buildAppViewURL(serverURL) // Get device name (hostname) deviceName, err := os.Hostname() @@ -190,32 +188,44 @@ func authorizeDevice() (*DeviceConfig, error) { return nil, fmt.Errorf("failed to decode device code response: %w", err) } - // 2. Open browser for user to approve + // 2. Display authorization URL and user code verificationURL := codeResp.VerificationURI + "?user_code=" + codeResp.UserCode - fmt.Fprintf(os.Stderr, "\nOpening browser for device authorization...\n") - fmt.Fprintf(os.Stderr, "User code: %s\n", codeResp.UserCode) - fmt.Fprintf(os.Stderr, "\nIf browser doesn't open, visit: %s\n\n", verificationURL) + fmt.Fprintf(os.Stderr, "\n╔════════════════════════════════════════════════════════════════╗\n") + fmt.Fprintf(os.Stderr, "║ Device Authorization Required ║\n") + fmt.Fprintf(os.Stderr, "╚════════════════════════════════════════════════════════════════╝\n\n") + fmt.Fprintf(os.Stderr, "Visit this URL in your browser:\n") + fmt.Fprintf(os.Stderr, " %s\n\n", verificationURL) + fmt.Fprintf(os.Stderr, "Your code: %s\n\n", codeResp.UserCode) - if err := openBrowser(verificationURL); err != nil { - fmt.Fprintf(os.Stderr, "Could not open browser: %v\n", err) + // Try to open browser (may fail on headless systems) + if err := openBrowser(verificationURL); err == nil { + fmt.Fprintf(os.Stderr, "Opening browser...\n\n") + } else { + fmt.Fprintf(os.Stderr, "Could not open browser automatically (%v)\n", err) + fmt.Fprintf(os.Stderr, "Please open the URL above manually.\n\n") } - fmt.Fprintf(os.Stderr, "Waiting for authorization...\n") + fmt.Fprintf(os.Stderr, "Waiting for authorization") // 3. Poll for authorization completion pollInterval := time.Duration(codeResp.Interval) * time.Second timeout := time.Duration(codeResp.ExpiresIn) * time.Second deadline := time.Now().Add(timeout) + dots := 0 for time.Now().Before(deadline) { time.Sleep(pollInterval) + // Show progress dots + dots = (dots + 1) % 4 + fmt.Fprintf(os.Stderr, "\rWaiting for authorization%s ", strings.Repeat(".", dots)) + // Poll token endpoint tokenReqBody, _ := json.Marshal(DeviceTokenRequest{DeviceCode: codeResp.DeviceCode}) tokenResp, err := http.Post(appViewURL+"/auth/device/token", "application/json", bytes.NewReader(tokenReqBody)) if err != nil { - fmt.Fprintf(os.Stderr, "Poll failed: %v\n", err) + fmt.Fprintf(os.Stderr, "\nPoll failed: %v\n", err) continue } @@ -229,10 +239,12 @@ func authorizeDevice() (*DeviceConfig, error) { } if tokenResult.Error != "" { + fmt.Fprintf(os.Stderr, "\n") return nil, fmt.Errorf("authorization failed: %s", tokenResult.Error) } // Success! + fmt.Fprintf(os.Stderr, "\n") return &DeviceConfig{ Handle: tokenResult.Handle, DeviceSecret: tokenResult.DeviceSecret, @@ -240,6 +252,7 @@ func authorizeDevice() (*DeviceConfig, error) { }, nil } + fmt.Fprintf(os.Stderr, "\n") return nil, fmt.Errorf("authorization timeout") } @@ -302,3 +315,106 @@ func openBrowser(url string) error { return cmd.Start() } + +// buildAppViewURL constructs the AppView URL with the appropriate protocol +func buildAppViewURL(serverURL string) string { + // If serverURL already has a scheme, use it as-is + if strings.HasPrefix(serverURL, "http://") || strings.HasPrefix(serverURL, "https://") { + return serverURL + } + + // Determine protocol based on Docker configuration and heuristics + if isInsecureRegistry(serverURL) { + return "http://" + serverURL + } + + // Default to HTTPS (mirrors Docker's default behavior) + return "https://" + serverURL +} + +// isInsecureRegistry checks if a registry should use HTTP instead of HTTPS +func isInsecureRegistry(serverURL string) bool { + // Check Docker's insecure-registries configuration + insecureRegistries := getDockerInsecureRegistries() + for _, reg := range insecureRegistries { + // Match exact serverURL or just the host part + if reg == serverURL || reg == stripPort(serverURL) { + return true + } + } + + // Fallback heuristics: localhost and private IPs + host := stripPort(serverURL) + + // Check for localhost variants + if host == "localhost" || host == "127.0.0.1" || host == "::1" { + return true + } + + // Check if it's a private IP address + if ip := net.ParseIP(host); ip != nil { + if ip.IsLoopback() || ip.IsPrivate() { + return true + } + } + + return false +} + +// getDockerInsecureRegistries reads Docker's insecure-registries configuration +func getDockerInsecureRegistries() []string { + var paths []string + + // Common Docker daemon.json locations + switch runtime.GOOS { + case "windows": + programData := os.Getenv("ProgramData") + if programData != "" { + paths = append(paths, filepath.Join(programData, "docker", "config", "daemon.json")) + } + default: + // Linux and macOS + paths = append(paths, "/etc/docker/daemon.json") + if homeDir, err := os.UserHomeDir(); err == nil { + // Rootless Docker location + paths = append(paths, filepath.Join(homeDir, ".docker", "daemon.json")) + } + } + + // Try each path + for _, path := range paths { + if config := readDockerDaemonConfig(path); config != nil && len(config.InsecureRegistries) > 0 { + return config.InsecureRegistries + } + } + + return nil +} + +// readDockerDaemonConfig reads and parses a Docker daemon.json file +func readDockerDaemonConfig(path string) *DockerDaemonConfig { + data, err := os.ReadFile(path) + if err != nil { + return nil + } + + var config DockerDaemonConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil + } + + return &config +} + +// stripPort removes the port from a host:port string +func stripPort(hostPort string) string { + if colonIdx := strings.LastIndex(hostPort, ":"); colonIdx != -1 { + // Check if this is IPv6 (has multiple colons) + if strings.Count(hostPort, ":") > 1 { + // IPv6 address, don't strip + return hostPort + } + return hostPort[:colonIdx] + } + return hostPort +} diff --git a/pkg/appview/db/device_store.go b/pkg/appview/db/device_store.go index 75d5580..00ff70a 100644 --- a/pkg/appview/db/device_store.go +++ b/pkg/appview/db/device_store.go @@ -28,15 +28,15 @@ type Device struct { // PendingAuthorization represents a device awaiting user approval type PendingAuthorization struct { - DeviceCode string `json:"device_code"` - UserCode string `json:"user_code"` - DeviceName string `json:"device_name"` - IPAddress string `json:"ip_address"` - UserAgent string `json:"user_agent"` - ExpiresAt time.Time `json:"expires_at"` - ApprovedDID string `json:"approved_did"` - ApprovedAt time.Time `json:"approved_at"` - DeviceSecret string `json:"device_secret"` + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + DeviceName string `json:"device_name"` + IPAddress string `json:"ip_address"` + UserAgent string `json:"user_agent"` + ExpiresAt time.Time `json:"expires_at"` + ApprovedDID *string `json:"approved_did"` + ApprovedAt *time.Time `json:"approved_at"` + DeviceSecret *string `json:"device_secret"` } // DeviceStore manages devices and pending authorizations with SQLite persistence @@ -194,7 +194,7 @@ func (s *DeviceStore) ApprovePending(userCode, did, handle string) (deviceSecret } // Check if already approved - if pending.ApprovedDID != "" { + if pending.ApprovedDID != nil && *pending.ApprovedDID != "" { return "", fmt.Errorf("already approved") } @@ -259,6 +259,7 @@ func (s *DeviceStore) ValidateDeviceSecret(secret string) (*Device, error) { for rows.Next() { var device Device var lastUsed sql.NullTime + var location sql.NullString err := rows.Scan( &device.ID, @@ -267,7 +268,7 @@ func (s *DeviceStore) ValidateDeviceSecret(secret string) (*Device, error) { &device.Name, &device.SecretHash, &device.IPAddress, - &device.Location, + &location, &device.UserAgent, &device.CreatedAt, &lastUsed, @@ -279,6 +280,9 @@ func (s *DeviceStore) ValidateDeviceSecret(secret string) (*Device, error) { if lastUsed.Valid { device.LastUsed = lastUsed.Time } + if location.Valid { + device.Location = location.String + } // Check if this device's hash matches the secret if err := bcrypt.CompareHashAndPassword([]byte(device.SecretHash), []byte(secret)); err == nil { @@ -302,7 +306,6 @@ func (s *DeviceStore) ListDevices(did string) []*Device { `, did) if err != nil { - fmt.Printf("Warning: Failed to list devices: %v\n", err) return []*Device{} } defer rows.Close() @@ -311,6 +314,7 @@ func (s *DeviceStore) ListDevices(did string) []*Device { for rows.Next() { var device Device var lastUsed sql.NullTime + var location sql.NullString err := rows.Scan( &device.ID, @@ -318,7 +322,7 @@ func (s *DeviceStore) ListDevices(did string) []*Device { &device.Handle, &device.Name, &device.IPAddress, - &device.Location, + &location, &device.UserAgent, &device.CreatedAt, &lastUsed, @@ -330,6 +334,9 @@ func (s *DeviceStore) ListDevices(did string) []*Device { if lastUsed.Valid { device.LastUsed = lastUsed.Time } + if location.Valid { + device.Location = location.String + } devices = append(devices, &device) } diff --git a/pkg/appview/handlers/device.go b/pkg/appview/handlers/device.go index 5b2bfd8..57b4664 100644 --- a/pkg/appview/handlers/device.go +++ b/pkg/appview/handlers/device.go @@ -117,7 +117,7 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Check if approved - if pending.ApprovedDID == "" { + if pending.ApprovedDID == nil || *pending.ApprovedDID == "" { // Still pending resp := DeviceTokenResponse{ Error: "authorization_pending", @@ -128,10 +128,11 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // Approved! Get device from store to find handle - devices := h.Store.ListDevices(pending.ApprovedDID) + devices := h.Store.ListDevices(*pending.ApprovedDID) + var handle string for _, d := range devices { - if d.DID == pending.ApprovedDID { + if d.DID == *pending.ApprovedDID { handle = d.Handle break } @@ -139,9 +140,9 @@ func (h *DeviceTokenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { // Return device secret resp := DeviceTokenResponse{ - DeviceSecret: pending.DeviceSecret, + DeviceSecret: *pending.DeviceSecret, Handle: handle, - DID: pending.ApprovedDID, + DID: *pending.ApprovedDID, } w.Header().Set("Content-Type", "application/json") @@ -204,7 +205,7 @@ func (h *DeviceApprovalPageHandler) ServeHTTP(w http.ResponseWriter, r *http.Req } // Check if already approved - if pending.ApprovedDID != "" { + if pending.ApprovedDID != nil && *pending.ApprovedDID != "" { h.renderSuccess(w, pending.DeviceName) return } @@ -260,10 +261,10 @@ func (h *DeviceApproveHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) // Approve the device _, err := h.Store.ApprovePending(req.UserCode, sess.DID, sess.Handle) if err != nil { + fmt.Printf("ERROR [device/approve]: Failed to approve: %v\n", err) http.Error(w, fmt.Sprintf("failed to approve: %v", err), http.StatusInternalServerError) return } - w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(map[string]string{"status": "approved"}) } diff --git a/pkg/appview/jetstream/worker.go b/pkg/appview/jetstream/worker.go index a271316..73f3990 100644 --- a/pkg/appview/jetstream/worker.go +++ b/pkg/appview/jetstream/worker.go @@ -602,4 +602,3 @@ type AccountInfo struct { Time string `json:"time"` Status string `json:"status,omitempty"` } -