feat: add registry:auth-status to read back login state

There was no way to ask whether Dokku was already logged in to a registry with a given credential, so tooling converging a declared state onto a server had to re-run `registry:login` on every pass purely to discover that nothing needed to change. The new `registry:auth-status` command compares a supplied credential against the stored one and reports the result through its exit code alone, distinguishing a server with no credential from one whose credential differs, and from one whose secret a docker credential helper or an identity token puts out of reach. An `auth-servers` triad on `registry:report` exposes which servers have credentials without emitting any part of one.
This commit is contained in:
Jose Diaz-Gonzalez
2026-09-19 19:19:57 -04:00
parent 2e3d144aca
commit 624eea1959
11 changed files with 877 additions and 12 deletions

View File

@@ -4,10 +4,11 @@
> New as of 0.25.0
```
registry:login [--global|--password-stdin] [<app>] <server> <username> [<password>] # Login to a docker registry
registry:logout [--global] [<app>] <server> # Logout from a docker registry
registry:report [<app>] [<flag>] # Displays a registry report for one or more apps
registry:set <app>|--global <key> (<value>) # Set or clear a registry property for an app
registry:auth-status [--password-stdin] <app>|--global <server> [<username> [<password>]] # Reports whether the stored registry credential matches the requested state
registry:login [--global|--password-stdin] [<app>] <server> <username> [<password>] # Login to a docker registry
registry:logout [--global] [<app>] <server> # Logout from a docker registry
registry:report [<app>] [<flag>] # Displays a registry report for one or more apps
registry:set <app>|--global <key> (<value>) # Set or clear a registry property for an app
```
The registry plugin enables interacting with remote registries, which is useful when either deploying images via `git:from-image` or when interacting with custom schedulers to deploy built image artifacts.
@@ -85,6 +86,52 @@ dokku registry:logout node-js-app docker.io
When an app is destroyed, any per-app registry credentials are automatically removed.
### Checking the configured auth state
> [!IMPORTANT]
> New as of 0.38.29
The `registry:auth-status` command reports whether a stored registry credential matches a desired state without exposing it. This allows external tooling such as configuration management systems to converge idempotently, rather than re-running `registry:login` on every pass to discover that nothing needed to change.
```shell
# check whether an app is configured with the expected credentials
dokku registry:auth-status node-js-app ghcr.io $USERNAME $REGISTRY_PAT_TOKEN
# check whether the global credentials match
dokku registry:auth-status --global docker.io $USERNAME $PASSWORD
# check whether any credential is configured for a server
dokku registry:auth-status node-js-app ghcr.io
```
As with `registry:login`, the password may be provided via `STDIN`. This is the preferred form, as it keeps the password out of the process list:
```shell
echo "$PASSWORD" | dokku registry:auth-status --password-stdin node-js-app ghcr.io $USERNAME
```
The result is reported via the exit code, and nothing is printed when the state could be determined:
| Exit code | Description |
| --------- | ----------- |
| `0` | The stored credential matches the requested state. |
| `1` | There is no stored credential for the specified server. |
| `2` | There is a stored credential for the specified server, but it does not match the requested state. |
| `3` | The command was called with invalid arguments and the state could not be checked. |
| `4` | A credential is stored for the specified server, but it cannot be compared. |
| `20` | The specified app does not exist. |
The distinction between `1` and `2` allows a caller to tell a server that needs a credential created apart from a server whose existing credential would be replaced, without issuing a second call.
When called without a username, the requested state is that no credential exists for the server. An existing credential is therefore reported as `2`, and `1` is never returned. This is the check to perform before deciding whether a `registry:logout` is necessary.
Exit code `4` is returned when a credential exists but Dokku cannot read the secret it holds - either because a docker credential helper is configured for that server and stores the secret outside of `config.json`, or because the registry issued an identity token in place of a password. In both cases a username that differs from the one supplied is still reported as `2`, so `4` means only that an otherwise matching credential could not be confirmed. Callers should treat `4` as "unknown" rather than retrying the login indefinitely.
Unlike `registry:login`, the `--global` flag is required in order to check the global credentials - the deprecated implicit-global form is not accepted. A per-app check reads only the credentials created by `registry:login <app>` and does not fall back to the global credentials, so an app configured with a credential identical to the global one is still reported as needing one of its own. To see which credentials would actually be used for an app, read the `computed-auth-servers` field of `registry:report`.
> [!WARNING]
> A `0` exit code means the stored credential matches the one supplied, not that it still authenticates against the registry. A password that has been revoked upstream compares equal here and fails at push or pull time.
### Setting a remote server
To specify a remote server registry for pushes, set the `server` property via the `registry:set` command. The default value for this property is empty string. Setting the value to `docker.io` or `hub.docker.com` will result in the computed value being empty string (as that is the default, implicit registry), while any non-zero length value will have a `/` appended to it if there is not one already.
@@ -241,3 +288,20 @@ dokku registry:set --global push-extra-tags
| `push-extra-tags` | app + global | none | `--registry-push-extra-tags`, `--registry-global-push-extra-tags`, `--registry-computed-push-extra-tags` | Comma-separated list of additional tags pushed alongside the deploy tag |
| `push-on-release` | app + global | `false` | `--registry-push-on-release`, `--registry-global-push-on-release`, `--registry-computed-push-on-release` | When `true`, pushes the image to the registry on every successful build |
| `server` | app + global | none | `--registry-server`, `--registry-global-server`, `--registry-computed-server` | Registry server host (e.g. `ghcr.io`) used when pushing images |
### Read-only report fields
> [!IMPORTANT]
> New as of 0.38.29
The following `registry:report` fields describe credentials created by `registry:login` and are not settable via `registry:set`.
| Report flag | Scope | Description |
|---|---|---|
| `--registry-auth-servers` | app only | Comma-separated list of servers the app has its own credentials for |
| `--registry-global-auth-servers` | app + global | Comma-separated list of servers the global credentials cover |
| `--registry-computed-auth-servers` | app + global | Comma-separated list of servers whose credentials would be used for the app |
Servers are listed under the name they would be passed to `registry:login`, so a Docker Hub credential is reported as `docker.io` rather than the index server address docker stores it under. Only the server names are reported; no part of a credential is ever emitted.
Docker is pointed at one config directory or the other rather than a merge of the two, so an app with any credential of its own shadows the global credentials entirely. The computed value is therefore the app's own list when it has one and the global list otherwise, rather than a per-server merge of the two.

View File

@@ -16,6 +16,30 @@ type ErrWithExitCode interface {
ExitCode() int
}
// ExitCodeError carries an explicit exit code for a command failure. A blank
// Message exits with the code without printing anything, which suits commands
// that report their result through the exit code alone.
type ExitCodeError struct {
// Code is the exit code to terminate with
Code int
// Message is the error text printed to stderr, if any
Message string
}
var _ ErrWithExitCode = (*ExitCodeError)(nil)
// Error returns the error text to print to stderr
func (e *ExitCodeError) Error() string {
return e.Message
}
// ExitCode returns an exit code to use in case this error bubbles
// up into an os.Exit() call
func (e *ExitCodeError) ExitCode() int {
return e.Code
}
type writer struct {
mu *sync.Mutex
source string

View File

@@ -1,4 +1,4 @@
SUBCOMMANDS = subcommands/login subcommands/logout subcommands/report subcommands/set
SUBCOMMANDS = subcommands/auth-status subcommands/login subcommands/logout subcommands/report subcommands/set
TRIGGERS = triggers/deployed-app-image-repo triggers/deployed-app-image-tag triggers/deployed-app-repository triggers/install triggers/post-app-clone-setup triggers/post-app-rename-setup triggers/post-create triggers/post-delete triggers/post-release-builder triggers/report
BUILD = commands subcommands triggers
PLUGIN_NAME = registry

View File

@@ -0,0 +1,219 @@
package registry
import (
"encoding/base64"
"encoding/json"
"fmt"
"sort"
"strings"
)
// exit codes reported by registry:auth-status. Codes 0 through 3 match the
// codes git:auth-status reports for the equivalent netrc states.
const (
// authStatusMatch means the stored credential matches the requested state
authStatusMatch = 0
// authStatusMissing means no credential is stored for the server
authStatusMissing = 1
// authStatusDiffers means a credential is stored but does not match
authStatusDiffers = 2
// authStatusInvalidArguments means the state could not be checked
authStatusInvalidArguments = 3
// authStatusUnknown means a credential is stored but its secret is unreadable
authStatusUnknown = 4
)
// dockerAuthEntry is the subset of a docker config.json auths entry dokku reads
type dockerAuthEntry struct {
Auth string `json:"auth"`
Username string `json:"username"`
Password string `json:"password"`
IdentityToken string `json:"identitytoken"`
}
// dockerConfigFile is the subset of a docker config.json dokku reads
type dockerConfigFile struct {
Auths map[string]dockerAuthEntry `json:"auths"`
CredsStore string `json:"credsStore"`
CredHelpers map[string]string `json:"credHelpers"`
}
// authStatusInput describes the credential state a caller is asking about
type authStatusInput struct {
// ConfigBytes is the contents of the docker config.json, nil when absent
ConfigBytes []byte
// Server is the registry server being asked about
Server string
// Username is the expected username, empty to assert no credential exists
Username string
// Password is the expected password
Password string
}
// parseDockerConfig unmarshals the subset of a docker config.json dokku reads
func parseDockerConfig(b []byte) (dockerConfigFile, error) {
var config dockerConfigFile
if len(b) == 0 {
return config, nil
}
if err := json.Unmarshal(b, &config); err != nil {
return config, err
}
return config, nil
}
// lookupAuthEntry returns the auths entry docker would use for a server. The
// exact key is tried first, then every stored key is compared in hostname form,
// which is how docker matches credentials written under a legacy key.
func lookupAuthEntry(config dockerConfigFile, server string) (dockerAuthEntry, bool) {
key := dockerAuthKey(server)
if entry, ok := config.Auths[key]; ok {
return entry, true
}
// map iteration order is random, so the keys are sorted to keep the answer
// stable when more than one of them resolves to the same server
storedKeys := make([]string, 0, len(config.Auths))
for storedKey := range config.Auths {
storedKeys = append(storedKeys, storedKey)
}
sort.Strings(storedKeys)
for _, storedKey := range storedKeys {
if convertToHostname(storedKey) == key {
return config.Auths[storedKey], true
}
}
return dockerAuthEntry{}, false
}
// credentialHelperFor returns the credential helper docker would consult for a
// server, preferring a server-specific helper over the global store
func credentialHelperFor(config dockerConfigFile, server string) string {
key := dockerAuthKey(server)
if helper, ok := config.CredHelpers[key]; ok && helper != "" {
return helper
}
hostname := convertToHostname(normalizeRegistryServer(server))
if helper, ok := config.CredHelpers[hostname]; ok && helper != "" {
return helper
}
return config.CredsStore
}
// decodeAuthEntry returns the username and password stored in an auths entry.
// The secret is unreadable when a helper holds it or the registry issued an
// identity token, in which case a username may still be available.
func decodeAuthEntry(entry dockerAuthEntry) (string, string, bool) {
if entry.Auth == "" {
if entry.Username != "" && entry.Password != "" {
return entry.Username, entry.Password, true
}
return entry.Username, "", false
}
decoded, err := base64.StdEncoding.DecodeString(entry.Auth)
if err != nil {
return "", "", false
}
username, password, found := strings.Cut(string(decoded), ":")
if !found {
// without a separator there is no username to read either
return "", "", false
}
if password == "" {
return username, "", false
}
return username, password, true
}
// checkAuthStatus reports how a stored docker credential compares to the
// requested state. It returns the exit code to terminate with and, when the
// credential cannot be compared, the reason to print to stderr.
func checkAuthStatus(input authStatusInput) (int, string) {
config, err := parseDockerConfig(input.ConfigBytes)
if err != nil {
return authStatusUnknown, fmt.Sprintf("Unable to parse the docker config for %s: %s", input.Server, err.Error())
}
entry, ok := lookupAuthEntry(config, input.Server)
if !ok {
if input.Username == "" {
return authStatusMatch, ""
}
return authStatusMissing, ""
}
// that a credential exists is the whole answer an empty username asks for,
// and needs no access to the secret it holds
if input.Username == "" {
return authStatusDiffers, ""
}
// a helper keeps the secret outside of the file, leaving nothing to compare
// against in the entry docker blanked when it stored the credential
if helper := credentialHelperFor(config, input.Server); helper != "" {
return authStatusUnknown, fmt.Sprintf("Unable to compare the credential for %s: the %s credential helper holds it", input.Server, helper)
}
username, password, readable := decodeAuthEntry(entry)
if readable {
if username != input.Username || password != input.Password {
return authStatusDiffers, ""
}
return authStatusMatch, ""
}
// a username that differs settles the comparison without the secret, so
// only an otherwise matching credential is left indeterminate
if username != "" && username != input.Username {
return authStatusDiffers, ""
}
if entry.IdentityToken != "" {
return authStatusUnknown, fmt.Sprintf("Unable to compare the credential for %s: it is stored as an identity token", input.Server)
}
return authStatusUnknown, fmt.Sprintf("Unable to compare the credential for %s: no password is stored for it", input.Server)
}
// authServersFromConfig returns the sorted registry servers a docker config
// holds credentials for, named as they would be passed to registry:login
func authServersFromConfig(config dockerConfigFile) []string {
servers := []string{}
seen := map[string]bool{}
for key := range config.Auths {
server := convertToHostname(key)
if dockerAuthKey(key) == dockerIndexServer {
server = "docker.io"
}
if server == "" || seen[server] {
continue
}
seen[server] = true
servers = append(servers, server)
}
sort.Strings(servers)
return servers
}

View File

@@ -0,0 +1,138 @@
package registry
import (
"encoding/base64"
"fmt"
"reflect"
"strings"
"testing"
)
func encodeAuth(value string) string {
return base64.StdEncoding.EncodeToString([]byte(value))
}
func hubConfig() string {
return fmt.Sprintf(`{"auths":{"https://index.docker.io/v1/":{"auth":%q}}}`, encodeAuth("user:pass"))
}
func TestCheckAuthStatus(t *testing.T) {
cases := []struct {
name string
config string
server string
username string
password string
want int
}{
{"no config with credential", "", "docker.io", "user", "pass", authStatusMissing},
{"no config without credential", "", "docker.io", "", "", authStatusMatch},
{"empty auths after logout", `{"auths":{}}`, "docker.io", "user", "pass", authStatusMissing},
{"empty auths without credential", `{"auths":{}}`, "docker.io", "", "", authStatusMatch},
{"hub via docker.io", hubConfig(), "docker.io", "user", "pass", authStatusMatch},
{"hub via hub.docker.com", hubConfig(), "hub.docker.com", "user", "pass", authStatusMatch},
{"hub via docker.com", hubConfig(), "docker.com", "user", "pass", authStatusMatch},
{"hub via index.docker.io", hubConfig(), "index.docker.io", "user", "pass", authStatusMatch},
{"hub via index server address", hubConfig(), dockerIndexServer, "user", "pass", authStatusMatch},
// docker stores registry-1.docker.io under its own key, so a hub
// credential must not be reported as covering it
{"registry-1.docker.io is not hub", hubConfig(), "registry-1.docker.io", "user", "pass", authStatusMissing},
{"wrong password", hubConfig(), "docker.io", "user", "wrong", authStatusDiffers},
{"wrong username", hubConfig(), "docker.io", "other", "pass", authStatusDiffers},
{"no username with credential stored", hubConfig(), "docker.io", "", "", authStatusDiffers},
{"unrelated server", hubConfig(), "ghcr.io", "user", "pass", authStatusMissing},
{"other registry exact key", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}}}`, encodeAuth("user:pass")), "ghcr.io", "user", "pass", authStatusMatch},
{"other registry legacy key", fmt.Sprintf(`{"auths":{"https://ghcr.io/v1/":{"auth":%q}}}`, encodeAuth("user:pass")), "ghcr.io", "user", "pass", authStatusMatch},
{"registry with port", fmt.Sprintf(`{"auths":{"localhost:5000":{"auth":%q}}}`, encodeAuth("user:pass")), "localhost:5000", "user", "pass", authStatusMatch},
{"password containing a colon", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}}}`, encodeAuth("user:pa:ss")), "ghcr.io", "user", "pa:ss", authStatusMatch},
{"plaintext username and password fields", `{"auths":{"ghcr.io":{"username":"user","password":"pass"}}}`, "ghcr.io", "user", "pass", authStatusMatch},
{"identity token with matching username", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q,"identitytoken":"token"}}}`, encodeAuth("user:")), "ghcr.io", "user", "pass", authStatusUnknown},
{"identity token with differing username", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q,"identitytoken":"token"}}}`, encodeAuth("user:")), "ghcr.io", "other", "pass", authStatusDiffers},
{"identity token without credential", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q,"identitytoken":"token"}}}`, encodeAuth("user:")), "ghcr.io", "", "", authStatusDiffers},
{"credsStore holds the secret", `{"auths":{"ghcr.io":{}},"credsStore":"secretservice"}`, "ghcr.io", "user", "pass", authStatusUnknown},
{"credsStore without credential", `{"auths":{"ghcr.io":{}},"credsStore":"secretservice"}`, "ghcr.io", "", "", authStatusDiffers},
{"credHelpers for this server", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}},"credHelpers":{"ghcr.io":"ecr-login"}}`, encodeAuth("user:pass")), "ghcr.io", "user", "pass", authStatusUnknown},
{"credHelpers for another server", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}},"credHelpers":{"other.io":"ecr-login"}}`, encodeAuth("user:pass")), "ghcr.io", "user", "pass", authStatusMatch},
{"credHelpers keyed by hub index server", `{"auths":{"https://index.docker.io/v1/":{}},"credHelpers":{"https://index.docker.io/v1/":"desktop"}}`, "docker.io", "user", "pass", authStatusUnknown},
{"empty entry", `{"auths":{"ghcr.io":{}}}`, "ghcr.io", "user", "pass", authStatusUnknown},
{"undecodable auth", `{"auths":{"ghcr.io":{"auth":"!!!not base64!!!"}}}`, "ghcr.io", "user", "pass", authStatusUnknown},
{"auth without a separator", fmt.Sprintf(`{"auths":{"ghcr.io":{"auth":%q}}}`, encodeAuth("nocolon")), "ghcr.io", "user", "pass", authStatusUnknown},
{"invalid json", `{`, "ghcr.io", "user", "pass", authStatusUnknown},
{"auths is not an object", `{"auths":"nope"}`, "ghcr.io", "user", "pass", authStatusUnknown},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var contents []byte
if tc.config != "" {
contents = []byte(tc.config)
}
got, message := checkAuthStatus(authStatusInput{
ConfigBytes: contents,
Server: tc.server,
Username: tc.username,
Password: tc.password,
})
if got != tc.want {
t.Errorf("checkAuthStatus() = %d, want %d", got, tc.want)
}
if got == authStatusUnknown && message == "" {
t.Error("checkAuthStatus() returned no reason for an unreadable credential")
}
if got != authStatusUnknown && message != "" {
t.Errorf("checkAuthStatus() returned an unexpected message: %s", message)
}
})
}
}
func TestCheckAuthStatusDoesNotLeakCredentials(t *testing.T) {
_, message := checkAuthStatus(authStatusInput{
ConfigBytes: []byte(`{"auths":{"ghcr.io":{}},"credsStore":"secretservice"}`),
Server: "ghcr.io",
Username: "user",
Password: "supersecret",
})
if message == "" {
t.Fatal("expected a reason to be reported")
}
for _, secret := range []string{"supersecret", "user"} {
if strings.Contains(message, secret) {
t.Errorf("message %q leaks %q", message, secret)
}
}
}
func TestAuthServersFromConfig(t *testing.T) {
cases := []struct {
name string
config string
want []string
}{
{"no config", "", []string{}},
{"no credentials", `{"auths":{}}`, []string{}},
{"hub rendered as docker.io", hubConfig(), []string{"docker.io"}},
{"sorted across registries", `{"auths":{"quay.io":{},"ghcr.io":{},"https://index.docker.io/v1/":{}}}`, []string{"docker.io", "ghcr.io", "quay.io"}},
{"legacy keys reduced to hostnames", `{"auths":{"https://ghcr.io/v1/":{}}}`, []string{"ghcr.io"}},
{"duplicate hub keys collapsed", `{"auths":{"https://index.docker.io/v1/":{},"docker.io":{}}}`, []string{"docker.io"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
config, err := parseDockerConfig([]byte(tc.config))
if err != nil {
t.Fatalf("parseDockerConfig() errored: %s", err)
}
if got := authServersFromConfig(config); !reflect.DeepEqual(got, tc.want) {
t.Errorf("authServersFromConfig() = %v, want %v", got, tc.want)
}
})
}
}

View File

@@ -2,7 +2,6 @@ package registry
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
@@ -58,13 +57,12 @@ func HasAppRegistryAuth(appName string) bool {
return false
}
var config map[string]interface{}
if err := json.Unmarshal(content, &config); err != nil {
config, err := parseDockerConfig(content)
if err != nil {
return false
}
auths, ok := config["auths"].(map[string]interface{})
return ok && len(auths) > 0
return len(config.Auths) > 0
}
// GetDockerConfigArgs returns docker --config arguments if per-app config exists

View File

@@ -1,6 +1,8 @@
package registry
import (
"os"
"path/filepath"
"strings"
"github.com/dokku/dokku/plugins/common"
@@ -17,6 +19,8 @@ func ReportSingleApp(appName string, format string, infoFlag string) error {
var flags map[string]common.ReportFunc
if appName == "--global" {
flags = map[string]common.ReportFunc{
"--registry-computed-auth-servers": reportComputedAuthServers,
"--registry-global-auth-servers": reportGlobalAuthServers,
"--registry-computed-push-on-release": reportComputedPushOnRelease,
"--registry-global-push-on-release": reportGlobalPushOnRelease,
"--registry-computed-server": reportComputedServer,
@@ -28,6 +32,9 @@ func ReportSingleApp(appName string, format string, infoFlag string) error {
}
} else {
flags = map[string]common.ReportFunc{
"--registry-auth-servers": reportAuthServers,
"--registry-computed-auth-servers": reportComputedAuthServers,
"--registry-global-auth-servers": reportGlobalAuthServers,
"--registry-computed-image-repo": reportComputedImageRepo,
"--registry-image-repo": reportImageRepo,
"--registry-computed-push-on-release": reportComputedPushOnRelease,
@@ -156,3 +163,36 @@ func reportComputedPushExtraTags(appName string) string {
}
return value
}
// reportAuthServers lists the servers the app has its own credentials for
func reportAuthServers(appName string) string {
return authServersFromConfigPath(GetAppRegistryConfigPath(appName))
}
// reportGlobalAuthServers lists the servers the global config has credentials for
func reportGlobalAuthServers(appName string) string {
return authServersFromConfigPath(GetGlobalRegistryConfigPath())
}
// reportComputedAuthServers lists the servers whose credentials dokku would use
// for the app. Docker is pointed at one config directory or the other rather
// than a merge of both, so an app with any credential of its own shadows the
// global config entirely.
func reportComputedAuthServers(appName string) string {
return authServersFromConfigPath(filepath.Join(GetComputedAppRegistryConfigDir(appName), "config.json"))
}
// authServersFromConfigPath lists the servers a docker config holds credentials for
func authServersFromConfigPath(configPath string) string {
contents, err := os.ReadFile(configPath)
if err != nil {
return ""
}
config, err := parseDockerConfig(contents)
if err != nil {
return ""
}
return strings.Join(authServersFromConfig(config), ",")
}

View File

@@ -18,6 +18,7 @@ Manage registry settings for an app
Additional commands:`
helpContent = `
registry:auth-status [--password-stdin] <app>|--global <server> [<username> [<password>]], Reports whether the stored registry credential matches the requested state (exit 0 if matches)
registry:login [--global|--password-stdin] [<app>] <server> <username> [<password>], Login to a docker registry
registry:logout [--global] [<app>] <server>, Logout from a docker registry
registry:report [<app>] [<flag>], Displays a registry report for one or more apps

View File

@@ -1,7 +1,9 @@
package main
import (
"errors"
"fmt"
"io"
"os"
"strings"
@@ -18,6 +20,40 @@ func main() {
var err error
switch subcommand {
case "auth-status":
// the result of this command is its exit code, so parse errors must not
// be left to pflag: ExitOnError exits 2 on an unknown flag, which this
// command defines as a differing credential, and 0 on --help
args := flag.NewFlagSet("registry:auth-status", flag.ContinueOnError)
args.SetOutput(io.Discard)
passwordStdin := args.Bool("password-stdin", false, "--password-stdin: read password from stdin")
global := args.Bool("global", false, "--global: check the global registry credentials")
if parseErr := args.Parse(os.Args[2:]); parseErr != nil {
message := parseErr.Error()
if errors.Is(parseErr, flag.ErrHelp) {
message = "Usage: dokku registry:auth-status [--password-stdin] <app>|--global <server> [<username> [<password>]]"
}
common.LogFailWithError(registry.InvalidAuthStatusArguments(message))
}
var appName, server, username, password string
offset := 0
if !*global {
appName = args.Arg(0)
offset = 1
if appName == "" {
common.LogFailWithError(registry.InvalidAuthStatusArguments("Please specify an app or the --global flag"))
}
}
server = args.Arg(offset)
username = args.Arg(offset + 1)
if !*passwordStdin {
password = args.Arg(offset + 2)
}
err = registry.CommandAuthStatus(appName, server, username, password, *passwordStdin)
case "login":
args := flag.NewFlagSet("registry:login", flag.ExitOnError)
passwordStdin := args.Bool("password-stdin", false, "--password-stdin: read password from stdin")

View File

@@ -11,15 +11,92 @@ import (
"github.com/dokku/dokku/plugins/common"
)
// readPasswordFromStdin reads a registry password from stdin
func readPasswordFromStdin() (string, error) {
stdin, err := io.ReadAll(os.Stdin)
if err != nil {
return "", err
}
return strings.TrimSpace(string(stdin)), nil
}
// CommandAuthStatus reports whether the stored registry credential matches the
// requested state, communicating the result via the exit code alone
func CommandAuthStatus(appName string, server string, username string, password string, passwordStdin bool) error {
if passwordStdin {
if username == "" {
return InvalidAuthStatusArguments("Missing username argument")
}
stdin, err := readPasswordFromStdin()
if err != nil {
return InvalidAuthStatusArguments(err.Error())
}
password = stdin
}
if server == "" {
return InvalidAuthStatusArguments("Missing server argument")
}
if username != "" && password == "" {
return InvalidAuthStatusArguments("Missing password argument")
}
if username == "" && password != "" {
return InvalidAuthStatusArguments("Missing username argument")
}
configPath := GetGlobalRegistryConfigPath()
if appName != "" {
if err := common.VerifyAppName(appName); err != nil {
// a missing app carries its own exit code, while an invalid name is
// a malformed call and must not be reported as a missing credential
if _, ok := err.(common.ErrWithExitCode); ok {
return err
}
return InvalidAuthStatusArguments(err.Error())
}
configPath = GetAppRegistryConfigPath(appName)
}
contents, err := os.ReadFile(configPath)
if err != nil && !os.IsNotExist(err) {
return &common.ExitCodeError{
Code: authStatusUnknown,
Message: fmt.Sprintf("Unable to read the docker config: %s", err.Error()),
}
}
code, message := checkAuthStatus(authStatusInput{
ConfigBytes: contents,
Server: server,
Username: username,
Password: password,
})
if code == authStatusMatch {
return nil
}
return &common.ExitCodeError{Code: code, Message: message}
}
// InvalidAuthStatusArguments reports a registry:auth-status call that could not be checked
func InvalidAuthStatusArguments(message string) error {
return &common.ExitCodeError{Code: authStatusInvalidArguments, Message: message}
}
// CommandLogin logs a user into the specified server
func CommandLogin(appName string, server string, username string, password string, passwordStdin bool) error {
if passwordStdin {
stdin, err := io.ReadAll(os.Stdin)
stdin, err := readPasswordFromStdin()
if err != nil {
return err
}
password = strings.TrimSpace(string(stdin))
password = stdin
}
if server == "" {

View File

@@ -120,6 +120,49 @@ teardown() {
assert_output_not_contains "index.docker.io"
}
@test "(registry:auth-status) matches a credential written by registry:login" {
if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
fi
run /bin/bash -c "dokku registry:login --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku registry:auth-status --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
echo "output: $output"
echo "status: $status"
assert_exit_status 0
assert_output ""
run /bin/bash -c "dokku registry:auth-status --global docker.io $DOCKERHUB_USERNAME wrong-password"
echo "output: $output"
echo "status: $status"
assert_exit_status 2
run /bin/bash -c "dokku registry:report --global --format json | jq -r '.\"global-auth-servers\"'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "docker.io"
run /bin/bash -c "dokku registry:logout --global docker.io"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku registry:auth-status --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
echo "output: $output"
echo "status: $status"
assert_exit_status 1
run /bin/bash -c "dokku registry:auth-status --global docker.io"
echo "output: $output"
echo "status: $status"
assert_exit_status 0
}
@test "(registry) per-app credentials deleted on app destroy" {
if [[ -z "$DOCKERHUB_USERNAME" ]] || [[ -z "$DOCKERHUB_TOKEN" ]]; then
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
@@ -579,6 +622,13 @@ teardown() {
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
fi
# this test pushes to the registry, so it establishes its own credentials
# rather than relying on a login left behind by an earlier test
run /bin/bash -c "dokku registry:login --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku registry:set $TEST_APP push-on-release true"
echo "output: $output"
echo "status: $status"
@@ -708,6 +758,13 @@ teardown() {
skip "skipping due to missing docker.io credentials DOCKERHUB_USERNAME:DOCKERHUB_TOKEN"
fi
# this test pushes to the registry, so it establishes its own credentials
# rather than relying on a login left behind by an earlier test
run /bin/bash -c "dokku registry:login --global docker.io $DOCKERHUB_USERNAME $DOCKERHUB_TOKEN"
echo "output: $output"
echo "status: $status"
assert_success
run /bin/bash -c "dokku registry:set $TEST_APP push-on-release true"
echo "output: $output"
echo "status: $status"
@@ -755,3 +812,214 @@ teardown() {
assert_success
assert_output "true"
}
@test "(registry:auth-status) validates arguments" {
run /bin/bash -c "dokku registry:auth-status"
echo "output: $output"
echo "status: $status"
assert_exit_status 3
assert_output_contains "Please specify an app or the --global flag"
run /bin/bash -c "dokku registry:auth-status --global"
echo "output: $output"
echo "status: $status"
assert_exit_status 3
assert_output_contains "Missing server argument"
run /bin/bash -c "dokku registry:auth-status $TEST_APP"
echo "output: $output"
echo "status: $status"
assert_exit_status 3
assert_output_contains "Missing server argument"
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io username"
echo "output: $output"
echo "status: $status"
assert_exit_status 3
assert_output_contains "Missing password argument"
# an unknown flag must not be reported as a differing credential, which is
# what pflag would exit with if it handled the error itself
run /bin/bash -c "dokku registry:auth-status --nonsense --global docker.io"
echo "output: $output"
echo "status: $status"
assert_exit_status 3
run /bin/bash -c "dokku registry:auth-status --help"
echo "output: $output"
echo "status: $status"
assert_exit_status 3
run /bin/bash -c "dokku registry:auth-status nonexistent-app-name docker.io username password"
echo "output: $output"
echo "status: $status"
assert_exit_status 20
# a malformed app name must not be reported as a missing credential
run /bin/bash -c "dokku registry:auth-status 'BAD NAME' docker.io username password"
echo "output: $output"
echo "status: $status"
assert_exit_status 3
}
@test "(registry:auth-status) reports per-app credential state" {
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io fakeuser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 1
assert_output ""
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io"
echo "output: $output"
echo "status: $status"
assert_exit_status 0
assert_output ""
write_registry_auth_config "$TEST_APP" "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(fake_registry_auth fakeuser fakepass)\"}}}"
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io fakeuser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 0
assert_output ""
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io fakeuser wrongpass"
echo "output: $output"
echo "status: $status"
assert_exit_status 2
assert_output ""
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io otheruser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 2
run /bin/bash -c "dokku registry:auth-status $TEST_APP docker.io"
echo "output: $output"
echo "status: $status"
assert_exit_status 2
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io fakeuser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 1
run /bin/bash -c "printf 'fakepass' | dokku registry:auth-status --password-stdin $TEST_APP docker.io fakeuser"
echo "output: $output"
echo "status: $status"
assert_exit_status 0
run /bin/bash -c "printf 'wrongpass' | dokku registry:auth-status --password-stdin $TEST_APP docker.io fakeuser"
echo "output: $output"
echo "status: $status"
assert_exit_status 2
run /bin/bash -c "dokku --app $TEST_APP registry:auth-status docker.io fakeuser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 0
}
@test "(registry:auth-status) normalizes server names and reports unreadable credentials" {
write_registry_auth_config "$TEST_APP" "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(fake_registry_auth fakeuser fakepass)\"}}}"
for server in docker.io hub.docker.com docker.com index.docker.io; do
run /bin/bash -c "dokku registry:auth-status $TEST_APP $server fakeuser fakepass"
echo "server: $server"
echo "output: $output"
echo "status: $status"
assert_exit_status 0
done
# docker stores registry-1.docker.io under a key of its own rather than the
# index server, so a docker hub credential does not cover it
run /bin/bash -c "dokku registry:auth-status $TEST_APP registry-1.docker.io fakeuser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 1
write_registry_auth_config "$TEST_APP" '{"auths":{"ghcr.io":{}},"credsStore":"secretservice"}'
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io fakeuser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 4
assert_output_contains "credential helper"
assert_output_not_contains "fakepass"
# knowing a credential exists needs no access to the secret
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io"
echo "output: $output"
echo "status: $status"
assert_exit_status 2
write_registry_auth_config "$TEST_APP" "{\"auths\":{\"ghcr.io\":{\"auth\":\"$(fake_registry_auth fakeuser '')\",\"identitytoken\":\"token\"}}}"
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io fakeuser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 4
assert_output_contains "identity token"
# a username that differs settles the comparison without reading the secret
run /bin/bash -c "dokku registry:auth-status $TEST_APP ghcr.io otheruser fakepass"
echo "output: $output"
echo "status: $status"
assert_exit_status 2
}
@test "(registry:report) auth-servers raw vs computed vs global" {
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r '.\"auth-servers\"'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output ""
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r '.\"computed-auth-servers\" == .\"global-auth-servers\"'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "true"
write_registry_auth_config "$TEST_APP" "{\"auths\":{\"https://index.docker.io/v1/\":{\"auth\":\"$(fake_registry_auth fakeuser fakepass)\"},\"ghcr.io\":{\"auth\":\"$(fake_registry_auth fakeuser fakepass)\"}}}"
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r '.\"auth-servers\"'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "docker.io,ghcr.io"
# a per-app config shadows the global one entirely rather than merging
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r '.\"computed-auth-servers\"'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "docker.io,ghcr.io"
run /bin/bash -c "dokku registry:report $TEST_APP --format json | jq -r 'has(\"auth-servers\") and has(\"registry-auth-servers\")'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "true"
run /bin/bash -c "dokku registry:report --global --format json | jq -r 'has(\"global-auth-servers\") and has(\"computed-auth-servers\")'"
echo "output: $output"
echo "status: $status"
assert_success
assert_output "true"
}
fake_registry_auth() {
declare desc="base64 encodes a username and password as docker stores them"
declare USERNAME="$1" PASSWORD="$2"
printf '%s:%s' "$USERNAME" "$PASSWORD" | base64 -w0
}
write_registry_auth_config() {
declare desc="writes a docker config.json for an app without contacting a registry"
declare APP="$1" CONTENTS="$2"
mkdir -p "/var/lib/dokku/config/registry/$APP"
echo "$CONTENTS" >"/var/lib/dokku/config/registry/$APP/config.json"
chown -R dokku:dokku "/var/lib/dokku/config/registry/$APP"
chmod 600 "/var/lib/dokku/config/registry/$APP/config.json"
}