mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-16 15:24:15 +00:00
fix credential helper to read insecure-registries. fix device registration flow
This commit is contained in:
+137
-21
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"})
|
||||
}
|
||||
|
||||
@@ -602,4 +602,3 @@ type AccountInfo struct {
|
||||
Time string `json:"time"`
|
||||
Status string `json:"status,omitempty"`
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user