From 9d8bd513da838c4866760d9506fe3ec70dd0d7af Mon Sep 17 00:00:00 2001 From: Evan Jarrett Date: Wed, 2 Sep 2026 21:38:10 -0500 Subject: [PATCH] appview: render the install scripts from config instead of shipping ATCR's seamark.dev's /install and /settings/devices told users to pipe seamark.dev/static/install.sh into bash. That file was the unmodified ATCR script: it announced itself as the "ATCR Credential Helper Installer", installed docker-credential-atcr, and finished by telling the user to configure credHelpers for atcr.io, the wrong registry for that deployment. Anyone following the documented setup ended up pointed at another service. The templates hardcoded docker-credential-atcr, "atcr" and ~/.atcr/device.json alongside a correctly themed {{ .RegistryURL }}. The scripts are now rendered from config by a handler, rather than forked per brand. A theme overlay was the alternative and was worse: it needed a full copy of both install.sh and install.ps1 per brand, four scripts to keep in sync, and the operator asked for these values to come from config. credential_helper.name is the single knob. Docker resolves a credHelpers value x by exec'ing docker-credential-x, so the credHelpers value, the binary suffix and the config directory are genuinely one word, not three that can drift. It is validated against a strict pattern because it is interpolated into a shell script. install.sh renders byte-identical to the deleted static file under the atcr default, so existing installs are unaffected. install.ps1 differs by one line, where a stale usage comment named a path the script is not served at. Two behaviour changes worth noting: these two URLs drop from a one-year Cache-Control to five minutes, since the body now depends on deployment config; and credential_helper.tangled_repo becomes a real overridable default. It was previously assigned over unconditionally and read by nothing, while the shipped script used a different URL form. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9 --- config-appview.example.yaml | 6 +- deploy/upcloud/configs/appview.yaml.tmpl | 3 + docs/CREDENTIAL_HELPER.md | 19 +- pkg/appview/config.go | 41 +++- pkg/appview/config_test.go | 69 ++++++ pkg/appview/handlers/base.go | 7 + pkg/appview/handlers/common.go | 7 + pkg/appview/handlers/install.go | 55 +++++ pkg/appview/handlers/install_test.go | 231 ++++++++++++++++++ pkg/appview/installscript/installscript.go | 115 +++++++++ .../installscript/installscript_test.go | 218 +++++++++++++++++ .../templates/install.ps1.tmpl} | 26 +- .../templates/install.sh.tmpl} | 26 +- pkg/appview/routes/routes.go | 13 + pkg/appview/server.go | 1 + pkg/appview/templates/pages/home.html | 2 +- pkg/appview/templates/pages/install.html | 6 +- .../partials/settings-panel-devices.html | 4 +- 18 files changed, 805 insertions(+), 44 deletions(-) create mode 100644 pkg/appview/handlers/install_test.go create mode 100644 pkg/appview/installscript/installscript.go create mode 100644 pkg/appview/installscript/installscript_test.go rename pkg/appview/{public/static/install.ps1 => installscript/templates/install.ps1.tmpl} (84%) rename pkg/appview/{public/static/install.sh => installscript/templates/install.sh.tmpl} (79%) mode change 100755 => 100644 diff --git a/config-appview.example.yaml b/config-appview.example.yaml index d18e340..a4707cd 100644 --- a/config-appview.example.yaml +++ b/config-appview.example.yaml @@ -86,10 +86,12 @@ jetstream: auth: # X.509 certificate matching the JWT signing key (auto-generated on each boot from the JWT key in the database). cert_path: /var/lib/atcr/auth/private-key.crt -# Credential helper download settings. +# Credential helper branding and download settings. credential_helper: - # Tangled repository URL for credential helper downloads. + # Tangled repository URL for credential helper downloads. Defaults to the upstream ATCR release repo. tangled_repo: "" + # Credential helper brand: the docker credHelpers value, the docker-credential- binary suffix, and the ~/. config directory. Defaults to "atcr". + name: "" # Legal page customization for self-hosted instances. legal: # Organization name for Terms of Service and Privacy Policy. Defaults to server.client_name. diff --git a/deploy/upcloud/configs/appview.yaml.tmpl b/deploy/upcloud/configs/appview.yaml.tmpl index e62e49d..0b67ebb 100644 --- a/deploy/upcloud/configs/appview.yaml.tmpl +++ b/deploy/upcloud/configs/appview.yaml.tmpl @@ -47,6 +47,9 @@ jetstream: - https://relay1.us-west.bsky.network auth: cert_path: "{{.BasePath}}/auth/private-key.crt" +credential_helper: + # docker-credential-seamark, credHelpers value "seamark", config dir ~/.seamark. + name: seamark legal: company_name: Seamark jurisdiction: State of Texas, United States diff --git a/docs/CREDENTIAL_HELPER.md b/docs/CREDENTIAL_HELPER.md index bac6141..147d357 100644 --- a/docs/CREDENTIAL_HELPER.md +++ b/docs/CREDENTIAL_HELPER.md @@ -95,11 +95,17 @@ The artifacts become downloadable from the Tangled repo's tag download path ### 2. Install Scripts -Both scripts are served by the AppView from its static directory -(`pkg/appview/public/static/`) at `/static/install.sh` and -`/static/install.ps1`. They resolve the latest version by following the -`{repo}/tags/latest` redirect chain on Tangled, then download the matching -archive from the tag download path. +Both scripts are rendered by the AppView from templates in +`pkg/appview/installscript/templates/` and served at `/static/install.sh` and +`/static/install.ps1` (public, no auth: `curl | bash` runs before the user has +credentials). They are rendered rather than served as static files so the +helper binary name, the Docker `credHelpers` key/value and the config +directory follow the deployment's own branding: `credential_helper.name` in +the AppView config picks the brand (`atcr` by default, `seamark` on +seamark.dev), and the `credHelpers` key is the deployment's primary registry +domain. They resolve the latest version by following the `{repo}/tags/latest` +redirect chain on Tangled, then download the matching archive from the tag +download path. **Linux/macOS:** `install.sh` - Detects OS and architecture @@ -110,7 +116,8 @@ archive from the tag download path. **Windows:** `install.ps1` - Detects architecture - Resolves the latest tag from Tangled and downloads the archive -- Installs to `%ProgramFiles%\ATCR` (override with `ATCR_INSTALL_DIR`) +- Installs to `%ProgramFiles%\ATCR` (override with `ATCR_INSTALL_DIR`; the + brand name and env prefix follow `credential_helper.name`) - Adds to system PATH (requires Administrator to modify the machine PATH) - Uses the bundled `tar.exe` to extract the `.tar.gz` diff --git a/pkg/appview/config.go b/pkg/appview/config.go index c2bfc2e..af0a069 100644 --- a/pkg/appview/config.go +++ b/pkg/appview/config.go @@ -17,6 +17,7 @@ import ( "github.com/distribution/distribution/v3/configuration" "github.com/spf13/viper" + "atcr.io/pkg/appview/installscript" "atcr.io/pkg/appview/registryauth" "atcr.io/pkg/auth/token" "atcr.io/pkg/billing" @@ -34,12 +35,16 @@ type Config struct { Leases LeasesConfig `yaml:"leases" comment:"Leader election for background workers. Required when running more than one AppView instance."` Jetstream JetstreamConfig `yaml:"jetstream" comment:"ATProto Jetstream event stream settings."` Auth AuthConfig `yaml:"auth" comment:"JWT authentication settings."` - CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper download settings."` + CredentialHelper CredentialHelperConfig `yaml:"credential_helper" comment:"Credential helper branding and download settings."` Legal LegalConfig `yaml:"legal" comment:"Legal page customization for self-hosted instances."` AI AIConfig `yaml:"ai" comment:"AI-powered image advisor settings."` Labeler LabelerRefConfig `yaml:"labeler" comment:"ATProto labeler for content moderation (DMCA takedowns)."` Billing billing.Config `yaml:"billing" comment:"Stripe billing integration (requires -tags billing build)."` Distribution *configuration.Configuration `yaml:"-"` // Wrapped distribution config for compatibility + + // CredentialHelperBrand is CredentialHelper resolved and validated at load + // time. Derived, not configured, so it stays out of the marshaled YAML. + CredentialHelperBrand installscript.Brand `yaml:"-"` } // ServerConfig defines server settings @@ -189,10 +194,18 @@ func (a AuthConfig) PrimaryService() string { return a.Services[0] } -// CredentialHelperConfig defines credential helper download settings +// CredentialHelperConfig defines credential helper download and branding settings type CredentialHelperConfig struct { // TangledRepo is the Tangled repository URL for downloads - TangledRepo string `yaml:"tangled_repo" comment:"Tangled repository URL for credential helper downloads."` + TangledRepo string `yaml:"tangled_repo" comment:"Tangled repository URL for credential helper downloads. Defaults to the upstream ATCR release repo."` + + // Name is the credential helper brand for this deployment. Docker resolves + // a credHelpers value "x" by executing "docker-credential-x", so this one + // word is simultaneously the credHelpers value, the binary name suffix and + // (by convention) the helper's config directory (~/.x). Rebranded + // deployments set it so the install scripts and the install/settings pages + // stop naming atcr's helper. + Name string `yaml:"name" comment:"Credential helper brand: the docker credHelpers value, the docker-credential- binary suffix, and the ~/. config directory. Defaults to \"atcr\"."` } // LegalConfig defines legal page customization for self-hosted instances @@ -242,6 +255,12 @@ func setDefaults(v *viper.Viper) { v.SetDefault("ui.source_url", "https://tangled.org/evan.jarrett.net/at-container-registry") v.SetDefault("ui.bluesky_profile", "did:plc:wfj5kyialpmcv2fzk6uqwsln") + // Credential helper defaults. Registered so Viper binds the matching env + // vars (ATCR_CREDENTIAL_HELPER_NAME, ATCR_CREDENTIAL_HELPER_TANGLED_REPO); + // the real defaults are applied by installscript.NewBrand at load time. + v.SetDefault("credential_helper.name", "") + v.SetDefault("credential_helper.tangled_repo", "") + // Health defaults v.SetDefault("health.cache_ttl", "15m") v.SetDefault("health.check_interval", "15m") @@ -339,7 +358,21 @@ func LoadConfig(yamlPath string) (*Config, error) { // Post-load: fixed values cfg.Auth.TokenExpiration = 5 * time.Minute cfg.Auth.Services = deriveServices(cfg) - cfg.CredentialHelper.TangledRepo = "https://tangled.org/evan.jarrett.net/at-container-registry" + + // Post-load: credential helper brand. NewBrand normalizes and validates, + // because both fields are interpolated into the shell and PowerShell + // install scripts we render at /static/install.{sh,ps1}. + credBrand, err := installscript.NewBrand( + cfg.CredentialHelper.Name, + cfg.Server.ClientShortName, + cfg.CredentialHelper.TangledRepo, + ) + if err != nil { + return nil, fmt.Errorf("credential_helper.name: %w", err) + } + cfg.CredentialHelperBrand = credBrand + cfg.CredentialHelper.Name = credBrand.Name + cfg.CredentialHelper.TangledRepo = credBrand.ReleasesBaseURL // Post-load: CompanyName defaults to ClientName if cfg.Legal.CompanyName == "" { diff --git a/pkg/appview/config_test.go b/pkg/appview/config_test.go index 9756f60..954abcf 100644 --- a/pkg/appview/config_test.go +++ b/pkg/appview/config_test.go @@ -428,3 +428,72 @@ func TestDomainRoutingMiddleware_UsesNormalizedDomains(t *testing.T) { }) } } + +// The credential helper brand must come from config, not from literals baked +// into the install scripts and templates. A rebranded deployment configures +// credential_helper.name; everything else (binary name, credHelpers value, +// config directory, script env prefix) derives from it. +func TestLoadConfigCredentialHelperBrand(t *testing.T) { + tests := []struct { + name string + envName string + wantName string + wantBinary string + wantConfigDir string + wantEnvPrefix string + wantLoadFailed bool + }{ + { + name: "default is atcr", + wantName: "atcr", + wantBinary: "docker-credential-atcr", + wantConfigDir: "~/.atcr", + wantEnvPrefix: "ATCR", + }, + { + name: "seamark deployment", + envName: "seamark", + wantName: "seamark", + wantBinary: "docker-credential-seamark", + wantConfigDir: "~/.seamark", + wantEnvPrefix: "SEAMARK", + }, + { + name: "unsafe name is refused at load", + envName: "sea;rm -rf /", + wantLoadFailed: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("ATCR_SERVER_MANAGED_HOLDS", "did:web:hold01.atcr.io") + t.Setenv("ATCR_CREDENTIAL_HELPER_NAME", tt.envName) + + cfg, err := LoadConfig("") + if tt.wantLoadFailed { + if err == nil { + t.Fatal("LoadConfig() accepted an unsafe credential_helper.name") + } + return + } + if err != nil { + t.Fatalf("LoadConfig() error = %v", err) + } + + brand := cfg.CredentialHelperBrand + if brand.Name != tt.wantName { + t.Errorf("Name = %q, want %q", brand.Name, tt.wantName) + } + if brand.BinaryName() != tt.wantBinary { + t.Errorf("BinaryName() = %q, want %q", brand.BinaryName(), tt.wantBinary) + } + if brand.ConfigDir() != tt.wantConfigDir { + t.Errorf("ConfigDir() = %q, want %q", brand.ConfigDir(), tt.wantConfigDir) + } + if brand.EnvPrefix() != tt.wantEnvPrefix { + t.Errorf("EnvPrefix() = %q, want %q", brand.EnvPrefix(), tt.wantEnvPrefix) + } + }) + } +} diff --git a/pkg/appview/handlers/base.go b/pkg/appview/handlers/base.go index 83fe4d2..897f115 100644 --- a/pkg/appview/handlers/base.go +++ b/pkg/appview/handlers/base.go @@ -7,6 +7,7 @@ import ( "atcr.io/pkg/appview/db" "atcr.io/pkg/appview/holdhealth" + "atcr.io/pkg/appview/installscript" "atcr.io/pkg/appview/readme" "atcr.io/pkg/appview/webhooks" "atcr.io/pkg/auth/oauth" @@ -54,6 +55,12 @@ type BaseUIHandler struct { BillingEnabled bool // True when the billing build is compiled in and Stripe is configured SourceURL string // Source code URL for the footer "Source" link BlueskyProfile string // Bluesky handle or DID for the footer link ("" hides it) + + // CredHelper is this deployment's credential helper identity: the Docker + // credHelpers value, the docker-credential- binary and the ~/. + // config directory. Templates and the rendered install scripts read it + // from here so a rebranded deployment never names atcr's helper. + CredHelper installscript.Brand } // IsManagedHold reports whether a hold DID is one of the appview's managed diff --git a/pkg/appview/handlers/common.go b/pkg/appview/handlers/common.go index 19e1573..a17c340 100644 --- a/pkg/appview/handlers/common.go +++ b/pkg/appview/handlers/common.go @@ -6,6 +6,7 @@ import ( "strings" "atcr.io/pkg/appview/db" + "atcr.io/pkg/appview/installscript" "atcr.io/pkg/appview/middleware" ) @@ -22,6 +23,11 @@ type PageData struct { SourceURL string // Source code URL for the footer "Source" link BlueskyProfile string // Bluesky handle or DID for the footer link ("" hides it) CurrentPath string // Request path (used for OAuth return_to) + + // CredHelper is the credential helper brand for this deployment. Use + // .CredHelper.BinaryName / .CredHelper.Name / .CredHelper.DeviceFile in + // templates instead of hardcoding "docker-credential-atcr". + CredHelper installscript.Brand } // resolveRegistryURL returns the user's preferred registry domain when it is @@ -54,6 +60,7 @@ func NewPageData(r *http.Request, h *BaseUIHandler) PageData { SourceURL: h.SourceURL, BlueskyProfile: h.BlueskyProfile, CurrentPath: r.URL.RequestURI(), + CredHelper: h.CredHelper, } } diff --git a/pkg/appview/handlers/install.go b/pkg/appview/handlers/install.go index c7de196..8152e63 100644 --- a/pkg/appview/handlers/install.go +++ b/pkg/appview/handlers/install.go @@ -1,7 +1,12 @@ package handlers import ( + "bytes" + "io" "net/http" + "strconv" + + "atcr.io/pkg/appview/installscript" ) // InstallHandler handles the /install page @@ -29,3 +34,53 @@ func (h *InstallHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } } + +// InstallScriptHandler serves the credential helper install scripts. They used +// to be static files, which meant every deployment served atcr.io's copy: +// it installed docker-credential-atcr and told the user to point Docker's +// credHelpers at atcr.io regardless of which registry they were setting up. +// Rendering from config keeps one script for every brand. +// +// The scripts are public: `curl -fsSL /static/install.sh | bash` has to +// work before the user has any credentials at all. +type InstallScriptHandler struct { + BaseUIHandler +} + +// params builds the render inputs. RegistryHost is the credHelpers key, which +// must be the *registry* host Docker authenticates against, not the web UI +// host the script was downloaded from: on Seamark those differ. +func (h *InstallScriptHandler) params() installscript.Params { + return installscript.Params{ + Brand: h.CredHelper, + RegistryHost: h.RegistryURL, + SiteHost: h.SiteURL, + } +} + +// ServeShell renders install.sh. +func (h *InstallScriptHandler) ServeShell(w http.ResponseWriter, r *http.Request) { + h.serve(w, "text/x-shellscript; charset=utf-8", installscript.RenderShell) +} + +// ServePowerShell renders install.ps1. +func (h *InstallScriptHandler) ServePowerShell(w http.ResponseWriter, r *http.Request) { + h.serve(w, "text/plain; charset=utf-8", installscript.RenderPowerShell) +} + +func (h *InstallScriptHandler) serve(w http.ResponseWriter, contentType string, render func(io.Writer, installscript.Params) error) { + // Render to a buffer first: a mid-stream template error would otherwise + // leave a truncated script that a piped `| bash` would happily execute. + var buf bytes.Buffer + if err := render(&buf, h.params()); err != nil { + http.Error(w, "failed to render install script", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", contentType) + w.Header().Set("Content-Length", strconv.Itoa(buf.Len())) + // Short cache: the body now depends on this deployment's config, so it + // must not be pinned for a year the way the static file was. + w.Header().Set("Cache-Control", "public, max-age=300") + _, _ = w.Write(buf.Bytes()) +} diff --git a/pkg/appview/handlers/install_test.go b/pkg/appview/handlers/install_test.go new file mode 100644 index 0000000..3bc7fd4 --- /dev/null +++ b/pkg/appview/handlers/install_test.go @@ -0,0 +1,231 @@ +package handlers_test + +import ( + "bytes" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "atcr.io/pkg/appview" + "atcr.io/pkg/appview/handlers" + "atcr.io/pkg/appview/installscript" + "github.com/go-chi/chi/v5" +) + +func brand(t *testing.T, name, display string) installscript.Brand { + t.Helper() + b, err := installscript.NewBrand(name, display, "") + if err != nil { + t.Fatalf("NewBrand: %v", err) + } + return b +} + +func installBase(t *testing.T, name, display, registry, site string) handlers.BaseUIHandler { + t.Helper() + templates, err := appview.Templates(nil) + if err != nil { + t.Fatalf("Failed to load templates: %v", err) + } + return handlers.BaseUIHandler{ + Templates: templates, + RegistryURL: registry, + RegistryDomains: []string{registry}, + SiteURL: site, + ClientName: display, + ClientShortName: display, + CredHelper: brand(t, name, display), + } +} + +// The script must stay fetchable at exactly the path the install page and the +// docs document, even though /static/* is also mounted as a file server. +func TestInstallScriptRouteShadowsStaticFileServer(t *testing.T) { + base := installBase(t, "seamark", "Seamark", "seamark.cr", "seamark.dev") + scripts := &handlers.InstallScriptHandler{BaseUIHandler: base} + + r := chi.NewRouter() + r.Get("/static/install.sh", scripts.ServeShell) + r.Get("/static/install.ps1", scripts.ServePowerShell) + r.Handle("/static/*", http.StripPrefix("/static/", http.HandlerFunc( + func(w http.ResponseWriter, _ *http.Request) { + http.Error(w, "static file server", http.StatusNotFound) + }))) + + for _, tc := range []struct{ path, contentType, want string }{ + {"/static/install.sh", "text/x-shellscript; charset=utf-8", `BINARY_NAME="docker-credential-seamark"`}, + {"/static/install.ps1", "text/plain; charset=utf-8", `$BinaryName = "docker-credential-seamark.exe"`}, + } { + // No session, no cookie: `curl | bash` runs unauthenticated. + req := httptest.NewRequest(http.MethodGet, tc.path, nil) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("%s: status = %d, want 200 (body %q)", tc.path, rr.Code, rr.Body.String()) + } + if got := rr.Header().Get("Content-Type"); got != tc.contentType { + t.Errorf("%s: Content-Type = %q, want %q", tc.path, got, tc.contentType) + } + if !strings.Contains(rr.Body.String(), tc.want) { + t.Errorf("%s: body missing %q", tc.path, tc.want) + } + if strings.Contains(rr.Body.String(), "atcr") { + t.Errorf("%s: seamark deployment served an atcr-branded script", tc.path) + } + } +} + +func TestInstallScriptServesATCRDefault(t *testing.T) { + base := installBase(t, "", "ATCR", "atcr.io", "atcr.io") + scripts := &handlers.InstallScriptHandler{BaseUIHandler: base} + + rr := httptest.NewRecorder() + scripts.ServeShell(rr, httptest.NewRequest(http.MethodGet, "/static/install.sh", nil)) + + body := rr.Body.String() + for _, want := range []string{ + `BINARY_NAME="docker-credential-atcr"`, + `{"credHelpers": {"atcr.io": "atcr"}}`, + } { + if !strings.Contains(body, want) { + t.Errorf("default install.sh missing %q", want) + } + } + if strings.Contains(body, "seamark") { + t.Error("default install.sh leaked seamark branding") + } +} + +// The /install page told every deployment to install docker-credential-atcr +// and to key credHelpers on atcr.io. Both now follow the configured brand. +func TestInstallPageNamesConfiguredHelper(t *testing.T) { + tests := []struct { + name string + helper string + display string + registry string + site string + want []string + unexpected []string + }{ + { + name: "seamark", helper: "seamark", display: "Seamark", + registry: "seamark.cr", site: "seamark.dev", + want: []string{ + `"seamark.cr": "seamark"`, + "which docker-credential-seamark", + "~/.seamark/device.json", + "curl -fsSL seamark.dev/static/install.sh | bash", + }, + unexpected: []string{"docker-credential-atcr", `: "atcr"`, "~/.atcr/"}, + }, + { + name: "atcr default", helper: "", display: "ATCR", + registry: "atcr.io", site: "atcr.io", + want: []string{ + `"atcr.io": "atcr"`, + "which docker-credential-atcr", + "~/.atcr/device.json", + "curl -fsSL atcr.io/static/install.sh | bash", + }, + unexpected: []string{"seamark"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + h := &handlers.InstallHandler{ + BaseUIHandler: installBase(t, tt.helper, tt.display, tt.registry, tt.site), + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/install", nil)) + + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range tt.want { + if !strings.Contains(body, want) { + t.Errorf("/install missing %q", want) + } + } + for _, bad := range tt.unexpected { + if strings.Contains(body, bad) { + t.Errorf("/install still contains %q", bad) + } + } + }) + } +} + +// Same defect on the devices settings panel, which is where a logged-in user +// is actually sent to set Docker up. +func TestDevicesPanelNamesConfiguredHelper(t *testing.T) { + templates, err := appview.Templates(nil) + if err != nil { + t.Fatalf("Failed to load templates: %v", err) + } + + type profile struct{ Handle string } + type panelData struct { + handlers.PageData + Profile profile + } + + tests := []struct { + name string + helper string + registry string + site string + want []string + unexpected []string + }{ + { + name: "seamark", helper: "seamark", registry: "seamark.cr", site: "seamark.dev", + want: []string{ + "docker-credential-seamark", + `"seamark.cr": "seamark"`, + "curl -fsSL seamark.dev/static/install.sh | bash", + }, + unexpected: []string{"docker-credential-atcr", `: "atcr"`}, + }, + { + name: "atcr default", helper: "", registry: "atcr.io", site: "atcr.io", + want: []string{ + "docker-credential-atcr", + `"atcr.io": "atcr"`, + }, + unexpected: []string{"seamark"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + data := panelData{ + PageData: handlers.PageData{ + RegistryURL: tt.registry, + SiteURL: tt.site, + CredHelper: brand(t, tt.helper, "Brand"), + }, + Profile: profile{Handle: "alice.test"}, + } + + var buf bytes.Buffer + if err := templates.ExecuteTemplate(&buf, "settings-panel-devices", data); err != nil { + t.Fatalf("ExecuteTemplate: %v", err) + } + for _, want := range tt.want { + if !strings.Contains(buf.String(), want) { + t.Errorf("devices panel missing %q", want) + } + } + for _, bad := range tt.unexpected { + if strings.Contains(buf.String(), bad) { + t.Errorf("devices panel still contains %q", bad) + } + } + }) + } +} diff --git a/pkg/appview/installscript/installscript.go b/pkg/appview/installscript/installscript.go new file mode 100644 index 0000000..8e3aa1f --- /dev/null +++ b/pkg/appview/installscript/installscript.go @@ -0,0 +1,115 @@ +// Package installscript renders the credential-helper install scripts +// (install.sh, install.ps1) from the running deployment's configuration. +// +// The scripts used to be static files under pkg/appview/public/static/, which +// meant every rebranded deployment served atcr.io's script: it installed +// docker-credential-atcr and told the user to point Docker's credHelpers at +// atcr.io no matter which registry they had actually been browsing. Rendering +// them from a Brand keeps the helper binary name, the credHelpers value, the +// config directory and the registry host in exactly one place, shared with the +// install/settings UI templates. +package installscript + +import ( + "embed" + "fmt" + "io" + "regexp" + "strings" + "text/template" +) + +//go:embed templates/*.tmpl +var templatesFS embed.FS + +var tmpl = template.Must(template.ParseFS(templatesFS, "templates/*.tmpl")) + +// DefaultName is the credential helper brand used when nothing is configured. +const DefaultName = "atcr" + +// DefaultReleasesBaseURL is where the helper release archives are published. +// The DID-based Tangled URL is used rather than the handle-based one so the +// link survives a handle rename; pkg/credhelper's self-updater uses the same. +const DefaultReleasesBaseURL = "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64" + +// nameRE constrains Brand.Name. The value is interpolated into a shell script +// and a PowerShell script, and Docker additionally requires that it be usable +// as a filename suffix (docker-credential-), so keep it boring. +var nameRE = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`) + +// Brand is the credential-helper identity of one deployment. Docker resolves a +// credHelpers value "x" by executing "docker-credential-x", so the helper's +// binary name, the credHelpers value and (by convention) the helper's config +// directory are all the same word. Keeping them as one field is what stops +// them drifting apart across the scripts and the UI. +type Brand struct { + // Name is the credHelpers value, e.g. "atcr" or "seamark". + Name string + + // DisplayName is the human brand shown in script output, e.g. "Seamark". + DisplayName string + + // ReleasesBaseURL is the Tangled repo the release archives hang off. + ReleasesBaseURL string +} + +// NewBrand normalizes a configured brand, filling in defaults. +func NewBrand(name, displayName, releasesBaseURL string) (Brand, error) { + b := Brand{ + Name: strings.TrimSpace(name), + DisplayName: strings.TrimSpace(displayName), + ReleasesBaseURL: strings.TrimRight(strings.TrimSpace(releasesBaseURL), "/"), + } + if b.Name == "" { + b.Name = DefaultName + } + if !nameRE.MatchString(b.Name) { + return Brand{}, fmt.Errorf("credential helper name %q must match %s", b.Name, nameRE) + } + if b.DisplayName == "" { + b.DisplayName = strings.ToUpper(b.Name) + } + if b.ReleasesBaseURL == "" { + b.ReleasesBaseURL = DefaultReleasesBaseURL + } + return b, nil +} + +// BinaryName is the executable Docker looks for: docker-credential-. +func (b Brand) BinaryName() string { return "docker-credential-" + b.Name } + +// ConfigDir is the helper's home-relative config directory, e.g. "~/.atcr". +func (b Brand) ConfigDir() string { return "~/." + b.Name } + +// DeviceFile is where the helper stores its device credential. +func (b Brand) DeviceFile() string { return b.ConfigDir() + "/device.json" } + +// EnvPrefix is the prefix for the install scripts' override variables, e.g. +// ATCR_VERSION / SEAMARK_VERSION. +func (b Brand) EnvPrefix() string { + return strings.ToUpper(strings.ReplaceAll(b.Name, "-", "_")) +} + +// Params is everything a rendered install script needs. +type Params struct { + Brand + + // RegistryHost is the credHelpers key: the registry host Docker + // authenticates against, e.g. "atcr.io" or "seamark.cr". This is NOT + // necessarily the site the script was downloaded from. + RegistryHost string + + // SiteHost is the web UI host the script is served from, used only for + // the usage comment at the top of the script. + SiteHost string +} + +// RenderShell writes install.sh for the given params. +func RenderShell(w io.Writer, p Params) error { + return tmpl.ExecuteTemplate(w, "install.sh.tmpl", p) +} + +// RenderPowerShell writes install.ps1 for the given params. +func RenderPowerShell(w io.Writer, p Params) error { + return tmpl.ExecuteTemplate(w, "install.ps1.tmpl", p) +} diff --git a/pkg/appview/installscript/installscript_test.go b/pkg/appview/installscript/installscript_test.go new file mode 100644 index 0000000..3421d18 --- /dev/null +++ b/pkg/appview/installscript/installscript_test.go @@ -0,0 +1,218 @@ +package installscript_test + +import ( + "bytes" + "strings" + "testing" + + "atcr.io/pkg/appview/installscript" +) + +func mustBrand(t *testing.T, name, display, releases string) installscript.Brand { + t.Helper() + b, err := installscript.NewBrand(name, display, releases) + if err != nil { + t.Fatalf("NewBrand(%q, %q, %q) error = %v", name, display, releases, err) + } + return b +} + +func TestNewBrandDefaultsToATCR(t *testing.T) { + b := mustBrand(t, "", "", "") + + if b.Name != "atcr" { + t.Errorf("Name = %q, want atcr", b.Name) + } + if b.BinaryName() != "docker-credential-atcr" { + t.Errorf("BinaryName() = %q", b.BinaryName()) + } + if b.ConfigDir() != "~/.atcr" { + t.Errorf("ConfigDir() = %q", b.ConfigDir()) + } + if b.DeviceFile() != "~/.atcr/device.json" { + t.Errorf("DeviceFile() = %q", b.DeviceFile()) + } + if b.EnvPrefix() != "ATCR" { + t.Errorf("EnvPrefix() = %q", b.EnvPrefix()) + } + if b.ReleasesBaseURL != installscript.DefaultReleasesBaseURL { + t.Errorf("ReleasesBaseURL = %q", b.ReleasesBaseURL) + } +} + +func TestNewBrandSeamark(t *testing.T) { + b := mustBrand(t, "seamark", "Seamark", "") + + if b.BinaryName() != "docker-credential-seamark" { + t.Errorf("BinaryName() = %q", b.BinaryName()) + } + if b.DeviceFile() != "~/.seamark/device.json" { + t.Errorf("DeviceFile() = %q", b.DeviceFile()) + } + if b.EnvPrefix() != "SEAMARK" { + t.Errorf("EnvPrefix() = %q", b.EnvPrefix()) + } +} + +// The name lands inside a shell script and a PowerShell script, so anything +// that is not a plain lowercase word has to be refused at config load. +func TestNewBrandRejectsUnsafeNames(t *testing.T) { + for _, name := range []string{ + "ATCR", + "sea mark", + "sea/mark", + "$(id)", + "a`id`", + "-lead", + "sea\nmark", + } { + if _, err := installscript.NewBrand(name, "", ""); err == nil { + t.Errorf("NewBrand(%q) accepted an unsafe name", name) + } + } +} + +func TestRenderShellSeamark(t *testing.T) { + var buf bytes.Buffer + err := installscript.RenderShell(&buf, installscript.Params{ + Brand: mustBrand(t, "seamark", "Seamark", ""), + RegistryHost: "seamark.cr", + SiteHost: "seamark.dev", + }) + if err != nil { + t.Fatalf("RenderShell() error = %v", err) + } + got := buf.String() + + for _, want := range []string{ + "# Seamark Credential Helper Installation Script", + "# Usage: curl -fsSL https://seamark.dev/static/install.sh | bash", + `BINARY_NAME="docker-credential-seamark"`, + `TANGLED_REPO="${SEAMARK_TANGLED_REPO:-` + installscript.DefaultReleasesBaseURL + `}"`, + `download/docker-credential-seamark_${version_without_v}_${OS}_${ARCH}.tar.gz`, + `{"credHelpers": {"seamark.cr": "seamark"}}`, + ` "seamark.cr": "seamark"`, + `if [ -n "$SEAMARK_VERSION" ]; then`, + `VERSION="$SEAMARK_VERSION"`, + "Seamark Credential Helper Installer", + } { + if !strings.Contains(got, want) { + t.Errorf("rendered install.sh missing %q", want) + } + } + + // The whole point of the fix: a Seamark deployment must not name atcr + // anywhere in the script it hands to `curl | bash`. + for _, forbidden := range []string{"atcr", "ATCR"} { + if strings.Contains(got, forbidden) { + t.Errorf("rendered install.sh still contains %q:\n%s", forbidden, offendingLines(got, forbidden)) + } + } +} + +func TestRenderPowerShellSeamark(t *testing.T) { + var buf bytes.Buffer + err := installscript.RenderPowerShell(&buf, installscript.Params{ + Brand: mustBrand(t, "seamark", "Seamark", ""), + RegistryHost: "seamark.cr", + SiteHost: "seamark.dev", + }) + if err != nil { + t.Fatalf("RenderPowerShell() error = %v", err) + } + got := buf.String() + + for _, want := range []string{ + `$BinaryName = "docker-credential-seamark.exe"`, + `$env:SEAMARK_INSTALL_DIR`, + `"$env:ProgramFiles\Seamark"`, + `docker-credential-seamark_${versionClean}_Windows_${Arch}.tar.gz`, + ` "seamark.cr": "seamark"`, + } { + if !strings.Contains(got, want) { + t.Errorf("rendered install.ps1 missing %q", want) + } + } + for _, forbidden := range []string{"atcr", "ATCR"} { + if strings.Contains(got, forbidden) { + t.Errorf("rendered install.ps1 still contains %q:\n%s", forbidden, offendingLines(got, forbidden)) + } + } +} + +// The default deployment must keep producing exactly the script it shipped as +// a static file, so a rebrand cannot regress atcr.io's documented install. +func TestRenderDefaultIsUnchangedATCR(t *testing.T) { + p := installscript.Params{ + Brand: mustBrand(t, "", "ATCR", ""), + RegistryHost: "atcr.io", + SiteHost: "atcr.io", + } + + var sh bytes.Buffer + if err := installscript.RenderShell(&sh, p); err != nil { + t.Fatalf("RenderShell() error = %v", err) + } + for _, want := range []string{ + "# ATCR Credential Helper Installation Script", + "# Usage: curl -fsSL https://atcr.io/static/install.sh | bash", + `BINARY_NAME="docker-credential-atcr"`, + `TANGLED_REPO="${ATCR_TANGLED_REPO:-https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64}"`, + `download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz`, + `{"credHelpers": {"atcr.io": "atcr"}}`, + ` "atcr.io": "atcr"`, + `if [ -n "$ATCR_VERSION" ]; then`, + `VERSION="$ATCR_VERSION"`, + "ATCR Credential Helper Installer", + } { + if !strings.Contains(sh.String(), want) { + t.Errorf("rendered install.sh missing %q", want) + } + } + if !strings.HasPrefix(sh.String(), "#!/bin/bash\n") { + t.Error("rendered install.sh lost its shebang") + } + + var ps bytes.Buffer + if err := installscript.RenderPowerShell(&ps, p); err != nil { + t.Fatalf("RenderPowerShell() error = %v", err) + } + for _, want := range []string{ + `$BinaryName = "docker-credential-atcr.exe"`, + `$env:ATCR_INSTALL_DIR`, + `"$env:ProgramFiles\ATCR"`, + ` "atcr.io": "atcr"`, + } { + if !strings.Contains(ps.String(), want) { + t.Errorf("rendered install.ps1 missing %q", want) + } + } +} + +// A registry host that differs from the site host is the Seamark shape: +// the UI is seamark.dev, but Docker authenticates against seamark.cr, so the +// credHelpers key has to be the registry. +func TestCredHelpersKeyIsRegistryNotSite(t *testing.T) { + var buf bytes.Buffer + err := installscript.RenderShell(&buf, installscript.Params{ + Brand: mustBrand(t, "seamark", "Seamark", ""), + RegistryHost: "seamark.cr", + SiteHost: "seamark.dev", + }) + if err != nil { + t.Fatalf("RenderShell() error = %v", err) + } + if strings.Contains(buf.String(), `"seamark.dev": "seamark"`) { + t.Error("credHelpers key used the site host instead of the registry host") + } +} + +func offendingLines(s, needle string) string { + var out []string + for _, line := range strings.Split(s, "\n") { + if strings.Contains(line, needle) { + out = append(out, " "+line) + } + } + return strings.Join(out, "\n") +} diff --git a/pkg/appview/public/static/install.ps1 b/pkg/appview/installscript/templates/install.ps1.tmpl similarity index 84% rename from pkg/appview/public/static/install.ps1 rename to pkg/appview/installscript/templates/install.ps1.tmpl index a91eb14..9a6d6c7 100644 --- a/pkg/appview/public/static/install.ps1 +++ b/pkg/appview/installscript/templates/install.ps1.tmpl @@ -1,14 +1,14 @@ -# ATCR Credential Helper Installation Script for Windows -# Usage: iwr -useb https://atcr.io/install.ps1 | iex +# {{ .DisplayName }} Credential Helper Installation Script for Windows +# Usage: iwr -useb https://{{ .SiteHost }}/static/install.ps1 | iex $ErrorActionPreference = "Stop" # Configuration -$BinaryName = "docker-credential-atcr.exe" -$InstallDir = if ($env:ATCR_INSTALL_DIR) { $env:ATCR_INSTALL_DIR } else { "$env:ProgramFiles\ATCR" } -$TangledRepo = if ($env:ATCR_TANGLED_REPO) { $env:ATCR_TANGLED_REPO } else { "https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64" } +$BinaryName = "{{ .BinaryName }}.exe" +$InstallDir = if ($env:{{ .EnvPrefix }}_INSTALL_DIR) { $env:{{ .EnvPrefix }}_INSTALL_DIR } else { "$env:ProgramFiles\{{ .DisplayName }}" } +$TangledRepo = if ($env:{{ .EnvPrefix }}_TANGLED_REPO) { $env:{{ .EnvPrefix }}_TANGLED_REPO } else { "{{ .ReleasesBaseURL }}" } -Write-Host "ATCR Credential Helper Installer for Windows" -ForegroundColor Green +Write-Host "{{ .DisplayName }} Credential Helper Installer for Windows" -ForegroundColor Green Write-Host "" # Detect architecture @@ -67,13 +67,13 @@ function Get-DownloadUrl { param([string]$Version, [string]$Arch) $versionClean = $Version.TrimStart('v') - $fileName = "docker-credential-atcr_${versionClean}_Windows_${Arch}.tar.gz" + $fileName = "{{ .BinaryName }}_${versionClean}_Windows_${Arch}.tar.gz" return "$TangledRepo/tags/$Version/download/$fileName" } # Determine version and download URL -if ($env:ATCR_VERSION) { - $Version = $env:ATCR_VERSION +if ($env:{{ .EnvPrefix }}_VERSION) { + $Version = $env:{{ .EnvPrefix }}_VERSION Write-Host "Using specified version: $Version" -ForegroundColor Yellow } else { $Version = Get-LatestVersion @@ -90,8 +90,8 @@ function Install-Binary { Write-Host "Downloading from: $DownloadUrl" -ForegroundColor Yellow - $tempDir = New-Item -ItemType Directory -Path "$env:TEMP\atcr-install-$(Get-Random)" -Force - $archivePath = Join-Path $tempDir "docker-credential-atcr.tar.gz" + $tempDir = New-Item -ItemType Directory -Path "$env:TEMP\{{ .Name }}-install-$(Get-Random)" -Force + $archivePath = Join-Path $tempDir "{{ .BinaryName }}.tar.gz" try { Invoke-WebRequest -Uri $DownloadUrl -OutFile $archivePath -UseBasicParsing @@ -171,11 +171,11 @@ function Show-Configuration { Write-Host "" Write-Host "Installation complete!" -ForegroundColor Green Write-Host "" - Write-Host "To use ATCR with Docker, configure Docker to use this credential helper:" -ForegroundColor Yellow + Write-Host "To use {{ .DisplayName }} with Docker, configure Docker to use this credential helper:" -ForegroundColor Yellow Write-Host ' Edit %USERPROFILE%\.docker\config.json and add:' Write-Host ' { "credHelpers": { - "atcr.io": "atcr" + "{{ .RegistryHost }}": "{{ .Name }}" } }' Write-Host "" diff --git a/pkg/appview/public/static/install.sh b/pkg/appview/installscript/templates/install.sh.tmpl old mode 100755 new mode 100644 similarity index 79% rename from pkg/appview/public/static/install.sh rename to pkg/appview/installscript/templates/install.sh.tmpl index b64adf4..b4837ca --- a/pkg/appview/public/static/install.sh +++ b/pkg/appview/installscript/templates/install.sh.tmpl @@ -1,6 +1,6 @@ #!/bin/bash -# ATCR Credential Helper Installation Script -# Usage: curl -fsSL https://atcr.io/static/install.sh | bash +# {{ .DisplayName }} Credential Helper Installation Script +# Usage: curl -fsSL https://{{ .SiteHost }}/static/install.sh | bash set -e @@ -11,9 +11,9 @@ YELLOW='\033[1;33m' NC='\033[0m' # No Color # Configuration -BINARY_NAME="docker-credential-atcr" +BINARY_NAME="{{ .BinaryName }}" INSTALL_DIR="${INSTALL_DIR:-/usr/local/bin}" -TANGLED_REPO="${ATCR_TANGLED_REPO:-https://tangled.org/did:plc:e3kzdezk5gsirzh7eoqplc64}" +TANGLED_REPO="${ {{- .EnvPrefix }}_TANGLED_REPO:-{{ .ReleasesBaseURL }}}" # Detect OS and architecture detect_platform() { @@ -74,7 +74,7 @@ fetch_latest_version() { # Build the download URL from version and platform build_download_url() { local version_without_v="${VERSION#v}" - DOWNLOAD_URL="${TANGLED_REPO}/tags/${VERSION}/download/docker-credential-atcr_${version_without_v}_${OS}_${ARCH}.tar.gz" + DOWNLOAD_URL="${TANGLED_REPO}/tags/${VERSION}/download/{{ .BinaryName }}_${version_without_v}_${OS}_${ARCH}.tar.gz" } # Download and install binary @@ -84,13 +84,13 @@ install_binary() { local tmp_dir=$(mktemp -d) trap "rm -rf $tmp_dir" EXIT - if ! curl -fsSL "$DOWNLOAD_URL" -o "$tmp_dir/docker-credential-atcr.tar.gz"; then + if ! curl -fsSL "$DOWNLOAD_URL" -o "$tmp_dir/{{ .BinaryName }}.tar.gz"; then echo -e "${RED}Failed to download release${NC}" exit 1 fi echo -e "${YELLOW}Extracting...${NC}" - tar -xzf "$tmp_dir/docker-credential-atcr.tar.gz" -C "$tmp_dir" + tar -xzf "$tmp_dir/{{ .BinaryName }}.tar.gz" -C "$tmp_dir" # Check if we need sudo if [ -w "$INSTALL_DIR" ]; then @@ -125,27 +125,27 @@ configure_docker() { echo "" echo -e "${GREEN}Installation complete!${NC}" echo "" - echo -e "${YELLOW}To use ATCR with Docker, configure Docker to use this credential helper:${NC}" - echo -e ' echo '\''{"credHelpers": {"atcr.io": "atcr"}}'\'' > ~/.docker/config.json' + echo -e "${YELLOW}To use {{ .DisplayName }} with Docker, configure Docker to use this credential helper:${NC}" + echo -e ' echo '\''{"credHelpers": {"{{ .RegistryHost }}": "{{ .Name }}"}}'\'' > ~/.docker/config.json' echo "" echo -e "${YELLOW}Or add to existing config.json:${NC}" echo -e ' { "credHelpers": { - "atcr.io": "atcr" + "{{ .RegistryHost }}": "{{ .Name }}" } }' } # Main main() { - echo -e "${GREEN}ATCR Credential Helper Installer${NC}" + echo -e "${GREEN}{{ .DisplayName }} Credential Helper Installer${NC}" echo "" detect_platform echo -e "Detected: ${GREEN}${OS} ${ARCH}${NC}" - if [ -n "$ATCR_VERSION" ]; then - VERSION="$ATCR_VERSION" + if [ -n "${{ .EnvPrefix }}_VERSION" ]; then + VERSION="${{ .EnvPrefix }}_VERSION" echo -e "Using specified version: ${GREEN}${VERSION}${NC}" else fetch_latest_version diff --git a/pkg/appview/routes/routes.go b/pkg/appview/routes/routes.go index 85dc097..166c178 100644 --- a/pkg/appview/routes/routes.go +++ b/pkg/appview/routes/routes.go @@ -9,6 +9,7 @@ import ( "atcr.io/pkg/appview/db" uihandlers "atcr.io/pkg/appview/handlers" "atcr.io/pkg/appview/holdhealth" + "atcr.io/pkg/appview/installscript" "atcr.io/pkg/appview/middleware" "atcr.io/pkg/appview/readme" "atcr.io/pkg/appview/webhooks" @@ -49,6 +50,7 @@ type UIDependencies struct { ClaudeAPIKey string // Anthropic API key for AI advisor (empty = disabled) SourceURL string // Source code URL for the footer "Source" link BlueskyProfile string // Bluesky handle or DID for the footer link ("" hides it) + CredHelper installscript.Brand // Credential helper brand (credHelpers value, binary name, config dir) } // RegisterUIRoutes registers all web UI and API routes on the provided router @@ -90,6 +92,7 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { AIAdvisorEnabled: deps.BillingManager != nil && deps.BillingManager.Enabled() && deps.ClaudeAPIKey != "", SourceURL: deps.SourceURL, BlueskyProfile: deps.BlueskyProfile, + CredHelper: deps.CredHelper, } // OAuth login routes (public) @@ -120,6 +123,16 @@ func RegisterUIRoutes(router chi.Router, deps UIDependencies) { &uihandlers.InstallHandler{BaseUIHandler: base}, ).ServeHTTP) + // Install scripts (public, and unauthenticated by design: `curl | bash` + // runs before the user has any credentials). Rendered from config rather + // than served as static files so the helper name, the credHelpers key and + // the config directory follow this deployment's brand. These exact paths + // shadow the /static/* file server mounted in server.go, which is what the + // install page and the docs have always told users to fetch. + installScripts := &uihandlers.InstallScriptHandler{BaseUIHandler: base} + router.Get("/static/install.sh", installScripts.ServeShell) + router.Get("/static/install.ps1", installScripts.ServePowerShell) + // Learn more page (public) router.Get("/learn-more", middleware.OptionalAuth(deps.SessionStore, deps.Database)( &uihandlers.LearnMoreHandler{BaseUIHandler: base}, diff --git a/pkg/appview/server.go b/pkg/appview/server.go index 69d6038..5a8d86f 100644 --- a/pkg/appview/server.go +++ b/pkg/appview/server.go @@ -414,6 +414,7 @@ func NewAppViewServer(cfg *Config, branding *BrandingOverrides) (*AppViewServer, ClaudeAPIKey: cfg.AI.APIKey, SourceURL: cfg.UI.SourceURL, BlueskyProfile: cfg.UI.BlueskyProfile, + CredHelper: cfg.CredentialHelperBrand, LegalConfig: routes.LegalConfig{ CompanyName: cfg.Legal.CompanyName, Jurisdiction: cfg.Legal.Jurisdiction, diff --git a/pkg/appview/templates/pages/home.html b/pkg/appview/templates/pages/home.html index f1db41e..e881d9c 100644 --- a/pkg/appview/templates/pages/home.html +++ b/pkg/appview/templates/pages/home.html @@ -64,7 +64,7 @@ {{ icon "package" "size-12 mx-auto mb-4 text-base-content/30" }}

Nothing here yet

Push your first image to get started.

- {{ template "docker-command" (print "docker push atcr.io/" .User.Handle "/my-image:latest") }} + {{ template "docker-command" (print "docker push " .RegistryURL "/" .User.Handle "/my-image:latest") }} {{ else }} {{ icon "package" "size-12 mx-auto mb-4 text-base-content/30" }}

No public repositories yet

diff --git a/pkg/appview/templates/pages/install.html b/pkg/appview/templates/pages/install.html index af7d2bb..e42b487 100644 --- a/pkg/appview/templates/pages/install.html +++ b/pkg/appview/templates/pages/install.html @@ -61,7 +61,7 @@
{
  "credHelpers": {
-
    "{{ .RegistryURL }}": "atcr"
+
    "{{ .RegistryURL }}": "{{ .CredHelper.Name }}"
  }
}
@@ -118,7 +118,7 @@

Ensure the credential helper is in your PATH:

Check if installed
-
which docker-credential-atcr
+
which {{ .CredHelper.BinaryName }}

                     
Add to PATH if needed
export PATH="/usr/local/bin:$PATH"
@@ -131,7 +131,7 @@

Security

    -
  • Credentials are stored in ~/.atcr/device.json with secure permissions (0600)
  • +
  • Credentials are stored in {{ .CredHelper.DeviceFile }} with secure permissions (0600)
  • Device secrets are issued per-device and can be revoked anytime
  • No passwords are stored locally
  • Uses ATProto OAuth with device authorization flow
  • diff --git a/pkg/appview/templates/partials/settings-panel-devices.html b/pkg/appview/templates/partials/settings-panel-devices.html index ca8c703..e33d3cd 100644 --- a/pkg/appview/templates/partials/settings-panel-devices.html +++ b/pkg/appview/templates/partials/settings-panel-devices.html @@ -2,7 +2,7 @@

    Authorized Devices

    -

    Devices authorized via docker-credential-atcr credential helper.

    +

    Devices authorized via {{ .CredHelper.BinaryName }} credential helper.

    @@ -14,7 +14,7 @@
  • Configure Docker to use the helper. Add to ~/.docker/config.json:
    {
       "credHelpers": {
    -    "{{ .RegistryURL }}": "atcr"
    +    "{{ .RegistryURL }}": "{{ .CredHelper.Name }}"
       }
     }