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

This commit is contained in:
Evan Jarrett
2025-10-10 19:55:15 -05:00
parent f3748abf31
commit f5e6e6954f
4 changed files with 165 additions and 42 deletions
+137 -21
View File
@@ -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
}
+20 -13
View File
@@ -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)
}
+8 -7
View File
@@ -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"})
}
-1
View File
@@ -602,4 +602,3 @@ type AccountInfo struct {
Time string `json:"time"`
Status string `json:"status,omitempty"`
}