mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-10 04:06:06 +00:00
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:
co-authored by
Claude Opus 5
parent
2743445e65
commit
9d8bd513da
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 == "" {
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
+13
-13
@@ -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 ""
|
||||
Executable → Regular
+13
-13
@@ -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
|
||||
@@ -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},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user