create a seamark credential helper, have the appview redirect to the source if navigating to go paths

This commit is contained in:
Evan Jarrett
2026-05-25 09:01:46 -05:00
parent 2f02d3e7e5
commit fd3132a545
30 changed files with 366 additions and 194 deletions
+3 -1
View File
@@ -1,3 +1,4 @@
# yaml-language-server: $schema=https://raw.githubusercontent.com/goreleaser/goreleaser/refs/heads/main/www/static/schema.json
# GoReleaser configuration for ATCR
# See https://goreleaser.com for documentation
@@ -12,7 +13,8 @@ builds:
# Credential helper - cross-platform native binary distribution
- id: credential-helper
binary: docker-credential-atcr
main: ./cmd/credential-helper
main: .
dir: ./cmd/credential-helper/atcr
env:
- CGO_ENABLED=0
goos:
+3 -3
View File
@@ -51,10 +51,10 @@ build-hold: $(GENERATED_ASSETS) ## Build hold binary only
@mkdir -p bin
go build -o bin/atcr-hold ./cmd/hold
build-credential-helper: ## Build credential helper only
build-credential-helper: ## Build credential helper only (atcr brand)
@echo "→ Building credential helper..."
@mkdir -p bin
go build -o bin/docker-credential-atcr ./cmd/credential-helper
cd cmd/credential-helper/atcr && go build -ldflags="-X main.version=$(shell git describe --tags --always 2>/dev/null || echo dev) -X main.commit=$(shell git rev-parse HEAD 2>/dev/null || echo none)" -o ../../../bin/docker-credential-atcr .
build-oauth-helper: ## Build OAuth helper only
@echo "→ Building OAuth helper..."
@@ -84,7 +84,7 @@ build-trixie: $(GENERATED_ASSETS) ## Build all production binaries (appview, hol
set -e && \
go build -trimpath -tags billing -ldflags="-s -w $(APPVIEW_LDFLAGS)" -o bin/atcr-appview ./cmd/appview && \
go build -trimpath -ldflags="-s -w" -o bin/atcr-hold ./cmd/hold && \
go build -trimpath -ldflags="-s -w" -o bin/docker-credential-atcr ./cmd/credential-helper && \
(cd cmd/credential-helper/atcr && go build -trimpath -ldflags="-s -w" -o ../../../bin/docker-credential-atcr .) && \
go build -trimpath -ldflags="-s -w" -o bin/atcr-labeler ./cmd/labeler && \
cd scanner && go build -trimpath -ldflags="-s -w" -o ../bin/atcr-scanner ./cmd/scanner'
@echo "✓ Built to bin/ (glibc ≥ 2.41 compatible)"
+10
View File
@@ -0,0 +1,10 @@
module atcr.io/cmd/credential-helper/atcr
go 1.26.2
// atcr.io is provided by the workspace during local development (go.work
// at the repo root). The require line below is what `go install
// atcr.io/cmd/credential-helper/atcr@latest` resolves against when the
// module is fetched standalone via the proxy. Bump it when cutting a
// new credhelper release.
require atcr.io v0.1.4
View File
+28
View File
@@ -0,0 +1,28 @@
// docker-credential-atcr is the Docker credential helper for atcr.io.
//
// Thin main that delegates to atcr.io/pkg/credhelper. The seamark.dev sibling
// at ../seamark mirrors this with brand-specific Config values.
package main
import "atcr.io/pkg/credhelper"
// Stamped at link time via -ldflags by the Makefile and goreleaser.
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
credhelper.Run(credhelper.Config{
BinaryName: "docker-credential-atcr",
DefaultRegistry: "atcr.io",
ConfigDirName: ".atcr",
SecretPrefix: "atcr_device_",
UpdateAssetName: "docker-credential-atcr",
ReleasesBaseURL: "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64",
Version: version,
Commit: commit,
Date: date,
})
}
-54
View File
@@ -1,54 +0,0 @@
package main
import (
"fmt"
"os"
"time"
"github.com/spf13/cobra"
)
var (
version = "dev"
commit = "none"
date = "unknown"
// Update check cache TTL (24 hours)
updateCheckCacheTTL = 24 * time.Hour
)
// timeNow is a variable so tests can override it.
var timeNow = time.Now
func main() {
rootCmd := &cobra.Command{
Use: "docker-credential-atcr",
Short: "ATCR container registry credential helper",
Long: `docker-credential-atcr manages authentication for ATCR-compatible container registries.
It implements the Docker credential helper protocol and provides commands
for managing multiple accounts across multiple registries.`,
Version: fmt.Sprintf("%s (commit: %s, built: %s)", version, commit, date),
SilenceUsage: true,
SilenceErrors: true,
}
// Docker protocol commands (hidden — called by Docker, not users)
rootCmd.AddCommand(newGetCmd())
rootCmd.AddCommand(newStoreCmd())
rootCmd.AddCommand(newEraseCmd())
rootCmd.AddCommand(newListCmd())
// User-facing commands
rootCmd.AddCommand(newLoginCmd())
rootCmd.AddCommand(newLogoutCmd())
rootCmd.AddCommand(newStatusCmd())
rootCmd.AddCommand(newSwitchCmd())
rootCmd.AddCommand(newConfigureDockerCmd())
rootCmd.AddCommand(newUpdateCmd())
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
+10
View File
@@ -0,0 +1,10 @@
module seamark.dev/cmd/credential-helper/seamark
go 1.26.2
// atcr.io is provided by the workspace during local development (go.work
// at the repo root). The require line below is what `go install
// seamark.dev/cmd/credential-helper/seamark@latest` resolves against when
// the module is fetched standalone via the proxy. Bump it when cutting a
// new credhelper release.
require atcr.io v0.1.4
+28
View File
@@ -0,0 +1,28 @@
// docker-credential-seamark is the Docker credential helper for seamark.dev.
//
// Thin main that delegates to atcr.io/pkg/credhelper. The atcr.io sibling
// at ../atcr mirrors this with brand-specific Config values.
package main
import "atcr.io/pkg/credhelper"
// Stamped at link time via -ldflags by the Makefile and goreleaser.
var (
version = "dev"
commit = "none"
date = "unknown"
)
func main() {
credhelper.Run(credhelper.Config{
BinaryName: "docker-credential-seamark",
DefaultRegistry: "seamark.cr",
ConfigDirName: ".seamark",
SecretPrefix: "seamark_device_",
UpdateAssetName: "docker-credential-seamark",
ReleasesBaseURL: "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64",
Version: version,
Commit: commit,
Date: date,
})
}
+1
View File
@@ -184,6 +184,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b // indirect
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 // indirect
go.etcd.io/bbolt v1.4.3 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.68.0 // indirect
go.opentelemetry.io/contrib/exporters/autoexport v0.68.0 // indirect
+2 -2
View File
@@ -732,8 +732,8 @@ gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b h1:CzigHMRyS
gitlab.com/yawning/secp256k1-voi v0.0.0-20230925100816-f2616030848b/go.mod h1:/y/V339mxv2sZmYYR64O07VuCpdNZqCTwO8ZcouTMI8=
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02 h1:qwDnMxjkyLmAFgcfgTnfJrmYKWhHnci3GjDqcZp1M3Q=
gitlab.com/yawning/tuplehash v0.0.0-20230713102510-df83abbf9a02/go.mod h1:JTnUj0mpYiAsuZLmKjTx/ex3AtMowcCgnE7YNyCEP0I=
go.etcd.io/bbolt v1.3.5 h1:XAzx9gjCb0Rxj7EoqcClPD1d5ZBxZJk0jbuoPHenBt0=
go.etcd.io/bbolt v1.3.5/go.mod h1:G5EMThwa9y8QZGBClrRx5EY+Yw9kAhnjy3bSjsnlVTQ=
go.etcd.io/bbolt v1.4.3 h1:dEadXpI6G79deX5prL3QRNP6JB8UxVkqo4UPnHaNXJo=
go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E=
go.etcd.io/etcd/api/v3 v3.5.0/go.mod h1:cbVKeC6lCfl7j/8jBhAK6aIYO9XOjdptoxU/nLQcPvs=
go.etcd.io/etcd/client/pkg/v3 v3.5.0/go.mod h1:IJHfcCEKxYu1Os13ZdwCwIUTUVGYTSAM3YSwc9/Ac1g=
go.etcd.io/etcd/client/v2 v2.305.0/go.mod h1:h9puh54ZTgAKtEbut2oe9P4L/oqKCVB6xsXlzd7alYQ=
+2
View File
@@ -2,6 +2,8 @@ go 1.26.2
use (
.
./cmd/credential-helper/atcr
./cmd/credential-helper/seamark
./deploy/upcloud
./scanner
)
+35 -5
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"html"
"net/http"
"strings"
)
// GoImport serves the `<meta name="go-import">` tag required by `go install` /
@@ -17,6 +18,12 @@ import (
// The meta tag must be present on every subpath under the module root, so this
// runs as middleware at the top of the chain and short-circuits any request
// carrying `?go-get=1`.
//
// For non-go-get requests on paths that look like Go module subpaths
// (/cmd/, /pkg/, /internal/, /scanner/), the middleware redirects to the
// corresponding source-tree URL in the git host so a browser visit doesn't
// 404. The redirect template mirrors the `go-source` meta tag's
// `{repoURL}/tree/main{/dir}` form.
func GoImport(modulePath, repoURL string) func(http.Handler) http.Handler {
body := fmt.Sprintf(
`<!DOCTYPE html><html><head><meta name="go-import" content="%s git %s"><meta name="go-source" content="%s %s %s/tree/main{/dir} %s/tree/main{/dir}/{file}#L{line}"></head><body>go get %s</body></html>`,
@@ -31,13 +38,36 @@ func GoImport(modulePath, repoURL string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Query().Get("go-get") != "1" {
next.ServeHTTP(w, r)
if r.URL.Query().Get("go-get") == "1" {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write([]byte(body))
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=300")
_, _ = w.Write([]byte(body))
// Browser visits to recognizable Go subpaths get redirected to
// the repo source tree instead of falling through to the
// appview router (which 404s for these paths).
if isGoModuleSubpath(r.URL.Path) {
target := repoURL + "/tree/main" + r.URL.Path
http.Redirect(w, r, target, http.StatusFound)
return
}
next.ServeHTTP(w, r)
})
}
}
// isGoModuleSubpath reports whether p looks like a Go module source path
// (under cmd/, pkg/, internal/, or scanner/). The check is intentionally
// narrow so the redirect doesn't hijack other appview routes.
func isGoModuleSubpath(p string) bool {
switch {
case strings.HasPrefix(p, "/cmd/"),
strings.HasPrefix(p, "/pkg/"),
strings.HasPrefix(p, "/internal/"):
return true
}
return false
}
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"encoding/json"
@@ -21,20 +21,20 @@ func newConfigureDockerCmd() *cobra.Command {
}
func runConfigureDocker(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
if len(cfg.Registries) == 0 {
if len(sc.Registries) == 0 {
fmt.Fprintf(os.Stderr, "No registries configured.\n")
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
fmt.Fprintf(os.Stderr, "Run: %s login\n", cfg.BinaryName)
return nil
}
// Collect registry hosts
var hosts []string
for url := range cfg.Registries {
for url := range sc.Registries {
host := strings.TrimPrefix(url, "https://")
host = strings.TrimPrefix(host, "http://")
hosts = append(hosts, host)
@@ -58,11 +58,13 @@ func runConfigureDocker(cmd *cobra.Command, args []string) error {
helpersMap = make(map[string]any)
}
helper := helperName(cfg)
// Check what needs to change
var toAdd []string
for _, host := range hosts {
current, exists := helpersMap[host]
if !exists || current != "atcr" {
if !exists || current != helper {
toAdd = append(toAdd, host)
}
}
@@ -74,7 +76,7 @@ func runConfigureDocker(cmd *cobra.Command, args []string) error {
fmt.Printf("Will update %s:\n", dockerConfigPath)
for _, host := range toAdd {
fmt.Printf(" + credHelpers[%q] = \"atcr\"\n", host)
fmt.Printf(" + credHelpers[%q] = %q\n", host, helper)
}
fmt.Println()
@@ -90,7 +92,7 @@ func runConfigureDocker(cmd *cobra.Command, args []string) error {
// Apply changes
for _, host := range toAdd {
helpersMap[host] = "atcr"
helpersMap[host] = helper
}
dockerCfg["credHelpers"] = helpersMap
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"bufio"
@@ -12,31 +12,30 @@ import (
)
func newLoginCmd() *cobra.Command {
cmd := &cobra.Command{
return &cobra.Command{
Use: "login [registry]",
Short: "Authenticate with a container registry",
Long: "Starts a device authorization flow to authenticate with a registry.\nDefault registry: atcr.io",
Long: "Starts a device authorization flow to authenticate with a registry.\nDefault registry: " + cfg.DefaultRegistry,
Args: cobra.MaximumNArgs(1),
RunE: runLogin,
}
return cmd
}
func runLogin(cmd *cobra.Command, args []string) error {
serverURL := "atcr.io"
serverURL := cfg.DefaultRegistry
if len(args) > 0 {
serverURL = args[0]
}
appViewURL := buildAppViewURL(serverURL)
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err)
}
// Check if already logged in
reg := cfg.findRegistry(appViewURL)
reg := sc.findRegistry(appViewURL)
if reg != nil && len(reg.Accounts) > 0 {
var lines []string
for _, acct := range reg.Accounts {
@@ -102,8 +101,8 @@ func runLogin(cmd *cobra.Command, args []string) error {
logSuccess("Authentication complete.")
// 4. Save
cfg.addAccount(resolvedURL, acct)
if err := cfg.save(); err != nil {
sc.addAccount(resolvedURL, acct)
if err := sc.save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
@@ -174,7 +173,7 @@ func configureDockerForRegistry(serverURL string) error {
helpersMap = make(map[string]any)
}
helpersMap[host] = "atcr"
helpersMap[host] = helperName(cfg)
dockerCfg["credHelpers"] = helpersMap
return saveDockerConfig(dockerConfigPath, dockerCfg)
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"fmt"
@@ -13,26 +13,26 @@ func newLogoutCmd() *cobra.Command {
return &cobra.Command{
Use: "logout [registry]",
Short: "Remove account credentials",
Long: "Remove stored credentials for an account.\nDefault registry: atcr.io",
Long: "Remove stored credentials for an account.\nDefault registry: " + cfg.DefaultRegistry,
Args: cobra.MaximumNArgs(1),
RunE: runLogout,
}
}
func runLogout(cmd *cobra.Command, args []string) error {
serverURL := "atcr.io"
serverURL := cfg.DefaultRegistry
if len(args) > 0 {
serverURL = args[0]
}
appViewURL := buildAppViewURL(serverURL)
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
reg := cfg.findRegistry(appViewURL)
reg := sc.findRegistry(appViewURL)
if reg == nil || len(reg.Accounts) == 0 {
fmt.Fprintf(os.Stderr, "No accounts configured for %s.\n", serverURL)
return nil
@@ -83,8 +83,8 @@ func runLogout(cmd *cobra.Command, args []string) error {
return nil
}
cfg.removeAccount(appViewURL, handle)
if err := cfg.save(); err != nil {
sc.removeAccount(appViewURL, handle)
if err := sc.save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"fmt"
@@ -17,26 +17,26 @@ func newStatusCmd() *cobra.Command {
}
func runStatus(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
if len(cfg.Registries) == 0 {
if len(sc.Registries) == 0 {
fmt.Fprintf(os.Stderr, "No accounts configured.\n")
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
fmt.Fprintf(os.Stderr, "Run: %s login\n", cfg.BinaryName)
return nil
}
// Sort registry URLs for stable output
var urls []string
for url := range cfg.Registries {
for url := range sc.Registries {
urls = append(urls, url)
}
sort.Strings(urls)
for _, url := range urls {
reg := cfg.Registries[url]
reg := sc.Registries[url]
fmt.Printf("%s\n", url)
// Sort handles for stable output
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"fmt"
@@ -13,29 +13,29 @@ func newSwitchCmd() *cobra.Command {
return &cobra.Command{
Use: "switch [registry]",
Short: "Switch the active account for a registry",
Long: "Switch the active account used for Docker operations.\nDefault registry: atcr.io",
Long: "Switch the active account used for Docker operations.\nDefault registry: " + cfg.DefaultRegistry,
Args: cobra.MaximumNArgs(1),
RunE: runSwitch,
}
}
func runSwitch(cmd *cobra.Command, args []string) error {
serverURL := "atcr.io"
serverURL := cfg.DefaultRegistry
if len(args) > 0 {
serverURL = args[0]
}
appViewURL := buildAppViewURL(serverURL)
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
return fmt.Errorf("loading config: %w", err)
}
reg := cfg.findRegistry(appViewURL)
reg := sc.findRegistry(appViewURL)
if reg == nil || len(reg.Accounts) == 0 {
fmt.Fprintf(os.Stderr, "No accounts configured for %s.\n", serverURL)
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
fmt.Fprintf(os.Stderr, "Run: %s login\n", cfg.BinaryName)
return nil
}
@@ -51,7 +51,7 @@ func runSwitch(cmd *cobra.Command, args []string) error {
for h := range reg.Accounts {
if h != reg.Active {
reg.Active = h
if err := cfg.save(); err != nil {
if err := sc.save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
fmt.Printf("Switched to %s on %s\n", h, serverURL)
@@ -87,7 +87,7 @@ func runSwitch(cmd *cobra.Command, args []string) error {
}
reg.Active = selected
if err := cfg.save(); err != nil {
if err := sc.save(); err != nil {
return fmt.Errorf("saving config: %w", err)
}
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"fmt"
@@ -16,11 +16,6 @@ import (
"github.com/spf13/cobra"
)
// tangledReleasesBase is the tangled.org path for the credential-helper's
// release repository. /tags/latest issues a 302 redirect to the latest tag,
// and /tags/{version}/download/{filename} serves goreleaser artifacts directly.
const tangledReleasesBase = "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64"
func newUpdateCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "update",
@@ -39,12 +34,12 @@ func runUpdate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("checking for updates: %w", err)
}
if !isNewerVersion(latest, version) {
fmt.Printf("You're already running the latest version (%s)\n", version)
if !isNewerVersion(latest, cfg.Version) {
fmt.Printf("You're already running the latest version (%s)\n", cfg.Version)
return nil
}
fmt.Printf("New version available: %s (current: %s)\n", latest, version)
fmt.Printf("New version available: %s (current: %s)\n", latest, cfg.Version)
if checkOnly {
return nil
@@ -59,13 +54,13 @@ func runUpdate(cmd *cobra.Command, args []string) error {
}
// fetchLatestVersion resolves the latest released version by following the
// {tangledReleasesBase}/tags/latest redirect chain. Tangled redirects
// {ReleasesBaseURL}/tags/latest redirect chain. Tangled redirects
// DID→handle first, then handle/tags/latest→handle/tags/vX.Y.Z, so we follow
// the chain and read the tag from the final effective URL.
func fetchLatestVersion() (string, error) {
client := httpClientWithTimeout(10*time.Second, nil)
resp, err := client.Get(tangledReleasesBase + "/tags/latest")
resp, err := client.Get(cfg.ReleasesBaseURL + "/tags/latest")
if err != nil {
return "", fmt.Errorf("fetching latest tag: %w", err)
}
@@ -115,7 +110,7 @@ func isNewerVersion(newVersion, currentVersion string) bool {
// goreleaserArchiveName returns the archive filename goreleaser publishes for
// the given version and the current platform. The naming template lives in
// .goreleaser.yaml: docker-credential-atcr_{Version}_{Title(OS)}_{Arch} with
// .goreleaser.yaml: <UpdateAssetName>_{Version}_{Title(OS)}_{Arch} with
// amd64→x86_64 and 386→i386.
func goreleaserArchiveName(version string) string {
versionNoV := strings.TrimPrefix(version, "v")
@@ -130,17 +125,17 @@ func goreleaserArchiveName(version string) string {
arch = "i386"
}
return fmt.Sprintf("docker-credential-atcr_%s_%s_%s.tar.gz", versionNoV, os, arch)
return fmt.Sprintf("%s_%s_%s_%s.tar.gz", cfg.UpdateAssetName, versionNoV, os, arch)
}
// performUpdate downloads and installs the new version
func performUpdate(latest string) error {
filename := goreleaserArchiveName(latest)
downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", tangledReleasesBase, latest, filename)
downloadURL := fmt.Sprintf("%s/tags/%s/download/%s", cfg.ReleasesBaseURL, latest, filename)
fmt.Printf("Downloading update from %s...\n", downloadURL)
tmpDir, err := os.MkdirTemp("", "atcr-update-")
tmpDir, err := os.MkdirTemp("", cfg.BinaryName+"-update-")
if err != nil {
return fmt.Errorf("creating temp directory: %w", err)
}
@@ -151,7 +146,7 @@ func performUpdate(latest string) error {
return fmt.Errorf("downloading: %w", err)
}
binaryPath := filepath.Join(tmpDir, "docker-credential-atcr")
binaryPath := filepath.Join(tmpDir, cfg.BinaryName)
if runtime.GOOS == "windows" {
binaryPath += ".exe"
}
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"encoding/json"
@@ -7,8 +7,9 @@ import (
"time"
)
// Config is the top-level credential helper configuration (v2).
type Config struct {
// StoredConfig is the on-disk credential helper state (v2). Distinct from
// the per-binary [Config] passed to [Run].
type StoredConfig struct {
Version int `json:"version"`
Registries map[string]*RegistryConfig `json:"registries"`
}
@@ -34,21 +35,21 @@ type UpdateCheckCache struct {
}
// loadConfig loads the config from disk, auto-migrating old formats.
// Returns a valid Config (possibly empty) even on error.
func loadConfig() (*Config, error) {
// Returns a valid StoredConfig (possibly empty) even on error.
func loadConfig() (*StoredConfig, error) {
path := getConfigPath()
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return newConfig(), nil
return newStoredConfig(), nil
}
return newConfig(), err
return newStoredConfig(), err
}
// Try v2 format first
var cfg Config
if err := json.Unmarshal(data, &cfg); err == nil && cfg.Version == 2 && cfg.Registries != nil {
return &cfg, nil
var sc StoredConfig
if err := json.Unmarshal(data, &sc); err == nil && sc.Version == 2 && sc.Registries != nil {
return &sc, nil
}
// Try current multi-registry format: {"credentials": {"url": {...}}}
@@ -61,7 +62,7 @@ func loadConfig() (*Config, error) {
} `json:"credentials"`
}
if err := json.Unmarshal(data, &multiCreds); err == nil && multiCreds.Credentials != nil {
migrated := newConfig()
migrated := newStoredConfig()
for appViewURL, cred := range multiCreds.Credentials {
handle := cred.Handle
if handle == "" {
@@ -91,11 +92,11 @@ func loadConfig() (*Config, error) {
AppViewURL string `json:"appview_url"`
}
if err := json.Unmarshal(data, &legacy); err == nil && legacy.DeviceSecret != "" {
migrated := newConfig()
migrated := newStoredConfig()
handle := legacy.Handle
registryURL := legacy.AppViewURL
if registryURL == "" {
registryURL = "https://atcr.io"
registryURL = "https://" + cfg.DefaultRegistry
}
reg := migrated.getOrCreateRegistry(registryURL)
reg.Accounts[handle] = &Account{
@@ -109,18 +110,18 @@ func loadConfig() (*Config, error) {
return migrated, nil
}
return newConfig(), fmt.Errorf("unrecognized config format")
return newStoredConfig(), fmt.Errorf("unrecognized config format")
}
func newConfig() *Config {
return &Config{
func newStoredConfig() *StoredConfig {
return &StoredConfig{
Version: 2,
Registries: make(map[string]*RegistryConfig),
}
}
// save writes the config to disk.
func (c *Config) save() error {
func (c *StoredConfig) save() error {
path := getConfigPath()
data, err := json.MarshalIndent(c, "", " ")
if err != nil {
@@ -130,7 +131,7 @@ func (c *Config) save() error {
}
// getOrCreateRegistry returns (or creates) a RegistryConfig for the given URL.
func (c *Config) getOrCreateRegistry(registryURL string) *RegistryConfig {
func (c *StoredConfig) getOrCreateRegistry(registryURL string) *RegistryConfig {
reg, ok := c.Registries[registryURL]
if !ok {
reg = &RegistryConfig{
@@ -142,7 +143,7 @@ func (c *Config) getOrCreateRegistry(registryURL string) *RegistryConfig {
}
// findRegistry looks up a RegistryConfig by registry URL.
func (c *Config) findRegistry(registryURL string) *RegistryConfig {
func (c *StoredConfig) findRegistry(registryURL string) *RegistryConfig {
return c.Registries[registryURL]
}
@@ -152,10 +153,10 @@ func (c *Config) findRegistry(registryURL string) *RegistryConfig {
// 2. Active account (set by `switch`)
// 3. Sole account (if only one exists)
// 4. Error
func (c *Config) resolveAccount(registryURL, serverURL string) (*Account, error) {
func (c *StoredConfig) resolveAccount(registryURL, serverURL string) (*Account, error) {
reg := c.findRegistry(registryURL)
if reg == nil || len(reg.Accounts) == 0 {
return nil, fmt.Errorf("no accounts configured for %s\nRun: docker-credential-atcr login", serverURL)
return nil, fmt.Errorf("no accounts configured for %s\nRun: %s login", serverURL, cfg.BinaryName)
}
// 1. Try to detect identity from parent process
@@ -182,11 +183,11 @@ func (c *Config) resolveAccount(registryURL, serverURL string) (*Account, error)
}
// 4. Ambiguous
return nil, fmt.Errorf("multiple accounts configured for %s\nRun: docker-credential-atcr switch", serverURL)
return nil, fmt.Errorf("multiple accounts configured for %s\nRun: %s switch", serverURL, cfg.BinaryName)
}
// addAccount adds or updates an account in a registry and sets it active.
func (c *Config) addAccount(registryURL string, acct *Account) {
func (c *StoredConfig) addAccount(registryURL string, acct *Account) {
reg := c.getOrCreateRegistry(registryURL)
reg.Accounts[acct.Handle] = acct
reg.Active = acct.Handle
@@ -194,7 +195,7 @@ func (c *Config) addAccount(registryURL string, acct *Account) {
// removeAccount removes an account from a registry.
// If it was the active account, clears active (or sets to remaining account if exactly one left).
func (c *Config) removeAccount(registryURL, handle string) {
func (c *StoredConfig) removeAccount(registryURL, handle string) {
reg := c.findRegistry(registryURL)
if reg == nil {
return
@@ -223,7 +224,7 @@ func getUpdateCheckCachePath() string {
if err != nil {
return ""
}
return fmt.Sprintf("%s/.atcr/update-check.json", homeDir)
return fmt.Sprintf("%s/%s/update-check.json", homeDir, cfg.ConfigDirName)
}
// loadUpdateCheckCache loads the update check cache from disk
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"os"
@@ -94,7 +94,7 @@ func parseImageRef(s string, matchHost string) *ImageRef {
parts := strings.Split(refPart, "/")
// ATCR pattern requires host/identity/repo (3+ parts)
// Registry pattern requires host/identity/repo (3+ parts)
if len(parts) < 3 {
return nil
}
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"bytes"
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"encoding/json"
@@ -172,7 +172,8 @@ func isTerminal(f *os.File) bool {
return (stat.Mode() & os.ModeCharDevice) != 0
}
// getConfigDir returns the path to the .atcr config directory, creating it if needed
// getConfigDir returns the per-brand config directory under $HOME, creating
// it if needed. The directory name comes from cfg.ConfigDirName.
func getConfigDir() string {
homeDir, err := os.UserHomeDir()
if err != nil {
@@ -180,13 +181,13 @@ func getConfigDir() string {
os.Exit(1)
}
atcrDir := filepath.Join(homeDir, ".atcr")
if err := os.MkdirAll(atcrDir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating .atcr directory: %v\n", err)
dir := filepath.Join(homeDir, cfg.ConfigDirName)
if err := os.MkdirAll(dir, 0700); err != nil {
fmt.Fprintf(os.Stderr, "Error creating %s directory: %v\n", cfg.ConfigDirName, err)
os.Exit(1)
}
return atcrDir
return dir
}
// getConfigPath returns the path to the device configuration file
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"fmt"
@@ -10,19 +10,19 @@ import (
// userAgent returns the User-Agent string for outgoing HTTP requests.
//
// Format: docker-credential-atcr/<version> (<os>/<arch>; commit <short>)
// Format: <binary-name>/<version> (<os>/<arch>; commit <short>)
//
// Format follows the convention Docker's own clients use, so it parses
// cleanly with the same regexes server-side log analyzers already
// understand. The commit suffix lets users on the device-approval page
// distinguish two devices on the same version line if they ever need to.
func userAgent() string {
short := commit
short := cfg.Commit
if len(short) > 7 {
short = short[:7]
}
return fmt.Sprintf("docker-credential-atcr/%s (%s/%s; commit %s)",
version, runtime.GOOS, runtime.GOARCH, short)
return fmt.Sprintf("%s/%s (%s/%s; commit %s)",
cfg.BinaryName, cfg.Version, runtime.GOOS, runtime.GOARCH, short)
}
// uaTransport wraps another RoundTripper and sets the User-Agent header
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"net/http"
@@ -8,12 +8,16 @@ import (
)
func TestUserAgent_Format(t *testing.T) {
old := commit
commit = "abc1234deadbeef"
t.Cleanup(func() { commit = old })
old := cfg
cfg = Config{
BinaryName: "docker-credential-test",
Version: "0.0.0",
Commit: "abc1234deadbeef",
}
t.Cleanup(func() { cfg = old })
ua := userAgent()
if !strings.HasPrefix(ua, "docker-credential-atcr/") {
if !strings.HasPrefix(ua, "docker-credential-test/") {
t.Errorf("UA missing product prefix: %q", ua)
}
if !strings.Contains(ua, "commit abc1234)") {
@@ -25,6 +29,14 @@ func TestUserAgent_Format(t *testing.T) {
}
func TestHTTPClient_SetsUserAgent(t *testing.T) {
old := cfg
cfg = Config{
BinaryName: "docker-credential-test",
Version: "0.0.0",
Commit: "abc1234",
}
t.Cleanup(func() { cfg = old })
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("User-Agent")
@@ -45,6 +57,10 @@ func TestHTTPClient_SetsUserAgent(t *testing.T) {
}
func TestHTTPClient_RespectsExplicitUserAgent(t *testing.T) {
old := cfg
cfg = Config{BinaryName: "docker-credential-test"}
t.Cleanup(func() { cfg = old })
var got string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
got = r.Header.Get("User-Agent")
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"bytes"
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"fmt"
@@ -1,6 +1,6 @@
//go:build !linux && !darwin
package main
package credhelper
import (
"fmt"
@@ -1,4 +1,4 @@
package main
package credhelper
import (
"encoding/json"
@@ -58,9 +58,9 @@ func runGet(cmd *cobra.Command, args []string) error {
fmt.Fprintf(os.Stderr, "The 'get' command is part of the Docker credential helper protocol.\n")
fmt.Fprintf(os.Stderr, "It should not be run directly.\n\n")
fmt.Fprintf(os.Stderr, "To authenticate with a registry, run:\n")
fmt.Fprintf(os.Stderr, " docker-credential-atcr login\n\n")
fmt.Fprintf(os.Stderr, " %s login\n\n", cfg.BinaryName)
fmt.Fprintf(os.Stderr, "To check your accounts:\n")
fmt.Fprintf(os.Stderr, " docker-credential-atcr status\n")
fmt.Fprintf(os.Stderr, " %s status\n", cfg.BinaryName)
return fmt.Errorf("not a pipe")
}
@@ -72,12 +72,12 @@ func runGet(cmd *cobra.Command, args []string) error {
appViewURL := buildAppViewURL(serverURL)
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err)
}
acct, err := cfg.resolveAccount(appViewURL, serverURL)
acct, err := sc.resolveAccount(appViewURL, serverURL)
if err != nil {
return err
}
@@ -98,9 +98,9 @@ func runGet(cmd *cobra.Command, args []string) error {
// Generic auth failure — remove the bad account
fmt.Fprintf(os.Stderr, "Credentials for %s are invalid.\n", acct.Handle)
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr login\n")
cfg.removeAccount(appViewURL, acct.Handle)
cfg.save() //nolint:errcheck
fmt.Fprintf(os.Stderr, "Run: %s login\n", cfg.BinaryName)
sc.removeAccount(appViewURL, acct.Handle)
sc.save() //nolint:errcheck
return fmt.Errorf("invalid credentials")
}
@@ -123,25 +123,25 @@ func runStore(cmd *cobra.Command, args []string) error {
return fmt.Errorf("decoding credentials: %w", err)
}
// Only store if the secret looks like a device secret
if !strings.HasPrefix(creds.Secret, "atcr_device_") {
// Only store if the secret looks like one of our device secrets
if !strings.HasPrefix(creds.Secret, cfg.SecretPrefix) {
// Not our device secret — ignore (e.g., docker login with app-password)
return nil
}
appViewURL := buildAppViewURL(creds.ServerURL)
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: config load error: %v\n", err)
}
cfg.addAccount(appViewURL, &Account{
sc.addAccount(appViewURL, &Account{
Handle: creds.Username,
DeviceSecret: creds.Secret,
})
return cfg.save()
return sc.save()
}
func runErase(cmd *cobra.Command, args []string) error {
@@ -152,12 +152,12 @@ func runErase(cmd *cobra.Command, args []string) error {
appViewURL := buildAppViewURL(serverURL)
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
return nil // No config, nothing to erase
}
reg := cfg.findRegistry(appViewURL)
reg := sc.findRegistry(appViewURL)
if reg == nil {
return nil
}
@@ -173,12 +173,12 @@ func runErase(cmd *cobra.Command, args []string) error {
return nil
}
cfg.removeAccount(appViewURL, handle)
return cfg.save()
sc.removeAccount(appViewURL, handle)
return sc.save()
}
func runList(cmd *cobra.Command, args []string) error {
cfg, err := loadConfig()
sc, err := loadConfig()
if err != nil {
// Return empty object
fmt.Println("{}")
@@ -187,7 +187,7 @@ func runList(cmd *cobra.Command, args []string) error {
// Docker list protocol: {"ServerURL": "Username", ...}
result := make(map[string]string)
for url, reg := range cfg.Registries {
for url, reg := range sc.Registries {
// Strip scheme for Docker compatibility
host := strings.TrimPrefix(url, "https://")
host = strings.TrimPrefix(host, "http://")
@@ -202,11 +202,11 @@ func runList(cmd *cobra.Command, args []string) error {
// checkAndNotifyUpdate checks for updates in the background and notifies the user
func checkAndNotifyUpdate() {
cache := loadUpdateCheckCache()
if cache != nil && cache.Current == version {
if cache != nil && cache.Current == cfg.Version {
// Cache is fresh and for current version
if isNewerVersion(cache.Latest, version) {
fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", cache.Latest, version)
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n")
if isNewerVersion(cache.Latest, cfg.Version) {
fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", cache.Latest, cfg.Version)
fmt.Fprintf(os.Stderr, "Run: %s update\n\n", cfg.BinaryName)
}
// Check if cache is still fresh (24h)
if cache.CheckedAt.Add(updateCheckCacheTTL).After(timeNow()) {
@@ -222,11 +222,11 @@ func checkAndNotifyUpdate() {
saveUpdateCheckCache(&UpdateCheckCache{
CheckedAt: timeNow(),
Latest: latest,
Current: version,
Current: cfg.Version,
})
if isNewerVersion(latest, version) {
fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", latest, version)
fmt.Fprintf(os.Stderr, "Run: docker-credential-atcr update\n\n")
if isNewerVersion(latest, cfg.Version) {
fmt.Fprintf(os.Stderr, "\nUpdate available: %s (current: %s)\n", latest, cfg.Version)
fmt.Fprintf(os.Stderr, "Run: %s update\n\n", cfg.BinaryName)
}
}
+101
View File
@@ -0,0 +1,101 @@
// Package credhelper implements the Docker credential helper for ATProto-backed
// container registries. It is consumed by per-brand main packages (e.g.
// cmd/credential-helper/atcr, cmd/credential-helper/seamark) that supply a
// Config and call Run.
package credhelper
import (
"fmt"
"os"
"strings"
"time"
"github.com/spf13/cobra"
)
// Config holds the per-brand identity for one built binary.
type Config struct {
// BinaryName is the program name (e.g. "docker-credential-atcr"). Used in
// the root cobra Use line, help text, and the User-Agent.
BinaryName string
// DefaultRegistry is the default registry hostname used when login/logout/
// switch is invoked without a positional argument (e.g. "atcr.io").
DefaultRegistry string
// ConfigDirName is the directory under $HOME where stored credentials live
// (e.g. ".atcr"). Created with 0700 perms on first use.
ConfigDirName string
// SecretPrefix is the magic prefix the helper uses to recognise its own
// device-issued secrets when Docker calls `store` (e.g. "atcr_device_").
// Secrets not carrying this prefix are silently ignored.
SecretPrefix string
// UpdateAssetName is the goreleaser archive prefix (e.g.
// "docker-credential-atcr") used to construct download URLs in
// `update`.
UpdateAssetName string
// ReleasesBaseURL is the tangled.org base URL for fetching latest tag
// and downloading release artifacts.
ReleasesBaseURL string
// Version, Commit, Date are stamped at link time via -ldflags.
Version, Commit, Date string
}
// helperName derives the Docker credHelpers map value from the binary name.
// Docker discovers helpers by `docker-credential-<name>`; the map value must
// be exactly the suffix.
func helperName(cfg Config) string {
return strings.TrimPrefix(cfg.BinaryName, "docker-credential-")
}
// Package-level state. Run() initialises `cfg` before any cobra command
// executes; helpers (http.go, etc.) read from it.
var cfg Config
// timeNow is a variable so tests can override it.
var timeNow = time.Now
// updateCheckCacheTTL is how long an update check is cached on disk.
const updateCheckCacheTTL = 24 * time.Hour
// Run executes the credential helper CLI with the supplied per-brand Config.
// Intended to be called from a thin main package; never returns under normal
// operation (calls os.Exit on cobra error).
func Run(c Config) {
cfg = c
rootCmd := &cobra.Command{
Use: cfg.BinaryName,
Short: cfg.BinaryName + " — ATProto container registry credential helper",
Long: fmt.Sprintf(`%s manages authentication for ATProto-backed container registries.
It implements the Docker credential helper protocol and provides commands
for managing multiple accounts across multiple registries.`, cfg.BinaryName),
Version: fmt.Sprintf("%s (commit: %s, built: %s)", cfg.Version, cfg.Commit, cfg.Date),
SilenceUsage: true,
SilenceErrors: true,
}
// Docker protocol commands (hidden — called by Docker, not users)
rootCmd.AddCommand(newGetCmd())
rootCmd.AddCommand(newStoreCmd())
rootCmd.AddCommand(newEraseCmd())
rootCmd.AddCommand(newListCmd())
// User-facing commands
rootCmd.AddCommand(newLoginCmd())
rootCmd.AddCommand(newLogoutCmd())
rootCmd.AddCommand(newStatusCmd())
rootCmd.AddCommand(newSwitchCmd())
rootCmd.AddCommand(newConfigureDockerCmd())
rootCmd.AddCommand(newUpdateCmd())
if err := rootCmd.Execute(); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}