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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PDqoCE1j3njokkZ9b1C5n9
This commit is contained in:
Evan Jarrett
2026-09-02 21:38:10 -05:00
co-authored by Claude Opus 5
parent 2743445e65
commit 9d8bd513da
18 changed files with 805 additions and 44 deletions
+4 -2
View File
@@ -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-<name> binary suffix, and the ~/.<name> 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.
+3
View File
@@ -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
+13 -6
View File
@@ -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`
+37 -4
View File
@@ -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-<name> binary suffix, and the ~/.<name> 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 == "" {
+69
View File
@@ -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)
}
})
}
}
+7
View File
@@ -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-<name> binary and the ~/.<name>
// 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
+7
View File
@@ -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,
}
}
+55
View File
@@ -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 <site>/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())
}
+231
View File
@@ -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)
}
}
})
}
}
+115
View File
@@ -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-<name>), 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-<name>.
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)
}
@@ -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")
}
@@ -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 ""
@@ -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
+13
View File
@@ -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},
+1
View File
@@ -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,
+1 -1
View File
@@ -64,7 +64,7 @@
{{ icon "package" "size-12 mx-auto mb-4 text-base-content/30" }}
<p class="text-lg font-medium text-base-content/70">Nothing here yet</p>
<p class="mt-2 text-sm">Push your first image to get started.</p>
{{ 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" }}
<p class="text-lg font-medium text-base-content/70">No public repositories yet</p>
+3 -3
View File
@@ -61,7 +61,7 @@
<div class="mockup-code bg-base-300 text-base-content mb-4">
<pre><code>{</code></pre>
<pre><code> "credHelpers": {</code></pre>
<pre><code> "{{ .RegistryURL }}": "atcr"</code></pre>
<pre><code> "{{ .RegistryURL }}": "{{ .CredHelper.Name }}"</code></pre>
<pre><code> }</code></pre>
<pre><code>}</code></pre>
</div>
@@ -118,7 +118,7 @@
<p class="mb-4">Ensure the credential helper is in your PATH:</p>
<div class="mockup-code bg-base-300 text-base-content mb-6">
<pre data-prefix="#" class="text-base-content/70"><code>Check if installed</code></pre>
<pre data-prefix="$"><code>which docker-credential-atcr</code></pre>
<pre data-prefix="$"><code>which {{ .CredHelper.BinaryName }}</code></pre>
<pre></pre>
<pre data-prefix="#" class="text-base-content/70"><code>Add to PATH if needed</code></pre>
<pre data-prefix="$"><code>export PATH="/usr/local/bin:$PATH"</code></pre>
@@ -131,7 +131,7 @@
<section>
<h2 class="text-xl font-semibold mb-4">Security</h2>
<ul class="list-disc list-inside space-y-2 ml-4">
<li>Credentials are stored in <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">~/.atcr/device.json</code> with secure permissions (0600)</li>
<li>Credentials are stored in <code class="bg-base-300 px-1.5 py-0.5 rounded text-sm font-mono">{{ .CredHelper.DeviceFile }}</code> with secure permissions (0600)</li>
<li>Device secrets are issued per-device and can be revoked anytime</li>
<li>No passwords are stored locally</li>
<li>Uses ATProto OAuth with device authorization flow</li>
@@ -2,7 +2,7 @@
<section class="card bg-base-200 shadow-sm p-6 space-y-6">
<div>
<h2 class="text-xl font-semibold">Authorized Devices</h2>
<p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">docker-credential-atcr</code> credential helper.</p>
<p class="text-base-content/70 mt-1">Devices authorized via <code class="cmd">{{ .CredHelper.BinaryName }}</code> credential helper.</p>
</div>
<div class="bg-base-200 rounded-lg p-4 space-y-4">
@@ -14,7 +14,7 @@
<li>Configure Docker to use the helper. Add to <code class="cmd">~/.docker/config.json</code>:
<pre class="mt-2 p-3 bg-base-300 rounded-lg overflow-x-auto"><code>{
"credHelpers": {
"{{ .RegistryURL }}": "atcr"
"{{ .RegistryURL }}": "{{ .CredHelper.Name }}"
}
}</code></pre>
</li>