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 }}"
       }
     }